Connect lifecycle revisions to protected human review

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-04 20:33:24 +02:00
parent 0e79d69e27
commit f3b71b1521
16 changed files with 649 additions and 23 deletions

View file

@ -15,6 +15,7 @@ import uuid
from .intake import IntakeRefused, SubmitterGrant, canonical, idempotency_outcome, validate_submission
from .memo import Principal
from .records import dumps
from .store import Conflict
AUDIENCE = "informed-decision-intake"
MAX_BODY = 1024 * 1024
@ -104,6 +105,8 @@ class IntakeService:
recovered = None
# Reserve before any network side effect. SQLite serializes concurrent poses.
with self.store._transaction() as db:
from .lifecycle_review import check_intake
check_intake(db, envelope, submission.submitter_id)
previous = db.execute("SELECT * FROM intake_ledger WHERE submitter=? AND key=?",
(submission.submitter_id, submission.idempotency_key)).fetchone()
if previous:
@ -154,6 +157,7 @@ class IntakeService:
binding=replace(submission.memo.binding, principal=Principal(
recipient["subject"], "person", recipient["display_name"])))
with self.store._transaction() as db:
check_intake(db, envelope, submission.submitter_id)
current = db.execute("SELECT state,receipt FROM intake_ledger WHERE submitter=? AND key=?",
(submission.submitter_id, submission.idempotency_key)).fetchone()
if current["state"] == "ready":
@ -203,6 +207,8 @@ class IntakeService:
status, body = self.pose(authorization[7:], json.loads(raw))
except IntakeError as exc:
status, body = exc.status, {"error": exc.code}
except Conflict:
status, body = 409, {"error": "review_handoff_conflict"}
except IntakeRefused as exc:
status = 409 if exc.reason == "idempotency_conflict" else 400
body = {"error": exc.reason} # No client-controlled field paths in logs/responses.

View file

@ -16,7 +16,7 @@ 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|publications))?")
ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations|revisions|publications|review-requests|outcomes))?")
def _object(pairs):
@ -95,8 +95,12 @@ class LifecycleAPI:
raise ValueError()
return 200, {"events": self.service.events(caller, memo_id,
after=int(params.get("after", ["0"])[0]), limit=int(params.get("limit", ["100"])[0]))}
if method == "GET" and resource == "outcomes":
if set(params) != {"version"}:
raise ValueError()
return 200, self.service.outcomes(caller, memo_id, int(params["version"][0]))
kinds = {"comments": "comment", "sources": "source", "evaluations": "evaluation"}
if method != "POST" or resource not in {*kinds, "register", "revisions", "publications"}:
if method != "POST" or resource not in {*kinds, "register", "revisions", "publications", "review-requests"}:
raise LifecycleError(405, "method_not_allowed")
if params:
raise ValueError()
@ -115,6 +119,10 @@ 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 == "review-requests":
if not isinstance(data, dict) or set(data) != {"version", "options"}:
raise ValueError()
return 200, self.service.request_review(caller, memo_id, data["version"], options=data["options"], key=key)
if resource == "publications":
if not isinstance(data, dict) or set(data) != {"version", "audience"}:
raise ValueError()

View file

