"""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 .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 import json def _form(action, session, fields, button): return (f'
' + hidden("csrf", session.csrf) + hidden("key", str(uuid.uuid4())) + fields + f'
') def _textarea(name, label, value=""): return f'' 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 = ('

Decision workspace

Prepare a decision, examine evidence, and invite discussion.

' '
' '

Prepare a decision

' if not page["decisions"]: content += '

No decisions to display on this page.

' if page["next_cursor"]: content += f'

Next page

' 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") + '' '' '' '

Saving a draft does not request or record approval.

') content = '

Prepare a decision

' + _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"}, {"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="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. 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|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() 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 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", "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"}, "document": {"version", "label", "content", "highlight", "summary"}, "request-review": {"version", "principal", "purpose"}}[action] self._fields(form, fields) version = int(form["version"]) 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, 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, 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"]) def _page(self, memo, events, session, previous=None, outcome=None): base = "/decisions/" + memo.id revision = hidden("version", memo.version) content = (f'

Decision workspace

{text(memo.question)}

' f'

Revision {memo.version} · {text(memo.binding.target.label)}

' f'

Context and recommendation

{text(memo.brief)}

' '

Publishing invites discussion; it does not record acceptance.

') if not outcome or not outcome['review'] or not outcome['review'].get('path'): content += '

Human acceptance is not available for this draft.

' if outcome and outcome["review"]: review = outcome["review"] content += '

Human review

' if review.get("path"): content += f'

Open protected human review

' elif review["stale"]: content += '

This review belongs to an earlier revision and cannot receive a new answer.

' else: content += '

Review handoff: ' + text(review["intake_state"].replace("_", " ")) + '

' if review["intake_state"] == "not_submitted": content += (f'

Download review request' ' for the admitted State Hub agent to submit. Preparing this request has not created an approval.

') for item in outcome["dispositions"]: answer = item["disposition"] content += f'

{text(answer["actor"]["sub"])}: {text(answer["verb"])}' if item["submission_state"]: content += ' · approval entry ' + text(item["submission_state"]) content += '

' content += '
' elif supports_review(memo): content += '

Prepare human review

' if memo.packet: content += ('

Publish this revision before preparing its review. The initial review group is ' 'net-kingdom-admins, with the configured platform reviewer as recipient.

' '

Preparation saves a request for the admitted State Hub agent to submit. ' 'You can download it afterwards; no human answer is recorded here.

') content += _form(base + "/request-review", session, revision + _textarea("principal", "Responsible party or system") + _textarea("purpose", "Purpose of this approval"), "Prepare review request") else: content += '

Add a supporting document, then publish the new revision to prepare human review.

' content += '
' else: content += '

This draft’s review type is not supported by the current human approval flow.

' if previous is not None: content += f'
Compare with revision {previous.version}' for field, label in (("question", "Question"), ("brief", "Context and recommendation")): before, after = getattr(previous, field), getattr(memo, field) if before != after: content += (f'

{label}

Before

{text(before)}

' f'

After

{text(after)}

') if previous.packet != memo.packet: content += '

Supporting documents changed

' for label, version in (("Before", previous), ("After", memo)): content += f'

{label}

' if previous.highlights != memo.highlights: content += '

Review acknowledgments changed

' for label, version in (("Before", previous), ("After", memo)): content += f'

{label}

' content += '
' if memo.packet: content += '

Supporting documents

' content += '

Discussion and history

' for event in events: body = {key: value for key, value in event["body"].items() if key not in {"policy_observation_id", "envelope"}} content += (f'

{text(event["kind"])} · revision {event["version"]}

' f'

{text(event["actor"]["sub"])} ({text(event["actor"]["kind"])}) · {text(event["at"])}

') if event["kind"] == "source": content += f'

Source reference: {text(event["id"])}

' for key, value in body.items(): content += f'

{text(key.replace("_", " "))}: {text(dumps(value) if isinstance(value, (dict, list)) else value)}

' content += '
' if len(events) == 100: content += f'

More history

' content += '

Add a comment

' content += _form(base + "/comment", session, revision + _textarea("text", "Comment"), "Add comment") + '
' content += '
Add a supporting document' content += ('

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.

') content += _form(base + "/document", session, revision + _textarea("label", "Document title") + _textarea("content", "Document text") + '' + _textarea("summary", "What changed"), "Save document in new revision") + '
' content += '
Refine this decision' 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") + '
' content += '
Publish for discussion

Enter explicit member or group identifiers, one per line.

' content += _form(base + "/publish", session, revision + '' '', "Publish this revision") + '
' content += '
Add a research source' 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") + '
' content += '
Evaluate the evidence' content += _form(base + "/evaluate", session, revision + _textarea("assessment", "Assessment") + '' + _textarea("uncertainty", "Uncertainty and open questions"), "Add evaluation") + '
' return document("Decision workspace", content, session.subject)