diff --git a/SCOPE.md b/SCOPE.md index b2789bf..5efb3dc 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -24,14 +24,26 @@ Core counts match; prior 24 confirmed submissions were preserved. Human acceptance of source revision 2 is confirmed at 2026-10-04T22:17:19Z, with an exact presentation/disposition link and independent audit reconciliation. -Broader binding -profiles and browser acceptance remain live tasks in INFD-WP-0007; intake +Broader binding profiles and browser discovery remain live tasks in INFD-WP-0007; intake initial delivery is completed in INFD-WP-0005. Issuer rotation is separately governed under Platform RPF-IN-0004. Automatic Hub resolution write-back remains disabled. Release/cutover procedure: `deploy/lifecycle/README.md`. Native receipts: `docs/evidence/2026-10-05-lifecycle-cutover/`. Earlier statuses below are historical. +## Lifecycle authoring source increment — 2026-10-05 + +Implemented but not yet deployed: browser approval/discussion drafts, immutable +supporting text documents with required acknowledgments, document/revision +comparison, and retained review-request downloads. The private API can retrieve +an exact revision's handoff under the existing events permission. These adapters +use the headless service; request preparation creates no native approval. + +Validation: 669 tests passed (one skipped), followed by 50 focused checks and +16 Chromium checks against actual owner components with synthetic identity. +Evidence: `docs/evidence/2026-10-05-lifecycle-authoring/`. Attribute-based legacy +review discovery and broader binding contracts remain open in INFD-WP-0007. + ## Status — 2026-09-27 **Deployed and in live use.** `decisions.coulomb.social` serves diff --git a/docs/evidence/2026-10-05-lifecycle-authoring/README.md b/docs/evidence/2026-10-05-lifecycle-authoring/README.md new file mode 100644 index 0000000..d928dc7 --- /dev/null +++ b/docs/evidence/2026-10-05-lifecycle-authoring/README.md @@ -0,0 +1,28 @@ +# Lifecycle authoring validation — 2026-10-05 + +Source implementation evidence; this increment has not been deployed. + +The browser can prepare approval or discussion drafts, append immutable text +supporting documents and required acknowledgments, compare revisions, publish, +and download the retained review request for the admitted State Hub workload. +The shared service also exposes exact-version handoff retrieval under the +existing lifecycle events permission. Preparation does not create an approval; +only a human can complete a disposition through the existing review surface. + +Validation: + +- Locked project environment, actual owner Flex Auth, Approval Engine, Audit Core + and State Hub sources: 669 passed, 1 skipped. +- Final focused lifecycle UI/review/HTTP regression run: 50 passed, 2 skipped + (optional real-engine checks excluded from this focused run). +- Chromium: 16 checks passed using real HTTPS and the actual lifecycle policy + package, with synthetic identity and TokenReview. This is fixture evidence, + not another native acceptance claim. +- Desktop and mobile screenshots inspected; no viewport overflow observed. +- Coverage includes replay, restart, stale revision refusal, protected export, + CSRF refusal, rejected documents without partial writes, and no approval entry + created by review-request preparation. + +The browser receipt includes the existing fixture human review's single entry; +the new lifecycle preparation adds no entry. Fixture databases, TLS keys and +session credentials are deliberately excluded from this directory. diff --git a/docs/evidence/2026-10-05-lifecycle-authoring/browser-result.json b/docs/evidence/2026-10-05-lifecycle-authoring/browser-result.json new file mode 100644 index 0000000..8a432c4 --- /dev/null +++ b/docs/evidence/2026-10-05-lifecycle-authoring/browser-result.json @@ -0,0 +1,154 @@ +{ + "fixture_only": true, + "browser": "Chromium", + "checks_passed": 16, + "checks": [ + "PKCE callback and secure browser session", + "Home overview lists the open memo addressed to the reviewer", + "Entitled page escapes memo and packet markup", + "Server refuses bypass of missing acknowledgments", + "Explicit acknowledgment unlocks the form", + "Complete attachment download stays behind entitlement", + "Return records a memo response without an engine entry", + "Mobile review fits the viewport", + "One actual approval entry, stable on browser reload", + "Audit receiver holds all commitments; unsigned decision gap retained", + "Browser creates an approval draft and immutable supporting document revision", + "Browser publication and durable handoff download use the actual lifecycle PDP", + "Prepared request creates no approval or human entry; lifecycle audit is delivered", + "Real workload caller refusal hides review content", + "Sign-out clears the session; all forms carry the exact origin", + "No page errors or injected script execution" + ], + "fixture_transport": "real HTTPS on ephemeral ports; synthetic issuer and TokenReview", + "native_human_login_proven": false, + "native_policy_admitted": false, + "factory_attempts": 0, + "paid_model_calls": 0, + "final_component_state": { + "entries": 1, + "intents": [ + { + "disposition_id": "disp-2e416ff7-a0b8-4e6a-bf5f-fd90e65f9d18", + "approval_id": "fixture", + "subject": "human-fixture", + "state": "confirmed", + "attempt": "a693dad4-6d79-4ab3-b177-9a93de7bbdb9", + "approved_at": "2026-10-04T22:44:43+00:00", + "presentation_id": "pres-af92dcbd-51e7-4089-ba1e-76fdffcd9e8c", + "operation_id": "cc819583-73a7-4f24-8723-2aa4aaeb4280" + } + ], + "outbox_states": [ + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered" + ], + "policy_outcomes": [ + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "caller_refused" + ], + "decision_attributable": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "entry_posts": 1, + "consume_calls": 0, + "tokenreviews": 15 + } +} diff --git a/docs/evidence/2026-10-05-lifecycle-authoring/lifecycle-desktop.png b/docs/evidence/2026-10-05-lifecycle-authoring/lifecycle-desktop.png new file mode 100644 index 0000000..0cbfb4f Binary files /dev/null and b/docs/evidence/2026-10-05-lifecycle-authoring/lifecycle-desktop.png differ diff --git a/docs/evidence/2026-10-05-lifecycle-authoring/lifecycle-mobile.png b/docs/evidence/2026-10-05-lifecycle-authoring/lifecycle-mobile.png new file mode 100644 index 0000000..158ff9d Binary files /dev/null and b/docs/evidence/2026-10-05-lifecycle-authoring/lifecycle-mobile.png differ diff --git a/docs/specs/DecisionLifecycleService.md b/docs/specs/DecisionLifecycleService.md index 68c8c1f..8552c8f 100644 --- a/docs/specs/DecisionLifecycleService.md +++ b/docs/specs/DecisionLifecycleService.md @@ -1,7 +1,9 @@ # Decision lifecycle service -Status: product direction requested by the founder on 2026-10-04; architecture -and API below are proposed delivery contracts, not shipped endpoints. +Status: initial audience deployed and human-accepted on 2026-10-05. +This is the product contract, including wider capabilities not yet delivered. +LifecycleHTTPContract.md names the implemented routes and source-only additions; +SCOPE.md and the workplan distinguish current delivery from remaining work. Workplan: `INFD-WP-0007`; intake prerequisite: `INFD-WP-0005`. ## Outcome and boundaries diff --git a/docs/specs/LifecycleHTTPContract.md b/docs/specs/LifecycleHTTPContract.md index 30ed4c6..88554ec 100644 --- a/docs/specs/LifecycleHTTPContract.md +++ b/docs/specs/LifecycleHTTPContract.md @@ -1,8 +1,9 @@ # Lifecycle HTTP adapter — registration, revisions and collaboration -2026-10-04; INFD-WP-0007-T04, in progress. `LifecycleAPI` is an unmounted WSGI -adapter over `LifecycleService`. It starts no listener and adds no route to the -existing browser app or public ingress. +2026-10-05; INFD-WP-0007. `LifecycleAPI` is a WSGI adapter over +`LifecycleService`, mounted on the admitted private 8081 listener. It adds no +route to public ingress. The direct retained-handoff GET is a source addition +awaiting a separate release; the other routes below are deployed. | Method and path | Input | Result | | --- | --- | --- | @@ -11,6 +12,7 @@ existing browser app or public ingress. | `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}/review-requests` | Required positive `version` | Exact retained request receipt/envelope, checked as `lifecycle.events`; no submission | | `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 | @@ -28,8 +30,8 @@ revision numbers and truncated bodies are rejected. Disposition routes are not e Authentication runs before routing or reading a mutation body. The composition must explicitly supply a bearer-token verifier returning an authenticated `Caller`. There is no default or testing fallback. Cookies are not credentials -for this adapter; its future human browser integration must provide a separately -reviewed session/CSRF boundary or admitted bearer identity. Neither the intake SA +for this adapter; the browser workspace uses the separate +verified session/CSRF adapter. Neither the intake SA audience nor the browser's approval-engine access token is implicitly admitted. Every handled command invokes the shared policy-consuming service. Clients cannot @@ -44,13 +46,12 @@ identity, 403 policy denial, 404 unavailable record/route, 405 unsupported metho service or unusable policy evidence. Raw exception text and credentials are never returned. Upstream raw diagnostics are not forwarded. -T04 remains in progress: admitted workload authenticators and API listener -composition. The browser workspace uses verified sessions through a separate -adapter (see LifecycleWorkspaceDelivery.md). Current -tests use the real service/repository/PolicyClient with synthetic authenticator -and PDP transport; they establish adapter behavior, not live identity admission. -Runtime must not expose this adapter before lifecycle policy and independent -event-delivery contracts are accepted and readiness can be satisfied. +The deployed runtime authenticates the selected State Hub service account using +Kubernetes TokenReview and the lifecycle audience. See LifecycleProductionRuntime.md. +Human workspace forms use verified KeyCape sessions through a separate adapter. +Native admission, negative identity tests and initial human acceptance are +recorded in ../evidence/2026-10-05-lifecycle-cutover/. New clients still require +owner-reviewed admission; this contract does not widen identity or listeners. ## Memo commands and packet custody @@ -72,7 +73,7 @@ Idempotency-Key. Register/revise receipts include a relative API resource link `/v1/decisions/{id}?version={version}` and a `workspace_url` at `https://decisions.coulomb.social/decisions/{id}?version={version}`. The latter is an authorized authoring workspace, not a binding review link. The API remains -unmounted. Search cursors are caller/tenant/query scoped, expire after five +private. Search cursors are caller/tenant/query scoped, expire after five minutes and are invalidated on process restart. Every page rechecks policy. Each document has exactly `digest`, `media_type`, `content_base64`. At most 100 diff --git a/docs/specs/LifecycleProductionRuntime.md b/docs/specs/LifecycleProductionRuntime.md index 8adffbe..9efd08a 100644 --- a/docs/specs/LifecycleProductionRuntime.md +++ b/docs/specs/LifecycleProductionRuntime.md @@ -1,8 +1,9 @@ -# Initial production runtime candidate +# Initial production runtime INFD-WP-0007 / INFD-WP-0005. Founder selected net-kingdom-admins humans and -system:serviceaccount:state-hub:state-hub on 2026-10-04. This is a preparation -contract; deployment still requires the concrete rollout approval. +system:serviceaccount:state-hub:state-hub on 2026-10-04. The concrete rollout was approved and accepted on 2026-10-05. +This describes the deployed initial runtime; later artifacts require their own +release verification. ## Identity and listeners @@ -28,11 +29,11 @@ package, version and digest from human review. Only humans can bind via the existing protected review flow. Access Engine keeps the authorization decision; Approval Engine keeps authoritative approval state. -## Initial proposal data +## Admitted initial data `deploy/lifecycle/lifecycle-callers.json` names the exact State Hub account and platform tenant. The intake registry permits only decision-record class and -net-kingdom-admins, with a proposed cap of 50 new attempts per UTC day. Replays +net-kingdom-admins, with a cap of 50 new attempts per UTC day. Replays do not spend a second attempt; uncertain creation never automatically reposts. The cap and routing are configuration, not per-decision deployments. @@ -64,7 +65,7 @@ forwarding route. Queueing is trusted workload execution. New review envelopes are data operations and do not change deployments. The exact lifecycle snapshot id/key is preserved without inventing a Hub relationship when serves is absent. Lost replies replay the same envelope; native-create uncertainty blocks the row. -Native deployment/acceptance still remains open. Notification/write-back receiver +Native deployment and human acceptance passed. Notification/write-back receiver admission is unchanged; INFD_HUB_WRITEBACK remains off. The image migrates schema 2 to schema 5 on open. Existing records, presentations, @@ -74,16 +75,16 @@ new evidence, and restore a verified pre-cutover copy on a separate private volu only under the reviewed recovery plan. An in-flight/unresolved human submission must be reconciled, never retried or discarded to make rollback convenient. -## Remaining live gates +## Observed acceptance and remaining scope -- CCR-2026-0032 requester projection: current namespace has only the audit - ExternalSecret; requester custody, native identity/path refusals and rotation - evidence remain with RPF-WP-0051. -- FLEX-WP-0033: publish scanned policy artifact; approve exact policy/RBAC/network - objects and native caller/subject allow/refusal tests. -- INFD-WP-0005 / STATE-WP-0096: activate and verify the prepared private State Hub - dispatcher and its projected tokens; retain gated resolution write-back. -- INFD-WP-0007-T06/T07: backup/restore, exact rollout approval, native human review - and independent audit receipts across two decisions and a revision with unchanged - release/configuration pins. Browser packet authoring and broader binding profiles - remain on T05; the supported review handoff profile is documented separately. +CCR-2026-0032 delivery is applied: actual receiving mount/exchange and bounded +identity/path/scope/revocation tests passed. FLEX-WP-0033 and STATE-WP-0096 +completed native deployment and human acceptance. INFD-WP-0007-T06/T07 retain +backup/restore, caller isolation, unchanged pins across two records/a revision, +restart retention and independent audit proof. See +../evidence/2026-10-05-lifecycle-cutover/acceptance-summary.json. + +Issuer rotation remains separately governed under Platform RPF-IN-0004. +Browser authoring continuation is T05; new source features do not imply a +production update. Wider identity/binding/publication and optional Hub write-back +contracts remain explicit T02 work. No new caller, group or listener is admitted. diff --git a/docs/specs/LifecycleReviewHandoff.md b/docs/specs/LifecycleReviewHandoff.md index f27ab2a..fb6d5f5 100644 --- a/docs/specs/LifecycleReviewHandoff.md +++ b/docs/specs/LifecycleReviewHandoff.md @@ -1,6 +1,8 @@ # Revision-specific lifecycle review handoff -2026-10-04; INFD-WP-0007-T02 through T06. Source implementation, not deployed. +2026-10-05; INFD-WP-0007-T02/T05. The initial handoff is deployed and +human-accepted; browser document authoring and direct handoff retrieval below +are the next source increment, not yet deployed. ## Headless contract @@ -22,8 +24,9 @@ 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 +The admitted State Hub dispatcher performs explicit forwarding. Browser packet +authoring and handoff preparation now have a source implementation; broader +L0/L2 binding and automatic discovery remain separate contracts. 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. @@ -75,8 +78,8 @@ 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. +synthetic. Native initial-audience grants, forwarding, human acceptance and +restart/audit evidence are recorded in ../evidence/2026-10-05-lifecycle-cutover/. ## Migration and rollback boundary @@ -88,3 +91,37 @@ 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. + +## Browser authoring and retained handoff retrieval + +New browser drafts explicitly select organizational human approval or discussion +only. Existing rendered forms without that field retain the earlier draft type; +there is no silent conversion of existing records. Discussion drafts cannot +enter the approval handoff. The shared structural profile check is descriptive, +not an authorization grant. + +A text document is appended through the shared revise command with its actual +UTF-8 bytes, digest and optional required highlight. The complete form is limited +to 64 KiB by the existing adapter; large/binary packets remain available through +the bounded API. Previous packets and highlights are preserved, and a new +revision requires new publication. Comparison shows changed documents/digests +and acknowledgment text as well as the question and brief. + +The browser prepares the same immutable request with an explicit approval purpose +and responsible principal. Its initial routing is State Hub / decision-record / +net-kingdom-admins. This route is checked by the existing PDP, and intake still +authenticates the actual submitter and assigns the configured named recipient. +Preparation alone does not enqueue, create an approval, notify or bind. + +`GET /v1/decisions/{id}/review-requests?version=N` returns the exact retained +`event_id`, `review_memo_id` and `envelope`. It requires a fresh `lifecycle.events` +decision for that revision, preserving the permission already required to read +the envelope in history. Missing requests return 404; invalid/nonpositive versions +return 400. Reads do not create requests or repeat external effects. Historical +exports remain historical: intake refuses a stale source revision. + +The browser equivalent `/decisions/{id}/review-request?version=N` downloads +only the envelope as an attachment. It uses the same service check, without +credential copying or a public privileged forwarding API. A prepared current +request links to this download; a loaded request links to protected human review. +The original idempotency identity survives download, retry and process restart. diff --git a/docs/specs/LifecycleServiceContract.md b/docs/specs/LifecycleServiceContract.md index 00ddea5..86d5473 100644 --- a/docs/specs/LifecycleServiceContract.md +++ b/docs/specs/LifecycleServiceContract.md @@ -3,7 +3,7 @@ 2026-10-04; `INFD-WP-0007-T02/T03`. Implemented in `informed_decision/lifecycle_service.py`, above `LifecycleRepository`. This is a headless Python application interface. An optional runtime composition -enables the browser workspace; production admission is still outstanding. +enables the browser workspace; the initial audience is deployed and accepted. ## Caller and authority @@ -21,7 +21,7 @@ The existing intake TokenReview audience is not silently reused for this service Non-cluster/workload composition remains T02/T04 work. The service consumes `PolicyClient` observations. It neither evaluates policy -nor falls back to local permissions. New proposed action names are +nor falls back to local permissions. The admitted action names are `lifecycle.register`, `lifecycle.revise`, `lifecycle.source`, `lifecycle.evaluation`, `lifecycle.comment`, `lifecycle.read`, `lifecycle.events`, `lifecycle.publish`, `lifecycle.search`, `lifecycle.packet`, @@ -93,8 +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 source handoff now follows LifecycleReviewHandoff.md. Production forwarding -and broader review profiles remain open. +presentation/disposition path; the source handoff now follows LifecycleReviewHandoff.md. The selected State Hub dispatcher forwards explicit requests; 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 @@ -108,3 +108,8 @@ Production candidate record facts also include `revision_by`, the immutable acto of the requested revision. This lets its editor retain access to an unpublished successor after refining a published decision. Original authorship remains `registered_by`; both facts participate in the same transaction/read race guard. + +The next source increment adds `review_handoff(caller, memo_id, version)`, a +revision-specific read of the exact immutable request under `lifecycle.events`. +It reuses the event permission and post-read currency checks, adds no grant, and +never forwards or regenerates the envelope. See LifecycleReviewHandoff.md. diff --git a/docs/specs/LifecycleWorkspaceDelivery.md b/docs/specs/LifecycleWorkspaceDelivery.md index 4fff1f6..3a3b378 100644 --- a/docs/specs/LifecycleWorkspaceDelivery.md +++ b/docs/specs/LifecycleWorkspaceDelivery.md @@ -1,6 +1,7 @@ # Lifecycle workspace and delivery -2026-10-04; INFD-WP-0007-T02 through T06, source implementation. +2026-10-05; INFD-WP-0007-T02/T05. Initial workspace deployed; document +authoring and review-request preparation are implemented for the next release. ## Human workspace @@ -10,7 +11,8 @@ sessions establish identity; existing origin and CSRF checks protect forms. Users can search, prepare a draft, record source metadata and evaluations, comment, publish to explicit members/groups, refine, compare consecutive revisions and download authorized packet entries. Source content is not fetched. -Packet uploads currently use the API. All forms receive fresh policy checks; +The next release adds short text documents and optional required highlights +through immutable revisions. Large or binary packets continue to use the API. All forms receive fresh policy checks; rendering a button grants no authority. Publication records an audience for one revision. Access Engine determines access @@ -33,7 +35,8 @@ requires explicit `lifecycle_delivery: true` with that package, and generates exact egress and an owner-review ingress proposal. It references the existing mounted workload policy credential path; this does not authorize that workload for a new package. Existing admission inputs render without lifecycle changes. -The API still requires its own admitted bearer verifier and listener composition. +The deployed private API has its own admitted TokenReview verifier and separate +listener; browser sessions are not bearer credentials there. ## Independent evidence @@ -57,15 +60,21 @@ records, publishes/comments/refines and drains five events with unchanged runtim configuration bytes. Its identity and PDP transport are synthetic. It proves source behavior, not deployed workload authority or all seven acceptance cases. -## Remaining delivery work +## Delivery boundary and continuation -T02/T04 require owner-admitted lifecycle policy actions/context, workload identity -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. +The initial rollout passed native agent-to-human-to-agent acceptance, restart +retention, public/private isolation and Audit Core reconciliation. See +../evidence/2026-10-05-lifecycle-cutover/. Those receipts describe the deployed +804d89fb image; they do not prove the next source increment is live. -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. +Browser authoring now exposes explicit approval/discussion draft types, document +addition with required acknowledgment, packet/highlight comparisons, and durable +review-request preparation/download. The adapter calls only the shared service; +no store access, local permit or automatic privileged forwarding is introduced. +LifecycleReviewHandoff.md specifies the shared retrieval and profile contract. + +Review overview discovery remains exact-record-only under compact-sitting v12. +The lifecycle workspace discovers authorized records and exposes their outcomes. +A later change to legacy overview discovery needs an Access Engine contract, +not a consumer-side policy bypass. Broader binding profiles, external identities, +source connectors and optional Hub write-back remain T02/T05 work. diff --git a/informed_decision/lifecycle_http.py b/informed_decision/lifecycle_http.py index d97bcff..df7c8be 100644 --- a/informed_decision/lifecycle_http.py +++ b/informed_decision/lifecycle_http.py @@ -99,6 +99,10 @@ class LifecycleAPI: if set(params) != {"version"}: raise ValueError() return 200, self.service.outcomes(caller, memo_id, int(params["version"][0])) + if method == "GET" and resource == "review-requests": + if set(params) != {"version"}: + raise ValueError() + return 200, self.service.review_handoff(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", "review-requests"}: raise LifecycleError(405, "method_not_allowed") diff --git a/informed_decision/lifecycle_review.py b/informed_decision/lifecycle_review.py index ffbbfc6..27c7898 100644 --- a/informed_decision/lifecycle_review.py +++ b/informed_decision/lifecycle_review.py @@ -10,6 +10,26 @@ from .records import dumps, memo_from from .store import Conflict, EvidenceUnavailable +def supports_review(memo): + """Structural compatibility only; publication and authorization remain required.""" + return (memo.locale == 'en' and memo.binding_level is BindingLevel.ORGANIZATIONAL + and memo.step_kind is StepKind.APPROVE and memo.ui_release == UI_RELEASE) + + +def handoff(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 id,body FROM lifecycle_events WHERE tenant=? AND memo_id=? " + "AND version=? AND kind='review_requested'", (tenant, memo_id, version)).fetchone() + if row is None: + raise EvidenceUnavailable('review request unavailable') + body = json.loads(row['body']) + return {'event_id': row['id'], **{k: body[k] for k in ('review_memo_id', 'envelope')}} + + 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() @@ -53,8 +73,7 @@ def prepare(repository, context, memo_id, version, options, *, key, guard): 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): + if not supports_review(memo): 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') diff --git a/informed_decision/lifecycle_service.py b/informed_decision/lifecycle_service.py index b5a34b7..cf3ddbe 100644 --- a/informed_decision/lifecycle_service.py +++ b/informed_decision/lifecycle_service.py @@ -182,6 +182,14 @@ class LifecycleService: current() return result + def review_handoff(self, caller, memo_id, version): + """Retrieve the exact retained event; this never resubmits an approval.""" + from .lifecycle_review import handoff + current, _ = self._authorize(caller, memo_id, "events", {"version": version}) + result = handoff(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 5ed6748..6cf032e 100644 --- a/informed_decision/lifecycle_ui.py +++ b/informed_decision/lifecycle_ui.py @@ -1,13 +1,16 @@ """Human workspace adapter: forms and rendering, with no storage or policy rules.""" from dataclasses import replace +import base64 +import hashlib import re from urllib.parse import urlencode import uuid from .lifecycle_commands import decode_memo from .lifecycle_service import Caller, LifecycleError -from .memo import Memo, BindingLevel, BindingSlice, Principal, Scope, StepKind +from .lifecycle_review import supports_review +from .memo import Memo, BindingLevel, BindingSlice, Principal, Scope, StepKind, PacketItem, Highlight from .records import dumps from .store import EvidenceUnavailable from .ui import document, hidden, text @@ -59,14 +62,23 @@ class LifecycleWorkspace: if method == "GET": fields = (_textarea("question", "Question") + _textarea("brief", "Context, alternatives and recommendation") + '' - '') + '' + '' + '

Saving a draft does not request or record approval.

') content = '

Prepare a decision

' + _form(path, session, fields, "Save draft") return 200, document("Prepare a decision", content, session.subject), [] if method == "POST": - self._fields(form, {"question", "brief", "scope_id", "scope_label"}) + self._fields(form, {"question", "brief", "scope_id", "scope_label"}, {"review_profile"}) + profile = form.get("review_profile", "legacy") + if profile not in {"approval", "discussion", "legacy"}: + raise ValueError() memo = Memo(id=str(uuid.uuid4()), version=1, question=form["question"], brief=form["brief"], - requested_act="accept", binding_level=BindingLevel.ORGANIZATIONAL, - step_kind=StepKind.MITZEICHNUNG, + requested_act="approve" if profile == "approval" else "comment" if profile == "discussion" else "accept", + binding_level=BindingLevel.ORGANIZATIONAL, + step_kind={"approval": StepKind.APPROVE, "discussion": StepKind.COMMENT, + "legacy": StepKind.MITZEICHNUNG}[profile], binding=BindingSlice(Principal("pending-human-session", "person", "To be assigned"), Scope("resource", form["scope_id"], form["scope_label"]))) # Stable create identity across an HTTP retry of the same rendered form. @@ -74,10 +86,16 @@ class LifecycleWorkspace: dumps([caller.attribution.tenant, session.subject, form["key"]])))) self.service.register(caller, decode_memo(json.loads(dumps(memo))), key=form["key"]) return 303, "", [("Location", "/decisions/" + memo.id)] - match = re.fullmatch(r"/decisions/([A-Za-z0-9._:-]{1,256})(?:/(revise|comment|publish|source|evaluate|packet/([0-9]{1,3})))?", path) + match = re.fullmatch(r"/decisions/([A-Za-z0-9._:-]{1,256})(?:/(revise|comment|publish|source|evaluate|document|request-review|review-request|packet/([0-9]{1,3})))?", path) if not match: return 404, "Not found.", [] memo_id, action, index = match.groups() + if method == "GET" and action == "review-request": + if set(params) != {"version"}: + raise ValueError() + receipt = self.service.review_handoff(caller, memo_id, int(params["version"])) + return 200, dumps(receipt["envelope"]).encode(), [ + ("Content-Disposition", 'attachment; filename="decision-review-request.json"')] if method == "GET" and (action is None or index is not None): if set(params) - {"version", "after"}: raise ValueError() @@ -105,16 +123,40 @@ class LifecycleWorkspace: if error.status != 403: raise return 200, self._page(memo, events, session, previous, outcome), [] - if method == "POST" and action in {"revise", "comment", "publish", "source", "evaluate"}: + if method == "POST" and action in {"revise", "comment", "publish", "source", "evaluate", "document", "request-review"}: if params: raise ValueError() fields = {"revise": {"version", "question", "brief", "summary"}, "comment": {"version", "text"}, "publish": {"version", "subjects", "groups"}, "source": {"version", "reference", "retrieved_at", "digest", "summary"}, - "evaluate": {"version", "assessment", "source_ids", "uncertainty"}}[action] + "evaluate": {"version", "assessment", "source_ids", "uncertainty"}, + "document": {"version", "label", "content", "highlight", "summary"}, + "request-review": {"version", "principal", "purpose"}}[action] self._fields(form, fields) version = int(form["version"]) - if action == "revise": + if action == "document": + previous = self.service.retrieve(caller, memo_id, version) + if not form["content"].strip() or not form["label"].strip(): + raise ValueError() + content = form["content"].encode() + digest = "sha256:" + hashlib.sha256(content).hexdigest() + item_id = "doc-" + form["key"] + highlights = previous.highlights + if form["highlight"].strip(): + highlights += (Highlight("highlight-" + form["key"], item_id, form["highlight"], required_ack=True),) + memo = previous.next_version(packet=previous.packet + (PacketItem(item_id, form["label"], digest),), + highlights=highlights) + self.service.revise(caller, decode_memo(json.loads(dumps(memo))), expected_version=version, + summary=form["summary"], key=form["key"], documents=[{"digest": digest, + "media_type": "text/plain", "content_base64": base64.b64encode(content).decode()}]) + elif action == "request-review": + memo = self.service.retrieve(caller, memo_id, version) + self.service.request_review(caller, memo_id, version, key=form["key"], options={ + "submitter": "state-hub", "memo_class": "decision-record", "review_group": "net-kingdom-admins", + "approval_intent": {"action": "accept", "principal": form["principal"], "purpose": form["purpose"], + "target": {"id": memo.binding.target.id, "type": memo.binding.target.kind, + "system": "informed-decision"}}}) + elif action == "revise": previous = self.service.retrieve(caller, memo_id, version) memo = previous.next_version(question=form["question"], brief=form["brief"]) self.service.revise(caller, decode_memo(json.loads(dumps(memo))), expected_version=version, @@ -132,8 +174,9 @@ class LifecycleWorkspace: return 405, "Method not allowed.", [] @staticmethod - def _fields(form, required): - if not isinstance(form, dict) or set(form) != required | {"csrf", "key"}: + def _fields(form, required, optional=frozenset()): + required = required | {"csrf", "key"} + if not isinstance(form, dict) or not required <= form.keys() or form.keys() - required - optional: raise ValueError() uuid.UUID(form["key"]) @@ -155,6 +198,9 @@ class LifecycleWorkspace: content += '

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