@ -0,0 +1,114 @@
"""Durable revision-to-intake handoff; never an approval state machine."""
from dataclasses import replace
import json
import uuid
from .intake import KIND, MEMO_KEYS, SubmitterGrant, validate_submission
from .memo import Principal, BindingLevel, StepKind
from .review import UI_RELEASE
from .records import dumps, memo_from
from .store import Conflict, EvidenceUnavailable
def review_request(db, review_id):
row = db.execute("SELECT * FROM lifecycle_events WHERE kind='review_requested' "
"AND json_extract(body,'$.review_memo_id')=?", (review_id,)).fetchone()
return row
def require_current(db, review_id):
row = review_request(db, review_id)
if row is not None:
current = db.execute('SELECT MAX(version) FROM memos WHERE id=?', (row['memo_id'],)).fetchone()[0]
if current != row['version']:
raise Conflict('review source revision changed')
return row
def check_intake(db, envelope, submitter):
row = require_current(db, envelope['memo']['id'])
if row is not None:
body = json.loads(row['body'])
if row['tenant'] != 'tenant:platform' or body['submitter'] != submitter or body['envelope'] != envelope:
raise Conflict('review handoff does not match the retained request')
def prepare(repository, context, memo_id, version, options, *, key, guard):
required = {'submitter', 'memo_class', 'review_group', 'approval_intent'}
if not isinstance(options, dict) or set(options) != required:
raise ValueError('exact review routing required')
if context.tenant != 'tenant:platform' or type(version) is not int or version < 1:
raise ValueError('platform revision required for intake')
if not isinstance(options['submitter'], str) or not options['submitter'].strip() or len(options['submitter']) > 200:
raise ValueError('invalid submitter')
scope, digest = repository._request(context, 'request_review', key,
{'memo_id': memo_id, 'version': version, 'options': options})
with repository.store._transaction() as db:
policy_id = guard(db)
repository._record(db, context.tenant, memo_id)
replay = repository._replay(db, scope, digest)
if replay:
body = json.loads(db.execute('SELECT body FROM lifecycle_events WHERE id=?', (replay,)).fetchone()[0])
return {'event_id': replay, **{k: body[k] for k in ('review_memo_id', 'envelope')}}
memo = repository.store._memo(db, memo_id)
if memo.version != version:
raise Conflict('review requires the current revision')
if (memo.locale != 'en' or memo.binding_level is not BindingLevel.ORGANIZATIONAL
or memo.step_kind is not StepKind.APPROVE or memo.ui_release != UI_RELEASE):
raise ValueError('unsupported human review profile')
if repository._policy_context(db, context.tenant, memo_id, version)['published_audience'] is None:
raise Conflict('publish this revision before requesting review')
if db.execute("SELECT 1 FROM lifecycle_events WHERE memo_id=? AND version=? AND kind='review_requested'",
(memo_id, version)).fetchone():
raise Conflict('this revision already has a review request')
review_id = 'review-' + str(uuid.uuid4())
draft = replace(memo, id=review_id, version=1,
binding=replace(memo.binding, principal=Principal('pending-human-session', 'person', 'To be assigned')))
projection = {k: v for k, v in json.loads(dumps(draft)).items() if k in MEMO_KEYS}
# Never silently discard content the narrower intake projection cannot carry.
if dumps(memo_from(projection)) != dumps(draft):
raise ValueError('memo requires a broader intake projection')
packet = []
for item in draft.packet:
document = db.execute('SELECT media_type,content FROM documents WHERE digest=?', (item.hash,)).fetchone()
if document is None:
raise EvidenceUnavailable('packet unavailable')
packet.append({'item_id': item.item_id, 'media_type': document['media_type'],
'content': bytes(document['content']).decode('utf-8')})
envelope = {'kind': KIND, 'version': 1, 'idempotency_key': review_id,
'memo_class': options['memo_class'], 'review_group': options['review_group'],
'approval_intent': options['approval_intent'], 'memo': projection, 'packet': packet}
# Shape validation only. Actual submitter admission is always rechecked by intake.
validate_submission(envelope, submitter_id=options['submitter'], grant=SubmitterGrant(
options['submitter'], frozenset({options['memo_class']}), frozenset({options['review_group']})))
if len(dumps(envelope).encode()) > 256 * 1024:
raise ValueError('review envelope too large')
body = {'review_memo_id': review_id, 'submitter': options['submitter'], 'envelope': envelope}
event_id = repository._append(db, scope, digest, memo, 'review_requested', body, policy_id)
return {'event_id': event_id, 'review_memo_id': review_id, 'envelope': envelope}
def outcomes(repository, tenant, memo_id, version):
if type(version) is not int or version < 1:
raise ValueError('positive revision required')
with repository.store._connection() as db:
repository._record(db, tenant, memo_id)
repository.store._memo(db, memo_id, version)
row = db.execute("SELECT body FROM lifecycle_events WHERE tenant=? AND memo_id=? AND version=? "
"AND kind='review_requested'", (tenant, memo_id, version)).fetchone()
if row is None:
return {'review': None, 'dispositions': []}
body = json.loads(row['body'])
review_id = body['review_memo_id']
ledger = db.execute("SELECT state FROM intake_ledger WHERE memo_id=?", (review_id,)).fetchone()
result = {'review': {'memo_id': review_id, 'intake_state': ledger['state'] if ledger else 'not_submitted'},
'dispositions': []}
result['review']['stale'] = repository.store._memo(db, memo_id).version != version
if ledger and ledger['state'] == 'ready' and not result['review']['stale']:
result['review']['path'] = '/review?memo_id=' + review_id
for item in db.execute('SELECT d.body,s.state,s.approved_at FROM dispositions d '
'JOIN presentations p ON p.id=d.presentation_id '
'LEFT JOIN submissions s ON s.disposition_id=d.id WHERE p.memo_id=? ORDER BY d.rowid', (review_id,)):
result['dispositions'].append({'disposition': json.loads(item['body']),
'submission_state': item['state'], 'approved_at': item['approved_at']})
return result

