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

@ -184,6 +184,54 @@ class LifecycleRepository:
raise EvidenceUnavailable("evaluation source unavailable for this revision")
return self._append(db, scope, digest, memo, kind, body, policy_id)
def publish(self, context, memo_id, version, *, audience, key, guard=None):
audience = publication_audience(audience)
if type(version) is not int or version < 1:
raise ValueError("positive revision required")
scope, digest = self._request(context, "publish", key,
{"memo_id": memo_id, "version": version, "audience": audience})
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:
return replay
memo = self.store._memo(db, memo_id)
if memo.version != version:
raise Conflict("publish requires the current revision")
return self._append(db, scope, digest, memo, "published", {"audience": audience}, policy_id)
def policy_context(self, tenant, memo_id, version=None):
with self.store._connection() as db:
return self._policy_context(db, tenant, memo_id, version)
@staticmethod
def _policy_context(db, tenant, memo_id, version=None):
row = db.execute("SELECT e.actor, (SELECT MAX(version) FROM memos WHERE id=l.memo_id) AS current_version "
"FROM lifecycle_records l JOIN lifecycle_events e ON e.memo_id=l.memo_id "
"WHERE l.tenant=? AND l.memo_id=? AND e.kind='registered'", (tenant, memo_id)).fetchone()
if row is None:
return {"registered": False}
published = db.execute("SELECT body FROM lifecycle_events WHERE tenant=? AND memo_id=? "
"AND version=? AND kind='published' ORDER BY sequence DESC LIMIT 1",
(tenant, memo_id, version if version is not None else row["current_version"])).fetchone()
return {"registered": True, "registered_by": json.loads(row["actor"]),
"current_version": row["current_version"],
"published_audience": json.loads(published["body"])["audience"] if published else None}
def candidates(self, tenant, *, query="", after="", limit=100):
"""Internal bounded search candidates; callers must authorize each result."""
if (not isinstance(query, str) or len(query) > 200 or not isinstance(after, str)
or len(after) > 256 or type(limit) is not int or not 1 <= limit <= 100):
raise ValueError("invalid search parameters")
with self.store._connection() as db:
return [row[0] for row in db.execute(
"SELECT l.memo_id FROM lifecycle_records l JOIN memos m ON m.id=l.memo_id "
"WHERE l.tenant=? AND l.memo_id>? AND m.version=(SELECT MAX(version) FROM memos WHERE id=l.memo_id) "
"AND (instr(lower(l.memo_id),lower(?))>0 OR instr(lower(json_extract(m.body,'$.question')),lower(?))>0 "
"OR instr(lower(json_extract(m.body,'$.brief')),lower(?))>0) ORDER BY l.memo_id LIMIT ?",
(tenant, after, query, query, query, limit))]
@staticmethod
def _validate_annotation(kind, body):
fields = {
@ -221,6 +269,19 @@ class LifecycleRepository:
self._record(db, tenant, memo_id)
return self.store._memo(db, memo_id, version)
def packet(self, tenant, memo_id, version, index):
if type(version) is not int or version < 1 or type(index) is not int or index < 0:
raise ValueError("invalid packet reference")
with self.store._connection() as db:
self._record(db, tenant, memo_id)
memo = self.store._memo(db, memo_id, version)
if index >= len(memo.packet):
raise EvidenceUnavailable("packet unavailable")
row = db.execute("SELECT content FROM documents WHERE digest=?", (memo.packet[index].hash,)).fetchone()
if row is None:
raise EvidenceUnavailable("packet unavailable")
return bytes(row[0])
def events(self, tenant: str, memo_id: str, *, after: int = 0, limit: int = 100) -> list[dict]:
if type(after) is not int or after < 0 or type(limit) is not int or not 1 <= limit <= 100:
raise ValueError("invalid event page")
@ -231,3 +292,20 @@ class LifecycleRepository:
(tenant, memo_id, after, limit)).fetchall()
return [{**dict(row), "actor": json.loads(row["actor"]), "body": json.loads(row["body"])}
for row in rows]
def publication_audience(value):
if not isinstance(value, dict) or set(value) != {"subjects", "groups"}:
raise ValueError("explicit publication audience required")
result = {}
for name, items in value.items():
if not isinstance(items, list) or len(items) > 32:
raise ValueError("invalid publication audience")
for item in items:
_text(item, "audience member")
if not item.strip() or item == "*":
raise ValueError("explicit audience member required")
result[name] = sorted(set(items))
if not any(result.values()):
raise ValueError("publication audience must not be empty")
return result