From f3b71b15214960daa05d03a02505cfb36947e7e9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 4 Oct 2026 20:33:24 +0200 Subject: [PATCH] Connect lifecycle revisions to protected human review Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f --- SCOPE.md | 11 +- docs/specs/LifecycleFoundationContract.md | 4 + docs/specs/LifecycleHTTPContract.md | 5 + docs/specs/LifecycleReviewHandoff.md | 90 ++++++ docs/specs/LifecycleServiceContract.md | 6 +- docs/specs/LifecycleWorkspaceDelivery.md | 9 +- informed_decision/intake_service.py | 6 + informed_decision/lifecycle_http.py | 12 +- informed_decision/lifecycle_review.py | 114 +++++++ informed_decision/lifecycle_service.py | 15 + informed_decision/lifecycle_ui.py | 32 +- informed_decision/review.py | 11 +- informed_decision/store.py | 45 ++- informed_decision/ui.py | 7 +- tests/test_lifecycle_review.py | 280 ++++++++++++++++++ ...INFD-WP-0007-decision-lifecycle-service.md | 25 +- 16 files changed, 649 insertions(+), 23 deletions(-) create mode 100644 docs/specs/LifecycleReviewHandoff.md create mode 100644 informed_decision/lifecycle_review.py create mode 100644 tests/test_lifecycle_review.py diff --git a/SCOPE.md b/SCOPE.md index 8df1add..c4cb528 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -17,15 +17,20 @@ creation, research, discussion, publication, refinement, revision comparison and packet downloads. Runtime configuration requires a separate lifecycle policy package. The bearer API adapter remains unmounted pending workload identity and listener admission. Lifecycle records remain outside the legacy approver overview; -the human binding handoff is not implemented. +a durable revision-specific request now connects supported records to intake +and the existing human-only review path. Outcomes retain exact source revision +and distinguish prepared/unresolved submissions from confirmed entries. See +`docs/specs/LifecycleReviewHandoff.md`; production forwarding is still outstanding. Opt-in delivery commits lifecycle evidence through the existing Audit Core outbox, with receipt-based settlement, retries and reconciliation. Current delivery is platform-tenant-only. Pending evidence closes runtime readiness. The deployment renderer supports separately pinned lifecycle policy and exact network proposals; -no live configuration was changed. Cross-repository validation: 623 passed, +no live configuration was changed. Cross-repository validation: 634 passed, 5 skipped, including two agent-created decisions and human collaboration with -unchanged runtime configuration and actual Audit Core delivery. Identity and PDP +unchanged runtime configuration, actual Approval Engine acceptance of a linked +source revision, and Audit Core delivery. Schema 5 prevents older code without +source-revision guards from opening the database. Identity and PDP responses in that scenario are synthetic; this is not live acceptance evidence. Contracts: `docs/specs/DecisionLifecycleService.md`, `LifecycleFoundationContract.md`, diff --git a/docs/specs/LifecycleFoundationContract.md b/docs/specs/LifecycleFoundationContract.md index 1393688..b094e0c 100644 --- a/docs/specs/LifecycleFoundationContract.md +++ b/docs/specs/LifecycleFoundationContract.md @@ -99,3 +99,7 @@ content, verified and written in the same transaction as the memo/event. New registration requires packet bytes; revisions may retain their current packet's digests. The details and limits are in `LifecycleHTTPContract.md`. Idempotency digests for prior commands with no documents remain compatible. + +Schema 5 adds unique review-handoff indexes and prevents older code without +source-revision guards from opening the store. Migration preserves schema-4 +records. See LifecycleReviewHandoff.md for the rollback boundary. diff --git a/docs/specs/LifecycleHTTPContract.md b/docs/specs/LifecycleHTTPContract.md index a476910..30ed4c6 100644 --- a/docs/specs/LifecycleHTTPContract.md +++ b/docs/specs/LifecycleHTTPContract.md @@ -10,6 +10,8 @@ existing browser app or public ingress. | `POST /v1/decisions/{id}/publications` | `version`, `audience` with explicit `subjects` and `groups` lists | Durable publication event receipt | | `POST /v1/decisions` | `memo`, optional `documents` | Register an unbound version-one memo and return event/record/revision/resource receipt | | `POST /v1/decisions/{id}/revisions` | `memo`, `expected_version`, `summary`, optional `documents` | Compare-and-append successor revision; same receipt shape | +| `POST /v1/decisions/{id}/review-requests` | `version`, `options` per LifecycleReviewHandoff | Durable request receipt and exact intake envelope | +| `GET /v1/decisions/{id}/outcomes` | Required positive `version` | Correlated review/disposition evidence and submission state | | `GET /v1/decisions/{id}` | Optional positive integer `version` | Existing Memo JSON for the current or specified revision | | `GET /v1/decisions/{id}/events` | Optional `after` sequence cursor, `limit` 1–100 | `{"events": [...]}` ordered by sequence; use last returned sequence for next page | | `POST /v1/decisions/{id}/comments` | `{"version": 1, "body": {"text": "...", "locator": "optional"}}` | Durable event receipt | @@ -88,3 +90,6 @@ the command including documents; identity and decision freshness are rechecked in the write transaction. Replays are reauthorized and return the original event. Conflicting content for a key, stale revision preconditions and attempts to replace an existing digest's media type produce 409 without a partial write. + +Review handoff, its supported profile and source-revision guards are specified in +`LifecycleReviewHandoff.md`. Human acceptance remains on the protected review route. diff --git a/docs/specs/LifecycleReviewHandoff.md b/docs/specs/LifecycleReviewHandoff.md new file mode 100644 index 0000000..f27ab2a --- /dev/null +++ b/docs/specs/LifecycleReviewHandoff.md @@ -0,0 +1,90 @@ +# Revision-specific lifecycle review handoff + +2026-10-04; INFD-WP-0007-T02 through T06. Source implementation, not deployed. + +## Headless contract + +`POST /v1/decisions/{id}/review-requests` accepts `version` and `options`, with +an Idempotency-Key. Options have exactly `submitter`, `memo_class`, `review_group` +and `approval_intent` (the existing intake action/principal/purpose/target object). +A fresh `lifecycle.request_review` policy observation covers the complete command; +`context.review_routing` exposes these routing/act facts to the PDP. Publication +is required for that current revision. Publication itself never initiates review. + +The service atomically retains one immutable `review_requested` event per source +revision, its source attribution, and an intake envelope under a fresh review +memo id. Retries return the retained request; different commands for the same +key or a second request for that revision conflict. This creates neither an +approval nor a disposition. The envelope and event fit within 256 KiB. An admitted +workload submits the exact returned envelope to the existing private intake. +The envelope is also retained in authorized event history for recovery. + +The handoff currently supports the platform tenant, English organizational +`approve` memos at the current renderer release, and the intake's nonempty UTF-8 +packet profile. Unsupported projections are refused rather than dropping fields. +Broader L0/L2 profiles, browser packet authoring and automated forwarding remain +live work in T02/T04/T05 and WP-0005. There is no automatic submitter or default +workload credential. A caller-selected submitter is a routing request, not identity. +The local use of intake's shape validator does not grant admission: intake always +reauthenticates the workload and reloads its actual grant and recipient mapping. + +## Immutable snapshot and uncertainty + +The original lifecycle memo remains unchanged. Intake alone assigns the named +human and the engine-returned approval pins to the protected review snapshot. +The snapshot's local version is one; the immutable request and presentation +content link it to the original source id and revision. The human review page +shows that source revision. The shared Decision Memo schema and signature rules +are unchanged; the source link is provenance, not a new authorization claim. + +Before reserving engine creation and before local loading, intake checks exact +envelope equality, the designated submitter and source currency. Any change +fails closed. Existing durable `creating`/`created` receipts retain uncertainty; +a lost create reply is never automatically reposted. A revision during creation +can leave an engine object with a retained receipt but no reviewable snapshot. +This needs existing explicit recovery/reconciliation, not an automatic retry or +an inferred revocation. Approval Engine remains the sole approval-state owner. + +Direct import and revision of reserved review snapshot ids are refused. Source +revisions invalidate subsequent presentation, acknowledgment, disposition and +entry reservation. Once an entry is in flight or unresolved, source revision is +blocked until the original attempt is resolved. This serializes local mutation +against external entry attempts without pretending to make the network atomic. +Historical accepted evidence remains available; a successor inherits no answer. + +## Outcomes and human workspace + +`GET /v1/decisions/{id}/outcomes?version=N` requires a fresh `lifecycle.outcomes` +check. It returns the correlated intake delivery state, source staleness and +recorded dispositions with their submission state and confirmation timestamp. +These are local evidence and transport states, never cached approval validity. +`prepared`, `in_flight` and `unresolved` are not confirmed acceptance. + +The workspace displays authorized outcomes and links a ready, current handoff to +`/review?memo_id=...`. That route still requires its independent review PDP checks, +verified human identity, fresh presentation and required acknowledgments. Agents +can prepare, forward and retrieve; they cannot answer. No lifecycle accept route +exists. Audience access and the named human review entitlement are separately +checked by their existing policy consumers; a workspace link grants neither. + +## Evidence and delivery + +The request event enters the existing lifecycle commitment queue. Intake posing, +presentation, disposition and submission evidence keep their existing independent +outboxes and receipt requirements. The integration test creates an approval via +the real Approval Engine API, records a signed-fixture human entry, retrieves the +confirmed result for source revision two, and delivers lifecycle and review +commitments to the real Audit Core receiver. Identity and PDP transport are +synthetic. Production grants, private listener/forwarding composition, full +release/rollback evidence and separately approved rollout remain outstanding. + +## Migration and rollback boundary + +Opening the store upgrades schema 4 to schema 5 transactionally, adding unique +indexes for review snapshot identity and source revision. Existing evidence is +preserved. The version change deliberately causes older Store implementations to +refuse the database: they do not enforce these source guards. Before activation, +retain the normal database backup and image/config pins. An older-image rollback +requires a compatible restored database plus reconciliation of any subsequent +engine side effects; it cannot safely discard new review/entry evidence. A +reviewed forward fix remains preferable once new handoffs have been written. diff --git a/docs/specs/LifecycleServiceContract.md b/docs/specs/LifecycleServiceContract.md index d64c67a..b4a9063 100644 --- a/docs/specs/LifecycleServiceContract.md +++ b/docs/specs/LifecycleServiceContract.md @@ -24,7 +24,8 @@ The service consumes `PolicyClient` observations. It neither evaluates policy nor falls back to local permissions. New proposed action names are `lifecycle.register`, `lifecycle.revise`, `lifecycle.source`, `lifecycle.evaluation`, `lifecycle.comment`, `lifecycle.read`, -`lifecycle.events`, `lifecycle.publish`, `lifecycle.search` and `lifecycle.packet`. These are not added to the existing review-action allowlist; +`lifecycle.events`, `lifecycle.publish`, `lifecycle.search`, `lifecycle.packet`, +`lifecycle.request_review` and `lifecycle.outcomes`. These are not added to the existing review-action allowlist; review and human disposition contracts remain unchanged. The request carries the authenticated subject, tenant, resource `memo:`, @@ -92,7 +93,8 @@ Publication appends an immutable event for the current revision with an explicit nonempty audience (at most 32 subjects and 32 groups, no wildcard). A successor revision does not inherit publication. Audience facts are inputs to Access Engine, never local permissions. Human acceptance requires the separate existing -presentation/disposition path; the lifecycle-to-review handoff is still open. +presentation/disposition path; the source handoff now follows LifecycleReviewHandoff.md. Production forwarding +and broader review profiles remain open. Before enabling a listener, T02/T04 must provide: reviewed lifecycle PDP package and action/context fixtures; admitted human/workload identity adapters; closed diff --git a/docs/specs/LifecycleWorkspaceDelivery.md b/docs/specs/LifecycleWorkspaceDelivery.md index b699a2a..4fff1f6 100644 --- a/docs/specs/LifecycleWorkspaceDelivery.md +++ b/docs/specs/LifecycleWorkspaceDelivery.md @@ -16,9 +16,9 @@ rendering a button grants no authority. Publication records an audience for one revision. Access Engine determines access using authenticated identity and retained record facts. Publication creates no approval and no human disposition. New revisions require their own publication. -The workspace explicitly states that human acceptance is unavailable for these -drafts. Connecting a lifecycle revision to the existing protected presentation -and Approval Engine path remains T03/T05 work. +The workspace exposes authorized outcomes and a protected human review link +when an exact revision handoff has been loaded by intake. Drafts without that +handoff remain unavailable for acceptance. See LifecycleReviewHandoff.md. ## Runtime and deployment inputs @@ -64,7 +64,8 @@ and API ingress. The policy must cover all actions in LifecycleServiceContract, including collection search and per-record checks. Existing compact-sitting pins do not supply these grants. WP-0005 retains private intake composition work. -T03/T05 require the durable review handoff and human-only acceptance integration. +The source handoff now integrates human-only acceptance for the admitted intake +profile; T03/T05 retain broader profiles and browser authoring/forwarding work. T06 requires full acceptance evidence, sender/cadence admission, immutable image, database backup/migration and rollback verification. T07 requires the separately approved concrete live rollout. No production pins or resources changed here. diff --git a/informed_decision/intake_service.py b/informed_decision/intake_service.py index e9c6647..45172f0 100644 --- a/informed_decision/intake_service.py +++ b/informed_decision/intake_service.py @@ -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. diff --git a/informed_decision/lifecycle_http.py b/informed_decision/lifecycle_http.py index 05e61ae..d97bcff 100644 --- a/informed_decision/lifecycle_http.py +++ b/informed_decision/lifecycle_http.py @@ -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() diff --git a/informed_decision/lifecycle_review.py b/informed_decision/lifecycle_review.py new file mode 100644 index 0000000..ffbbfc6 --- /dev/null +++ b/informed_decision/lifecycle_review.py @@ -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 diff --git a/informed_decision/lifecycle_service.py b/informed_decision/lifecycle_service.py index 454c88f..b5a34b7 100644 --- a/informed_decision/lifecycle_service.py +++ b/informed_decision/lifecycle_service.py @@ -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: diff --git a/informed_decision/lifecycle_ui.py b/informed_decision/lifecycle_ui.py index 05b2e2c..5ed6748 100644 --- a/informed_decision/lifecycle_ui.py +++ b/informed_decision/lifecycle_ui.py @@ -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'