View file

@ -90,6 +90,7 @@ class LifecycleService:
"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 {}),
**({"review_routing": payload["options"]} if operation == "request_review" else {}),
"command_digest": "sha256:" + hashlib.sha256(command).hexdigest()},
"policy_version": self.policy.version}
observation = self.policy.check(request)
@ -167,6 +168,20 @@ class LifecycleService:
current()
return result
def request_review(self, caller, memo_id, version, *, options, key):
from .lifecycle_review import prepare
options = json.loads(dumps(options))
_, guard = self._authorize(caller, memo_id, "request_review",
{"version": version, "options": options, "key": key})
return prepare(self.repository, caller.attribution, memo_id, version, options, key=key, guard=guard)
def outcomes(self, caller, memo_id, version):
from .lifecycle_review import outcomes
current, _ = self._authorize(caller, memo_id, "outcomes", {"version": version})
result = outcomes(self.repository, caller.attribution.tenant, memo_id, version)
current()
return result
def search(self, caller, *, query="", cursor=None):
self._caller(caller)
if not isinstance(query, str) or len(query) > 200:

View file

@ -98,7 +98,13 @@ class LifecycleWorkspace:
except LifecycleError as error:
if error.status != 403:
raise
return 200, self._page(memo, events, session, previous), []
outcome = None
try:
outcome = self.service.outcomes(caller, memo.id, memo.version)
except LifecycleError as error:
if error.status != 403:
raise
return 200, self._page(memo, events, session, previous, outcome), []
if method == "POST" and action in {"revise", "comment", "publish", "source", "evaluate"}:
if params:
raise ValueError()
@ -131,13 +137,31 @@ class LifecycleWorkspace:
raise ValueError()
uuid.UUID(form["key"])
def _page(self, memo, events, session, previous=None):
def _page(self, memo, events, session, previous=None, outcome=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>')
'<p>Publishing invites discussion; it does not record acceptance.</p>')
if not outcome or not outcome['review'] or not outcome['review'].get('path'):
content += '<p>Human acceptance is not available for this draft.</p>'
if outcome and outcome["review"]:
review = outcome["review"]
content += '<section class="card"><h2>Human review</h2>'
if review.get("path"):
content += f'<p><a href="{text(review["path"])}">Open protected human review</a></p>'
elif review["stale"]:
content += '<p>This review belongs to an earlier revision and cannot receive a new answer.</p>'
else:
content += '<p>Review handoff: ' + text(review["intake_state"].replace("_", " ")) + '</p>'
for item in outcome["dispositions"]:
answer = item["disposition"]
content += f'<p>{text(answer["actor"]["sub"])}: {text(answer["verb"])}'
if item["submission_state"]:
content += ' · approval entry ' + text(item["submission_state"])
content += '</p>'
content += '</section>'
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")):
@ -153,7 +177,7 @@ class LifecycleWorkspace:
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"}
body = {key: value for key, value in event["body"].items() if key not in {"policy_observation_id", "envelope"}}
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":

View file

