Implement lifecycle workspace publication and audit delivery

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-04 20:17:24 +02:00
parent ee472acc87
commit 5fec7de4cb
21 changed files with 1022 additions and 78 deletions

View file

@ -8,11 +8,13 @@ from dataclasses import dataclass
import hashlib
import json
import math
import secrets
import threading
import time
import uuid
from .disposition import Actor, ActorKind
from .lifecycle import Attribution, LifecycleRepository
from .lifecycle import Attribution, LifecycleRepository, publication_audience
from .oidc import HumanSession
from .policy import PolicyError
from .provenance import Route
@ -58,6 +60,8 @@ class LifecycleError(RuntimeError):
class LifecycleService:
def __init__(self, repository: LifecycleRepository, policy, *, clock=time.time):
self.repository, self.policy, self.clock = repository, policy, clock
self._cursors = {}
self._cursor_lock = threading.Lock()
def _caller(self, caller):
if not isinstance(caller, Caller) or caller.expires_at <= self.clock():
@ -71,14 +75,21 @@ class LifecycleService:
command = dumps(payload).encode()
if len(command) > 256 * 1024:
raise ValueError("lifecycle command too large")
version = payload.get("expected_version", payload.get("memo").version
if payload.get("memo") is not None else payload.get("version"))
state = (self.repository.policy_context(tenant, memo_id, version)
if operation != "search" else {"registered": False})
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",
"resource": {"id": "decisions" if operation == "search" else "memo:" + memo_id,
"type": "decision-collection" if operation == "search" else "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"),
"record": state,
**({"publication_audience": payload["audience"]} if operation == "publish" else {}),
"command_digest": "sha256:" + hashlib.sha256(command).hexdigest()},
"policy_version": self.policy.version}
observation = self.policy.check(request)
@ -102,10 +113,17 @@ class LifecycleService:
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")
if self.repository._policy_context(db, tenant, memo_id, version) != state:
raise LifecycleError(409, "record_changed")
return policy_id
def read_current():
current()
if operation != "search" and self.repository.policy_context(tenant, memo_id, version) != state:
raise LifecycleError(409, "record_changed")
current()
return current, guard
return read_current, guard
def register(self, caller, memo, *, key, documents=None):
# Snapshot before the PDP call: mutable caller-owned objects cannot change
@ -137,6 +155,56 @@ class LifecycleService:
current()
return result
def publish(self, caller, memo_id, version, *, audience, key):
audience = publication_audience(audience)
_, guard = self._authorize(caller, memo_id, "publish",
{"version": version, "audience": audience, "key": key})
return self.repository.publish(caller.attribution, memo_id, version, audience=audience, key=key, guard=guard)
def packet(self, caller, memo_id, version, index):
current, _ = self._authorize(caller, memo_id, "packet", {"version": version, "index": index})
result = self.repository.packet(caller.attribution.tenant, memo_id, version, index)
current()
return result
def search(self, caller, *, query="", cursor=None):
self._caller(caller)
if not isinstance(query, str) or len(query) > 200:
raise ValueError("invalid search query")
scope = (caller.attribution.tenant, dumps(caller.attribution.actor), query)
after = ""
with self._cursor_lock:
self._cursors = {key: row for key, row in self._cursors.items() if row[2] > self.clock()}
if cursor is not None:
row = self._cursors.get(cursor) if isinstance(cursor, str) else None
if row is None or row[0] != scope:
raise ValueError("invalid search cursor")
after = row[1]
current, _ = self._authorize(caller, "decisions", "search", {"query": query, "after": after})
candidates = self.repository.candidates(caller.attribution.tenant, query=query, after=after, limit=50)
results, checks = [], [current]
for memo_id in candidates:
try:
check, _ = self._authorize(caller, memo_id, "read", {"version": None})
except LifecycleError as error:
if error.status == 403:
continue
raise
memo = self.repository.retrieve(caller.attribution.tenant, memo_id)
check()
checks.append(check)
results.append({"memo_id": memo.id, "version": memo.version, "question": memo.question})
for check in checks:
check()
next_cursor = None
if len(candidates) == 50:
with self._cursor_lock:
if len(self._cursors) >= 1000:
raise LifecycleError(503, "search_capacity")
next_cursor = secrets.token_urlsafe(24)
self._cursors[next_cursor] = (scope, candidates[-1], self.clock() + 300)
return {"decisions": results, "next_cursor": next_cursor}
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)