feat: governed function runtime + billing drain

GCAP-SPEC-FUNCTION-DESCRIPTOR-0001 implementation.
Mirrors connector runtime pattern exactly.

FunctionPlugin — trigger_events, handle(), descriptor(), knative_manifest()
FunctionRegistry — trigger_index for event-driven routing
FunctionRuntime — invoke() + dispatch() with Chronicle lineage
governed_function decorator — SDK surface for function authors
BillingProcessor — GSAP_CR_RECEIVED → billable event with Chronicle CID
EchoFunction — dev/test

API: /functions/ catalogue, invoke, dispatch, manifest, health
8 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-03-30 22:12:29 -04:00
parent 0987704264
commit 0c77943ceb
11 changed files with 697 additions and 1 deletions

View file

@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from gsap_broker.settings import settings from gsap_broker.settings import settings
from gsap_broker.db import init_db from gsap_broker.db import init_db
from gsap_broker.routers import authorize, complete, session, elevate, health, drivers, connectors from gsap_broker.routers import authorize, complete, session, elevate, health, drivers, connectors, functions
logger = structlog.get_logger() logger = structlog.get_logger()
@ -25,4 +25,5 @@ app.include_router(session.router, prefix="/governance", tags=["Session"])
app.include_router(elevate.router, prefix="/governance", tags=["Elevation"]) app.include_router(elevate.router, prefix="/governance", tags=["Elevation"])
app.include_router(drivers.router, prefix="/governance", tags=["Drivers"]) app.include_router(drivers.router, prefix="/governance", tags=["Drivers"])
app.include_router(connectors.router, prefix="/connectors", tags=["Connectors"]) app.include_router(connectors.router, prefix="/connectors", tags=["Connectors"])
app.include_router(functions.router, prefix="/functions", tags=["Functions"])
app.include_router(health.router, tags=["Health"]) app.include_router(health.router, tags=["Health"])

View file

@ -0,0 +1,4 @@
from .base import FunctionPlugin, FunctionContext, FunctionResult
from .registry import FunctionRegistry
from .runtime import FunctionRuntime
from .decorator import governed_function

View file

@ -0,0 +1,116 @@
"""FunctionPlugin ABC per GCAP-SPEC-FUNCTION-DESCRIPTOR-0001 section 4."""
from __future__ import annotations
import hashlib
import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@dataclass
class FunctionContext:
trigger_event_kind: str = ""
trigger_event_cid: str = ""
chronicle_session_id: str = ""
gsap_context_id: str = ""
invocation_id: str = ""
pipeline_run_id: str = ""
@dataclass
class FunctionResult:
success: bool = False
data: Any = None
error: str | None = None
lineage_cid: str = ""
duration_ms: float = 0.0
metadata: dict[str, Any] = field(default_factory=dict)
def output_cid(self) -> str:
"""Compute content-addressable hash of the output data."""
canonical = json.dumps(self.data, sort_keys=True, separators=(",", ":"), default=str)
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
class FunctionPlugin(ABC):
"""Abstract base for governed serverless functions."""
function_id: str = ""
corpus_entry_cid: str = ""
capability_mask: int = 0
trigger_events: list[str] = []
accord_template: str = ""
gsap_required: bool = True
chronicle_enabled: bool = True
max_duration_seconds: int = 30
display_name: str = ""
description: str = ""
version: str = "0.1.0"
@abstractmethod
async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult:
...
@abstractmethod
def health_check(self) -> bool:
...
def descriptor(self) -> dict[str, Any]:
"""JSON-LD descriptor per GCAP-SPEC-FUNCTION-DESCRIPTOR-0001 §2."""
return {
"@context": "https://schema.gsap.dev/function/v1",
"@type": "FunctionDescriptor",
"function_id": self.function_id,
"corpus_entry_cid": self.corpus_entry_cid,
"capability_mask": self.capability_mask,
"trigger_events": self.trigger_events,
"accord_template": self.accord_template,
"gsap_required": self.gsap_required,
"chronicle_enabled": self.chronicle_enabled,
"max_duration_seconds": self.max_duration_seconds,
"display_name": self.display_name,
"description": self.description,
"version": self.version,
}
def knative_manifest(self, image: str, namespace: str = "default") -> dict[str, Any]:
"""Generate Knative Service manifest."""
return {
"apiVersion": "serving.knative.dev/v1",
"kind": "Service",
"metadata": {
"name": self.function_id,
"namespace": namespace,
"labels": {
"gcap.dev/function-id": self.function_id,
"gcap.dev/capability-mask": str(self.capability_mask),
},
"annotations": {
"gcap.dev/corpus-entry-cid": self.corpus_entry_cid,
"gcap.dev/gsap-required": str(self.gsap_required).lower(),
},
},
"spec": {
"template": {
"metadata": {
"annotations": {
"autoscaling.knative.dev/minScale": "0",
"autoscaling.knative.dev/maxScale": "10",
},
},
"spec": {
"timeoutSeconds": self.max_duration_seconds,
"containers": [
{
"image": image,
"env": [
{"name": "GCAP_FUNCTION_ID", "value": self.function_id},
{"name": "GCAP_CAPABILITY_MASK", "value": str(self.capability_mask)},
],
}
],
},
},
},
}

View file

@ -0,0 +1,70 @@
"""governed_function decorator — wraps async functions as FunctionPlugins."""
from __future__ import annotations
import functools
from typing import Any, Callable, Awaitable
from .base import FunctionContext, FunctionPlugin, FunctionResult
def governed_function(
function_id: str,
corpus_entry_cid: str,
capability_mask: int = 1,
trigger_events: list[str] | None = None,
accord_template: str = "",
gsap_required: bool = True,
chronicle_enabled: bool = True,
max_duration_seconds: int = 30,
display_name: str = "",
description: str = "",
version: str = "0.1.0",
registry: Any = None,
) -> Callable:
"""Decorator that wraps an async function as a governed FunctionPlugin.
Usage::
@governed_function(
function_id="my-func",
corpus_entry_cid="sha256:abc...",
)
async def my_func(event, context):
return FunctionResult(success=True, data=event)
"""
def decorator(
fn: Callable[[dict[str, Any], FunctionContext], Awaitable[FunctionResult]],
) -> FunctionPlugin:
class WrappedFunction(FunctionPlugin):
pass
instance = WrappedFunction.__new__(WrappedFunction)
instance.function_id = function_id
instance.corpus_entry_cid = corpus_entry_cid
instance.capability_mask = capability_mask
instance.trigger_events = trigger_events or []
instance.accord_template = accord_template
instance.gsap_required = gsap_required
instance.chronicle_enabled = chronicle_enabled
instance.max_duration_seconds = max_duration_seconds
instance.display_name = display_name or function_id
instance.description = description
instance.version = version
async def handle(event: dict[str, Any], context: FunctionContext) -> FunctionResult:
return await fn(event, context)
instance.handle = handle # type: ignore[assignment]
instance.health_check = lambda: True # type: ignore[assignment]
# Auto-register if a registry is provided
if registry is not None:
registry.register(instance)
# Preserve original function metadata
functools.update_wrapper(instance, fn)
return instance
return decorator

View file

@ -0,0 +1,2 @@
from .billing import BillingProcessor
from .echo import EchoFunction

View file

@ -0,0 +1,62 @@
"""BillingProcessor — governed billing drain function per §5."""
from __future__ import annotations
from typing import Any
from gsap_broker.functions.base import FunctionContext, FunctionPlugin, FunctionResult
RATE_CARD: dict[str, float] = {
"connector_invocation": 0.002,
"function_invocation": 0.001,
"chronicle_event": 0.0005,
"gsap_ac_issued": 0.003,
"gsap_cr_completed": 0.005,
}
class BillingProcessor(FunctionPlugin):
function_id = "billing-processor"
corpus_entry_cid = "sha256:" + "b" * 64
capability_mask = 3 # MUTATE
trigger_events = ["GSAP_CR_RECEIVED"]
accord_template = ""
gsap_required = False
chronicle_enabled = True
max_duration_seconds = 10
display_name = "Billing Processor"
description = "Governed billing drain — only bills completed outcomes with Chronicle CID reference."
version = "0.1.0"
async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult:
outcome = event.get("outcome", "")
if outcome != "completed":
return FunctionResult(
success=True,
data={"billed": False, "reason": f"Outcome '{outcome}' is not billable"},
metadata={"function_id": self.function_id},
)
event_type = event.get("event_type", "gsap_cr_completed")
rate = RATE_CARD.get(event_type, RATE_CARD["gsap_cr_completed"])
quantity = event.get("quantity", 1)
amount = rate * quantity
billing_record = {
"billed": True,
"event_type": event_type,
"rate": rate,
"quantity": quantity,
"amount": amount,
"chronicle_cid": context.trigger_event_cid or event.get("chronicle_cid", ""),
"invocation_id": context.invocation_id,
}
return FunctionResult(
success=True,
data=billing_record,
metadata={"function_id": self.function_id},
)
def health_check(self) -> bool:
return True

View file

@ -0,0 +1,26 @@
"""EchoFunction — minimal governed function example."""
from __future__ import annotations
from typing import Any
from gsap_broker.functions.base import FunctionContext, FunctionPlugin, FunctionResult
class EchoFunction(FunctionPlugin):
function_id = "echo-function"
corpus_entry_cid = "sha256:" + "e" * 64
capability_mask = 1 # READ
trigger_events = ["CONNECTOR_INVOKED"]
accord_template = ""
gsap_required = False
chronicle_enabled = True
max_duration_seconds = 5
display_name = "Echo Function"
description = "Returns event data unchanged — used for integration testing."
version = "0.1.0"
async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult:
return FunctionResult(success=True, data=event)
def health_check(self) -> bool:
return True

View file

@ -0,0 +1,37 @@
"""FunctionRegistry — catalogue of governed serverless functions."""
from __future__ import annotations
from .base import FunctionPlugin
class FunctionRegistry:
def __init__(self) -> None:
self._functions: dict[str, FunctionPlugin] = {}
self._trigger_index: dict[str, list[str]] = {}
def register(self, function: FunctionPlugin) -> None:
if not function.function_id:
raise ValueError("function_id must be non-empty")
if not function.corpus_entry_cid:
raise ValueError("corpus_entry_cid must be non-empty")
self._functions[function.function_id] = function
# Build trigger index
for event_kind in function.trigger_events:
self._trigger_index.setdefault(event_kind, []).append(function.function_id)
def get(self, function_id: str) -> FunctionPlugin | None:
return self._functions.get(function_id)
def catalogue(self) -> list[dict]:
return [f.descriptor() for f in self._functions.values()]
def list_ids(self) -> list[str]:
return list(self._functions.keys())
def trigger_index(self) -> dict[str, list[str]]:
return dict(self._trigger_index)
def by_trigger(self, event_kind: str) -> list[FunctionPlugin]:
"""Return all functions registered for a given trigger event kind."""
ids = self._trigger_index.get(event_kind, [])
return [self._functions[fid] for fid in ids if fid in self._functions]

View file

@ -0,0 +1,106 @@
"""FunctionRuntime — governed invocation with Chronicle emission."""
from __future__ import annotations
import time
import uuid
from typing import Any
from .base import FunctionContext, FunctionResult
from .registry import FunctionRegistry
class FunctionRuntime:
def __init__(
self, registry: FunctionRegistry, chronicle_client: Any = None
) -> None:
self.registry = registry
self.chronicle_client = chronicle_client
async def invoke(
self,
function_id: str,
event: dict[str, Any],
context: FunctionContext,
) -> FunctionResult:
"""Direct invocation of a function by ID."""
function = self.registry.get(function_id)
if function is None:
return FunctionResult(success=False, error=f"Unknown function: {function_id}")
if function.gsap_required and not context.gsap_context_id:
return FunctionResult(success=False, error="GSAP context required but not provided")
if not context.invocation_id:
context.invocation_id = str(uuid.uuid4())
# Emit FUNCTION_INVOKED (0x2A01) before execution
await self._emit_chronicle(
"FUNCTION_INVOKED",
{
"event_code": "0x2A01",
"function_id": function_id,
"trigger_event_kind": context.trigger_event_kind,
"trigger_event_cid": context.trigger_event_cid,
"chronicle_session_id": context.chronicle_session_id,
"gsap_context_id": context.gsap_context_id,
"invocation_id": context.invocation_id,
"pipeline_run_id": context.pipeline_run_id,
},
function,
)
start = time.monotonic()
result = await function.handle(event, context)
result.duration_ms = (time.monotonic() - start) * 1000
# Emit FUNCTION_COMPLETED (0x2A02) after execution
await self._emit_chronicle(
"FUNCTION_COMPLETED",
{
"event_code": "0x2A02",
"function_id": function_id,
"invocation_id": context.invocation_id,
"success": result.success,
"duration_ms": result.duration_ms,
"output_cid": result.output_cid() if result.data is not None else "",
"error": result.error,
},
function,
)
return result
async def dispatch(
self,
event_kind: str,
event_data: dict[str, Any],
trigger_cid: str = "",
) -> list[dict[str, Any]]:
"""Route an event to all functions registered for this event kind."""
functions = self.registry.by_trigger(event_kind)
results = []
for function in functions:
context = FunctionContext(
trigger_event_kind=event_kind,
trigger_event_cid=trigger_cid,
invocation_id=str(uuid.uuid4()),
)
result = await self.invoke(function.function_id, event_data, context)
results.append({
"function_id": function.function_id,
"success": result.success,
"data": result.data,
"error": result.error,
"duration_ms": result.duration_ms,
"lineage_cid": result.lineage_cid,
})
return results
async def _emit_chronicle(
self, event_type: str, payload: dict[str, Any], function: Any
) -> None:
if function.chronicle_enabled and self.chronicle_client is not None:
try:
await self.chronicle_client.emit(event_type, payload)
except Exception:
pass # Chronicle failure must not break invocation

View file

@ -0,0 +1,120 @@
"""Functions router — governed serverless function catalogue and invocation."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from gsap_broker.functions.base import FunctionContext
from gsap_broker.functions.registry import FunctionRegistry
from gsap_broker.functions.runtime import FunctionRuntime
from gsap_broker.functions.examples.billing import BillingProcessor
from gsap_broker.functions.examples.echo import EchoFunction
router = APIRouter()
# Module-level registry and runtime
_registry = FunctionRegistry()
_runtime = FunctionRuntime(registry=_registry)
# Register built-in functions
_registry.register(BillingProcessor())
_registry.register(EchoFunction())
class InvokeRequest(BaseModel):
event: dict[str, Any] = {}
trigger_event_kind: str = ""
trigger_event_cid: str = ""
chronicle_session_id: str = ""
gsap_context_id: str = ""
pipeline_run_id: str = ""
class DispatchRequest(BaseModel):
event_kind: str
event_data: dict[str, Any] = {}
trigger_cid: str = ""
class InvokeResponse(BaseModel):
success: bool
data: Any = None
error: str | None = None
lineage_cid: str = ""
function_id: str = ""
duration_ms: float = 0.0
class DispatchResponse(BaseModel):
event_kind: str
dispatched_count: int
results: list[dict[str, Any]]
@router.get("/")
async def catalogue() -> dict:
return {
"functions": _registry.catalogue(),
"trigger_index": _registry.trigger_index(),
}
@router.get("/{function_id}/")
async def get_descriptor(function_id: str) -> dict:
function = _registry.get(function_id)
if function is None:
raise HTTPException(status_code=404, detail=f"Function not found: {function_id}")
return function.descriptor()
@router.get("/{function_id}/manifest/")
async def knative_manifest(function_id: str, image: str = "gcr.io/default/function:latest", namespace: str = "default") -> dict:
function = _registry.get(function_id)
if function is None:
raise HTTPException(status_code=404, detail=f"Function not found: {function_id}")
return function.knative_manifest(image=image, namespace=namespace)
@router.post("/{function_id}/invoke/")
async def invoke_function(function_id: str, body: InvokeRequest) -> InvokeResponse:
function = _registry.get(function_id)
if function is None:
raise HTTPException(status_code=404, detail=f"Function not found: {function_id}")
ctx = FunctionContext(
trigger_event_kind=body.trigger_event_kind,
trigger_event_cid=body.trigger_event_cid,
chronicle_session_id=body.chronicle_session_id,
gsap_context_id=body.gsap_context_id,
pipeline_run_id=body.pipeline_run_id,
)
result = await _runtime.invoke(function_id, body.event, ctx)
return InvokeResponse(
success=result.success,
data=result.data,
error=result.error,
lineage_cid=result.lineage_cid,
function_id=function_id,
duration_ms=result.duration_ms,
)
@router.post("/dispatch/")
async def dispatch_event(body: DispatchRequest) -> DispatchResponse:
results = await _runtime.dispatch(body.event_kind, body.event_data, body.trigger_cid)
return DispatchResponse(
event_kind=body.event_kind,
dispatched_count=len(results),
results=results,
)
@router.get("/{function_id}/health/")
async def health_check(function_id: str) -> dict:
function = _registry.get(function_id)
if function is None:
raise HTTPException(status_code=404, detail=f"Function not found: {function_id}")
healthy = function.health_check()
return {"function_id": function_id, "healthy": healthy}

152
tests/test_functions.py Normal file
View file

@ -0,0 +1,152 @@
"""Tests for governed serverless function module."""
import pytest
@pytest.mark.asyncio
async def test_function_catalogue(client):
resp = await client.get("/functions/")
assert resp.status_code == 200
body = resp.json()
ids = [f["function_id"] for f in body["functions"]]
assert "billing-processor" in ids
assert "echo-function" in ids
assert "trigger_index" in body
@pytest.mark.asyncio
async def test_function_descriptor(client):
resp = await client.get("/functions/billing-processor/")
assert resp.status_code == 200
desc = resp.json()
assert desc["function_id"] == "billing-processor"
assert "trigger_events" in desc
assert "GSAP_CR_RECEIVED" in desc["trigger_events"]
assert desc["corpus_entry_cid"].startswith("sha256:")
@pytest.mark.asyncio
async def test_knative_manifest(client):
resp = await client.get("/functions/billing-processor/manifest/?image=gcr.io/test/billing:v1")
assert resp.status_code == 200
manifest = resp.json()
assert manifest["kind"] == "Service"
assert manifest["apiVersion"] == "serving.knative.dev/v1"
assert manifest["metadata"]["name"] == "billing-processor"
containers = manifest["spec"]["template"]["spec"]["containers"]
assert containers[0]["image"] == "gcr.io/test/billing:v1"
@pytest.mark.asyncio
async def test_invoke_echo(client):
resp = await client.post(
"/functions/echo-function/invoke/",
json={"event": {"msg": "hello", "value": 42}},
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["data"]["msg"] == "hello"
assert body["data"]["value"] == 42
assert body["function_id"] == "echo-function"
assert body["duration_ms"] >= 0
@pytest.mark.asyncio
async def test_billing_completed(client):
resp = await client.post(
"/functions/billing-processor/invoke/",
json={
"event": {
"outcome": "completed",
"event_type": "gsap_cr_completed",
"quantity": 2,
"chronicle_cid": "sha256:abc123",
},
},
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["data"]["billed"] is True
assert body["data"]["amount"] == 0.01 # 0.005 * 2
assert body["data"]["chronicle_cid"] == "sha256:abc123"
@pytest.mark.asyncio
async def test_billing_failed_not_billed(client):
resp = await client.post(
"/functions/billing-processor/invoke/",
json={
"event": {
"outcome": "failed",
"event_type": "gsap_cr_completed",
},
},
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["data"]["billed"] is False
@pytest.mark.asyncio
async def test_dispatch_routes_to_billing(client):
resp = await client.post(
"/functions/dispatch/",
json={
"event_kind": "GSAP_CR_RECEIVED",
"event_data": {
"outcome": "completed",
"event_type": "gsap_cr_completed",
"quantity": 1,
},
"trigger_cid": "sha256:trigger001",
},
)
assert resp.status_code == 200
body = resp.json()
assert body["event_kind"] == "GSAP_CR_RECEIVED"
assert body["dispatched_count"] >= 1
# billing-processor should be in the results
billing_results = [r for r in body["results"] if r["function_id"] == "billing-processor"]
assert len(billing_results) == 1
assert billing_results[0]["success"] is True
@pytest.mark.asyncio
async def test_gsap_required_without_ac(client):
"""A gsap_required function invoked without gsap_context_id returns error."""
# We need a function with gsap_required=True. Register one via the runtime.
# Use the billing-processor with gsap_required toggled, or invoke directly.
# Since billing-processor has gsap_required=False, we test via the runtime directly.
from gsap_broker.functions.base import FunctionContext, FunctionPlugin, FunctionResult
from gsap_broker.functions.registry import FunctionRegistry
from gsap_broker.functions.runtime import FunctionRuntime
from typing import Any
class StrictFunction(FunctionPlugin):
function_id = "strict-func"
corpus_entry_cid = "sha256:" + "s" * 64
capability_mask = 1
trigger_events = []
gsap_required = True
chronicle_enabled = False
max_duration_seconds = 5
display_name = "Strict"
description = "Requires GSAP"
version = "0.1.0"
async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult:
return FunctionResult(success=True, data=event)
def health_check(self) -> bool:
return True
reg = FunctionRegistry()
reg.register(StrictFunction())
rt = FunctionRuntime(registry=reg)
ctx = FunctionContext() # No gsap_context_id
result = await rt.invoke("strict-func", {"test": True}, ctx)
assert result.success is False
assert "GSAP context required" in (result.error or "")