@ -30,6 +30,7 @@ class ReviewPage:
stale: bool
engine_status: str
dispositions: tuple = ()
lifecycle_source: dict | None = None
# Overview groups, in display order. "unavailable" rows carry the memo id only.
@ -113,7 +114,8 @@ class ReviewController:
principal_type=session.principal_type, expected_version=memo.version,
policy_id=policy_id, approval_binding=current["binding"])
saved, p, docs = self.store.retrieve_presentation(p.id)
return ReviewPage(saved, p, current["binding"], docs, None, False, current["status"])
return ReviewPage(saved, p, current["binding"], docs, None, False, current["status"],
lifecycle_source=self.store.presentation_content(p.id).get("lifecycle_source"))
def _presentation(self, session, presentation_id, *, current=False):
self._session(session)
@ -122,7 +124,8 @@ class ReviewController:
raise ReviewError(403, "wrong_recipient")
memo = self.store.memo(p.memo_id, p.memo_version)
self._memo(session, memo)
if current and self.store.memo(memo.id).version != memo.version:
if current and (self.store.memo(memo.id).version != memo.version
or not self.store.review_source_current(memo.id)):
raise ReviewError(409, "stale_presentation")
return memo, p
@ -139,8 +142,8 @@ class ReviewController:
saved, p, docs = self.store.retrieve_presentation(p.id)
return ReviewPage(saved, p, binding, docs,
self.store.intent_for(memo.approval_id, session.subject),
self.store.memo(memo.id).version != memo.version, current["status"],
tuple(self.store.dispositions_for(p.id)))
self.store.memo(memo.id).version != memo.version or not self.store.review_source_current(memo.id), current["status"],
tuple(self.store.dispositions_for(p.id)), content.get("lifecycle_source"))
def overview(self, session):
"""The signed-in person's memos, classified. Never presents, binds or stores engine state.

View file

@ -27,7 +27,7 @@ from .provenance import Claim, Route, assert_human_control_dischargeable
from .stance import resolve
from .records import disposition_from, dumps, memo_from, presentation_from
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
_SCHEMA = """
CREATE TABLE documents (digest TEXT PRIMARY KEY, media_type TEXT NOT NULL, content BLOB NOT NULL);
CREATE TABLE memos (id TEXT NOT NULL, version INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(id,version));
@ -99,7 +99,7 @@ class Store:
triggers += (f"CREATE TRIGGER immutable_{table}_{operation} BEFORE {operation} ON {table} "
"BEGIN SELECT RAISE(ABORT,'immutable evidence'); END;\n")
db.executescript("BEGIN IMMEDIATE;" + _SCHEMA + triggers + "PRAGMA user_version=1; COMMIT;")
elif version not in (1, 2, 3, SCHEMA_VERSION):
elif version not in (1, 2, 3, 4, SCHEMA_VERSION):
raise StoreError("unsupported evidence schema version")
if version in (0, 1):
db.executescript("""BEGIN IMMEDIATE;
@ -131,6 +131,17 @@ class Store:
db.executescript("BEGIN IMMEDIATE;" + lifecycle_schema
+ "PRAGMA user_version=4; COMMIT;")
if version in (0, 1, 2, 3, 4):
# Earlier code cannot enforce source-revision guards. Refuse an
# image-only downgrade once this schema has been opened.
db.executescript("""BEGIN IMMEDIATE;
CREATE UNIQUE INDEX lifecycle_review_snapshot
ON lifecycle_events(json_extract(body,'$.review_memo_id'))
WHERE kind='review_requested';
CREATE UNIQUE INDEX lifecycle_review_revision
ON lifecycle_events(memo_id,version) WHERE kind='review_requested';
PRAGMA user_version=5; COMMIT;""")
@contextmanager
def _connection(self):
db = sqlite3.connect(self.path.as_uri() + "?mode=rw", uri=True, timeout=5, isolation_level=None)
@ -177,6 +188,15 @@ class Store:
def _save_memo(self, db, memo: Memo):
"""Shared transaction boundary for imports and lifecycle writes."""
from .lifecycle_review import review_request
if review_request(db, memo.id) is not None:
raise Conflict("review snapshots are immutable and loaded only by intake")
if db.execute("SELECT 1 FROM lifecycle_events e JOIN presentations p "
"ON p.memo_id=json_extract(e.body,'$.review_memo_id') "
"JOIN dispositions d ON d.presentation_id=p.id JOIN submissions s ON s.disposition_id=d.id "
"WHERE e.memo_id=? AND e.kind='review_requested' AND s.state IN ('in_flight','unresolved')",
(memo.id,)).fetchone():
raise Conflict("resolve the outstanding review entry before revising")
_text(memo.id, "memo id")
if type(memo.version) is not int or memo.version < 1:
raise ValueError("memo version must be a positive integer")
@ -210,6 +230,15 @@ class Store:
with self._connection() as db:
return self._memo(db, memo_id, version)
def review_source_current(self, memo_id):
from .lifecycle_review import require_current
with self._connection() as db:
try:
require_current(db, memo_id)
except Conflict:
return False
return True
def observe_policy(self, observation):
request = json.loads(observation.request_json)
_text(request["id"], "policy observation id")
@ -275,6 +304,8 @@ class Store:
raise ValueError("platform tenant and principal provenance are required")
with self._transaction() as db:
memo = self._memo(db, memo_id)
from .lifecycle_review import require_current
require_current(db, memo_id)
if expected_version is not None and memo.version != expected_version:
raise Conflict("memo changed after the entitlement check")
self._guard_policy(db, policy_id, memo, principal_sub, "read")
@ -284,6 +315,10 @@ class Store:
principal_type=principal_type, awareness=awareness)
content = {"memo": json.loads(dumps(memo)), "presentation": json.loads(dumps(presentation)),
"binding_document": memo.binding_document(), "awareness_document": memo.awareness_document(awareness)}
source = require_current(db, memo_id)
if source is not None:
content["lifecycle_source"] = {"memo_id": source["memo_id"], "version": source["version"],
"request_event_id": source["id"]}
if policy_id is not None:
content["policy_observation_id"] = policy_id
if approval_binding is not None:
@ -299,6 +334,8 @@ class Store:
with self._transaction() as db:
p = self._presentation(db, presentation_id)
memo = self._memo(db, p.memo_id)
from .lifecycle_review import require_current
require_current(db, p.memo_id)
if actor.sub != p.principal_sub:
raise DispositionRefused("G_ACTOR", "actor did not receive this presentation")
if memo.version != p.memo_version:
@ -333,6 +370,8 @@ class Store:
return disposition_from(json.loads(existing["body"]))
p = self._presentation(db, presentation_id)
memo = self._memo(db, p.memo_id)
from .lifecycle_review import require_current
require_current(db, p.memo_id)
self._guard_policy(db, policy_id, memo, actor.sub, verb.value)
d = record(memo, p, verb, actor, reasons=tuple(reasons), note=note)
if verb in BINDING_VERBS:
@ -377,6 +416,8 @@ class Store:
raise Conflict("submission is absent, already attempted or unresolved")
d = disposition_from(json.loads(db.execute("SELECT body FROM dispositions WHERE id=?", (disposition_id,)).fetchone()[0]))
p = self._presentation(db, d.presentation_id)
from .lifecycle_review import require_current
require_current(db, p.memo_id)
if self._memo(db, p.memo_id).version != p.memo_version:
raise DispositionRefused("G_PRES", "presentation is stale")
self._guard_policy(db, policy_id, self._memo(db, p.memo_id), d.actor.sub, "accept")

View file

@ -112,7 +112,12 @@ def review_page(page, session):
f'<p>Presentation {text(p.id)}</p><p class="hash">View: {text(p.view_hash)}</p>'
f'<p class="hash">Act: {text(memo.approval_binding_digest)}</p>'
f'<p>Account zone: {text(session.tenant.value)} ({text(session.tenant.route.value)}). This is separate from the act scope.</p></details>')
content = (f'<p class="eyebrow">Decision review · version {memo.version}</p><h1>{text(memo.question)}</h1>'
source = ''
if page.lifecycle_source:
origin = page.lifecycle_source
source = (f'<p>Prepared from <a href="/decisions/{text(origin["memo_id"])}?version={origin["version"]}">'
f'decision revision {origin["version"]}</a>.</p>')
content = (f'<p class="eyebrow">Decision review · version {memo.version}</p><h1>{text(memo.question)}</h1>' + source
+ notice + '<div class="grid"><div><section class="card"><h2>The request</h2>'
f'<p><strong>{text(memo.requested_act)}</strong></p><p class="brief">{text(memo.brief)}</p>{terms}</section>'
+ highlights + packet + '<section class="card"><h2>Your response</h2>' + (forms or '<p>No new approval submission is available from this record.</p>')