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

@ -39,6 +39,7 @@ class EventClass(str, Enum):
DISPOSITION = "informed-decision.disposition"
STANCE_APPLICATION = "informed-decision.stance_application"
MEMO_POSED = "informed-decision.memo.posed"
LIFECYCLE = "informed-decision.lifecycle"
HEARTBEAT = "audit-core.heartbeat"

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

View file

@ -11,11 +11,12 @@ from urllib.parse import parse_qs
from .lifecycle_service import Caller, LifecycleError
from .lifecycle_commands import decode_memo
from .oidc import ORIGIN
from .records import dumps
from .store import Conflict, EvidenceUnavailable
MAX_BODY = 256 * 1024
ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations|revisions))?")
ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations|revisions|publications))?")
def _object(pairs):
@ -77,6 +78,11 @@ class LifecycleAPI:
params = parse_qs(query, keep_blank_values=True, strict_parsing=True, max_num_fields=4)
if any(len(value) != 1 for value in params.values()):
raise ValueError()
if method == "GET" and registration:
if set(params) - {"q", "cursor"}:
raise ValueError()
return 200, self.service.search(caller, query=params.get("q", [""])[0],
cursor=params.get("cursor", [None])[0])
if method == "GET" and resource is None:
if set(params) - {"version"}:
raise ValueError()
@ -90,7 +96,7 @@ class LifecycleAPI:
return 200, {"events": self.service.events(caller, memo_id,
after=int(params.get("after", ["0"])[0]), limit=int(params.get("limit", ["100"])[0]))}
kinds = {"comments": "comment", "sources": "source", "evaluations": "evaluation"}
if method != "POST" or resource not in {*kinds, "register", "revisions"}:
if method != "POST" or resource not in {*kinds, "register", "revisions", "publications"}:
raise LifecycleError(405, "method_not_allowed")
if params:
raise ValueError()
@ -109,6 +115,11 @@ class LifecycleAPI:
key = environ.get("HTTP_IDEMPOTENCY_KEY", "")
if not isinstance(key, str) or not key.strip() or len(key) > 256:
raise ValueError()
if resource == "publications":
if not isinstance(data, dict) or set(data) != {"version", "audience"}:
raise ValueError()
event_id = self.service.publish(caller, memo_id, data["version"], audience=data["audience"], key=key)
return 200, {"event_id": event_id, "memo_id": memo_id, "version": data["version"]}
if resource in {"register", "revisions"}:
required = {"memo"} if registration else {"memo", "expected_version", "summary"}
if (not isinstance(data, dict) or not required <= data.keys()
@ -124,7 +135,8 @@ class LifecycleAPI:
event_id = self.service.revise(caller, memo, key=key,
expected_version=data["expected_version"], summary=data["summary"], documents=documents)
return 200, {"event_id": event_id, "memo_id": memo.id, "version": memo.version,
"resource": f"/v1/decisions/{memo.id}?version={memo.version}"}
"resource": f"/v1/decisions/{memo.id}?version={memo.version}",
"workspace_url": f"{ORIGIN}/decisions/{memo.id}?version={memo.version}"}
if not isinstance(data, dict) or set(data) != {"version", "body"}:
raise ValueError()
event_id = self.service.annotate(caller, memo_id, data["version"], kind=kinds[resource],

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)

View file

