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:
parent
0e79d69e27
commit
f3b71b1521
16 changed files with 649 additions and 23 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
114
informed_decision/lifecycle_review.py
Normal file
114
informed_decision/lifecycle_review.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue