Add lifecycle browser authoring and durable review handoff retrieval

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-05 00:48:07 +02:00
parent 52d372df0b
commit 9bc581a547
20 changed files with 632 additions and 74 deletions

View file

@ -1,13 +1,16 @@
"""Human workspace adapter: forms and rendering, with no storage or policy rules."""
from dataclasses import replace
import base64
import hashlib
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 .lifecycle_review import supports_review
from .memo import Memo, BindingLevel, BindingSlice, Principal, Scope, StepKind, PacketItem, Highlight
from .records import dumps
from .store import EvidenceUnavailable
from .ui import document, hidden, text
@ -59,14 +62,23 @@ class LifecycleWorkspace:
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>')
'<label>Subject or scope label <input name="scope_label" required maxlength="200"></label>'
'<label for="review-profile">Intended review</label><select id="review-profile" name="review_profile" required>'
'<option value="approval">Human approval (organizational)</option>'
'<option value="discussion">Discussion only</option></select>'
'<p>Saving a draft does not request or record approval.</p>')
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"})
self._fields(form, {"question", "brief", "scope_id", "scope_label"}, {"review_profile"})
profile = form.get("review_profile", "legacy")
if profile not in {"approval", "discussion", "legacy"}:
raise ValueError()
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,
requested_act="approve" if profile == "approval" else "comment" if profile == "discussion" else "accept",
binding_level=BindingLevel.ORGANIZATIONAL,
step_kind={"approval": StepKind.APPROVE, "discussion": StepKind.COMMENT,
"legacy": StepKind.MITZEICHNUNG}[profile],
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.
@ -74,10 +86,16 @@ class LifecycleWorkspace:
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)
match = re.fullmatch(r"/decisions/([A-Za-z0-9._:-]{1,256})(?:/(revise|comment|publish|source|evaluate|document|request-review|review-request|packet/([0-9]{1,3})))?", path)
if not match:
return 404, "Not found.", []
memo_id, action, index = match.groups()
if method == "GET" and action == "review-request":
if set(params) != {"version"}:
raise ValueError()
receipt = self.service.review_handoff(caller, memo_id, int(params["version"]))
return 200, dumps(receipt["envelope"]).encode(), [
("Content-Disposition", 'attachment; filename="decision-review-request.json"')]
if method == "GET" and (action is None or index is not None):
if set(params) - {"version", "after"}:
raise ValueError()
@ -105,16 +123,40 @@ class LifecycleWorkspace:
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 method == "POST" and action in {"revise", "comment", "publish", "source", "evaluate", "document", "request-review"}:
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]
"evaluate": {"version", "assessment", "source_ids", "uncertainty"},
"document": {"version", "label", "content", "highlight", "summary"},
"request-review": {"version", "principal", "purpose"}}[action]
self._fields(form, fields)
version = int(form["version"])
if action == "revise":
if action == "document":
previous = self.service.retrieve(caller, memo_id, version)
if not form["content"].strip() or not form["label"].strip():
raise ValueError()
content = form["content"].encode()
digest = "sha256:" + hashlib.sha256(content).hexdigest()
item_id = "doc-" + form["key"]
highlights = previous.highlights
if form["highlight"].strip():
highlights += (Highlight("highlight-" + form["key"], item_id, form["highlight"], required_ack=True),)
memo = previous.next_version(packet=previous.packet + (PacketItem(item_id, form["label"], digest),),
highlights=highlights)
self.service.revise(caller, decode_memo(json.loads(dumps(memo))), expected_version=version,
summary=form["summary"], key=form["key"], documents=[{"digest": digest,
"media_type": "text/plain", "content_base64": base64.b64encode(content).decode()}])
elif action == "request-review":
memo = self.service.retrieve(caller, memo_id, version)
self.service.request_review(caller, memo_id, version, key=form["key"], options={
"submitter": "state-hub", "memo_class": "decision-record", "review_group": "net-kingdom-admins",
"approval_intent": {"action": "accept", "principal": form["principal"], "purpose": form["purpose"],
"target": {"id": memo.binding.target.id, "type": memo.binding.target.kind,
"system": "informed-decision"}}})
elif 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,
@ -132,8 +174,9 @@ class LifecycleWorkspace:
return 405, "Method not allowed.", []
@staticmethod
def _fields(form, required):
if not isinstance(form, dict) or set(form) != required | {"csrf", "key"}:
def _fields(form, required, optional=frozenset()):
required = required | {"csrf", "key"}
if not isinstance(form, dict) or not required <= form.keys() or form.keys() - required - optional:
raise ValueError()
uuid.UUID(form["key"])
@ -155,6 +198,9 @@ class LifecycleWorkspace:
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>'
if review["intake_state"] == "not_submitted":
content += (f'<p><a href="{text(base)}/review-request?version={memo.version}">Download review request</a>'
' for the admitted State Hub agent to submit. Preparing this request has not created an approval.</p>')
for item in outcome["dispositions"]:
answer = item["disposition"]
content += f'<p>{text(answer["actor"]["sub"])}: {text(answer["verb"])}'
@ -162,6 +208,21 @@ class LifecycleWorkspace:
content += ' · approval entry ' + text(item["submission_state"])
content += '</p>'
content += '</section>'
elif supports_review(memo):
content += '<section class="card"><h2>Prepare human review</h2>'
if memo.packet:
content += ('<p>Publish this revision before preparing its review. The initial review group is '
'net-kingdom-admins, with the configured platform reviewer as recipient.</p>'
'<p>Preparation saves a request for the admitted State Hub agent to submit. '
'You can download it afterwards; no human answer is recorded here.</p>')
content += _form(base + "/request-review", session, revision
+ _textarea("principal", "Responsible party or system")
+ _textarea("purpose", "Purpose of this approval"), "Prepare review request")
else:
content += '<p>Add a supporting document, then publish the new revision to prepare human review.</p>'
content += '</section>'
else:
content += '<p>This draft’s review type is not supported by the current human approval flow.</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")):
@ -169,6 +230,22 @@ class LifecycleWorkspace:
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>')
if previous.packet != memo.packet:
content += '<h3>Supporting documents changed</h3>'
for label, version in (("Before", previous), ("After", memo)):
content += f'<p>{label}</p><ul>'
for index, item in enumerate(version.packet):
content += (f'<li><a href="{text(base)}/packet/{index}?version={version.version}">'
f'{text(item.label)}</a> · <code>{text(item.hash)}</code></li>')
content += '</ul>'
if previous.highlights != memo.highlights:
content += '<h3>Review acknowledgments changed</h3>'
for label, version in (("Before", previous), ("After", memo)):
content += f'<p>{label}</p><ul>'
for highlight in version.highlights:
content += (f'<li>{text(highlight.note)}'
+ (' · acknowledgment required' if highlight.required_ack else '') + '</li>')
content += '</ul>'
content += '</details>'
if memo.packet:
content += '<section class="card"><h2>Supporting documents</h2><ul>'
@ -189,6 +266,14 @@ class LifecycleWorkspace:
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>Add a supporting document</summary>'
content += ('<p>The document becomes part of a new revision. Earlier documents and answers remain unchanged. '
'Publish the new revision before requesting review. This form accepts short text documents; '
'larger or binary documents can be supplied by an agent.</p>')
content += _form(base + "/document", session, revision + _textarea("label", "Document title")
+ _textarea("content", "Document text")
+ '<label>Required acknowledgment (optional)<textarea name="highlight"></textarea></label>'
+ _textarea("summary", "What changed"), "Save document in new revision") + '</details>'
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)