Decision workspace

{text(memo.question)}

' f'

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

' f'

Context and recommendation

{text(memo.brief)}

' - '

Human acceptance is not available for this draft. Publishing invites discussion; it does not record acceptance.

') + '

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

' + 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 += '
' if previous is not None: content += f'
Compare with revision {previous.version}' for field, label in (("question", "Question"), ("brief", "Context and recommendation")): @@ -153,7 +177,7 @@ class LifecycleWorkspace: content += '' content += '

Discussion and history

' 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'

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

' f'

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

') if event["kind"] == "source": diff --git a/informed_decision/review.py b/informed_decision/review.py index 49d718d..ac15bca 100644 --- a/informed_decision/review.py +++ b/informed_decision/review.py @@ -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. diff --git a/informed_decision/store.py b/informed_decision/store.py index 105f9ec..f6f3d1e 100644 --- a/informed_decision/store.py +++ b/informed_decision/store.py @@ -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") diff --git a/informed_decision/ui.py b/informed_decision/ui.py index 4e185a5..257c08f 100644 --- a/informed_decision/ui.py +++ b/informed_decision/ui.py @@ -112,7 +112,12 @@ def review_page(page, session): f'

Presentation {text(p.id)}

View: {text(p.view_hash)}

' f'

Act: {text(memo.approval_binding_digest)}

