Add protected headless lifecycle application service

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-04 18:25:21 +02:00
parent f7094f8313
commit b2a4b863a8
7 changed files with 439 additions and 12 deletions

View file

@ -90,8 +90,10 @@ class LifecycleRepository:
return None
@staticmethod
def _append(db, scope, digest, memo, kind, body):
def _append(db, scope, digest, memo, kind, body, policy_id=None):
event_id = str(uuid.uuid4())
if policy_id is not None:
body = {**body, "policy_observation_id": policy_id}
db.execute("INSERT INTO lifecycle_events "
"(id,tenant,memo_id,version,kind,actor,at,body) VALUES (?,?,?,?,?,?,?,?)",
(event_id, scope[0], memo.id, memo.version, kind, scope[1], _now(), dumps(body)))
@ -99,12 +101,13 @@ class LifecycleRepository:
db.execute("INSERT INTO lifecycle_operations VALUES (?,?,?,?,?,?)", (*scope, digest, event_id))
return event_id
def register(self, context: Attribution, memo: Memo, *, key: str) -> str:
def register(self, context: Attribution, memo: Memo, *, key: str, guard=None) -> str:
"""Register a complete, unbound version-one memo without creating approval."""
if memo.version != 1 or memo.sealed or memo.approval_id is not None:
raise ValueError("registration requires an unbound version-one memo")
scope, digest = self._request(context, "register", key, memo)
with self.store._transaction() as db:
policy_id = guard(db) if guard is not None else None
replay = self._replay(db, scope, digest)
if replay:
return replay
@ -113,10 +116,10 @@ class LifecycleRepository:
raise Conflict("memo identifier already exists")
self.store._save_memo(db, memo)
db.execute("INSERT INTO lifecycle_records VALUES (?,?,?)", (memo.id, context.tenant, _now()))
return self._append(db, scope, digest, memo, "registered", {"memo_version": memo.version})
return self._append(db, scope, digest, memo, "registered", {"memo_version": memo.version}, policy_id)
def revise(self, context: Attribution, memo: Memo, *, expected_version: int,
summary: str, key: str) -> str:
summary: str, key: str, guard=None) -> str:
"""Compare-and-append; retain existing Store guards on outstanding entries."""
if type(expected_version) is not int or expected_version < 1:
raise ValueError("expected version must be a positive integer")
@ -127,6 +130,7 @@ class LifecycleRepository:
scope, digest = self._request(context, "revise", key,
{"memo": memo, "expected_version": expected_version, "summary": summary})
with self.store._transaction() as db:
policy_id = guard(db) if guard is not None else None
self._record(db, context.tenant, memo.id)
replay = self._replay(db, scope, digest)
if replay:
@ -137,10 +141,10 @@ class LifecycleRepository:
if current.sealed or current.approval_id is not None:
raise Conflict("bound memo requires the review integration path")
self.store._save_memo(db, memo)
return self._append(db, scope, digest, memo, "revised", {"summary": summary})
return self._append(db, scope, digest, memo, "revised", {"summary": summary}, policy_id)
def annotate(self, context: Attribution, memo_id: str, version: int, *, kind: str,
body: dict, key: str) -> str:
body: dict, key: str, guard=None) -> str:
"""Append a source, advisory evaluation or discussion; never a disposition."""
self._validate_annotation(kind, body)
if type(version) is not int or version < 1:
@ -148,6 +152,7 @@ class LifecycleRepository:
scope, digest = self._request(context, kind, key,
{"memo_id": memo_id, "version": version, "body": body})
with self.store._transaction() as db:
policy_id = guard(db) if guard is not None else None
self._record(db, context.tenant, memo_id)
replay = self._replay(db, scope, digest)
if replay:
@ -159,7 +164,7 @@ class LifecycleRepository:
"AND memo_id=? AND version=? AND kind='source'",
(source, context.tenant, memo_id, version)).fetchone():
raise EvidenceUnavailable("evaluation source unavailable for this revision")
return self._append(db, scope, digest, memo, kind, body)
return self._append(db, scope, digest, memo, kind, body, policy_id)
@staticmethod
def _validate_annotation(kind, body):

View file

@ -0,0 +1,142 @@
"""Headless application service consuming fresh, exact external policy decisions.
Identity adapters supply Caller after authentication; no request body can mint
one. Runtime composition and lifecycle policy admission remain separate work.
"""
from dataclasses import dataclass
import hashlib
import json
import math
import time
import uuid
from .disposition import Actor, ActorKind
from .lifecycle import Attribution, LifecycleRepository
from .oidc import HumanSession
from .policy import PolicyError
from .provenance import Route
from .records import dumps, memo_from
from .store import _text
@dataclass(frozen=True)
class Caller:
attribution: Attribution
expires_at: float
attributes_json: str
def __post_init__(self):
if (type(self.expires_at) not in (int, float) or not math.isfinite(self.expires_at)
or not isinstance(self.attributes_json, str) or len(self.attributes_json) > 16384):
raise ValueError("invalid authenticated caller")
attributes = json.loads(self.attributes_json)
if not isinstance(attributes, dict):
raise ValueError("invalid caller attributes")
@classmethod
def from_human_session(cls, session: HumanSession):
if (not isinstance(session, HumanSession)
or session.principal_type.value != "human"
or session.principal_type.route is not Route.AUTHENTICATION
or session.tenant.route not in (Route.DIRECTORY, Route.REGISTRATION)):
raise ValueError("verified human identity required")
return cls(Attribution(session.tenant.value, Actor(session.subject, ActorKind.PERSON)),
session.expires_at, dumps({
"tenant_source": session.tenant.route.value,
"principal_type_source": session.principal_type.route.value,
"assurance": session.assurance,
"roles": list(session.roles), "groups": list(session.groups)}))
class LifecycleError(RuntimeError):
def __init__(self, status, code):
self.status, self.code = status, code
super().__init__(code)
class LifecycleService:
def __init__(self, repository: LifecycleRepository, policy, *, clock=time.time):
self.repository, self.policy, self.clock = repository, policy, clock
def _caller(self, caller):
if not isinstance(caller, Caller) or caller.expires_at <= self.clock():
raise LifecycleError(401, "identity_expired")
def _authorize(self, caller, memo_id, operation, payload):
self._caller(caller)
_text(memo_id, "memo id")
actor = caller.attribution.actor
tenant = caller.attribution.tenant
command = dumps(payload).encode()
if len(command) > 256 * 1024:
raise ValueError("lifecycle command too large")
request = {"id": str(uuid.uuid4()), "tenant": tenant,
"subject": {"id": actor.sub, "type": "human" if actor.kind is ActorKind.PERSON else actor.kind.value,
"tenant": tenant, "attributes": json.loads(caller.attributes_json)},
"action": "lifecycle." + operation,
"resource": {"id": "memo:" + memo_id, "type": "decision-memo",
"system": "informed-decision", "tenant": tenant},
"context": {"contract": "informed-decision.lifecycle.v1",
"memo_version": payload.get("memo").version if payload.get("memo") is not None else payload.get("version"),
"command_digest": "sha256:" + hashlib.sha256(command).hexdigest()},
"policy_version": self.policy.version}
observation = self.policy.check(request)
# Defend the application boundary even with an incorrectly composed client.
if json.loads(observation.request_json) != request:
raise LifecycleError(503, "policy_request_mismatch")
policy_id = self.repository.store.observe_policy(observation)
def current():
self._caller(caller)
try:
observation.require_current(self.clock())
except PolicyError:
raise LifecycleError(403 if observation.outcome == "policy_denied" else 503,
observation.outcome if observation.outcome != "allow" else "decision_expired") from None
def guard(db):
current()
recorded = db.execute("SELECT request,outcome,usable_until FROM policy_observations WHERE id=?",
(policy_id,)).fetchone()
if (recorded is None or recorded["request"] != observation.request_json
or recorded["outcome"] != "allow" or recorded["usable_until"] <= self.clock()):
raise LifecycleError(503, "policy_observation_unavailable")
return policy_id
current()
return current, guard
def register(self, caller, memo, *, key):
# Snapshot before the PDP call: mutable caller-owned objects cannot change
# the command between authorization and persistence.
memo = memo_from(json.loads(dumps(memo)))
_, guard = self._authorize(caller, memo.id, "register", {"memo": memo, "key": key})
return self.repository.register(caller.attribution, memo, key=key, guard=guard)
def revise(self, caller, memo, *, expected_version, summary, key):
memo = memo_from(json.loads(dumps(memo)))
_, guard = self._authorize(caller, memo.id, "revise", {
"memo": memo, "expected_version": expected_version, "summary": summary, "key": key})
return self.repository.revise(caller.attribution, memo, expected_version=expected_version,
summary=summary, key=key, guard=guard)
def annotate(self, caller, memo_id, version, *, kind, body, key):
body = json.loads(dumps(body))
self.repository._validate_annotation(kind, body)
_, guard = self._authorize(caller, memo_id, kind, {
"memo_id": memo_id, "version": version, "body": body, "key": key})
return self.repository.annotate(caller.attribution, memo_id, version, kind=kind,
body=body, key=key, guard=guard)
def retrieve(self, caller, memo_id, version=None):
current, _ = self._authorize(caller, memo_id, "read", {"version": version})
result = self.repository.retrieve(caller.attribution.tenant, memo_id, version)
current()
return result
def events(self, caller, memo_id, *, after=0, limit=100):
current, _ = self._authorize(caller, memo_id, "events", {"after": after, "limit": limit})
result = self.repository.events(caller.attribution.tenant, memo_id, after=after, limit=limit)
current()
return result