' else: content += '

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

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

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

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

{text(answer["actor"]["sub"])}: {text(answer["verb"])}' @@ -162,6 +208,21 @@ class LifecycleWorkspace: content += ' · approval entry ' + text(item["submission_state"]) content += '

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

Prepare human review

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

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

' + '

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

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

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

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

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

' if previous is not None: content += f'
Compare with revision {previous.version}' for field, label in (("question", "Question"), ("brief", "Context and recommendation")): @@ -169,6 +230,22 @@ class LifecycleWorkspace: if before != after: content += (f'

{label}

Before

{text(before)}

' f'

After

{text(after)}

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

Supporting documents changed

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

{label}

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

Review acknowledgments changed

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

{label}

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

Supporting documents

Add a comment

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

The document becomes part of a new revision. Earlier documents and answers remain unchanged. ' + 'Publish the new revision before requesting review. This form accepts short text documents; ' + 'larger or binary documents can be supplied by an agent.

') + content += _form(base + "/document", session, revision + _textarea("label", "Document title") + + _textarea("content", "Document text") + + '' + + _textarea("summary", "What changed"), "Save document in new revision") + '
' content += '
Refine this decision' content += _form(base + "/revise", session, revision + _textarea("question", "Question", memo.question) + _textarea("brief", "Context and recommendation", memo.brief) diff --git a/tests/browser_fixture.py b/tests/browser_fixture.py index 8b69e29..06064f3 100644 --- a/tests/browser_fixture.py +++ b/tests/browser_fixture.py @@ -12,6 +12,7 @@ import threading import base64 import hashlib import ssl +from contextlib import ExitStack from datetime import datetime, timedelta, timezone from urllib.parse import parse_qs, urlsplit from wsgiref.simple_server import WSGIRequestHandler, make_server @@ -69,11 +70,20 @@ def main(): issuer.params={k:v[0] for k,v in parse_qs(urlsplit(url).query).items()} return url,browser login.start=start - with flex_component(os.environ['INFD_FLEX_AUTH_BINARY'],directory,key) as (policy,calls): + with flex_component(os.environ['INFD_FLEX_AUTH_BINARY'],directory,key) as (policy,calls), ExitStack() as components: controller.policy=policy - pump=AuditPump(controller.store,sink);pump.tick() + lifecycle = None + if os.environ.get('INFD_FLEX_AUTH_SOURCE'): + from informed_decision.lifecycle import LifecycleRepository + from informed_decision.lifecycle_service import LifecycleService + lifecycle_directory = directory/'lifecycle-policy'; lifecycle_directory.mkdir() + lifecycle_policy, _ = components.enter_context(flex_component( + os.environ['INFD_FLEX_AUTH_BINARY'], lifecycle_directory, key, + fixture=Path(os.environ['INFD_FLEX_AUTH_SOURCE'])/'examples/informed-decision-lifecycle')) + lifecycle = LifecycleService(LifecycleRepository(controller.store), lifecycle_policy) + pump=AuditPump(controller.store,sink,lifecycle_delivery=lifecycle is not None);pump.tick() assert pump.ready() - app=App(login,controller,readiness=pump.ready) + app=App(login,controller,readiness=pump.ready,lifecycle=lifecycle) def fixture(environ,start_response): path=environ.get('PATH_INFO') if path=='/authorize': @@ -104,7 +114,8 @@ def main(): tls_thread=threading.Thread(target=tls_server.serve_forever,daemon=True);tls_thread.start() with make_server('127.0.0.1',0,fixture,handler_class=Quiet) as server: (directory/'server.json').write_text(json.dumps({'origin':f'http://127.0.0.1:{server.server_port}', - 'public_origin':public_origin,'issuer':issuer_origin,'certificate_spki':spki,'memo_id':memo.id,'fixture_only':True})+'\n') + 'public_origin':public_origin,'issuer':issuer_origin,'certificate_spki':spki,'memo_id':memo.id, + 'lifecycle':lifecycle is not None,'fixture_only':True})+'\n') print('Browser fixture ready',flush=True) try:server.serve_forever() except KeyboardInterrupt:pass diff --git a/tests/browser_review.mjs b/tests/browser_review.mjs index e176ba7..70b3e64 100644 --- a/tests/browser_review.mjs +++ b/tests/browser_review.mjs @@ -88,6 +88,59 @@ try { assert(state.decision_attributable.every(value=>value===0)); checks.push('One actual approval entry, stable on browser reload'); checks.push('Audit receiver holds all commitments; unsigned decision gap retained'); + if (fixture.lifecycle) { + await page.goto(fixture.public_origin+'/decisions'); + await page.getByRole('link',{name:'Prepare a decision',exact:true}).click(); + await page.getByLabel('Question',{exact:true}).fill('Approve the browser-authored storage proposal?'); + await page.getByLabel('Context, alternatives and recommendation',{exact:true}).fill('Compare recovery time and costs.'); + await page.getByLabel('Subject or scope identifier',{exact:true}).fill('storage'); + await page.getByLabel('Subject or scope label',{exact:true}).fill('Storage strategy'); + await page.getByLabel('Intended review',{exact:true}).selectOption('approval'); + await page.getByRole('button',{name:'Save draft',exact:true}).click(); + const decisionPath=new URL(page.url()).pathname; + await page.getByText('Add a supporting document',{exact:true}).click(); + await page.getByLabel('Document title',{exact:true}).fill('Recovery proposal'); + await page.getByLabel('Document text',{exact:true}).fill('Recover in one hour. Assess cost and operational risk.'); + await page.getByLabel('Required acknowledgment (optional)',{exact:true}).fill('The proposal concerns production.'); + const documentForm=page.locator('form[action$="/document"]'); + await documentForm.getByLabel('What changed',{exact:true}).fill('Added the review document'); + await documentForm.getByRole('button',{name:'Save document in new revision',exact:true}).click(); + await page.getByText('Revision 2 · Storage strategy',{exact:true}).waitFor(); + checks.push('Browser creates an approval draft and immutable supporting document revision'); + await page.getByText('Compare with revision 1',{exact:true}).click(); + await page.getByRole('heading',{name:'Supporting documents changed',exact:true}).waitFor(); + await page.getByRole('heading',{name:'Review acknowledgments changed',exact:true}).waitFor(); + await page.getByText('Publish for discussion',{exact:true}).click(); + await page.getByLabel('Groups',{exact:true}).fill('net-kingdom-admins'); + await page.getByRole('button',{name:'Publish this revision',exact:true}).click(); + await page.getByLabel('Responsible party or system',{exact:true}).fill('storage-owner'); + await page.getByLabel('Purpose of this approval',{exact:true}).fill('Review the recovery proposal'); + await page.getByRole('button',{name:'Prepare review request',exact:true}).click(); + const [requestDownload]=await Promise.all([ + page.waitForEvent('download'),page.getByRole('link',{name:'Download review request',exact:true}).click() + ]); + const envelope=JSON.parse(await fs.readFile(await requestDownload.path(),'utf8')); + assert.equal(envelope.memo.step_kind,'approve'); + assert.equal(envelope.memo.highlights[0].required_ack,true); + assert.equal(envelope.packet[0].content,'Recover in one hour. Assess cost and operational risk.'); + assert.equal(envelope.memo.binding.principal.id,'pending-human-session'); + assert.equal(envelope.idempotency_key,envelope.memo.id); + await page.reload(); + assert.equal(await page.getByRole('link',{name:'Download review request',exact:true}).count(),1); + checks.push('Browser publication and durable handoff download use the actual lifecycle PDP'); + await page.setViewportSize({width:390,height:844}); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + await page.screenshot({path:path.join(directory,'lifecycle-mobile.png'),fullPage:true}); + await page.setViewportSize({width:1360,height:1000}); + await page.screenshot({path:path.join(directory,'lifecycle-desktop.png'),fullPage:true}); + state=await (await fetch(fixture.origin+'/_fixture/status')).json(); + assert.equal(state.entries,1);assert.equal(state.entry_posts,1);assert.equal(state.consume_calls,0); + assert(state.outbox_states.every(s=>s==='delivered')); + checks.push('Prepared request creates no approval or human entry; lifecycle audit is delivered'); + await page.goto(fixture.public_origin+'/decisions'); + assert.equal(await page.locator('a[href="'+decisionPath+'"]').count(),1); + await page.goto(fixture.public_origin+'/review?memo_id='+encodeURIComponent(fixture.memo_id)); + } await fetch(fixture.origin+'/_fixture/refuse-caller'); await page.reload();await page.getByRole('heading',{name:'Review unavailable'}).waitFor(); assert(!(await page.content()).includes('private-brief-sentinel')); diff --git a/tests/test_lifecycle_review.py b/tests/test_lifecycle_review.py index ef238e1..0623845 100644 --- a/tests/test_lifecycle_review.py +++ b/tests/test_lifecycle_review.py @@ -355,3 +355,24 @@ def test_intake_policy_guard_rechecks_retained_metadata(handoff): with pytest.raises(Conflict, match='policy observation'): store.present(review.id, principal_sub=session.subject, tenant=session.tenant, principal_type=session.principal_type, policy_id=policy_id) + + +def test_retained_handoff_api_is_revision_scoped_and_reauthorizes(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 _: actor) + path = '/v1/decisions/' + memo.id + '/review-requests' + assert call(api, path, QUERY_STRING='version=1')[0] == 404 + receipt = prepare(handoff) + assert call(api, path, QUERY_STRING='version=1')[:2] == (200, receipt) + assert not calls + for query in ('', 'version=0', 'version=1&extra=x', 'version=1&version=1'): + assert call(api, path, QUERY_STRING=query)[0] == 400 + app.revise(actor, memo.next_version(brief='Next revision'), expected_version=1, summary='Refined', key='revise') + assert call(api, path, QUERY_STRING='version=2')[0] == 404 + # Historical export is evidence, never permission to resubmit a stale request. + assert call(api, path, QUERY_STRING='version=1')[:2] == (200, receipt) + assert request(intake, receipt['envelope'])[0] == 409 + app.policy.transport.change = lambda data: data.update(effect='deny') + assert call(api, path, query='version=1')[:2] == (403, {'error': 'policy_denied'}) diff --git a/tests/test_lifecycle_ui.py b/tests/test_lifecycle_ui.py index f5ceae7..8abeac2 100644 --- a/tests/test_lifecycle_ui.py +++ b/tests/test_lifecycle_ui.py @@ -90,3 +90,93 @@ def test_workspace_escapes_content_and_policy_failure_returns_no_memo(browser): lifecycle.policy.transport.change = lambda data: data.update(effect="deny") response = call(app, path, cookie=cookie) assert response["status"] == 403 and "Compare cost" not in response["body"] + + +def approval_draft(browser): + response = post(browser, '/decisions/new/prepare', question='Approve the storage proposal?', + brief='Compare recovery time and costs.', scope_id='storage', scope_label='Storage strategy', + review_profile='approval') + assert response['status'] == 303 + return dict(response['headers'])['Location'] + + +def test_browser_authors_packet_and_exports_exact_durable_review_handoff(browser): + import json + from informed_decision.lifecycle import LifecycleRepository + from informed_decision.lifecycle_service import LifecycleService + from informed_decision.store import Store + app, lifecycle, session, cookie = browser + path = approval_draft(browser) + fields = dict(version='1', label='Storage proposal', content='Recover in one hour. Cost: 50 EUR.', + highlight='This is a production change.', summary='Added the proposal', key=str(uuid.uuid4())) + assert post(browser, path+'/document', **fields)['status'] == 303 + assert post(browser, path+'/document', **fields)['status'] == 303 + memo_id = path.split('/')[-1] + assert lifecycle.repository.store.memo(memo_id).version == 2 + assert lifecycle.repository.store.memo(memo_id, 1).packet == () + packet = call(app, path+'/packet/0', cookie=cookie, query='version=2') + assert packet['status'] == 200 and packet['body'] == fields['content'] + comparison = call(app, path, cookie=cookie)['body'] + assert 'Supporting documents changed' in comparison and 'Review acknowledgments changed' in comparison + assert fields['highlight'] in comparison + assert post(browser, path+'/publish', version='2', subjects='', groups='net-kingdom-admins')['status'] == 303 + review = dict(version='2', principal='storage-owner', purpose='Approve the recovery proposal', key=str(uuid.uuid4())) + assert post(browser, path+'/request-review', **review)['status'] == 303 + assert post(browser, path+'/request-review', **review)['status'] == 303 + page = call(app, path, cookie=cookie) + assert 'Download review request' in page['body'] and 'has not created an approval' in page['body'] + exported = call(app, path+'/review-request', cookie=cookie, query='version=2') + assert exported['status'] == 200 and 'attachment;' in dict(exported['headers'])['Content-Disposition'] + envelope = json.loads(exported['body']) + assert envelope['packet'][0]['content'] == fields['content'] + assert envelope['memo']['highlights'][0]['required_ack'] is True + assert envelope['memo']['binding']['principal']['id'] == 'pending-human-session' + assert envelope['approval_intent']['principal'] == 'storage-owner' + assert envelope['memo_class'] == 'decision-record' and envelope['review_group'] == 'net-kingdom-admins' + reopened = LifecycleService(LifecycleRepository(Store(lifecycle.repository.store.path)), lifecycle.policy) + retained = reopened.review_handoff(Caller.from_human_session(session), memo_id, 2) + assert retained['envelope'] == envelope + with reopened.repository.store._connection() as db: + assert db.execute('select count(*) from intake_ledger').fetchone()[0] == 0 + assert db.execute('select count(*) from submissions').fetchone()[0] == 0 + assert post(browser, path+'/revise', version='2', question='Approve the revised storage proposal?', + brief='A different proposal.', summary='Changed the proposal')['status'] == 303 + current = call(app, path, cookie=cookie)['body'] + assert 'Download review request' not in current + assert 'earlier revision' in call(app, path, cookie=cookie, query='version=2')['body'] + + +def test_packet_conflict_and_rejected_content_leave_no_partial_revision(browser): + app, lifecycle, session, cookie = browser + path = approval_draft(browser) + fields = dict(version='1', label='Document', content='Supporting text', highlight='', summary='Add document') + assert post(browser, path+'/document', **fields)['status'] == 303 + assert post(browser, path+'/document', **{**fields, 'content':'Concurrent replacement'})['status'] == 409 + assert post(browser, path+'/document', **{**fields, 'version':'2', 'content':'-----BEGIN OPENSSH PRIVATE KEY-----'})['status'] == 400 + assert lifecycle.repository.store.memo(path.split('/')[-1]).version == 2 + assert len(lifecycle.repository.store.memo(path.split('/')[-1]).packet) == 1 + assert call(app, path+'/review-request', cookie=cookie, query='version=2')['status'] == 404 + + +def test_review_and_document_mutations_require_policy_origin_and_csrf(browser): + app, lifecycle, session, cookie = browser + path = approval_draft(browser) + fields = dict(version='1', label='Document', content='Supporting text', highlight='', summary='Add document') + assert post(browser, path+'/document', **fields, csrf='wrong')['status'] == 403 + assert post(browser, path+'/request-review', version='1', principal='owner', purpose='Review', csrf='wrong')['status'] == 403 + lifecycle.policy.transport.change = lambda data: data.update(effect='deny') + assert post(browser, path+'/document', **fields)['status'] == 403 + assert call(app, path+'/review-request', cookie=cookie, query='version=1')['status'] == 403 + assert lifecycle.repository.store.memo(path.split('/')[-1]).version == 1 + + +def test_review_profiles_are_explicit_and_no_browser_route_binds(browser): + app, lifecycle, session, cookie = browser + form = call(app, '/decisions/new/prepare', cookie=cookie)['body'] + assert 'Human approval (organizational)' in form and 'Discussion only' in form + response = post(browser, '/decisions/new/prepare', question='Discuss the storage proposal?', brief='Discuss alternatives.', + scope_id='storage', scope_label='Storage', review_profile='discussion') + path = dict(response['headers'])['Location'] + assert 'not supported by the current human approval flow' in call(app, path, cookie=cookie)['body'] + assert post(browser, path+'/request-review', version='1', principal='owner', purpose='Review')['status'] == 400 + assert post(browser, path+'/accept', version='1')['status'] == 404 diff --git a/workplans/INFD-WP-0007-decision-lifecycle-service.md b/workplans/INFD-WP-0007-decision-lifecycle-service.md index 4bacfa0..d4ab5ac 100644 --- a/workplans/INFD-WP-0007-decision-lifecycle-service.md +++ b/workplans/INFD-WP-0007-decision-lifecycle-service.md @@ -371,3 +371,21 @@ lifecycle pending is zero. T03/T04/T06/T07 are done for the admitted initial audience. T02 and T05 remain active for the named broader contracts and UI work. The first reported review error was a truncated URL missing its final UUID segment; the complete link passed read, acknowledgment and acceptance checks. + +## Browser authoring and retained handoff — 2026-10-05 + +T05 now implements explicit approval/discussion drafts, immutable supporting +text documents and required acknowledgments, document-aware revision comparison, +and preparation/download of the exact retained review request. T02's service +and HTTP contracts include exact-version handoff retrieval using the existing +events authorization. No additional permission or database migration is needed. +The admitted State Hub workload still submits the request; preparing it does not +create an approval or bind a human. Historical exports remain retrievable while +stale intake is refused. Existing forms retain their previous draft semantics. + +Validation: 669 passed, one skipped with actual owner components; final focused +checks 50 passed, two optional checks skipped; Chromium 16 checks passed with +synthetic identity, including mobile and desktop inspection. Evidence is retained +in docs/evidence/2026-10-05-lifecycle-authoring/. This source increment is not yet +released; production remains on the accepted initial cutover image. T02/T05 stay +in progress for broader contracts and attribute-based legacy review discovery.