' f'

Account zone: {text(session.tenant.value)} ({text(session.tenant.route.value)}). This is separate from the act scope.

') - content = (f'

Decision review · version {memo.version}

{text(memo.question)}

' + source = '' + if page.lifecycle_source: + origin = page.lifecycle_source + source = (f'

Prepared from ' + f'decision revision {origin["version"]}.

') + content = (f'

Decision review · version {memo.version}

{text(memo.question)}

' + source + notice + '

The request

' f'

{text(memo.requested_act)}

{text(memo.brief)}

{terms}
' + highlights + packet + '

Your response

' + (forms or '

No new approval submission is available from this record.

') diff --git a/tests/test_lifecycle_review.py b/tests/test_lifecycle_review.py new file mode 100644 index 0000000..d04c619 --- /dev/null +++ b/tests/test_lifecycle_review.py @@ -0,0 +1,280 @@ +import base64 +from dataclasses import replace +import json +import time + +import pytest + +from informed_decision.disposition import Actor, ActorKind, Verb, DispositionRefused +from informed_decision.lifecycle import Attribution, LifecycleRepository +from informed_decision.lifecycle_service import Caller, LifecycleService, LifecycleError +from informed_decision.provenance import Claim, Route +from informed_decision.records import memo_from +from informed_decision.store import Conflict, Store +from test_intake import envelope +from test_intake_service import setup, request +from test_policy_client import policy + + +@pytest.fixture +def handoff(setup): + intake, store, calls, configuration = setup + env = envelope() + memo = memo_from(env['memo']) + app = LifecycleService(LifecycleRepository(store), policy()) + actor = Caller(Attribution('tenant:platform', Actor('author-agent', ActorKind.AGENT)), time.time()+3600, '{}') + documents = [{'digest': memo.packet[0].hash, 'media_type': 'text/markdown', + 'content_base64': base64.b64encode(env['packet'][0]['content'].encode()).decode()}] + app.register(actor, memo, key='register', documents=documents) + app.publish(actor, memo.id, 1, audience={'subjects': ['keycape-human'], 'groups': []}, key='publish') + options = {'submitter': 'state-hub', 'memo_class': env['memo_class'], 'review_group': env['review_group'], + 'approval_intent': env['approval_intent']} + return app, actor, memo, options, intake, calls + + +def prepare(handoff): + app, actor, memo, options, intake, calls = handoff + return app.request_review(actor, memo.id, 1, options=options, key='request-review') + + +def present(store, review_id): + return store.present(review_id, principal_sub='keycape-human', + tenant=Claim('tenant:platform', Route.REGISTRATION), + principal_type=Claim('human', Route.AUTHENTICATION)) + + +def test_durable_handoff_reuses_intake_and_exposes_revision_outcomes(handoff): + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + app.repository.store = Store(app.repository.store.path) + assert prepare(handoff) == receipt + assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'not_submitted' + assert request(intake, receipt['envelope'])[0] == 201 + assert request(intake, receipt['envelope'])[0] == 200 + assert len(calls) == 1 + store = app.repository.store + review = store.memo(receipt['review_memo_id']) + assert review.brief == memo.brief and review.packet == memo.packet + assert store.memo(memo.id).approval_id is None + p = present(store, review.id) + human = Actor('keycape-human', ActorKind.PERSON) + store.acknowledge(p.id, human, review.required_ack_ids) + d = store.record_disposition(p.id, human, Verb.DECLINE, operation_id='answer', reasons=('wrong_scope',)) + result = app.outcomes(actor, memo.id, 1) + assert result['review']['path'] == '/review?memo_id=' + review.id + assert result['dispositions'][0]['disposition']['id'] == d.id + assert result['dispositions'][0]['submission_state'] is None + app.revise(actor, memo.next_version(brief='Refined'), expected_version=1, summary='Update', key='revise') + assert app.outcomes(actor, memo.id, 2) == {'review': None, 'dispositions': []} + assert app.outcomes(actor, memo.id, 1)['dispositions'] == result['dispositions'] + + +def test_altered_handoff_and_wrong_submitter_fail_before_create(handoff): + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + changed = json.loads(json.dumps(receipt['envelope'])) + changed['memo']['brief'] += ' Altered' + assert request(intake, changed) == (409, {'error': 'review_handoff_conflict'}) + data = json.loads(intake.submitters_path.read_text()) + for row in data.values(): + row['submitter_id'] = 'another' + intake.submitters_path.write_text(json.dumps(data)) + assert request(intake, receipt['envelope'])[0] == 409 + assert not calls + + +def test_revision_invalidates_presented_review_and_prepared_accept(handoff): + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + assert request(intake, receipt['envelope'])[0] == 201 + store = app.repository.store + p = present(store, receipt['review_memo_id']) + human = Actor('keycape-human', ActorKind.PERSON) + store.acknowledge(p.id, human, memo.required_ack_ids) + d = store.record_disposition(p.id, human, Verb.ACCEPT, operation_id='accept') + app.revise(actor, memo.next_version(brief='Revised'), expected_version=1, summary='Update', key='revise') + with pytest.raises(Conflict): + store.begin_submission(d.id) + with pytest.raises(Conflict): + store.record_disposition(p.id, human, Verb.DECLINE, operation_id='late-answer', reasons=('wrong_scope',)) + with pytest.raises(Conflict): + present(store, receipt['review_memo_id']) + assert request(intake, receipt['envelope'])[0] == 409 + + +def test_unresolved_entry_freezes_source_and_snapshot_import_is_refused(handoff): + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + assert request(intake, receipt['envelope'])[0] == 201 + store = app.repository.store + review = store.memo(receipt['review_memo_id']) + with pytest.raises(Conflict): + store.save_memo(review.next_version(brief='Bypass')) + with pytest.raises(Conflict): + store.save_memo(review) + p = present(store, review.id) + human = Actor('keycape-human', ActorKind.PERSON) + store.acknowledge(p.id, human, memo.required_ack_ids) + d = store.record_disposition(p.id, human, Verb.ACCEPT, operation_id='accept') + attempt = store.begin_submission(d.id) + for unresolved in (False, True): + if unresolved: + store.finish_submission(d.id, attempt) + with pytest.raises(Conflict): + app.revise(actor, memo.next_version(brief='Update'), expected_version=1, summary='Update', key='revise') + assert store.memo(memo.id).version == 1 + + +def test_denial_replay_conflict_and_agent_binding_guard(handoff): + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + with pytest.raises(Conflict): + app.request_review(actor, memo.id, 1, options={**options, 'review_group': 'other'}, key='request-review') + with pytest.raises(Conflict): + app.request_review(actor, memo.id, 1, options=options, key='duplicate') + app.policy.transport.change = lambda data: data.update(effect='deny') + with pytest.raises(LifecycleError): + prepare(handoff) + with pytest.raises(LifecycleError): + app.outcomes(actor, memo.id, 1) + assert request(intake, receipt['envelope'])[0] == 201 + p = present(app.repository.store, receipt['review_memo_id']) + with pytest.raises(DispositionRefused): + app.repository.store.record_disposition(p.id, Actor('keycape-human', ActorKind.AGENT), Verb.ACCEPT, + operation_id='agent-accept') + + +def test_create_reply_loss_does_not_retry_and_source_change_before_load_refuses(handoff): + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + creator = intake.creator + def raced(body): + result = creator(body) + app.revise(actor, memo.next_version(brief='Changed during create'), expected_version=1, summary='Update', key='revise') + return result + intake.creator = raced + assert request(intake, receipt['envelope'])[0] == 409 + assert request(intake, receipt['envelope'])[0] == 409 + assert len(calls) == 1 + assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'created' + with app.repository.store._connection() as db: + assert not db.execute('SELECT 1 FROM memos WHERE id=?', (receipt['review_memo_id'],)).fetchone() + +from test_approval_component import component, signing_key +from test_durable_component import receiver + + +def test_actual_engine_human_entry_and_independent_audit_link_to_source_revision(handoff, component, receiver): + import jwt + from informed_decision.intake_approval import IntakeApprovalClient + from informed_decision.approval_http import ApprovalHTTPClient + from informed_decision.review import ReviewController + from informed_decision.runtime import AuditPump + app, actor, memo, options, intake, calls = handoff + client, engine, transport, human, signing = component + groups = json.loads(intake.groups_path.read_text()) + groups[options['review_group']]['subject'] = human.subject + intake.groups_path.write_text(json.dumps(groups)) + claims = jwt.decode(human.access_token, options={'verify_signature': False}) + claims.update(sub='informed-decision', principal_type='service', scope='approval:create') + token = jwt.encode(claims, signing, algorithm='RS256', headers={'kid': 'key-1'}) + intake.creator = IntakeApprovalClient('https://approval.test', lambda: token, transport=transport) + app.revise(actor, memo.next_version(brief='Refined for human acceptance'), expected_version=1, + summary='Responded to research', key='revise') + app.publish(actor, memo.id, 2, audience={'subjects': [human.subject], 'groups': []}, key='publish-second') + receipt = app.request_review(actor, memo.id, 2, options=options, key='review-second') + status, loaded = request(intake, receipt['envelope']) + assert status == 201, loaded + controller = ReviewController(app.repository.store, policy(), + lambda session: ApprovalHTTPClient('https://approval.test', session, transport=transport)) + page = controller.open(human, receipt['review_memo_id']) + controller.acknowledge(human, page.presentation.id, memo.required_ack_ids) + controller.act(human, page.presentation.id, Verb.ACCEPT, operation_id='human-accept') + result = app.outcomes(actor, memo.id, 2) + assert result['dispositions'][0]['submission_state'] == 'confirmed' + from informed_decision.web import App, SESSION_COOKIE + from test_browser_auth import call + class Login: + def session(self, sid): + return human if sid == 'fixture' else None + browser = App(Login(), controller, lifecycle=app) + page_response = call(browser, '/decisions/' + memo.id, query='version=2', cookie=SESSION_COOKIE + '=fixture') + assert page_response['status'] == 200 + assert 'Open protected human review' in page_response['body'] + assert 'approval entry confirmed' in page_response['body'] + native_page = call(browser, '/presentations/' + page.presentation.id, cookie=SESSION_COOKIE + '=fixture') + assert native_page['status'] == 200 and 'decision revision 2' in native_page['body'] + assert 'content_base64' not in page_response['body'] + assert len(engine.get(loaded['approval_id']).entries) == 1 + assert app.outcomes(actor, memo.id, 1)['dispositions'] == [] + source = app.repository.store.presentation_content(page.presentation.id)['lifecycle_source'] + assert source == {'memo_id': memo.id, 'version': 2, 'request_event_id': receipt['event_id']} + # Both lifecycle commitments and the existing presentation/disposition evidence drain. + pump = AuditPump(app.repository.store, receiver[0], lifecycle_delivery=True) + for _ in range(3): + pump.tick() + assert app.repository.store.lifecycle_counts()['pending'] == 0 + assert all(row['state'] == 'delivered' for row in app.repository.store.outbox()) + assert not any(url.endswith('/consume') for _, url in transport.calls) + + +def test_http_review_request_and_outcome_adapter(handoff): + from informed_decision.lifecycle_http import LifecycleAPI + from test_lifecycle_http import call + app, actor, memo, options, intake, calls = handoff + api = LifecycleAPI(app, lambda token: actor) + status, receipt, _ = call(api, f'/v1/decisions/{memo.id}/review-requests', method='POST', + body={'version': 1, 'options': options}) + assert status == 200 + assert request(intake, receipt['envelope'])[0] == 201 + status, outcome, _ = call(api, f'/v1/decisions/{memo.id}/outcomes', query='version=1') + assert status == 200 and outcome['review']['intake_state'] == 'ready' + assert call(api, f'/v1/decisions/{memo.id}/outcomes')[0] == 400 + + +def test_review_request_requires_publication_and_supported_profile(handoff): + app, actor, memo, options, intake, calls = handoff + app.revise(actor, memo.next_version(brief='Unpublished successor'), expected_version=1, summary='Update', key='revise') + with pytest.raises(Conflict, match='publish'): + app.request_review(actor, memo.id, 2, options=options, key='unpublished') + from informed_decision.memo import StepKind + app.revise(actor, app.retrieve(actor, memo.id).next_version(step_kind=StepKind.MITZEICHNUNG), + expected_version=2, summary='Other step', key='revise-step') + app.publish(actor, memo.id, 3, audience={'subjects': ['keycape-human'], 'groups': []}, key='publish-third') + with pytest.raises(ValueError, match='profile'): + app.request_review(actor, memo.id, 3, options=options, key='unsupported') + assert not calls + assert not any(event['kind'] == 'review_requested' for event in app.events(actor, memo.id)) + + +def test_lost_create_reply_remains_unresolved_across_restart(handoff): + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + def lost(body): + calls.append(body) + raise OSError('lost receiver reply') + intake.creator = lost + assert request(intake, receipt['envelope'])[0] == 503 + intake.store = Store(intake.store.path) + assert request(intake, receipt['envelope']) == (409, {'error': 'approval_create_unresolved'}) + assert len(calls) == 1 + assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'creating' + + +def test_schema_four_upgrade_preserves_evidence_and_fences_old_readers(handoff): + app, actor, memo, options, intake, calls = handoff + store = app.repository.store + before = app.events(actor, memo.id) + # Reconstruct the prior schema's exact index/version boundary. + with store._connection() as db: + db.execute('DROP INDEX lifecycle_review_snapshot') + db.execute('DROP INDEX lifecycle_review_revision') + db.execute('PRAGMA user_version=4') + migrated = Store(store.path) + with migrated._connection() as db: + assert db.execute('PRAGMA user_version').fetchone()[0] == 5 + assert db.execute("SELECT count(*) FROM sqlite_master WHERE name IN " + "('lifecycle_review_snapshot','lifecycle_review_revision')").fetchone()[0] == 2 + assert app.events(actor, memo.id) == before + receipt = prepare(handoff) + assert prepare(handoff) == receipt diff --git a/workplans/INFD-WP-0007-decision-lifecycle-service.md b/workplans/INFD-WP-0007-decision-lifecycle-service.md index 85401b1..02df8a1 100644 --- a/workplans/INFD-WP-0007-decision-lifecycle-service.md +++ b/workplans/INFD-WP-0007-decision-lifecycle-service.md @@ -22,7 +22,7 @@ state_hub_workstream_id: "2b3e04ed-2137-584f-a6c2-f1abb29636fa" Contract: `docs/specs/DecisionLifecycleService.md`. This extends the existing memo and intake implementation. `INFD-WP-0005` continues to own intake runtime, attribute admission, forwarding and live acceptance. Implementation started on -2026-10-04 at the founder's request. Production identity/policy admission and the human binding handoff remain +2026-10-04 at the founder's request. Production identity/policy admission and broader human binding profiles remain open. The shared service now has a bearer API adapter and optional browser workspace. ## Record product direction and architecture @@ -226,3 +226,26 @@ broader authoring UI (T03/T05), full acceptance scenarios, immutable release and rollback packet (T06), separately approved live rollout (T07). Source metadata entry is implemented; automated fetching/retention is not. No deployment occurred. Delivery and activation contract: `docs/specs/LifecycleWorkspaceDelivery.md`. + +## Revision-specific human review handoff — 2026-10-04 + +T02–T06 continued with policy-authorized review requests, immutable source-to-intake +snapshots and authorized outcome retrieval. Intake checks exact retained envelope, +designated submitter and current source revision before creation and loading. +Existing uncertainty/replay behavior is reused. Source revisions invalidate old +review actions; in-flight/unresolved human entries prevent source revision. +Protected review pages show the source revision, and the lifecycle workspace +links ready reviews and displays evidence without caching approval validity. + +The real Approval Engine/Audit Core source integration test creates and accepts +a snapshot of source revision two, correlates the confirmed human answer and +delivers evidence. Test identities and PDP transport are synthetic. Supported +profile and limits: `docs/specs/LifecycleReviewHandoff.md`. Production admission, +private forwarding, broader profiles/browser packet authoring, release/rollback +and approved live delivery remain on T02/T04/T05/T06/T07 and WP-0005. No task is +marked complete solely from source integration, and no rollout occurred. + +Validation for this continuation: full cross-repository suite **634 passed, +5 skipped**; `git diff --check` passed. Schema-4 upgrade preserves existing +records and marks schema 5 to fence older readers. Implementation decision logged +as State Hub `ccb2e110-6032-4259-a402-989415a8d9fe` (not a rollout approval).