diff --git a/docs/specs/DecisionLifecycleService.md b/docs/specs/DecisionLifecycleService.md index 8552c8f..a7e714d 100644 --- a/docs/specs/DecisionLifecycleService.md +++ b/docs/specs/DecisionLifecycleService.md @@ -163,8 +163,9 @@ document supersedes their exclusion of agent collaboration and human authoring for the new increment, while preserving human-only dispositions and the evidence and engine boundaries. L0 and L2 compatibility remain required. -Open implementation contracts: workload identity beyond cluster service accounts; -publication audience policy and any later anonymous publication; source connector -set and retention; non-approval L2 binding integration. T02 must make these -explicit before implementation depends on them. None authorizes a production -rollout or changes external owners' contracts by declaration. +Initial implementation choices are explicit in `LifecycleAdmissionBoundary.md`: +only the admitted cluster workload, authenticated publication, caller-supplied +research provenance, append-only retention, organizational human approval, and +no optional Hub outcome write-back. External identities, anonymous publication, +source connectors/retention controls and non-approval binding are expansion work +under INFD-WP-0008, not hidden prerequisites or enabled production capabilities. diff --git a/docs/specs/LifecycleAdmissionBoundary.md b/docs/specs/LifecycleAdmissionBoundary.md new file mode 100644 index 0000000..8e93670 --- /dev/null +++ b/docs/specs/LifecycleAdmissionBoundary.md @@ -0,0 +1,33 @@ +# Lifecycle admission and evidence boundary + +2026-10-05; INFD-WP-0007-T02. This closes the implementation choices for the +initial production audience. Expansion is tracked separately; these limits are +observable behavior, not an implied grant to extend another owner's interface. + +| Concern | Implemented contract | +| --- | --- | +| Human identity | KeyCape-authenticated net-kingdom-admins, tenant:platform; twelve-hour MFA authoring/list window, fifteen-minute review/read/act window | +| Agent identity | Kubernetes TokenReview for the registered State Hub service account with a separate lifecycle audience; unknown and external bearer identities refused | +| Publication | Explicit subject/group audience on an exact revision, authorized by Access Engine; not anonymous or indexed publicly; successor revisions require republication | +| Research | Caller retrieves evidence externally and submits reference, retrieval time, digest and summary; the service does not fetch URLs or attest their truth | +| Evaluation | Attributed assessment, uncertainty and source-event references constrained to the same record/revision/tenant | +| Retention | Append-only revisions, sources, comments, evaluations and binding evidence in the durable store; no automatic purge, mutable source replacement or source connector credentials | +| Documents | Digest-verified bytes supplied through the bounded API; the browser adds short text documents with optional required acknowledgments as new revisions | +| Review | Organizational English approval profile using the existing shared schema and native Approval Engine; recipient from the configured review group, initially platform-root | +| Discovery | Lifecycle workspace uses per-record lifecycle policy. Review overview uses its own list decision; attribute discovery requires the immutable named recipient plus verified native requester, human-control flag and digest | +| Human acceptance | Existing protected review surface, fresh policy/read/act decisions, exact revision/presentation and required acknowledgments; no agent binding route | +| Non-approval profiles | Discussion drafts may be authored and refined, but cannot create an approval review. L0/L2 core schema compatibility does not enable new production disposition profiles | +| Hub write-back | Notification/dispatcher delivery is admitted; optional automatic resolution write-back remains disabled and cannot imply permission to execute | + +Identity verification stays in KeyCape/TokenReview; authorization stays in Access +Engine. The headless application service is shared by browser and private API. +Client-supplied research descriptions and evaluation conclusions remain claims +with attribution, not machine-verified facts. Packet bytes are retained so an +external URL changing cannot alter a past presentation. + +No finite retention duration, deletion mechanism, new connector, external agent +identity or non-approval signing semantics is invented here. Those product +extensions require their own owner contracts and delivery evidence under +INFD-WP-0008. The existing source/evaluation and organizational approval workflows +provide all nine initial lifecycle operations without per-decision infrastructure +changes. diff --git a/docs/specs/LifecycleFoundationContract.md b/docs/specs/LifecycleFoundationContract.md index b094e0c..eb1e9eb 100644 --- a/docs/specs/LifecycleFoundationContract.md +++ b/docs/specs/LifecycleFoundationContract.md @@ -2,7 +2,8 @@ 2026-10-04; `INFD-WP-0007-T02/T03`. This is the internal persistence slice of `DecisionLifecycleService.md`. The protected application interface built on this -slice is documented in `LifecycleServiceContract.md`; HTTP exposure is pending. +slice is documented in `LifecycleServiceContract.md`; the deployed private HTTP +adapter is specified in `LifecycleHTTPContract.md`. ## Implemented boundary @@ -16,8 +17,8 @@ Each call carries `Attribution(tenant, Actor(subject, kind))` from its trusted caller. This is audit attribution, not an authentication assertion. Like `Store`, the repository is an internal persistence interface: the application/API layer must authenticate callers and enforce current access-engine authorization -before invoking it. Supplying a tenant string is not an access grant. No route -or runtime configuration exposes these operations in this increment. +before invoking it. Supplying a tenant string is not an access grant. The deployed application adapters authenticate and authorize these operations; +this internal persistence interface is never exposed directly. ## Commands and reads diff --git a/docs/specs/LifecycleWorkspaceDelivery.md b/docs/specs/LifecycleWorkspaceDelivery.md index 3a3b378..e6e284f 100644 --- a/docs/specs/LifecycleWorkspaceDelivery.md +++ b/docs/specs/LifecycleWorkspaceDelivery.md @@ -78,3 +78,12 @@ 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. + +## Named-recipient review discovery — source continuation + +The adapter now carries retained intake facts and the immutable memo recipient +for list decisions. It verifies native approval requester, human control and +binding digest before the PDP call. Access Engine compact-sitting v13 checks the +recipient and existing class/submitter/group admission with the twelve-hour list +window; read and act remain separate fifteen-minute decisions. Missing evidence +hides that row. This continuation requires the matching owner pin at release. diff --git a/informed_decision/policy.py b/informed_decision/policy.py index 7f93f45..2a754a9 100644 --- a/informed_decision/policy.py +++ b/informed_decision/policy.py @@ -63,6 +63,8 @@ def build_request(session: HumanSession, memo, action, policy_version, *, intake extra = {} if intake_context is None else dict(intake_context) if extra and set(extra) != {"review_group", "memo_class", "intake_submitter", "approval_requester", "approval_human_control"}: raise ValueError("exact intake policy context required") + if extra and action == "list": + extra["review_recipient"] = memo.binding.principal.id return {"id": str(uuid.uuid4()), "tenant": "tenant:platform", "subject": {"id": session.subject, "type": "human", "tenant": session.tenant.value, "attributes": {"tenant_source": session.tenant.route.value, diff --git a/informed_decision/review.py b/informed_decision/review.py index f2d855f..13dd6fd 100644 --- a/informed_decision/review.py +++ b/informed_decision/review.py @@ -81,7 +81,7 @@ class ReviewController: def _authorize(self, session, memo, action): self._memo(session, memo) - context = self.store.intake_context(memo) if self.intake_attributes and action != 'list' else None + context = self.store.intake_context(memo) if self.intake_attributes else None if context is not None: current = self._approval(session, memo) if (current['binding'].get('actor') != context['approval_requester'] @@ -167,6 +167,8 @@ class ReviewController: def _overview_row(self, session, memo): try: _, observation = self._authorize(session, memo, "list") + except (ApprovalEngineError, ValueError, Conflict): + return OverviewRow(memo.id, "unavailable", reason="review_evidence_unavailable") except ReviewError as exc: if exc.code == "session_expired": raise diff --git a/tests/test_lifecycle_review.py b/tests/test_lifecycle_review.py index 0623845..feb919e 100644 --- a/tests/test_lifecycle_review.py +++ b/tests/test_lifecycle_review.py @@ -8,12 +8,14 @@ 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.policy import build_request 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 +from test_browser_auth import signing_key @pytest.fixture @@ -376,3 +378,79 @@ def test_retained_handoff_api_is_revision_scoped_and_reauthorizes(handoff): 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'}) + + +def test_overview_uses_retained_intake_context_and_fails_closed_per_row(handoff): + from informed_decision.review import ReviewController + from informed_decision.approval_client import ApprovalEngineError + from test_policy_client import human + app, actor, memo, options, intake, calls = handoff + receipt = prepare(handoff) + assert request(intake, receipt['envelope'])[0] == 201 + store = app.repository.store + stored = store.memo(receipt['review_memo_id']) + session = replace(human(), subject=stored.binding.principal.id) + class Engine: + failure = False + def get_approval(self, approval_id): + if self.failure: + raise ApprovalEngineError(503, 'unavailable') + return {'status': 'requested', 'binding': {'digest': stored.approval_binding_digest, + 'actor': 'informed-decision', 'human_control': True}} + engine = Engine() + controller = ReviewController(store, policy(), lambda session: engine, intake_attributes=True) + [row] = controller.overview(session) + assert row.question == memo.question + sent = controller.policy.transport.calls[-1][3] + assert sent['action'] == 'list' + assert sent['context']['review_recipient'] == session.subject + assert sent['context']['intake_submitter'] == 'state-hub' + assert 'review_recipient' not in build_request( + session, stored, 'read', 'v1', intake_context=store.intake_context(stored))['context'] + assert store.evidence() == [] + engine.failure = True + [row] = controller.overview(session) + assert row.group == 'unavailable' and row.question is None + assert len(controller.policy.transport.calls) == 1 + + +def test_real_discovery_policy_lists_only_recipient_and_keeps_fresh_read(handoff, tmp_path, signing_key): + import os + from pathlib import Path + from informed_decision.policy import build_request + from informed_decision.review import ReviewController, ReviewError + from review_components import flex_component + from test_policy_client import human + binary, source = os.environ.get('INFD_FLEX_AUTH_BINARY'), os.environ.get('INFD_FLEX_AUTH_SOURCE') + if not binary or not source: + pytest.skip('actual owner discovery policy required') + fixture = Path(source) / 'examples/informed-decision-lifecycle-review' + if json.loads((fixture / 'pin.json').read_text())['version'] != 'v13': + pytest.skip('owner discovery v13 required') + app, actor, memo, options, intake, calls = handoff + options['memo_class'] = 'decision-record' + receipt = prepare(handoff) + # Admit this class in the synthetic intake registry used by the fixture. + registry = json.loads(intake.submitters_path.read_text()) + for value in registry.values(): + value['memo_classes'].append('decision-record') + intake.submitters_path.write_text(json.dumps(registry)) + assert request(intake, receipt['envelope'])[0] == 201 + store = app.repository.store + stored = store.memo(receipt['review_memo_id']) + session = replace(human(), subject=stored.binding.principal.id, + assurance={**human().assurance, 'at': int(time.time()) - 3600}) + class Engine: + def get_approval(self, approval_id): + return {'status': 'requested', 'binding': {'digest': stored.approval_binding_digest, + 'actor': 'informed-decision', 'human_control': True}} + with flex_component(binary, tmp_path, signing_key, fixture=fixture) as (pdp, calls): + controller = ReviewController(store, pdp, lambda session: Engine(), intake_attributes=True) + [row] = controller.overview(session) + assert row.question == memo.question and row.group == 'open' + with pytest.raises(ReviewError, match='policy_denied'): + controller.open(session, stored.id) + req = build_request(session, stored, 'list', pdp.version, intake_context=store.intake_context(stored)) + req['context']['review_recipient'] = 'another-human' + assert pdp.check(req).outcome == 'policy_denied' + assert store.evidence() == [] diff --git a/workplans/INFD-WP-0007-decision-lifecycle-service.md b/workplans/INFD-WP-0007-decision-lifecycle-service.md index d4ab5ac..ca7bdc0 100644 --- a/workplans/INFD-WP-0007-decision-lifecycle-service.md +++ b/workplans/INFD-WP-0007-decision-lifecycle-service.md @@ -42,7 +42,7 @@ boundary, revision/evidence semantics and acceptance without per-decision deploy ```task id: INFD-WP-0007-T02 -status: progress +status: done priority: high state_hub_task_id: "cde0c932-afce-5bec-8cee-a2f377955b9f" ``` @@ -389,3 +389,21 @@ synthetic identity, including mobile and desktop inspection. Evidence is retaine 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. + +## Authoring deployed; discovery continuation — 2026-10-05 + +Founder explicitly requested deployment and the remaining implementation. +ADMINISTER @ realm:kubernetes/railiance01, activation=APPROVED. Authoring image +79aabf37 is live through Argo source dfcef87 and Platform pin 0189965. Both health +endpoints and Argo Synced/Healthy pass. Fresh backup/isolated restore preserved +25 confirmed submissions, 174 delivered outbox rows, two records/nine events. +Native selected-workload GET retrieves the exact retained handoff and confirms +the prior human disposition. No new human acceptance is fabricated. + +T02 now records explicit initial contracts in LifecycleAdmissionBoundary.md. +Expansion residuals have live records INFD-WP-0008-T01 through T04: external +identity/publication, connectors/retention controls, non-approval binding, optional +Hub write-back. T05 implements named-recipient review discovery in the consumer +and Access Engine FLEX-WP-0034 v13, with fresh read/act checks retained. Validation +671 passed, one skipped; 32 owner embedded policy checks plus owner regressions. +Discovery release and final native checks remain in progress. diff --git a/workplans/INFD-WP-0008-lifecycle-expansion-contracts.md b/workplans/INFD-WP-0008-lifecycle-expansion-contracts.md new file mode 100644 index 0000000..9b7281e --- /dev/null +++ b/workplans/INFD-WP-0008-lifecycle-expansion-contracts.md @@ -0,0 +1,70 @@ +--- +id: INFD-WP-0008 +type: workplan +title: "Lifecycle expansion beyond the initial production audience" +domain: infotech +repo: informed-decision +status: backlog +owner: codex +topic_slug: netkingdom +created: "2026-10-05" +updated: "2026-10-05" +origin: residual +origin_ref: INFD-WP-0007 +--- + +Initial production choices are recorded in docs/specs/LifecycleAdmissionBoundary.md. +This workplan preserves expansion work without claiming it was delivered by the +initial organizational approval service. These tasks do not authorize live changes. + +## External workload identity and expanded publication + +```task +id: INFD-WP-0008-T01 +status: todo +priority: medium +``` + +Contract with KeyCape and Access Engine for agents outside cluster TokenReview, +additional tenants/audiences and any anonymous publication. Retain per-record +policy and authentication-derived attribution; do not treat arbitrary JWT claims +as admission. Initial authenticated publication remains the supported path. + +## Source connector and retention controls + +```task +id: INFD-WP-0008-T02 +status: todo +priority: medium +``` + +Select actual source connectors and define their retrieval/credential boundaries, +network restrictions, content limits and provenance. Define retention/export/ +erasure requirements and owner review before introducing deletion. Existing +caller-supplied evidence and append-only custody remain the default. + +## Non-approval binding profiles + +```task +id: INFD-WP-0008-T03 +status: todo +priority: medium +``` + +Define and implement non-approval L0/L2 presentation/disposition workflows against +the shared schema with the identity, authorization and audit owners. Do not create +an Approval Engine object solely to simulate an unrelated binding act. Preserve +schema compatibility, human-only binding and revision-specific evidence. + +## Optional State Hub outcome write-back + +```task +id: INFD-WP-0008-T04 +status: todo +priority: low +``` + +Independently admit and verify optional resolution write-back with State Hub. +Keep explicit correlation, replay, and uncertain-outcome recovery; a human answer +must never become an execution authorization. Current notification delivery and +agent outcome retrieval remain available without automatic write-back.