@ -0,0 +1,183 @@
"""Human workspace adapter: forms and rendering, with no storage or policy rules."""
from dataclasses import replace
import re
from urllib.parse import urlencode
import uuid
from .lifecycle_commands import decode_memo
from .lifecycle_service import Caller, LifecycleError
from .memo import Memo, BindingLevel, BindingSlice, Principal, Scope, StepKind
from .records import dumps
from .store import EvidenceUnavailable
from .ui import document, hidden, text
import json
def _form(action, session, fields, button):
return (f'<form method="post" action="{text(action)}">'
+ hidden("csrf", session.csrf) + hidden("key", str(uuid.uuid4()))
+ fields + f'<button type="submit">{text(button)}</button></form>')
def _textarea(name, label, value=""):
return f'<label>{text(label)}<textarea name="{text(name)}" required>{text(value)}</textarea></label>'
def _lines(value):
return [line.strip() for line in value.splitlines() if line.strip()]
class LifecycleWorkspace:
def __init__(self, service):
self.service = service
def dispatch(self, method, path, params, session, form=None):
caller = Caller.from_human_session(session)
self.service._caller(caller)
if method == "GET" and path == "/decisions":
if set(params) - {"q", "cursor"}:
raise ValueError()
query = params.get("q", "")
page = self.service.search(caller, query=query, cursor=params.get("cursor"))
content = ('<h1>Decision workspace</h1><p>Prepare a decision, examine evidence, and invite discussion.</p>'
'<form method="get" action="/decisions"><label>Search decisions '
f'<input name="q" value="{text(query)}" maxlength="200"></label><button>Search</button></form>'
'<p><a href="/decisions/new/prepare">Prepare a decision</a></p><ul>')
for row in page["decisions"]:
content += (f'<li><a href="/decisions/{text(row["memo_id"])}">{text(row["question"])}</a>'
f' · revision {row["version"]}</li>')
content += '</ul>'
if not page["decisions"]:
content += '<p>No decisions to display on this page.</p>'
if page["next_cursor"]:
content += f'<p><a href="/decisions?{text(urlencode({"q": query, "cursor": page["next_cursor"]}))}">Next page</a></p>'
return 200, document("Decision workspace", content, session.subject), []
if path == "/decisions/new/prepare":
if params:
raise ValueError()
if method == "GET":
fields = (_textarea("question", "Question") + _textarea("brief", "Context, alternatives and recommendation")
+ '<label>Subject or scope identifier <input name="scope_id" required maxlength="200"></label>'
'<label>Subject or scope label <input name="scope_label" required maxlength="200"></label>')
content = '<h1>Prepare a decision</h1>' + _form(path, session, fields, "Save draft")
return 200, document("Prepare a decision", content, session.subject), []
if method == "POST":
self._fields(form, {"question", "brief", "scope_id", "scope_label"})
memo = Memo(id=str(uuid.uuid4()), version=1, question=form["question"], brief=form["brief"],
requested_act="accept", binding_level=BindingLevel.ORGANIZATIONAL,
step_kind=StepKind.MITZEICHNUNG,
binding=BindingSlice(Principal("pending-human-session", "person", "To be assigned"),
Scope("resource", form["scope_id"], form["scope_label"])))
# Stable create identity across an HTTP retry of the same rendered form.
memo = replace(memo, id=str(uuid.uuid5(uuid.NAMESPACE_URL,
dumps([caller.attribution.tenant, session.subject, form["key"]]))))
self.service.register(caller, decode_memo(json.loads(dumps(memo))), key=form["key"])
return 303, "", [("Location", "/decisions/" + memo.id)]
match = re.fullmatch(r"/decisions/([A-Za-z0-9._:-]{1,256})(?:/(revise|comment|publish|source|evaluate|packet/([0-9]{1,3})))?", path)
if not match:
return 404, "Not found.", []
memo_id, action, index = match.groups()
if method == "GET" and (action is None or index is not None):
if set(params) - {"version", "after"}:
raise ValueError()
version = int(params["version"]) if "version" in params else None
if index is not None:
if version is None or "after" in params:
raise ValueError()
return 200, self.service.packet(caller, memo_id, version, int(index)), [
("Content-Disposition", f'attachment; filename="decision-document-{int(index)+1}.bin"')]
memo = self.service.retrieve(caller, memo_id, version)
events = self.service.events(caller, memo_id, after=int(params.get("after", "0")))
previous = None
if memo.version > 1:
try:
previous = self.service.retrieve(caller, memo_id, memo.version - 1)
except EvidenceUnavailable:
pass
except LifecycleError as error:
if error.status != 403:
raise
return 200, self._page(memo, events, session, previous), []
if method == "POST" and action in {"revise", "comment", "publish", "source", "evaluate"}:
if params:
raise ValueError()
fields = {"revise": {"version", "question", "brief", "summary"},
"comment": {"version", "text"}, "publish": {"version", "subjects", "groups"},
"source": {"version", "reference", "retrieved_at", "digest", "summary"},
"evaluate": {"version", "assessment", "source_ids", "uncertainty"}}[action]
self._fields(form, fields)
version = int(form["version"])
if action == "revise":
previous = self.service.retrieve(caller, memo_id, version)
memo = previous.next_version(question=form["question"], brief=form["brief"])
self.service.revise(caller, decode_memo(json.loads(dumps(memo))), expected_version=version,
summary=form["summary"], key=form["key"])
elif action == "publish":
self.service.publish(caller, memo_id, version, key=form["key"],
audience={"subjects": _lines(form["subjects"]), "groups": _lines(form["groups"])})
else:
body = {key: form[key] for key in fields - {"version"}}
if action == "evaluate":
body["source_ids"] = _lines(body["source_ids"])
self.service.annotate(caller, memo_id, version, key=form["key"],
kind="evaluation" if action == "evaluate" else action, body=body)
return 303, "", [("Location", "/decisions/" + memo_id)]
return 405, "Method not allowed.", []
@staticmethod
def _fields(form, required):
if not isinstance(form, dict) or set(form) != required | {"csrf", "key"}:
raise ValueError()
uuid.UUID(form["key"])
def _page(self, memo, events, session, previous=None):
base = "/decisions/" + memo.id
revision = hidden("version", memo.version)
content = (f'<p><a href="/decisions">Decision workspace</a></p><h1>{text(memo.question)}</h1>'
f'<p>Revision {memo.version} · {text(memo.binding.target.label)}</p>'
f'<section class="card"><h2>Context and recommendation</h2><p class="brief">{text(memo.brief)}</p></section>'
'<p>Human acceptance is not available for this draft. Publishing invites discussion; it does not record acceptance.</p>')
if previous is not None:
content += f'<details class="card"><summary>Compare with revision {previous.version}</summary>'
for field, label in (("question", "Question"), ("brief", "Context and recommendation")):
before, after = getattr(previous, field), getattr(memo, field)
if before != after:
content += (f'<h3>{label}</h3><p>Before</p><p class="brief">{text(before)}</p>'
f'<p>After</p><p class="brief">{text(after)}</p>')
content += '</details>'
if memo.packet:
content += '<section class="card"><h2>Supporting documents</h2><ul>'
for index, item in enumerate(memo.packet):
content += f'<li><a href="{text(base)}/packet/{index}?version={memo.version}">{text(item.label)}</a></li>'
content += '</ul></section>'
content += '<section class="card"><h2>Discussion and history</h2>'
for event in events:
body = {key: value for key, value in event["body"].items() if key != "policy_observation_id"}
content += (f'<article class="record"><h3>{text(event["kind"])} · revision {event["version"]}</h3>'
f'<p>{text(event["actor"]["sub"])} ({text(event["actor"]["kind"])}) · {text(event["at"])}</p>')
if event["kind"] == "source":
content += f'<p>Source reference: <code>{text(event["id"])}</code></p>'
for key, value in body.items():
content += f'<p><strong>{text(key.replace("_", " "))}:</strong> {text(dumps(value) if isinstance(value, (dict, list)) else value)}</p>'
content += '</article>'
if len(events) == 100:
content += f'<p><a href="{text(base)}?version={memo.version}&amp;after={events[-1]["sequence"]}">More history</a></p>'
content += '</section><section class="card"><h2>Add a comment</h2>'
content += _form(base + "/comment", session, revision + _textarea("text", "Comment"), "Add comment") + '</section>'
content += '<details class="card"><summary>Refine this decision</summary>'
content += _form(base + "/revise", session, revision + _textarea("question", "Question", memo.question)
+ _textarea("brief", "Context and recommendation", memo.brief)
+ _textarea("summary", "What changed"), "Save new revision") + '</details>'
content += '<details class="card"><summary>Publish for discussion</summary><p>Enter explicit member or group identifiers, one per line.</p>'
content += _form(base + "/publish", session, revision + '<label>Members<textarea name="subjects"></textarea></label>'
'<label>Groups<textarea name="groups"></textarea></label>', "Publish this revision") + '</details>'
content += '<details class="card"><summary>Add a research source</summary>'
content += _form(base + "/source", session, revision + ''.join(_textarea(name, label) for name, label in (
("reference", "Source location or reference"), ("retrieved_at", "Retrieved at (date, time and timezone)"),
("digest", "Source content digest (sha256:...)"), ("summary", "What the source supports"))), "Add source") + '</details>'
content += '<details class="card"><summary>Evaluate the evidence</summary>'
content += _form(base + "/evaluate", session, revision + _textarea("assessment", "Assessment")
+ '<label>Source references (one per line)<textarea name="source_ids"></textarea></label>'
+ _textarea("uncertainty", "Uncertainty and open questions"), "Add evaluation") + '</details>'
return document("Decision workspace", content, session.subject)

View file

@ -36,9 +36,10 @@ def token_file(path):
class AuditPump:
"""One process, bounded batches, 30s ticks; never drops a failed record."""
def __init__(self, store, sink, *, clock=time.time, intake_worker=None):
def __init__(self, store, sink, *, clock=time.time, intake_worker=None, lifecycle_delivery=False):
self.store, self.worker, self.clock = store, OutboxWorker(store, sink), clock
self.intake_worker = intake_worker
self.lifecycle_delivery = lifecycle_delivery
self._stop = threading.Event()
self._thread = None
self._lock = threading.Lock()
@ -57,10 +58,15 @@ class AuditPump:
try:
if self.intake_worker:
self.intake_worker.run_once(limit=10)
self.store.queue_heartbeats(include_posed=self.intake_worker is not None)
if self.lifecycle_delivery:
self.store.stage_lifecycle_audit()
self.store.queue_heartbeats(include_posed=self.intake_worker is not None,
include_lifecycle=self.lifecycle_delivery)
result = self.worker.run_once(limit=10)
if self.intake_worker:
self.store.settle_intake_audit()
if self.lifecycle_delivery:
self.store.settle_lifecycle_audit()
now = self.clock()
if now - self._last_reconciled >= 300:
stamp = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat(timespec="microseconds")
@ -112,6 +118,7 @@ class AuditPump:
class Runtime:
controller: ReviewController
pump: AuditPump
lifecycle: object | None = None
@classmethod
def from_file(cls, filename):
@ -122,11 +129,14 @@ class Runtime:
or info.st_size > 16384):
raise ValueError("runtime configuration must be an owned private 0600 file")
data = json.loads(path.read_text())
if ((set(data) - {"ordinary_approval_ids", "intake_delivery"}) != {"schema", "evidence_db", "policy", "approval_origin", "audit"}
if ((set(data) - {"ordinary_approval_ids", "intake_delivery", "lifecycle_policy", "lifecycle_delivery"}) != {"schema", "evidence_db", "policy", "approval_origin", "audit"}
or data["schema"] != "informed-decision.review-runtime.v1"
or set(data["policy"]) != {"origin", "package", "version", "package_digest", "caller_token_file"}
or set(data["audit"]) != {"origin", "sender_token_file"}):
raise ValueError("invalid review runtime configuration")
lifecycle_delivery = data.get("lifecycle_delivery", False)
if type(lifecycle_delivery) is not bool:
raise ValueError("invalid lifecycle delivery setting")
ordinary = ordinary_review_ids(data.get("ordinary_approval_ids", []))
if not Path(data["evidence_db"]).is_absolute():
raise ValueError("absolute evidence database path required")
@ -145,6 +155,17 @@ class Runtime:
if not isinstance(delivery, dict) or set(delivery) != {'hub_origin'}:
raise ValueError('invalid intake delivery configuration')
hub = HubDelivery(delivery['hub_origin'])
lifecycle_policy = None
if "lifecycle_policy" in data:
configuration = data["lifecycle_policy"]
if (not isinstance(configuration, dict)
or set(configuration) != {"origin", "package", "version", "package_digest", "caller_token_file"}):
raise ValueError("invalid lifecycle policy configuration")
if configuration["package"] == policy["package"]:
raise ValueError("lifecycle policy must have a separate admission package")
lifecycle_policy = PolicyClient(configuration["origin"], token_file(configuration["caller_token_file"]),
package=configuration["package"], version=configuration["version"],
package_digest=configuration["package_digest"], allow_internal_http=True)
store = Store(data["evidence_db"])
controller = ReviewController(store, client, lambda session: ApprovalHTTPClient(
approval_origin, session, allow_internal_http=True, ordinary_approval_ids=tuple(ordinary)))
@ -153,4 +174,10 @@ class Runtime:
from .intake_delivery import IntakeDeliveryWorker
worker = IntakeDeliveryWorker(store, audit=sink, notify=hub.notify,
hub_transport=hub.resolution, hub_base_url=hub.origin)
return cls(controller, AuditPump(store, sink, intake_worker=worker))
lifecycle = None
if lifecycle_policy is not None:
from .lifecycle import LifecycleRepository
from .lifecycle_service import LifecycleService
lifecycle = LifecycleService(LifecycleRepository(store), lifecycle_policy)
return cls(controller, AuditPump(store, sink, intake_worker=worker,
lifecycle_delivery=lifecycle_delivery), lifecycle)

View file

@ -20,7 +20,7 @@ import uuid
from .approval_client import EntryResult
from .disposition import Actor, BINDING_VERBS, DispositionRefused, Verb, record
from .evidence import EventClass, commit_disposition, commit_presentation, commit_stance_application, heartbeat, HEARTBEAT_CLASSES
from .evidence import Commitment, EventClass, commit_disposition, commit_presentation, commit_stance_application, heartbeat, HEARTBEAT_CLASSES
from .memo import Memo
from .presentation import render
from .provenance import Claim, Route, assert_human_control_dischargeable
@ -556,18 +556,50 @@ class Store:
"(SELECT id FROM outbox WHERE state='delivered')")
return result.rowcount
def stage_lifecycle_audit(self):
"""Opt-in commitment-only delivery through the existing audit outbox."""
with self._transaction() as db:
rows = db.execute("SELECT l.* FROM lifecycle_pending p JOIN lifecycle_events l ON l.id=p.event_id "
"ORDER BY l.sequence LIMIT 100").fetchall()
staged = 0
for row in rows:
if row["tenant"] != "tenant:platform":
raise Conflict("lifecycle audit tenant not admitted by this runtime")
content = {"event": dict(row), "memo": self._memo(db, row["memo_id"], row["version"])}
existing = db.execute("SELECT class,content FROM evidence WHERE id=?", (row["id"],)).fetchone()
if existing:
if existing["class"] != EventClass.LIFECYCLE.value or existing["content"] != dumps(content):
raise Conflict("lifecycle evidence id collision")
continue
commitment = Commitment(row["id"], EventClass.LIFECYCLE, row["at"], {
"memo_id": row["memo_id"], "memo_version": row["version"], "operation": row["kind"],
"content_sha256": "sha256:" + hashlib.sha256(dumps(content).encode()).hexdigest(),
"content_exists": True, "custody": "informed-decision:lifecycle:" + row["id"]})
self._event(db, commitment, content)
staged += 1
return staged
def settle_lifecycle_audit(self):
with self._transaction() as db:
return db.execute("DELETE FROM lifecycle_pending WHERE event_id IN "
"(SELECT o.id FROM outbox o JOIN evidence e ON e.id=o.id "
"WHERE o.state='delivered' AND o.receiver_reference IS NOT NULL AND e.class=?)",
(EventClass.LIFECYCLE.value,)).rowcount
def intake_audit_pending(self):
with self._connection() as db:
return db.execute("SELECT 1 FROM intake_deliveries WHERE kind='memo-posed' "
"AND state!='delivered' LIMIT 1").fetchone() is not None
def queue_heartbeats(self, now=None, *, include_posed=False):
def queue_heartbeats(self, now=None, *, include_posed=False, include_lifecycle=False):
now = time.time() if now is None else now
count = 0
with self._transaction() as db:
classes = {**HEARTBEAT_CLASSES}
if include_posed:
classes[EventClass.MEMO_POSED.value] = 86400
if include_lifecycle:
classes[EventClass.LIFECYCLE.value] = 86400
for event_class, interval in classes.items():
if db.execute("SELECT 1 FROM outbox o JOIN evidence e ON e.id=o.id WHERE o.state!='delivered' "
"AND (e.class=? OR (e.class=? AND json_extract(e.envelope,'$.data.class')=?)) LIMIT 1",

View file

@ -17,6 +17,7 @@ from .disposition import DispositionRefused, Verb
from .policy import PolicyError
from .provenance import HumanControlNotDischargeable
from .review import ReviewError
from .lifecycle_service import LifecycleError
from .store import Conflict, EvidenceUnavailable, StoreError
from .ui import STYLES, document, error_page, overview_section, review_page
@ -38,9 +39,10 @@ def _one(query: str, *, limit=8192, fields=16) -> dict[str, str]:
class App:
def __init__(self, login: KeyCapeLogin, review=None, *, readiness=lambda: False):
def __init__(self, login: KeyCapeLogin, review=None, *, readiness=lambda: False, lifecycle=None):
self.login = login
self.review, self.readiness = review, readiness
self.lifecycle = lifecycle
def __call__(self, environ, start_response):
path = environ.get("PATH_INFO", "/")
@ -84,6 +86,11 @@ class App:
}
status, body, content_type = exc.status, error_page(messages.get(exc.code,
"The review could not complete its required checks. Reload after the service is available.")), "text/html"
except LifecycleError as exc:
status, body, content_type = exc.status, document("Decision unavailable",
'<h1>Decision unavailable</h1><p>The request could not complete its required checks. '
'Sign in again if your session expired, or reload the decision before retrying.</p>'
'<p><a href="/decisions">Return to the workspace</a></p>'), "text/html"
except DispositionRefused as exc:
status, body, content_type = 409, error_page({
"G_ACK": "Record all required highlight acknowledgments before accepting or declining.",
@ -125,6 +132,14 @@ class App:
return 303, "", "text/plain", [("Location", "/"), _cookie(FLOW_COOKIE, "", 0),
_cookie(SESSION_COOKIE, new_sid, 900)]
session = self.login.session(sid)
if self.lifecycle is not None and (path == "/decisions" or path.startswith("/decisions/")):
if session is None:
raise LifecycleError(401, "identity_expired")
from .lifecycle_ui import LifecycleWorkspace
params = _one(environ.get("QUERY_STRING", ""))
form = self._form(environ, session) if method == "POST" else None
status, body, extra = LifecycleWorkspace(self.lifecycle).dispatch(method, path, params, session, form)
return status, body, "application/octet-stream" if isinstance(body, bytes) else "text/html", extra
if self.review is not None and (path == "/review" or path.startswith("/presentations/")):
if session is None:
raise ReviewError(401, "session_expired")
@ -188,6 +203,7 @@ class App:
if method == "GET" and path == "/":
if session:
content = (f"<p>Signed in as {html.escape(session.subject)}.</p>"
+ ('<p><a href="/decisions">Open the decision workspace</a></p>' if self.lifecycle is not None else '')
+ (self._overview(session) if self.review is not None else '')
+ ('<h2>Open a decision review</h2><p>Enter the memo identifier supplied with your review request.</p>'
'<form method="get" action="/review"><label>Memo identifier <input name="memo_id" required maxlength="256"></label>'
@ -244,7 +260,8 @@ def main():
runtime = Runtime.from_file(os.environ["INFD_REVIEW_CONFIG"])
runtime.pump.start()
app = App(login, runtime.controller if runtime else None,
readiness=runtime.pump.ready if runtime else lambda: False)
readiness=runtime.pump.ready if runtime else lambda: False,
lifecycle=runtime.lifecycle if runtime else None)
try:
serve(app, host=host, port=8080, threads=4)
finally: