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:
tegwick 2026-10-05 00:48:07 +02:00
parent 52d372df0b
commit 9bc581a547
20 changed files with 632 additions and 74 deletions

View file

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

View 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.

View 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

View file

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

View file

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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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")

View file

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

View file

@ -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:

View file

@ -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}&amp;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)

View file

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

View file

@ -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'));

View file

@ -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'})

View file

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

View file

@ -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.