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:
parent
ee472acc87
commit
5fec7de4cb
21 changed files with 1022 additions and 78 deletions
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
183
informed_decision/lifecycle_ui.py
Normal file
183
informed_decision/lifecycle_ui.py
Normal 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}&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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue