Add lifecycle browser authoring and durable review handoff retrieval
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
parent
52d372df0b
commit
9bc581a547
20 changed files with 632 additions and 74 deletions
16
SCOPE.md
16
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
|
||||
|
|
|
|||
28
docs/evidence/2026-10-05-lifecycle-authoring/README.md
Normal file
28
docs/evidence/2026-10-05-lifecycle-authoring/README.md
Normal file
|
|
@ -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.
|
||||
154
docs/evidence/2026-10-05-lifecycle-authoring/browser-result.json
Normal file
154
docs/evidence/2026-10-05-lifecycle-authoring/browser-result.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
+ '<label>Subject or scope identifier <input name="scope_id" required maxlength="200"></label>'
|
||||
'<label>Subject or scope label <input name="scope_label" required maxlength="200"></label>')
|
||||
'<label>Subject or scope label <input name="scope_label" required maxlength="200"></label>'
|
||||
'<label for="review-profile">Intended review</label><select id="review-profile" name="review_profile" required>'
|
||||
'<option value="approval">Human approval (organizational)</option>'
|
||||
'<option value="discussion">Discussion only</option></select>'
|
||||
'<p>Saving a draft does not request or record approval.</p>')
|
||||
content = '<h1>Prepare a decision</h1>' + _form(path, session, fields, "Save draft")
|
||||
return 200, document("Prepare a decision", content, session.subject), []
|
||||
if method == "POST":
|
||||
self._fields(form, {"question", "brief", "scope_id", "scope_label"})
|
||||
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 += '<p>This review belongs to an earlier revision and cannot receive a new answer.</p>'
|
||||
else:
|
||||
content += '<p>Review handoff: ' + text(review["intake_state"].replace("_", " ")) + '</p>'
|
||||
if review["intake_state"] == "not_submitted":
|
||||
content += (f'<p><a href="{text(base)}/review-request?version={memo.version}">Download review request</a>'
|
||||
' for the admitted State Hub agent to submit. Preparing this request has not created an approval.</p>')
|
||||
for item in outcome["dispositions"]:
|
||||
answer = item["disposition"]
|
||||
content += f'<p>{text(answer["actor"]["sub"])}: {text(answer["verb"])}'
|
||||
|
|
@ -162,6 +208,21 @@ class LifecycleWorkspace:
|
|||
content += ' · approval entry ' + text(item["submission_state"])
|
||||
content += '</p>'
|
||||
content += '</section>'
|
||||
elif supports_review(memo):
|
||||
content += '<section class="card"><h2>Prepare human review</h2>'
|
||||
if memo.packet:
|
||||
content += ('<p>Publish this revision before preparing its review. The initial review group is '
|
||||
'net-kingdom-admins, with the configured platform reviewer as recipient.</p>'
|
||||
'<p>Preparation saves a request for the admitted State Hub agent to submit. '
|
||||
'You can download it afterwards; no human answer is recorded here.</p>')
|
||||
content += _form(base + "/request-review", session, revision
|
||||
+ _textarea("principal", "Responsible party or system")
|
||||
+ _textarea("purpose", "Purpose of this approval"), "Prepare review request")
|
||||
else:
|
||||
content += '<p>Add a supporting document, then publish the new revision to prepare human review.</p>'
|
||||
content += '</section>'
|
||||
else:
|
||||
content += '<p>This draft’s review type is not supported by the current human approval flow.</p>'
|
||||
if previous is not None:
|
||||
content += f'<details class="card"><summary>Compare with revision {previous.version}</summary>'
|
||||
for field, label in (("question", "Question"), ("brief", "Context and recommendation")):
|
||||
|
|
@ -169,6 +230,22 @@ class LifecycleWorkspace:
|
|||
if before != after:
|
||||
content += (f'<h3>{label}</h3><p>Before</p><p class="brief">{text(before)}</p>'
|
||||
f'<p>After</p><p class="brief">{text(after)}</p>')
|
||||
if previous.packet != memo.packet:
|
||||
content += '<h3>Supporting documents changed</h3>'
|
||||
for label, version in (("Before", previous), ("After", memo)):
|
||||
content += f'<p>{label}</p><ul>'
|
||||
for index, item in enumerate(version.packet):
|
||||
content += (f'<li><a href="{text(base)}/packet/{index}?version={version.version}">'
|
||||
f'{text(item.label)}</a> · <code>{text(item.hash)}</code></li>')
|
||||
content += '</ul>'
|
||||
if previous.highlights != memo.highlights:
|
||||
content += '<h3>Review acknowledgments changed</h3>'
|
||||
for label, version in (("Before", previous), ("After", memo)):
|
||||
content += f'<p>{label}</p><ul>'
|
||||
for highlight in version.highlights:
|
||||
content += (f'<li>{text(highlight.note)}'
|
||||
+ (' · acknowledgment required' if highlight.required_ack else '') + '</li>')
|
||||
content += '</ul>'
|
||||
content += '</details>'
|
||||
if memo.packet:
|
||||
content += '<section class="card"><h2>Supporting documents</h2><ul>'
|
||||
|
|
@ -189,6 +266,14 @@ class LifecycleWorkspace:
|
|||
content += f'<p><a href="{text(base)}?version={memo.version}&after={events[-1]["sequence"]}">More history</a></p>'
|
||||
content += '</section><section class="card"><h2>Add a comment</h2>'
|
||||
content += _form(base + "/comment", session, revision + _textarea("text", "Comment"), "Add comment") + '</section>'
|
||||
content += '<details class="card"><summary>Add a supporting document</summary>'
|
||||
content += ('<p>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.</p>')
|
||||
content += _form(base + "/document", session, revision + _textarea("label", "Document title")
|
||||
+ _textarea("content", "Document text")
|
||||
+ '<label>Required acknowledgment (optional)<textarea name="highlight"></textarea></label>'
|
||||
+ _textarea("summary", "What changed"), "Save document in new revision") + '</details>'
|
||||
content += '<details class="card"><summary>Refine this decision</summary>'
|
||||
content += _form(base + "/revise", session, revision + _textarea("question", "Question", memo.question)
|
||||
+ _textarea("brief", "Context and recommendation", memo.brief)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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'})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue