Connect lifecycle revisions to protected human review
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
parent
0e79d69e27
commit
f3b71b1521
16 changed files with 649 additions and 23 deletions
11
SCOPE.md
11
SCOPE.md
|
|
@ -17,15 +17,20 @@ creation, research, discussion, publication, refinement, revision comparison and
|
|||
packet downloads. Runtime configuration requires a separate lifecycle policy
|
||||
package. The bearer API adapter remains unmounted pending workload identity and
|
||||
listener admission. Lifecycle records remain outside the legacy approver overview;
|
||||
the human binding handoff is not implemented.
|
||||
a durable revision-specific request now connects supported records to intake
|
||||
and the existing human-only review path. Outcomes retain exact source revision
|
||||
and distinguish prepared/unresolved submissions from confirmed entries. See
|
||||
`docs/specs/LifecycleReviewHandoff.md`; production forwarding is still outstanding.
|
||||
|
||||
Opt-in delivery commits lifecycle evidence through the existing Audit Core
|
||||
outbox, with receipt-based settlement, retries and reconciliation. Current delivery
|
||||
is platform-tenant-only. Pending evidence closes runtime readiness. The deployment
|
||||
renderer supports separately pinned lifecycle policy and exact network proposals;
|
||||
no live configuration was changed. Cross-repository validation: 623 passed,
|
||||
no live configuration was changed. Cross-repository validation: 634 passed,
|
||||
5 skipped, including two agent-created decisions and human collaboration with
|
||||
unchanged runtime configuration and actual Audit Core delivery. Identity and PDP
|
||||
unchanged runtime configuration, actual Approval Engine acceptance of a linked
|
||||
source revision, and Audit Core delivery. Schema 5 prevents older code without
|
||||
source-revision guards from opening the database. Identity and PDP
|
||||
responses in that scenario are synthetic; this is not live acceptance evidence.
|
||||
|
||||
Contracts: `docs/specs/DecisionLifecycleService.md`, `LifecycleFoundationContract.md`,
|
||||
|
|
|
|||
|
|
@ -99,3 +99,7 @@ content, verified and written in the same transaction as the memo/event. New
|
|||
registration requires packet bytes; revisions may retain their current packet's
|
||||
digests. The details and limits are in `LifecycleHTTPContract.md`. Idempotency
|
||||
digests for prior commands with no documents remain compatible.
|
||||
|
||||
Schema 5 adds unique review-handoff indexes and prevents older code without
|
||||
source-revision guards from opening the store. Migration preserves schema-4
|
||||
records. See LifecycleReviewHandoff.md for the rollback boundary.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ existing browser app or public ingress.
|
|||
| `POST /v1/decisions/{id}/publications` | `version`, `audience` with explicit `subjects` and `groups` lists | Durable publication event receipt |
|
||||
| `POST /v1/decisions` | `memo`, optional `documents` | Register an unbound version-one memo and return event/record/revision/resource receipt |
|
||||
| `POST /v1/decisions/{id}/revisions` | `memo`, `expected_version`, `summary`, optional `documents` | Compare-and-append successor revision; same receipt shape |
|
||||
| `POST /v1/decisions/{id}/review-requests` | `version`, `options` per LifecycleReviewHandoff | Durable request receipt and exact intake envelope |
|
||||
| `GET /v1/decisions/{id}/outcomes` | Required positive `version` | Correlated review/disposition evidence and submission state |
|
||||
| `GET /v1/decisions/{id}` | Optional positive integer `version` | Existing Memo JSON for the current or specified revision |
|
||||
| `GET /v1/decisions/{id}/events` | Optional `after` sequence cursor, `limit` 1–100 | `{"events": [...]}` ordered by sequence; use last returned sequence for next page |
|
||||
| `POST /v1/decisions/{id}/comments` | `{"version": 1, "body": {"text": "...", "locator": "optional"}}` | Durable event receipt |
|
||||
|
|
@ -88,3 +90,6 @@ the command including documents; identity and decision freshness are rechecked
|
|||
in the write transaction. Replays are reauthorized and return the original event.
|
||||
Conflicting content for a key, stale revision preconditions and attempts to
|
||||
replace an existing digest's media type produce 409 without a partial write.
|
||||
|
||||
Review handoff, its supported profile and source-revision guards are specified in
|
||||
`LifecycleReviewHandoff.md`. Human acceptance remains on the protected review route.
|
||||
|
|
|
|||
90
docs/specs/LifecycleReviewHandoff.md
Normal file
90
docs/specs/LifecycleReviewHandoff.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Revision-specific lifecycle review handoff
|
||||
|
||||
2026-10-04; INFD-WP-0007-T02 through T06. Source implementation, not deployed.
|
||||
|
||||
## Headless contract
|
||||
|
||||
`POST /v1/decisions/{id}/review-requests` accepts `version` and `options`, with
|
||||
an Idempotency-Key. Options have exactly `submitter`, `memo_class`, `review_group`
|
||||
and `approval_intent` (the existing intake action/principal/purpose/target object).
|
||||
A fresh `lifecycle.request_review` policy observation covers the complete command;
|
||||
`context.review_routing` exposes these routing/act facts to the PDP. Publication
|
||||
is required for that current revision. Publication itself never initiates review.
|
||||
|
||||
The service atomically retains one immutable `review_requested` event per source
|
||||
revision, its source attribution, and an intake envelope under a fresh review
|
||||
memo id. Retries return the retained request; different commands for the same
|
||||
key or a second request for that revision conflict. This creates neither an
|
||||
approval nor a disposition. The envelope and event fit within 256 KiB. An admitted
|
||||
workload submits the exact returned envelope to the existing private intake.
|
||||
The envelope is also retained in authorized event history for recovery.
|
||||
|
||||
The handoff currently supports the platform tenant, English organizational
|
||||
`approve` memos at the current renderer release, and the intake's nonempty UTF-8
|
||||
packet profile. Unsupported projections are refused rather than dropping fields.
|
||||
Broader L0/L2 profiles, browser packet authoring and automated forwarding remain
|
||||
live work in T02/T04/T05 and WP-0005. There is no automatic submitter or default
|
||||
workload credential. A caller-selected submitter is a routing request, not identity.
|
||||
The local use of intake's shape validator does not grant admission: intake always
|
||||
reauthenticates the workload and reloads its actual grant and recipient mapping.
|
||||
|
||||
## Immutable snapshot and uncertainty
|
||||
|
||||
The original lifecycle memo remains unchanged. Intake alone assigns the named
|
||||
human and the engine-returned approval pins to the protected review snapshot.
|
||||
The snapshot's local version is one; the immutable request and presentation
|
||||
content link it to the original source id and revision. The human review page
|
||||
shows that source revision. The shared Decision Memo schema and signature rules
|
||||
are unchanged; the source link is provenance, not a new authorization claim.
|
||||
|
||||
Before reserving engine creation and before local loading, intake checks exact
|
||||
envelope equality, the designated submitter and source currency. Any change
|
||||
fails closed. Existing durable `creating`/`created` receipts retain uncertainty;
|
||||
a lost create reply is never automatically reposted. A revision during creation
|
||||
can leave an engine object with a retained receipt but no reviewable snapshot.
|
||||
This needs existing explicit recovery/reconciliation, not an automatic retry or
|
||||
an inferred revocation. Approval Engine remains the sole approval-state owner.
|
||||
|
||||
Direct import and revision of reserved review snapshot ids are refused. Source
|
||||
revisions invalidate subsequent presentation, acknowledgment, disposition and
|
||||
entry reservation. Once an entry is in flight or unresolved, source revision is
|
||||
blocked until the original attempt is resolved. This serializes local mutation
|
||||
against external entry attempts without pretending to make the network atomic.
|
||||
Historical accepted evidence remains available; a successor inherits no answer.
|
||||
|
||||
## Outcomes and human workspace
|
||||
|
||||
`GET /v1/decisions/{id}/outcomes?version=N` requires a fresh `lifecycle.outcomes`
|
||||
check. It returns the correlated intake delivery state, source staleness and
|
||||
recorded dispositions with their submission state and confirmation timestamp.
|
||||
These are local evidence and transport states, never cached approval validity.
|
||||
`prepared`, `in_flight` and `unresolved` are not confirmed acceptance.
|
||||
|
||||
The workspace displays authorized outcomes and links a ready, current handoff to
|
||||
`/review?memo_id=...`. That route still requires its independent review PDP checks,
|
||||
verified human identity, fresh presentation and required acknowledgments. Agents
|
||||
can prepare, forward and retrieve; they cannot answer. No lifecycle accept route
|
||||
exists. Audience access and the named human review entitlement are separately
|
||||
checked by their existing policy consumers; a workspace link grants neither.
|
||||
|
||||
## Evidence and delivery
|
||||
|
||||
The request event enters the existing lifecycle commitment queue. Intake posing,
|
||||
presentation, disposition and submission evidence keep their existing independent
|
||||
outboxes and receipt requirements. The integration test creates an approval via
|
||||
the real Approval Engine API, records a signed-fixture human entry, retrieves the
|
||||
confirmed result for source revision two, and delivers lifecycle and review
|
||||
commitments to the real Audit Core receiver. Identity and PDP transport are
|
||||
synthetic. Production grants, private listener/forwarding composition, full
|
||||
release/rollback evidence and separately approved rollout remain outstanding.
|
||||
|
||||
## Migration and rollback boundary
|
||||
|
||||
Opening the store upgrades schema 4 to schema 5 transactionally, adding unique
|
||||
indexes for review snapshot identity and source revision. Existing evidence is
|
||||
preserved. The version change deliberately causes older Store implementations to
|
||||
refuse the database: they do not enforce these source guards. Before activation,
|
||||
retain the normal database backup and image/config pins. An older-image rollback
|
||||
requires a compatible restored database plus reconciliation of any subsequent
|
||||
engine side effects; it cannot safely discard new review/entry evidence. A
|
||||
reviewed forward fix remains preferable once new handoffs have been written.
|
||||
|
|
@ -24,7 +24,8 @@ The service consumes `PolicyClient` observations. It neither evaluates policy
|
|||
nor falls back to local permissions. New proposed action names are
|
||||
`lifecycle.register`, `lifecycle.revise`, `lifecycle.source`,
|
||||
`lifecycle.evaluation`, `lifecycle.comment`, `lifecycle.read`,
|
||||
`lifecycle.events`, `lifecycle.publish`, `lifecycle.search` and `lifecycle.packet`. These are not added to the existing review-action allowlist;
|
||||
`lifecycle.events`, `lifecycle.publish`, `lifecycle.search`, `lifecycle.packet`,
|
||||
`lifecycle.request_review` and `lifecycle.outcomes`. These are not added to the existing review-action allowlist;
|
||||
review and human disposition contracts remain unchanged.
|
||||
|
||||
The request carries the authenticated subject, tenant, resource `memo:<id>`,
|
||||
|
|
@ -92,7 +93,8 @@ Publication appends an immutable event for the current revision with an explicit
|
|||
nonempty audience (at most 32 subjects and 32 groups, no wildcard). A successor
|
||||
revision does not inherit publication. Audience facts are inputs to Access Engine,
|
||||
never local permissions. Human acceptance requires the separate existing
|
||||
presentation/disposition path; the lifecycle-to-review handoff is still open.
|
||||
presentation/disposition path; the source handoff now follows LifecycleReviewHandoff.md. Production forwarding
|
||||
and broader review profiles remain open.
|
||||
|
||||
Before enabling a listener, T02/T04 must provide: reviewed lifecycle PDP package
|
||||
and action/context fixtures; admitted human/workload identity adapters; closed
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ rendering a button grants no authority.
|
|||
Publication records an audience for one revision. Access Engine determines access
|
||||
using authenticated identity and retained record facts. Publication creates no
|
||||
approval and no human disposition. New revisions require their own publication.
|
||||
The workspace explicitly states that human acceptance is unavailable for these
|
||||
drafts. Connecting a lifecycle revision to the existing protected presentation
|
||||
and Approval Engine path remains T03/T05 work.
|
||||
The workspace exposes authorized outcomes and a protected human review link
|
||||
when an exact revision handoff has been loaded by intake. Drafts without that
|
||||
handoff remain unavailable for acceptance. See LifecycleReviewHandoff.md.
|
||||
|
||||
## Runtime and deployment inputs
|
||||
|
||||
|
|
@ -64,7 +64,8 @@ and API ingress. The policy must cover all actions in LifecycleServiceContract,
|
|||
including collection search and per-record checks. Existing compact-sitting pins
|
||||
do not supply these grants. WP-0005 retains private intake composition work.
|
||||
|
||||
T03/T05 require the durable review handoff and human-only acceptance integration.
|
||||
The source handoff now integrates human-only acceptance for the admitted intake
|
||||
profile; T03/T05 retain broader profiles and browser authoring/forwarding work.
|
||||
T06 requires full acceptance evidence, sender/cadence admission, immutable image,
|
||||
database backup/migration and rollback verification. T07 requires the separately
|
||||
approved concrete live rollout. No production pins or resources changed here.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import uuid
|
|||
from .intake import IntakeRefused, SubmitterGrant, canonical, idempotency_outcome, validate_submission
|
||||
from .memo import Principal
|
||||
from .records import dumps
|
||||
from .store import Conflict
|
||||
|
||||
AUDIENCE = "informed-decision-intake"
|
||||
MAX_BODY = 1024 * 1024
|
||||
|
|
@ -104,6 +105,8 @@ class IntakeService:
|
|||
recovered = None
|
||||
# Reserve before any network side effect. SQLite serializes concurrent poses.
|
||||
with self.store._transaction() as db:
|
||||
from .lifecycle_review import check_intake
|
||||
check_intake(db, envelope, submission.submitter_id)
|
||||
previous = db.execute("SELECT * FROM intake_ledger WHERE submitter=? AND key=?",
|
||||
(submission.submitter_id, submission.idempotency_key)).fetchone()
|
||||
if previous:
|
||||
|
|
@ -154,6 +157,7 @@ class IntakeService:
|
|||
binding=replace(submission.memo.binding, principal=Principal(
|
||||
recipient["subject"], "person", recipient["display_name"])))
|
||||
with self.store._transaction() as db:
|
||||
check_intake(db, envelope, submission.submitter_id)
|
||||
current = db.execute("SELECT state,receipt FROM intake_ledger WHERE submitter=? AND key=?",
|
||||
(submission.submitter_id, submission.idempotency_key)).fetchone()
|
||||
if current["state"] == "ready":
|
||||
|
|
@ -203,6 +207,8 @@ class IntakeService:
|
|||
status, body = self.pose(authorization[7:], json.loads(raw))
|
||||
except IntakeError as exc:
|
||||
status, body = exc.status, {"error": exc.code}
|
||||
except Conflict:
|
||||
status, body = 409, {"error": "review_handoff_conflict"}
|
||||
except IntakeRefused as exc:
|
||||
status = 409 if exc.reason == "idempotency_conflict" else 400
|
||||
body = {"error": exc.reason} # No client-controlled field paths in logs/responses.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from .records import dumps
|
|||
from .store import Conflict, EvidenceUnavailable
|
||||
|
||||
MAX_BODY = 256 * 1024
|
||||
ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations|revisions|publications))?")
|
||||
ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations|revisions|publications|review-requests|outcomes))?")
|
||||
|
||||
|
||||
def _object(pairs):
|
||||
|
|
@ -95,8 +95,12 @@ class LifecycleAPI:
|
|||
raise ValueError()
|
||||
return 200, {"events": self.service.events(caller, memo_id,
|
||||
after=int(params.get("after", ["0"])[0]), limit=int(params.get("limit", ["100"])[0]))}
|
||||
if method == "GET" and resource == "outcomes":
|
||||
if set(params) != {"version"}:
|
||||
raise ValueError()
|
||||
return 200, self.service.outcomes(caller, memo_id, int(params["version"][0]))
|
||||
kinds = {"comments": "comment", "sources": "source", "evaluations": "evaluation"}
|
||||
if method != "POST" or resource not in {*kinds, "register", "revisions", "publications"}:
|
||||
if method != "POST" or resource not in {*kinds, "register", "revisions", "publications", "review-requests"}:
|
||||
raise LifecycleError(405, "method_not_allowed")
|
||||
if params:
|
||||
raise ValueError()
|
||||
|
|
@ -115,6 +119,10 @@ class LifecycleAPI:
|
|||
key = environ.get("HTTP_IDEMPOTENCY_KEY", "")
|
||||
if not isinstance(key, str) or not key.strip() or len(key) > 256:
|
||||
raise ValueError()
|
||||
if resource == "review-requests":
|
||||
if not isinstance(data, dict) or set(data) != {"version", "options"}:
|
||||
raise ValueError()
|
||||
return 200, self.service.request_review(caller, memo_id, data["version"], options=data["options"], key=key)
|
||||
if resource == "publications":
|
||||
if not isinstance(data, dict) or set(data) != {"version", "audience"}:
|
||||
raise ValueError()
|
||||
|
|
|
|||
114
informed_decision/lifecycle_review.py
Normal file
114
informed_decision/lifecycle_review.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Durable revision-to-intake handoff; never an approval state machine."""
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from .intake import KIND, MEMO_KEYS, SubmitterGrant, validate_submission
|
||||
from .memo import Principal, BindingLevel, StepKind
|
||||
from .review import UI_RELEASE
|
||||
from .records import dumps, memo_from
|
||||
from .store import Conflict, EvidenceUnavailable
|
||||
|
||||
|
||||
def review_request(db, review_id):
|
||||
row = db.execute("SELECT * FROM lifecycle_events WHERE kind='review_requested' "
|
||||
"AND json_extract(body,'$.review_memo_id')=?", (review_id,)).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def require_current(db, review_id):
|
||||
row = review_request(db, review_id)
|
||||
if row is not None:
|
||||
current = db.execute('SELECT MAX(version) FROM memos WHERE id=?', (row['memo_id'],)).fetchone()[0]
|
||||
if current != row['version']:
|
||||
raise Conflict('review source revision changed')
|
||||
return row
|
||||
|
||||
|
||||
def check_intake(db, envelope, submitter):
|
||||
row = require_current(db, envelope['memo']['id'])
|
||||
if row is not None:
|
||||
body = json.loads(row['body'])
|
||||
if row['tenant'] != 'tenant:platform' or body['submitter'] != submitter or body['envelope'] != envelope:
|
||||
raise Conflict('review handoff does not match the retained request')
|
||||
|
||||
|
||||
def prepare(repository, context, memo_id, version, options, *, key, guard):
|
||||
required = {'submitter', 'memo_class', 'review_group', 'approval_intent'}
|
||||
if not isinstance(options, dict) or set(options) != required:
|
||||
raise ValueError('exact review routing required')
|
||||
if context.tenant != 'tenant:platform' or type(version) is not int or version < 1:
|
||||
raise ValueError('platform revision required for intake')
|
||||
if not isinstance(options['submitter'], str) or not options['submitter'].strip() or len(options['submitter']) > 200:
|
||||
raise ValueError('invalid submitter')
|
||||
scope, digest = repository._request(context, 'request_review', key,
|
||||
{'memo_id': memo_id, 'version': version, 'options': options})
|
||||
with repository.store._transaction() as db:
|
||||
policy_id = guard(db)
|
||||
repository._record(db, context.tenant, memo_id)
|
||||
replay = repository._replay(db, scope, digest)
|
||||
if replay:
|
||||
body = json.loads(db.execute('SELECT body FROM lifecycle_events WHERE id=?', (replay,)).fetchone()[0])
|
||||
return {'event_id': replay, **{k: body[k] for k in ('review_memo_id', 'envelope')}}
|
||||
memo = repository.store._memo(db, memo_id)
|
||||
if memo.version != version:
|
||||
raise Conflict('review requires the current revision')
|
||||
if (memo.locale != 'en' or memo.binding_level is not BindingLevel.ORGANIZATIONAL
|
||||
or memo.step_kind is not StepKind.APPROVE or memo.ui_release != UI_RELEASE):
|
||||
raise ValueError('unsupported human review profile')
|
||||
if repository._policy_context(db, context.tenant, memo_id, version)['published_audience'] is None:
|
||||
raise Conflict('publish this revision before requesting review')
|
||||
if db.execute("SELECT 1 FROM lifecycle_events WHERE memo_id=? AND version=? AND kind='review_requested'",
|
||||
(memo_id, version)).fetchone():
|
||||
raise Conflict('this revision already has a review request')
|
||||
review_id = 'review-' + str(uuid.uuid4())
|
||||
draft = replace(memo, id=review_id, version=1,
|
||||
binding=replace(memo.binding, principal=Principal('pending-human-session', 'person', 'To be assigned')))
|
||||
projection = {k: v for k, v in json.loads(dumps(draft)).items() if k in MEMO_KEYS}
|
||||
# Never silently discard content the narrower intake projection cannot carry.
|
||||
if dumps(memo_from(projection)) != dumps(draft):
|
||||
raise ValueError('memo requires a broader intake projection')
|
||||
packet = []
|
||||
for item in draft.packet:
|
||||
document = db.execute('SELECT media_type,content FROM documents WHERE digest=?', (item.hash,)).fetchone()
|
||||
if document is None:
|
||||
raise EvidenceUnavailable('packet unavailable')
|
||||
packet.append({'item_id': item.item_id, 'media_type': document['media_type'],
|
||||
'content': bytes(document['content']).decode('utf-8')})
|
||||
envelope = {'kind': KIND, 'version': 1, 'idempotency_key': review_id,
|
||||
'memo_class': options['memo_class'], 'review_group': options['review_group'],
|
||||
'approval_intent': options['approval_intent'], 'memo': projection, 'packet': packet}
|
||||
# Shape validation only. Actual submitter admission is always rechecked by intake.
|
||||
validate_submission(envelope, submitter_id=options['submitter'], grant=SubmitterGrant(
|
||||
options['submitter'], frozenset({options['memo_class']}), frozenset({options['review_group']})))
|
||||
if len(dumps(envelope).encode()) > 256 * 1024:
|
||||
raise ValueError('review envelope too large')
|
||||
body = {'review_memo_id': review_id, 'submitter': options['submitter'], 'envelope': envelope}
|
||||
event_id = repository._append(db, scope, digest, memo, 'review_requested', body, policy_id)
|
||||
return {'event_id': event_id, 'review_memo_id': review_id, 'envelope': envelope}
|
||||
|
||||
|
||||
def outcomes(repository, tenant, memo_id, version):
|
||||
if type(version) is not int or version < 1:
|
||||
raise ValueError('positive revision required')
|
||||
with repository.store._connection() as db:
|
||||
repository._record(db, tenant, memo_id)
|
||||
repository.store._memo(db, memo_id, version)
|
||||
row = db.execute("SELECT body FROM lifecycle_events WHERE tenant=? AND memo_id=? AND version=? "
|
||||
"AND kind='review_requested'", (tenant, memo_id, version)).fetchone()
|
||||
if row is None:
|
||||
return {'review': None, 'dispositions': []}
|
||||
body = json.loads(row['body'])
|
||||
review_id = body['review_memo_id']
|
||||
ledger = db.execute("SELECT state FROM intake_ledger WHERE memo_id=?", (review_id,)).fetchone()
|
||||
result = {'review': {'memo_id': review_id, 'intake_state': ledger['state'] if ledger else 'not_submitted'},
|
||||
'dispositions': []}
|
||||
result['review']['stale'] = repository.store._memo(db, memo_id).version != version
|
||||
if ledger and ledger['state'] == 'ready' and not result['review']['stale']:
|
||||
result['review']['path'] = '/review?memo_id=' + review_id
|
||||
for item in db.execute('SELECT d.body,s.state,s.approved_at FROM dispositions d '
|
||||
'JOIN presentations p ON p.id=d.presentation_id '
|
||||
'LEFT JOIN submissions s ON s.disposition_id=d.id WHERE p.memo_id=? ORDER BY d.rowid', (review_id,)):
|
||||
result['dispositions'].append({'disposition': json.loads(item['body']),
|
||||
'submission_state': item['state'], 'approved_at': item['approved_at']})
|
||||
return result
|
||||
|
|
@ -90,6 +90,7 @@ class LifecycleService:
|
|||
"memo_version": payload.get("memo").version if payload.get("memo") is not None else payload.get("version"),
|
||||
"record": state,
|
||||
**({"publication_audience": payload["audience"]} if operation == "publish" else {}),
|
||||
**({"review_routing": payload["options"]} if operation == "request_review" else {}),
|
||||
"command_digest": "sha256:" + hashlib.sha256(command).hexdigest()},
|
||||
"policy_version": self.policy.version}
|
||||
observation = self.policy.check(request)
|
||||
|
|
@ -167,6 +168,20 @@ class LifecycleService:
|
|||
current()
|
||||
return result
|
||||
|
||||
def request_review(self, caller, memo_id, version, *, options, key):
|
||||
from .lifecycle_review import prepare
|
||||
options = json.loads(dumps(options))
|
||||
_, guard = self._authorize(caller, memo_id, "request_review",
|
||||
{"version": version, "options": options, "key": key})
|
||||
return prepare(self.repository, caller.attribution, memo_id, version, options, key=key, guard=guard)
|
||||
|
||||
def outcomes(self, caller, memo_id, version):
|
||||
from .lifecycle_review import outcomes
|
||||
current, _ = self._authorize(caller, memo_id, "outcomes", {"version": version})
|
||||
result = outcomes(self.repository, caller.attribution.tenant, memo_id, version)
|
||||
current()
|
||||
return result
|
||||
|
||||
def search(self, caller, *, query="", cursor=None):
|
||||
self._caller(caller)
|
||||
if not isinstance(query, str) or len(query) > 200:
|
||||
|
|
|
|||
|
|
@ -98,7 +98,13 @@ class LifecycleWorkspace:
|
|||
except LifecycleError as error:
|
||||
if error.status != 403:
|
||||
raise
|
||||
return 200, self._page(memo, events, session, previous), []
|
||||
outcome = None
|
||||
try:
|
||||
outcome = self.service.outcomes(caller, memo.id, memo.version)
|
||||
except LifecycleError as error:
|
||||
if error.status != 403:
|
||||
raise
|
||||
return 200, self._page(memo, events, session, previous, outcome), []
|
||||
if method == "POST" and action in {"revise", "comment", "publish", "source", "evaluate"}:
|
||||
if params:
|
||||
raise ValueError()
|
||||
|
|
@ -131,13 +137,31 @@ class LifecycleWorkspace:
|
|||
raise ValueError()
|
||||
uuid.UUID(form["key"])
|
||||
|
||||
def _page(self, memo, events, session, previous=None):
|
||||
def _page(self, memo, events, session, previous=None, outcome=None):
|
||||
base = "/decisions/" + memo.id
|
||||
revision = hidden("version", memo.version)
|
||||
content = (f'<p><a href="/decisions">Decision workspace</a></p><h1>{text(memo.question)}</h1>'
|
||||
f'<p>Revision {memo.version} · {text(memo.binding.target.label)}</p>'
|
||||
f'<section class="card"><h2>Context and recommendation</h2><p class="brief">{text(memo.brief)}</p></section>'
|
||||
'<p>Human acceptance is not available for this draft. Publishing invites discussion; it does not record acceptance.</p>')
|
||||
'<p>Publishing invites discussion; it does not record acceptance.</p>')
|
||||
if not outcome or not outcome['review'] or not outcome['review'].get('path'):
|
||||
content += '<p>Human acceptance is not available for this draft.</p>'
|
||||
if outcome and outcome["review"]:
|
||||
review = outcome["review"]
|
||||
content += '<section class="card"><h2>Human review</h2>'
|
||||
if review.get("path"):
|
||||
content += f'<p><a href="{text(review["path"])}">Open protected human review</a></p>'
|
||||
elif review["stale"]:
|
||||
content += '<p>This review belongs to an earlier revision and cannot receive a new answer.</p>'
|
||||
else:
|
||||
content += '<p>Review handoff: ' + text(review["intake_state"].replace("_", " ")) + '</p>'
|
||||
for item in outcome["dispositions"]:
|
||||
answer = item["disposition"]
|
||||
content += f'<p>{text(answer["actor"]["sub"])}: {text(answer["verb"])}'
|
||||
if item["submission_state"]:
|
||||
content += ' · approval entry ' + text(item["submission_state"])
|
||||
content += '</p>'
|
||||
content += '</section>'
|
||||
if previous is not None:
|
||||
content += f'<details class="card"><summary>Compare with revision {previous.version}</summary>'
|
||||
for field, label in (("question", "Question"), ("brief", "Context and recommendation")):
|
||||
|
|
@ -153,7 +177,7 @@ class LifecycleWorkspace:
|
|||
content += '</ul></section>'
|
||||
content += '<section class="card"><h2>Discussion and history</h2>'
|
||||
for event in events:
|
||||
body = {key: value for key, value in event["body"].items() if key != "policy_observation_id"}
|
||||
body = {key: value for key, value in event["body"].items() if key not in {"policy_observation_id", "envelope"}}
|
||||
content += (f'<article class="record"><h3>{text(event["kind"])} · revision {event["version"]}</h3>'
|
||||
f'<p>{text(event["actor"]["sub"])} ({text(event["actor"]["kind"])}) · {text(event["at"])}</p>')
|
||||
if event["kind"] == "source":
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class ReviewPage:
|
|||
stale: bool
|
||||
engine_status: str
|
||||
dispositions: tuple = ()
|
||||
lifecycle_source: dict | None = None
|
||||
|
||||
|
||||
# Overview groups, in display order. "unavailable" rows carry the memo id only.
|
||||
|
|
@ -113,7 +114,8 @@ class ReviewController:
|
|||
principal_type=session.principal_type, expected_version=memo.version,
|
||||
policy_id=policy_id, approval_binding=current["binding"])
|
||||
saved, p, docs = self.store.retrieve_presentation(p.id)
|
||||
return ReviewPage(saved, p, current["binding"], docs, None, False, current["status"])
|
||||
return ReviewPage(saved, p, current["binding"], docs, None, False, current["status"],
|
||||
lifecycle_source=self.store.presentation_content(p.id).get("lifecycle_source"))
|
||||
|
||||
def _presentation(self, session, presentation_id, *, current=False):
|
||||
self._session(session)
|
||||
|
|
@ -122,7 +124,8 @@ class ReviewController:
|
|||
raise ReviewError(403, "wrong_recipient")
|
||||
memo = self.store.memo(p.memo_id, p.memo_version)
|
||||
self._memo(session, memo)
|
||||
if current and self.store.memo(memo.id).version != memo.version:
|
||||
if current and (self.store.memo(memo.id).version != memo.version
|
||||
or not self.store.review_source_current(memo.id)):
|
||||
raise ReviewError(409, "stale_presentation")
|
||||
return memo, p
|
||||
|
||||
|
|
@ -139,8 +142,8 @@ class ReviewController:
|
|||
saved, p, docs = self.store.retrieve_presentation(p.id)
|
||||
return ReviewPage(saved, p, binding, docs,
|
||||
self.store.intent_for(memo.approval_id, session.subject),
|
||||
self.store.memo(memo.id).version != memo.version, current["status"],
|
||||
tuple(self.store.dispositions_for(p.id)))
|
||||
self.store.memo(memo.id).version != memo.version or not self.store.review_source_current(memo.id), current["status"],
|
||||
tuple(self.store.dispositions_for(p.id)), content.get("lifecycle_source"))
|
||||
|
||||
def overview(self, session):
|
||||
"""The signed-in person's memos, classified. Never presents, binds or stores engine state.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from .provenance import Claim, Route, assert_human_control_dischargeable
|
|||
from .stance import resolve
|
||||
from .records import disposition_from, dumps, memo_from, presentation_from
|
||||
|
||||
SCHEMA_VERSION = 4
|
||||
SCHEMA_VERSION = 5
|
||||
_SCHEMA = """
|
||||
CREATE TABLE documents (digest TEXT PRIMARY KEY, media_type TEXT NOT NULL, content BLOB NOT NULL);
|
||||
CREATE TABLE memos (id TEXT NOT NULL, version INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(id,version));
|
||||
|
|
@ -99,7 +99,7 @@ class Store:
|
|||
triggers += (f"CREATE TRIGGER immutable_{table}_{operation} BEFORE {operation} ON {table} "
|
||||
"BEGIN SELECT RAISE(ABORT,'immutable evidence'); END;\n")
|
||||
db.executescript("BEGIN IMMEDIATE;" + _SCHEMA + triggers + "PRAGMA user_version=1; COMMIT;")
|
||||
elif version not in (1, 2, 3, SCHEMA_VERSION):
|
||||
elif version not in (1, 2, 3, 4, SCHEMA_VERSION):
|
||||
raise StoreError("unsupported evidence schema version")
|
||||
if version in (0, 1):
|
||||
db.executescript("""BEGIN IMMEDIATE;
|
||||
|
|
@ -131,6 +131,17 @@ class Store:
|
|||
db.executescript("BEGIN IMMEDIATE;" + lifecycle_schema
|
||||
+ "PRAGMA user_version=4; COMMIT;")
|
||||
|
||||
if version in (0, 1, 2, 3, 4):
|
||||
# Earlier code cannot enforce source-revision guards. Refuse an
|
||||
# image-only downgrade once this schema has been opened.
|
||||
db.executescript("""BEGIN IMMEDIATE;
|
||||
CREATE UNIQUE INDEX lifecycle_review_snapshot
|
||||
ON lifecycle_events(json_extract(body,'$.review_memo_id'))
|
||||
WHERE kind='review_requested';
|
||||
CREATE UNIQUE INDEX lifecycle_review_revision
|
||||
ON lifecycle_events(memo_id,version) WHERE kind='review_requested';
|
||||
PRAGMA user_version=5; COMMIT;""")
|
||||
|
||||
@contextmanager
|
||||
def _connection(self):
|
||||
db = sqlite3.connect(self.path.as_uri() + "?mode=rw", uri=True, timeout=5, isolation_level=None)
|
||||
|
|
@ -177,6 +188,15 @@ class Store:
|
|||
|
||||
def _save_memo(self, db, memo: Memo):
|
||||
"""Shared transaction boundary for imports and lifecycle writes."""
|
||||
from .lifecycle_review import review_request
|
||||
if review_request(db, memo.id) is not None:
|
||||
raise Conflict("review snapshots are immutable and loaded only by intake")
|
||||
if db.execute("SELECT 1 FROM lifecycle_events e JOIN presentations p "
|
||||
"ON p.memo_id=json_extract(e.body,'$.review_memo_id') "
|
||||
"JOIN dispositions d ON d.presentation_id=p.id JOIN submissions s ON s.disposition_id=d.id "
|
||||
"WHERE e.memo_id=? AND e.kind='review_requested' AND s.state IN ('in_flight','unresolved')",
|
||||
(memo.id,)).fetchone():
|
||||
raise Conflict("resolve the outstanding review entry before revising")
|
||||
_text(memo.id, "memo id")
|
||||
if type(memo.version) is not int or memo.version < 1:
|
||||
raise ValueError("memo version must be a positive integer")
|
||||
|
|
@ -210,6 +230,15 @@ class Store:
|
|||
with self._connection() as db:
|
||||
return self._memo(db, memo_id, version)
|
||||
|
||||
def review_source_current(self, memo_id):
|
||||
from .lifecycle_review import require_current
|
||||
with self._connection() as db:
|
||||
try:
|
||||
require_current(db, memo_id)
|
||||
except Conflict:
|
||||
return False
|
||||
return True
|
||||
|
||||
def observe_policy(self, observation):
|
||||
request = json.loads(observation.request_json)
|
||||
_text(request["id"], "policy observation id")
|
||||
|
|
@ -275,6 +304,8 @@ class Store:
|
|||
raise ValueError("platform tenant and principal provenance are required")
|
||||
with self._transaction() as db:
|
||||
memo = self._memo(db, memo_id)
|
||||
from .lifecycle_review import require_current
|
||||
require_current(db, memo_id)
|
||||
if expected_version is not None and memo.version != expected_version:
|
||||
raise Conflict("memo changed after the entitlement check")
|
||||
self._guard_policy(db, policy_id, memo, principal_sub, "read")
|
||||
|
|
@ -284,6 +315,10 @@ class Store:
|
|||
principal_type=principal_type, awareness=awareness)
|
||||
content = {"memo": json.loads(dumps(memo)), "presentation": json.loads(dumps(presentation)),
|
||||
"binding_document": memo.binding_document(), "awareness_document": memo.awareness_document(awareness)}
|
||||
source = require_current(db, memo_id)
|
||||
if source is not None:
|
||||
content["lifecycle_source"] = {"memo_id": source["memo_id"], "version": source["version"],
|
||||
"request_event_id": source["id"]}
|
||||
if policy_id is not None:
|
||||
content["policy_observation_id"] = policy_id
|
||||
if approval_binding is not None:
|
||||
|
|
@ -299,6 +334,8 @@ class Store:
|
|||
with self._transaction() as db:
|
||||
p = self._presentation(db, presentation_id)
|
||||
memo = self._memo(db, p.memo_id)
|
||||
from .lifecycle_review import require_current
|
||||
require_current(db, p.memo_id)
|
||||
if actor.sub != p.principal_sub:
|
||||
raise DispositionRefused("G_ACTOR", "actor did not receive this presentation")
|
||||
if memo.version != p.memo_version:
|
||||
|
|
@ -333,6 +370,8 @@ class Store:
|
|||
return disposition_from(json.loads(existing["body"]))
|
||||
p = self._presentation(db, presentation_id)
|
||||
memo = self._memo(db, p.memo_id)
|
||||
from .lifecycle_review import require_current
|
||||
require_current(db, p.memo_id)
|
||||
self._guard_policy(db, policy_id, memo, actor.sub, verb.value)
|
||||
d = record(memo, p, verb, actor, reasons=tuple(reasons), note=note)
|
||||
if verb in BINDING_VERBS:
|
||||
|
|
@ -377,6 +416,8 @@ class Store:
|
|||
raise Conflict("submission is absent, already attempted or unresolved")
|
||||
d = disposition_from(json.loads(db.execute("SELECT body FROM dispositions WHERE id=?", (disposition_id,)).fetchone()[0]))
|
||||
p = self._presentation(db, d.presentation_id)
|
||||
from .lifecycle_review import require_current
|
||||
require_current(db, p.memo_id)
|
||||
if self._memo(db, p.memo_id).version != p.memo_version:
|
||||
raise DispositionRefused("G_PRES", "presentation is stale")
|
||||
self._guard_policy(db, policy_id, self._memo(db, p.memo_id), d.actor.sub, "accept")
|
||||
|
|
|
|||
|
|
@ -112,7 +112,12 @@ def review_page(page, session):
|
|||
f'<p>Presentation {text(p.id)}</p><p class="hash">View: {text(p.view_hash)}</p>'
|
||||
f'<p class="hash">Act: {text(memo.approval_binding_digest)}</p>'
|
||||
f'<p>Account zone: {text(session.tenant.value)} ({text(session.tenant.route.value)}). This is separate from the act scope.</p></details>')
|
||||
content = (f'<p class="eyebrow">Decision review · version {memo.version}</p><h1>{text(memo.question)}</h1>'
|
||||
source = ''
|
||||
if page.lifecycle_source:
|
||||
origin = page.lifecycle_source
|
||||
source = (f'<p>Prepared from <a href="/decisions/{text(origin["memo_id"])}?version={origin["version"]}">'
|
||||
f'decision revision {origin["version"]}</a>.</p>')
|
||||
content = (f'<p class="eyebrow">Decision review · version {memo.version}</p><h1>{text(memo.question)}</h1>' + source
|
||||
+ notice + '<div class="grid"><div><section class="card"><h2>The request</h2>'
|
||||
f'<p><strong>{text(memo.requested_act)}</strong></p><p class="brief">{text(memo.brief)}</p>{terms}</section>'
|
||||
+ highlights + packet + '<section class="card"><h2>Your response</h2>' + (forms or '<p>No new approval submission is available from this record.</p>')
|
||||
|
|
|
|||
280
tests/test_lifecycle_review.py
Normal file
280
tests/test_lifecycle_review.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import base64
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.disposition import Actor, ActorKind, Verb, DispositionRefused
|
||||
from informed_decision.lifecycle import Attribution, LifecycleRepository
|
||||
from informed_decision.lifecycle_service import Caller, LifecycleService, LifecycleError
|
||||
from informed_decision.provenance import Claim, Route
|
||||
from informed_decision.records import memo_from
|
||||
from informed_decision.store import Conflict, Store
|
||||
from test_intake import envelope
|
||||
from test_intake_service import setup, request
|
||||
from test_policy_client import policy
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handoff(setup):
|
||||
intake, store, calls, configuration = setup
|
||||
env = envelope()
|
||||
memo = memo_from(env['memo'])
|
||||
app = LifecycleService(LifecycleRepository(store), policy())
|
||||
actor = Caller(Attribution('tenant:platform', Actor('author-agent', ActorKind.AGENT)), time.time()+3600, '{}')
|
||||
documents = [{'digest': memo.packet[0].hash, 'media_type': 'text/markdown',
|
||||
'content_base64': base64.b64encode(env['packet'][0]['content'].encode()).decode()}]
|
||||
app.register(actor, memo, key='register', documents=documents)
|
||||
app.publish(actor, memo.id, 1, audience={'subjects': ['keycape-human'], 'groups': []}, key='publish')
|
||||
options = {'submitter': 'state-hub', 'memo_class': env['memo_class'], 'review_group': env['review_group'],
|
||||
'approval_intent': env['approval_intent']}
|
||||
return app, actor, memo, options, intake, calls
|
||||
|
||||
|
||||
def prepare(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
return app.request_review(actor, memo.id, 1, options=options, key='request-review')
|
||||
|
||||
|
||||
def present(store, review_id):
|
||||
return store.present(review_id, principal_sub='keycape-human',
|
||||
tenant=Claim('tenant:platform', Route.REGISTRATION),
|
||||
principal_type=Claim('human', Route.AUTHENTICATION))
|
||||
|
||||
|
||||
def test_durable_handoff_reuses_intake_and_exposes_revision_outcomes(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
receipt = prepare(handoff)
|
||||
app.repository.store = Store(app.repository.store.path)
|
||||
assert prepare(handoff) == receipt
|
||||
assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'not_submitted'
|
||||
assert request(intake, receipt['envelope'])[0] == 201
|
||||
assert request(intake, receipt['envelope'])[0] == 200
|
||||
assert len(calls) == 1
|
||||
store = app.repository.store
|
||||
review = store.memo(receipt['review_memo_id'])
|
||||
assert review.brief == memo.brief and review.packet == memo.packet
|
||||
assert store.memo(memo.id).approval_id is None
|
||||
p = present(store, review.id)
|
||||
human = Actor('keycape-human', ActorKind.PERSON)
|
||||
store.acknowledge(p.id, human, review.required_ack_ids)
|
||||
d = store.record_disposition(p.id, human, Verb.DECLINE, operation_id='answer', reasons=('wrong_scope',))
|
||||
result = app.outcomes(actor, memo.id, 1)
|
||||
assert result['review']['path'] == '/review?memo_id=' + review.id
|
||||
assert result['dispositions'][0]['disposition']['id'] == d.id
|
||||
assert result['dispositions'][0]['submission_state'] is None
|
||||
app.revise(actor, memo.next_version(brief='Refined'), expected_version=1, summary='Update', key='revise')
|
||||
assert app.outcomes(actor, memo.id, 2) == {'review': None, 'dispositions': []}
|
||||
assert app.outcomes(actor, memo.id, 1)['dispositions'] == result['dispositions']
|
||||
|
||||
|
||||
def test_altered_handoff_and_wrong_submitter_fail_before_create(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
receipt = prepare(handoff)
|
||||
changed = json.loads(json.dumps(receipt['envelope']))
|
||||
changed['memo']['brief'] += ' Altered'
|
||||
assert request(intake, changed) == (409, {'error': 'review_handoff_conflict'})
|
||||
data = json.loads(intake.submitters_path.read_text())
|
||||
for row in data.values():
|
||||
row['submitter_id'] = 'another'
|
||||
intake.submitters_path.write_text(json.dumps(data))
|
||||
assert request(intake, receipt['envelope'])[0] == 409
|
||||
assert not calls
|
||||
|
||||
|
||||
def test_revision_invalidates_presented_review_and_prepared_accept(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
receipt = prepare(handoff)
|
||||
assert request(intake, receipt['envelope'])[0] == 201
|
||||
store = app.repository.store
|
||||
p = present(store, receipt['review_memo_id'])
|
||||
human = Actor('keycape-human', ActorKind.PERSON)
|
||||
store.acknowledge(p.id, human, memo.required_ack_ids)
|
||||
d = store.record_disposition(p.id, human, Verb.ACCEPT, operation_id='accept')
|
||||
app.revise(actor, memo.next_version(brief='Revised'), expected_version=1, summary='Update', key='revise')
|
||||
with pytest.raises(Conflict):
|
||||
store.begin_submission(d.id)
|
||||
with pytest.raises(Conflict):
|
||||
store.record_disposition(p.id, human, Verb.DECLINE, operation_id='late-answer', reasons=('wrong_scope',))
|
||||
with pytest.raises(Conflict):
|
||||
present(store, receipt['review_memo_id'])
|
||||
assert request(intake, receipt['envelope'])[0] == 409
|
||||
|
||||
|
||||
def test_unresolved_entry_freezes_source_and_snapshot_import_is_refused(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
receipt = prepare(handoff)
|
||||
assert request(intake, receipt['envelope'])[0] == 201
|
||||
store = app.repository.store
|
||||
review = store.memo(receipt['review_memo_id'])
|
||||
with pytest.raises(Conflict):
|
||||
store.save_memo(review.next_version(brief='Bypass'))
|
||||
with pytest.raises(Conflict):
|
||||
store.save_memo(review)
|
||||
p = present(store, review.id)
|
||||
human = Actor('keycape-human', ActorKind.PERSON)
|
||||
store.acknowledge(p.id, human, memo.required_ack_ids)
|
||||
d = store.record_disposition(p.id, human, Verb.ACCEPT, operation_id='accept')
|
||||
attempt = store.begin_submission(d.id)
|
||||
for unresolved in (False, True):
|
||||
if unresolved:
|
||||
store.finish_submission(d.id, attempt)
|
||||
with pytest.raises(Conflict):
|
||||
app.revise(actor, memo.next_version(brief='Update'), expected_version=1, summary='Update', key='revise')
|
||||
assert store.memo(memo.id).version == 1
|
||||
|
||||
|
||||
def test_denial_replay_conflict_and_agent_binding_guard(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
receipt = prepare(handoff)
|
||||
with pytest.raises(Conflict):
|
||||
app.request_review(actor, memo.id, 1, options={**options, 'review_group': 'other'}, key='request-review')
|
||||
with pytest.raises(Conflict):
|
||||
app.request_review(actor, memo.id, 1, options=options, key='duplicate')
|
||||
app.policy.transport.change = lambda data: data.update(effect='deny')
|
||||
with pytest.raises(LifecycleError):
|
||||
prepare(handoff)
|
||||
with pytest.raises(LifecycleError):
|
||||
app.outcomes(actor, memo.id, 1)
|
||||
assert request(intake, receipt['envelope'])[0] == 201
|
||||
p = present(app.repository.store, receipt['review_memo_id'])
|
||||
with pytest.raises(DispositionRefused):
|
||||
app.repository.store.record_disposition(p.id, Actor('keycape-human', ActorKind.AGENT), Verb.ACCEPT,
|
||||
operation_id='agent-accept')
|
||||
|
||||
|
||||
def test_create_reply_loss_does_not_retry_and_source_change_before_load_refuses(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
receipt = prepare(handoff)
|
||||
creator = intake.creator
|
||||
def raced(body):
|
||||
result = creator(body)
|
||||
app.revise(actor, memo.next_version(brief='Changed during create'), expected_version=1, summary='Update', key='revise')
|
||||
return result
|
||||
intake.creator = raced
|
||||
assert request(intake, receipt['envelope'])[0] == 409
|
||||
assert request(intake, receipt['envelope'])[0] == 409
|
||||
assert len(calls) == 1
|
||||
assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'created'
|
||||
with app.repository.store._connection() as db:
|
||||
assert not db.execute('SELECT 1 FROM memos WHERE id=?', (receipt['review_memo_id'],)).fetchone()
|
||||
|
||||
from test_approval_component import component, signing_key
|
||||
from test_durable_component import receiver
|
||||
|
||||
|
||||
def test_actual_engine_human_entry_and_independent_audit_link_to_source_revision(handoff, component, receiver):
|
||||
import jwt
|
||||
from informed_decision.intake_approval import IntakeApprovalClient
|
||||
from informed_decision.approval_http import ApprovalHTTPClient
|
||||
from informed_decision.review import ReviewController
|
||||
from informed_decision.runtime import AuditPump
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
client, engine, transport, human, signing = component
|
||||
groups = json.loads(intake.groups_path.read_text())
|
||||
groups[options['review_group']]['subject'] = human.subject
|
||||
intake.groups_path.write_text(json.dumps(groups))
|
||||
claims = jwt.decode(human.access_token, options={'verify_signature': False})
|
||||
claims.update(sub='informed-decision', principal_type='service', scope='approval:create')
|
||||
token = jwt.encode(claims, signing, algorithm='RS256', headers={'kid': 'key-1'})
|
||||
intake.creator = IntakeApprovalClient('https://approval.test', lambda: token, transport=transport)
|
||||
app.revise(actor, memo.next_version(brief='Refined for human acceptance'), expected_version=1,
|
||||
summary='Responded to research', key='revise')
|
||||
app.publish(actor, memo.id, 2, audience={'subjects': [human.subject], 'groups': []}, key='publish-second')
|
||||
receipt = app.request_review(actor, memo.id, 2, options=options, key='review-second')
|
||||
status, loaded = request(intake, receipt['envelope'])
|
||||
assert status == 201, loaded
|
||||
controller = ReviewController(app.repository.store, policy(),
|
||||
lambda session: ApprovalHTTPClient('https://approval.test', session, transport=transport))
|
||||
page = controller.open(human, receipt['review_memo_id'])
|
||||
controller.acknowledge(human, page.presentation.id, memo.required_ack_ids)
|
||||
controller.act(human, page.presentation.id, Verb.ACCEPT, operation_id='human-accept')
|
||||
result = app.outcomes(actor, memo.id, 2)
|
||||
assert result['dispositions'][0]['submission_state'] == 'confirmed'
|
||||
from informed_decision.web import App, SESSION_COOKIE
|
||||
from test_browser_auth import call
|
||||
class Login:
|
||||
def session(self, sid):
|
||||
return human if sid == 'fixture' else None
|
||||
browser = App(Login(), controller, lifecycle=app)
|
||||
page_response = call(browser, '/decisions/' + memo.id, query='version=2', cookie=SESSION_COOKIE + '=fixture')
|
||||
assert page_response['status'] == 200
|
||||
assert 'Open protected human review' in page_response['body']
|
||||
assert 'approval entry confirmed' in page_response['body']
|
||||
native_page = call(browser, '/presentations/' + page.presentation.id, cookie=SESSION_COOKIE + '=fixture')
|
||||
assert native_page['status'] == 200 and 'decision revision 2' in native_page['body']
|
||||
assert 'content_base64' not in page_response['body']
|
||||
assert len(engine.get(loaded['approval_id']).entries) == 1
|
||||
assert app.outcomes(actor, memo.id, 1)['dispositions'] == []
|
||||
source = app.repository.store.presentation_content(page.presentation.id)['lifecycle_source']
|
||||
assert source == {'memo_id': memo.id, 'version': 2, 'request_event_id': receipt['event_id']}
|
||||
# Both lifecycle commitments and the existing presentation/disposition evidence drain.
|
||||
pump = AuditPump(app.repository.store, receiver[0], lifecycle_delivery=True)
|
||||
for _ in range(3):
|
||||
pump.tick()
|
||||
assert app.repository.store.lifecycle_counts()['pending'] == 0
|
||||
assert all(row['state'] == 'delivered' for row in app.repository.store.outbox())
|
||||
assert not any(url.endswith('/consume') for _, url in transport.calls)
|
||||
|
||||
|
||||
def test_http_review_request_and_outcome_adapter(handoff):
|
||||
from informed_decision.lifecycle_http import LifecycleAPI
|
||||
from test_lifecycle_http import call
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
api = LifecycleAPI(app, lambda token: actor)
|
||||
status, receipt, _ = call(api, f'/v1/decisions/{memo.id}/review-requests', method='POST',
|
||||
body={'version': 1, 'options': options})
|
||||
assert status == 200
|
||||
assert request(intake, receipt['envelope'])[0] == 201
|
||||
status, outcome, _ = call(api, f'/v1/decisions/{memo.id}/outcomes', query='version=1')
|
||||
assert status == 200 and outcome['review']['intake_state'] == 'ready'
|
||||
assert call(api, f'/v1/decisions/{memo.id}/outcomes')[0] == 400
|
||||
|
||||
|
||||
def test_review_request_requires_publication_and_supported_profile(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
app.revise(actor, memo.next_version(brief='Unpublished successor'), expected_version=1, summary='Update', key='revise')
|
||||
with pytest.raises(Conflict, match='publish'):
|
||||
app.request_review(actor, memo.id, 2, options=options, key='unpublished')
|
||||
from informed_decision.memo import StepKind
|
||||
app.revise(actor, app.retrieve(actor, memo.id).next_version(step_kind=StepKind.MITZEICHNUNG),
|
||||
expected_version=2, summary='Other step', key='revise-step')
|
||||
app.publish(actor, memo.id, 3, audience={'subjects': ['keycape-human'], 'groups': []}, key='publish-third')
|
||||
with pytest.raises(ValueError, match='profile'):
|
||||
app.request_review(actor, memo.id, 3, options=options, key='unsupported')
|
||||
assert not calls
|
||||
assert not any(event['kind'] == 'review_requested' for event in app.events(actor, memo.id))
|
||||
|
||||
|
||||
def test_lost_create_reply_remains_unresolved_across_restart(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
receipt = prepare(handoff)
|
||||
def lost(body):
|
||||
calls.append(body)
|
||||
raise OSError('lost receiver reply')
|
||||
intake.creator = lost
|
||||
assert request(intake, receipt['envelope'])[0] == 503
|
||||
intake.store = Store(intake.store.path)
|
||||
assert request(intake, receipt['envelope']) == (409, {'error': 'approval_create_unresolved'})
|
||||
assert len(calls) == 1
|
||||
assert app.outcomes(actor, memo.id, 1)['review']['intake_state'] == 'creating'
|
||||
|
||||
|
||||
def test_schema_four_upgrade_preserves_evidence_and_fences_old_readers(handoff):
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
store = app.repository.store
|
||||
before = app.events(actor, memo.id)
|
||||
# Reconstruct the prior schema's exact index/version boundary.
|
||||
with store._connection() as db:
|
||||
db.execute('DROP INDEX lifecycle_review_snapshot')
|
||||
db.execute('DROP INDEX lifecycle_review_revision')
|
||||
db.execute('PRAGMA user_version=4')
|
||||
migrated = Store(store.path)
|
||||
with migrated._connection() as db:
|
||||
assert db.execute('PRAGMA user_version').fetchone()[0] == 5
|
||||
assert db.execute("SELECT count(*) FROM sqlite_master WHERE name IN "
|
||||
"('lifecycle_review_snapshot','lifecycle_review_revision')").fetchone()[0] == 2
|
||||
assert app.events(actor, memo.id) == before
|
||||
receipt = prepare(handoff)
|
||||
assert prepare(handoff) == receipt
|
||||
|
|
@ -22,7 +22,7 @@ state_hub_workstream_id: "2b3e04ed-2137-584f-a6c2-f1abb29636fa"
|
|||
Contract: `docs/specs/DecisionLifecycleService.md`. This extends the existing
|
||||
memo and intake implementation. `INFD-WP-0005` continues to own intake runtime,
|
||||
attribute admission, forwarding and live acceptance. Implementation started on
|
||||
2026-10-04 at the founder's request. Production identity/policy admission and the human binding handoff remain
|
||||
2026-10-04 at the founder's request. Production identity/policy admission and broader human binding profiles remain
|
||||
open. The shared service now has a bearer API adapter and optional browser workspace.
|
||||
|
||||
## Record product direction and architecture
|
||||
|
|
@ -226,3 +226,26 @@ broader authoring UI (T03/T05), full acceptance scenarios, immutable release and
|
|||
rollback packet (T06), separately approved live rollout (T07). Source metadata
|
||||
entry is implemented; automated fetching/retention is not. No deployment occurred.
|
||||
Delivery and activation contract: `docs/specs/LifecycleWorkspaceDelivery.md`.
|
||||
|
||||
## Revision-specific human review handoff — 2026-10-04
|
||||
|
||||
T02–T06 continued with policy-authorized review requests, immutable source-to-intake
|
||||
snapshots and authorized outcome retrieval. Intake checks exact retained envelope,
|
||||
designated submitter and current source revision before creation and loading.
|
||||
Existing uncertainty/replay behavior is reused. Source revisions invalidate old
|
||||
review actions; in-flight/unresolved human entries prevent source revision.
|
||||
Protected review pages show the source revision, and the lifecycle workspace
|
||||
links ready reviews and displays evidence without caching approval validity.
|
||||
|
||||
The real Approval Engine/Audit Core source integration test creates and accepts
|
||||
a snapshot of source revision two, correlates the confirmed human answer and
|
||||
delivers evidence. Test identities and PDP transport are synthetic. Supported
|
||||
profile and limits: `docs/specs/LifecycleReviewHandoff.md`. Production admission,
|
||||
private forwarding, broader profiles/browser packet authoring, release/rollback
|
||||
and approved live delivery remain on T02/T04/T05/T06/T07 and WP-0005. No task is
|
||||
marked complete solely from source integration, and no rollout occurred.
|
||||
|
||||
Validation for this continuation: full cross-repository suite **634 passed,
|
||||
5 skipped**; `git diff --check` passed. Schema-4 upgrade preserves existing
|
||||
records and marks schema 5 to fence older readers. Implementation decision logged
|
||||
as State Hub `ccb2e110-6032-4259-a402-989415a8d9fe` (not a rollout approval).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue