From e864305d4dc256c621e77d2f0ec3b014384fae70 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 15:03:24 +0200 Subject: [PATCH] Publish reviewed strict execution consumption confirmation client Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- approval_engine/execution_confirmation.py | 96 ++++++++++++ ...ecution-consumption-source-owner-review.md | 68 +++++++++ docs/task-execution-consumption-contract.md | 143 ++++++++++++++++++ tests/test_execution_confirmation.py | 128 ++++++++++++++++ workplans/ADHOC-2026-10-11.md | 20 +++ 5 files changed, 455 insertions(+) create mode 100644 approval_engine/execution_confirmation.py create mode 100644 docs/reviews/2026-10-11-task-execution-consumption-source-owner-review.md create mode 100644 docs/task-execution-consumption-contract.md create mode 100644 tests/test_execution_confirmation.py diff --git a/approval_engine/execution_confirmation.py b/approval_engine/execution_confirmation.py new file mode 100644 index 0000000..2fef8b9 --- /dev/null +++ b/approval_engine/execution_confirmation.py @@ -0,0 +1,96 @@ +"""Exact consumption evidence for an independently authenticated mediator. + +This client supplies PIP facts, never an execution admission or PDP decision. +The mediator still owns claim/PDP validation, deadlines and a one-shot journal. +""" + +from datetime import datetime +from typing import Any +from urllib.parse import quote + +from .binding import DIGEST_RE, canonical_binding +from .pep import ApprovalHTTPClient, ApprovalProtocolError, _ID_RE + + +def _inputs(approval_id: str, request_digest: str, decision_id: str) -> None: + if not isinstance(approval_id, str) or not _ID_RE.fullmatch(approval_id): + raise ApprovalProtocolError("approval id is invalid") + if not isinstance(request_digest, str) or not DIGEST_RE.fullmatch(request_digest): + raise ApprovalProtocolError("canonical request digest is required") + if not isinstance(decision_id, str) or not decision_id.strip(): + raise ApprovalProtocolError("exact decision id is required") + + +def _evidence(payload: dict[str, Any], request_digest: str, decision_id: str) -> str: + if payload.get("request_digest") != request_digest: + raise ApprovalProtocolError("consumed request digest does not match") + if payload.get("decision_id") != decision_id: + raise ApprovalProtocolError("consumed decision id does not match") + value = payload.get("consumed_at") + try: + stamp = datetime.fromisoformat(value) if isinstance(value, str) else None + except ValueError: + stamp = None + if stamp is None or stamp.tzinfo is None or stamp.utcoffset() is None: + raise ApprovalProtocolError("consumption time is missing or invalid") + if any(key in payload for key in ("allow", "deny", "effect")): + raise ApprovalProtocolError("consumption evidence is not a decision") + return value + + +class ExecutionConfirmationClient(ApprovalHTTPClient): + def consume_exact( + self, approval_id: str, request_digest: str, decision_id: str + ) -> dict[str, Any]: + """One CAS call, with exact response validation and no automatic retry.""" + _inputs(approval_id, request_digest, decision_id) + result = self._request( + "POST", f"/v1/approvals/{quote(approval_id, safe='')}/consume", + {"request_digest": request_digest, "decision_id": decision_id}, + ) + if result.get("approval_id") != approval_id or result.get("status") != "consumed": + raise ApprovalProtocolError("exact consumption was not confirmed") + _evidence(result, request_digest, decision_id) + if type(result.get("idempotent")) is not bool: + raise ApprovalProtocolError("idempotent must be an explicit boolean") + return {"approval_id": approval_id, "status": "consumed", + "request_digest": request_digest, "decision_id": decision_id, + "consumed_at": result["consumed_at"], "idempotent": result["idempotent"]} + + def confirm_consumption( + self, approval_id: str, request_digest: str, decision_id: str, *, + expected_binding: dict[str, Any], expected_pdp_digest: str, + ) -> dict[str, str]: + """Read the authoritative object; return historical facts only. + + Approval entries may be present on the wire but are not republished. + This method is safe for reconciliation after an unknown CAS outcome; + reconciliation cannot authorize retrying protected backend work. + """ + _inputs(approval_id, request_digest, decision_id) + if not isinstance(expected_pdp_digest, str) or not DIGEST_RE.fullmatch(expected_pdp_digest): + raise ApprovalProtocolError("exact approval-free PDP digest is required") + try: + expected = canonical_binding(expected_binding) + except (TypeError, ValueError, KeyError): + raise ApprovalProtocolError("expected act binding is invalid") from None + obj = self._request("GET", f"/v1/approvals/{quote(approval_id, safe='')}") + if obj.get("id") != approval_id or obj.get("status") != "consumed": + raise ApprovalProtocolError("durable consumption was not confirmed") + if any(key in obj for key in ("allow", "deny", "effect")): + raise ApprovalProtocolError("consumption object is not a decision") + served = obj.get("binding") + try: + same_act = isinstance(served, dict) and canonical_binding(served) == expected + except (TypeError, ValueError, KeyError): + same_act = False + if not same_act or served.get("pdp_digest") != expected_pdp_digest: + raise ApprovalProtocolError("consumed approval binding does not match") + if served.get("pdp_path") is not True or served.get("human_control") is not True: + raise ApprovalProtocolError("production execution requires declared PDP and human control") + consumption = obj.get("consumption") + if not isinstance(consumption, dict): + raise ApprovalProtocolError("durable consumption evidence is missing") + consumed_at = _evidence(consumption, request_digest, decision_id) + return {"approval_id": approval_id, "request_digest": request_digest, + "decision_id": decision_id, "consumed_at": consumed_at} diff --git a/docs/reviews/2026-10-11-task-execution-consumption-source-owner-review.md b/docs/reviews/2026-10-11-task-execution-consumption-source-owner-review.md new file mode 100644 index 0000000..0799b07 --- /dev/null +++ b/docs/reviews/2026-10-11-task-execution-consumption-source-owner-review.md @@ -0,0 +1,68 @@ +# Approval Engine task execution consumption source owner review + +```yaml +reviewer: approval-engine-owner (agent approval-engine, codex) +decision: approved +comment: >- + Source-only provider client and consumer contract are consistent with + approval-engine INTENT.md Purpose, What approval-engine Owns, The Claim Shape, + and Consumption Ordering under GH-DEC-2026-003: they supply exact durable + approval consumption facts, never a PDP verdict or execution admission. + Reviewed from the approval-engine checkout with its AGENTS.md/INTENT.md loaded, + after own work item APPROVAL-WP-ADHOC-2026-10-11-T01 was persisted in + 097a2f28340c0e768a327e3aff998835ff8226d0, anchor SHA256 + 6765012ef84045aa02cd68782ce2182149eaae0afbdd277c86d8ba7a6a545ad9. + Exact source revision: execution_confirmation.py SHA256 + 81dd085908e2aa3cb516af8a2660f2625fe9b5d1a5f2209725c139b8b8587318; + test_execution_confirmation.py SHA256 + a1d54e3a6bf1dff70d91cf1853280b9ae1c33e44e5f7c0227aa2d17188a246f2; + task-execution-consumption-contract.md SHA256 + b5f0a7e0d0b7812a35c98dc31d852660ebffb23c6c657303063becb12b22ee11. + Approval covers these immutable source artifacts only. Actual admitted + independent Platform mediator approval:read scope, service/agent consume + identity, credential custody and authenticated transport/ingress reachability + are absent; this judgement supplies none of them. No grant, human approval, + native execution, deployment, T-06 proof or broader lifecycle adoption. +``` + +Existing source facts checked against api.py, auth.py, store.py and binding.py: + +- POST /v1/approvals/{id}/consume is authenticated, tenant-isolated and + service/agent-scoped. BEGIN IMMEDIATE plus status CAS emits use atomically. + Engine validates request-digest syntax; it does not validate a PDP decision + or independently equate request_digest to the stored approval-free digest. +- GET /v1/approvals/{id} is the existing approval:read confirmation source. + It exposes persisted consumption request/decision/time and native binding; + the claim endpoint's consumed boolean alone does not prove that tuple. +- Same-digest retries retain the original decision and consumed_at, including + after expiry. A changed retry decision cannot be substituted by the client. + Different digest conflicts. Consumed is terminal; it cannot be revoked or + superseded to cancel further work. Historical confirmation renews no authority. +- The new client rejects changed identifiers, mismatched original decision, + malformed times and coerced booleans, rejects decision-shaped payloads, + compares exact act and approval-free digest on read, and filters returned + facts. It performs no protected callback, native mutation or automatic retry. + +Execution requires the independent mediator to obtain and validate a fresh +human-controlled claim and exact Access DecisionEnvelope before CAS, match +original approval-free/full request digests and owner-compiled body/target/source +context, and persist confirmed original consumption in a separate live one-shot +execution journal before native work. Merely possessing provider output, a local +file or idempotent success is insufficient. Unknown consume/read outcomes stop +execution and permit only owner reconciliation; another protected action needs +fresh exact recovery approval. Original approval/PDP/operation deadlines govern +continuation; scoped cleanup is separate. The client does not attest its caller's +identity or prove these mediator preconditions. + +The native binding still covers only the five act fields; context execution body +digests rely on Access's actual approval-free and enriched CheckRequest digests. +A new execute-ccr action/package/principal representation must be owner-published +and explicitly admitted. Existing apply/v2 package semantics cannot be assumed +to recognize it. No other owner role is reviewed here. + +Validation reviewed: unchanged source/tests match the exact hashes above; +24 focused tests and 219 full staged tests passed with the existing JWT test-key +warning. Existing authenticated in-process WSGI GET checks are source evidence, +not a native admitted mediator proof. Source patch check passes. The original +completed source-preparation outcome is preserved. APPROVAL-WP-0002-T01/T05 and +all native/adoption owner tasks remain unchanged; no residual is promoted. diff --git a/docs/task-execution-consumption-contract.md b/docs/task-execution-consumption-contract.md new file mode 100644 index 0000000..4e36bd5 --- /dev/null +++ b/docs/task-execution-consumption-contract.md @@ -0,0 +1,143 @@ +# Approval Engine metadata execution consumer contract — source proposal + +Author: approval-engine source reviewer (Codex), staged only. Work record: +APPROVAL-WP-ADHOC-2026-10-11-T01, bounded completed source preparation. Existing +APPROVAL-WP-0002-T05 remains unchanged wait/residual adoption. This is an +author-qualified technical proposal, not a formal delegated owner review, +owner acknowledgement, founder approval, identity admission or runtime receipt. +Source checkout: c8ae88057d5f2ff3743a37c158d7f692d90cce23, including pre-existing +uncommitted owner files listed in source-state.json; none modified. + +Reviewed proposals: SECRETS-EXEC-20261011-v1 and platform-task-execution/v1. +Their exact source-file hashes are in source-state.json. Initial execute-ccr +metadata pilot only; no generic grants, daemon, standing service permissions, +raw bearer handoff or full Secrets adoption. + +## Existing route and trust boundary + +- GET /v1/approvals/{id}/claim requires approval:read and exact store tenant. + It supplies fresh approval facts and native five-field act binding, approval-free + PDP digest, PDP/human declarations. consumed=true lacks consumed request/decision + correspondence and cannot confirm execution admission. +- POST /v1/approvals/{id}/consume requires approval:consume, exact tenant and + service/agent principal. Body is request_digest plus decision_id. It returns + approval_id, status=consumed, request_digest, original decision_id when stored, + consumed_at and explicit idempotent boolean. Direct response to an authenticated + independent mediator is authoritative mutation evidence on its trusted transport. +- GET /v1/approvals/{id} requires approval:read. Approval.as_dict returns id, + persisted status, exact binding, validity, and consumption containing original + request_digest/decision_id/consumed_at. This existing read is the independent + consume-confirmation surface. It includes approver entries, so capture in memory + and discard them; never serialize the raw object into a receipt or tool output. + +There is no signed portable consume receipt or dedicated public confirmation +endpoint. A caller boolean, ConsumedApproval dataclass, JSON receipt or local file +is not trusted proof. The mediator fetches the authoritative object itself through +its owner-pinned authenticated endpoint, with no caller-selected origin/opener. +Bearer authentication identifies the client to the server; the Platform owner must +also establish the server/transport identity. Generic HTTP support in the helper +is not evidence of an admitted production transport. + +Identity/read reachability is explicitly unproved. The admitted Secrets client +does not grant a new Platform mediator approval:read/consume custody. KeyCape owns +any exact client/audience/scope/tenant registration and Platform owns transport and +credential custody; do not widen an identity profile or reuse Secrets credentials +merely to make a read work. In-cluster callers require the existing reviewed +NetworkPolicy namespace admission too. No credential was obtained in this review. + +## Exact binding responsibilities + +Source mechanisms: api.py ApiApplication.identity and consume route; auth.py +RS256/JWKS claim verification; store.py Engine.consume/Approval.as_dict/claim; +binding.py canonical_binding; execution_confirmation.py staged strict client. + +Native binding digest covers action/actor/principal/purpose/target, preserving +arbitrary nested target. Extra top-level execution context is not part of this +native digest. It must be covered by the PDP's approval-free digest issued as +pdp_digest and the full enriched decision binding.request_digest submitted to +consume. Access owner reports ApprovalBindingDigest removes only top-level +context.approval: execution_contract remains covered. Compute through Access's +actual canonical CheckRequest implementation; never assume Python sorted-key JSON +is the Go request digest. Full enriched request includes the served claim. + +Engine.consume validates digest syntax, not equivalence to pdp_digest or act, and +does not validate a DecisionEnvelope, decision_id existence or consuming sub +against binding.actor. This is the PIP boundary, not a second policy decision +point. A service holding consume scope can spend an approved object with an +unrelated well-formed digest; that response cannot substitute for the mediator's +prior independent exact claim/PDP/body/action/target/actor/principal validation. + +Access's present v2 apply rules do not evaluate execution_contract. A distinct +execute-ccr action, published exact package/version/digest and verified initiating +principal/representation mapping are prerequisites, not inferred semantics. +Create enforces binding.actor==verified requester sub; the design must resolve +actual initiating principal vs execution service with Identity/Access owners, +without guessing actor strings or treating environment subject text as evidence. +All owner-compiled CCR/grant/catalog/source revisions, methods/paths/body hashes, +operation UUID and deadlines must remain in the approved underlying context. +Approval IDs and served claim additions must follow Access's explicit exclusion. + +## Idempotency and lifecycle + +BEGIN IMMEDIATE serializes consume; status compare-and-swap and local use outbox +are one transaction. The first consume requires approved status, current validity +window and declared human-control evidence. Different consumed digest conflicts. +Same digest returns idempotent=true and the original decision_id even if a retry +submits another decision_id, even after original expiry. No second use event is +created. That success is historical consumption, never renewed execution power. +The staged client requires exact original decision correspondence and rejects +string/integer booleans, missing times and decision-shaped responses. + +Revoked/superseded/expired objects reject a first consume. Consumed is terminal: +revoke and supersede cannot cancel it, and expiry does not erase historical +consumption. A spent claim has valid_now=false (after deadline its reason/state +is expired while consumed remains true). Rechecking valid_now after consume +cannot validate continuation. The original validated approval/PDP/operation +deadlines bound every business write; failure stops further writes. In-flight +cancellation requires a separately owned execution stop/cleanup mechanism, not +post-consume approval revocation. Cleanup has its explicit original obligation. + +## Independent mediator sequence + +1. Independently reconstruct owner-approved operation bodies and binding; check + identity/source/revisions/deadline and exact published PDP package. No native + secret read/login, token mint or mutation during preparation. +2. Atomically reserve a one-shot execution journal admission for the immutable + approval/request/operation tuple; only creator proceeds. This journal is + Platform execution state, distinct from approval-engine outbox/consumption. +3. Fetch and validate a fresh human-controlled PDP-path claim, call Access Check, + require exact underlying/full digest, original act and policy pin, then one + CAS consume. Prefer consume at this mediator boundary to early CLI consumption. +4. Validate direct consume evidence with consume_exact; if separate Secrets code + returns a receipt, independently GET confirm_consumption with expected native + act and approval-free PDP digest. Match original exact decision/full digest/time. + Do not issue another mutating consume merely to discover whether one happened. +5. Persist confirmed evidence in the existing live journal before protected + calls. A successful idempotent response or GET without that same live one-shot + admission never permits execution. A CAS/read timeout marks unknown; owner + reconciliation may read confirmation but does not automatically resume work. +6. Check original deadlines before every native call, record about-to-call then + outcome, and maintain separately confirmed cleanup. Duplicate completed input + returns existing receipt only. Any incomplete/unknown operation requires owner + reconciliation and fresh exact recovery approval for another protected action. + +The staged client implements only exact direct evidence and independent read +confirmation. It does not implement the journal, authorization policy, server +identity admission or execution. Existing Secrets consume_approval accepts wrong +approval_id, does not require original decision match and coerces idempotent; +Secrets owns its strict handoff refinement. Do not silently extend the generic +ProtectedActionHarness into an at-most-once execution service. + +## Gates and validation + +Existing owners/tasks retained: APPROVAL-WP-0002-T05 served T-06 consumer proof; +APPROVAL-WP-0002-T01 issuer/profile plus schema-v6 release evidence; +SECRETS-WP-0013 implementation and SECRETS-WP-0008-T06 full adoption; +RPF-WP-0035-T02 native lane adoption; exact Access/Identity action/representation +admission under those owners' records. No dependency is marked closed. + +24 focused tests pass, including authenticated WSGI GET with no mutation/outbox +change, wrong act/target/actor/principal/purpose/digest/decision refusal, strict +booleans/time, denied read, filtered evidence and idempotency after expiry with +terminal revoke/supersede refusal. Staged full suite: 219 passed, one existing +JWT short-test-key warning. No production HTTP/DB/OpenBao call or native action. diff --git a/tests/test_execution_confirmation.py b/tests/test_execution_confirmation.py new file mode 100644 index 0000000..63188e6 --- /dev/null +++ b/tests/test_execution_confirmation.py @@ -0,0 +1,128 @@ +import json +from datetime import timedelta +from urllib.parse import urlsplit +from urllib.error import HTTPError + +import pytest + +from approval_engine.api import call +from approval_engine.errors import Conflict +from approval_engine.execution_confirmation import ExecutionConfirmationClient +from approval_engine.pep import ApprovalProtocolError +from tests.conftest import FROZEN, binding, validity +from tests.test_pep import Response + +FULL = "sha256:" + "ab" * 32 +FREE = "sha256:" + "cd" * 32 + + +def client(tmp_path, opener): + token = tmp_path / "test-token" + token.write_text("test-token") + return ExecutionConfirmationClient("http://approval-engine", token, opener=opener) + + +def consumed(engine): + obj = engine.create(binding(), validity(), pdp_digest=FREE, pdp_path=True, + human_control=True) + engine.add_entry(obj.id, "human:founder", principal_type="human") + engine.consume(obj.id, FULL, decision_id="decision:original") + return obj + + +def confirmer(c, obj): + return c.confirm_consumption(obj.id, FULL, "decision:original", + expected_binding=binding(), expected_pdp_digest=FREE) + + +def test_confirmation_reads_authenticated_current_object_without_mutation(engine, app, tmp_path): + obj = consumed(engine) + before = list(engine.undrained()) + requests = [] + + def opener(req, timeout): + requests.append(req) + status, result = call(app, req.get_method(), urlsplit(req.full_url).path, + authorization=req.get_header("Authorization")) + assert status == 200 + return Response(result) + + result = confirmer(client(tmp_path, opener), obj) + assert result == {"approval_id": obj.id, "request_digest": FULL, + "decision_id": "decision:original", "consumed_at": FROZEN.isoformat()} + assert requests[0].get_method() == "GET" + assert engine.undrained() == before + + +@pytest.mark.parametrize("field,value", [ + ("id", "different"), ("status", "approved"), + ("consumption", {"request_digest": FULL, "decision_id": "different", "consumed_at": FROZEN.isoformat()}), + ("consumption", {"request_digest": FREE, "decision_id": "decision:original", "consumed_at": FROZEN.isoformat()}), + ("consumption", {"request_digest": FULL, "decision_id": "decision:original", "consumed_at": "naive"}), + ("consumption", None), ("effect", "ALLOW"), +]) +def test_wrong_consumption_object_refuses(engine, tmp_path, field, value): + obj = consumed(engine) + result = engine.get(obj.id).as_dict() + result[field] = value + with pytest.raises(ApprovalProtocolError): + confirmer(client(tmp_path, lambda *_a, **_k: Response(result)), obj) + + +@pytest.mark.parametrize("field,value", [ + ("action", "exec"), ("target", {"id": "sibling"}), ("actor", "forged"), + ("principal", "forged"), ("purpose", "different"), ("pdp_digest", FULL), + ("pdp_path", "true"), ("human_control", 1), +]) +def test_wrong_act_or_digest_or_declaration_refuses(engine, tmp_path, field, value): + obj = consumed(engine) + result = engine.get(obj.id).as_dict() + result["binding"][field] = value + with pytest.raises(ApprovalProtocolError): + confirmer(client(tmp_path, lambda *_a, **_k: Response(result)), obj) + + +def test_retry_is_historical_after_expiry_and_retains_original_decision(engine, tmp_path): + obj = consumed(engine) + engine.clock = lambda: FROZEN + timedelta(days=1) + retry = engine.consume(obj.id, FULL, decision_id="decision:new") + assert retry["idempotent"] is True + assert retry["decision_id"] == "decision:original" + with pytest.raises(ApprovalProtocolError): + client(tmp_path, lambda *_a, **_k: Response(retry)).consume_exact(obj.id, FULL, "decision:new") + assert engine.claim(obj.id)["valid_now"] is False + assert confirmer(client(tmp_path, lambda *_a, **_k: Response(engine.get(obj.id).as_dict())), obj) + with pytest.raises(Conflict): + engine.revoke(obj.id) + with pytest.raises(Conflict): + engine.supersede(obj.id) + + +@pytest.mark.parametrize("field,value", [ + ("approval_id", "wrong"), ("decision_id", None), ("idempotent", "false"), + ("consumed_at", "2026-10-11T12:00:00"), ("effect", "ALLOW"), +]) +def test_exact_consume_response_refuses_loose_receipt(engine, tmp_path, field, value): + obj = consumed(engine) + result = engine.consume(obj.id, FULL, decision_id="decision:original") + result[field] = value + with pytest.raises(ApprovalProtocolError): + client(tmp_path, lambda *_a, **_k: Response(result)).consume_exact(obj.id, FULL, "decision:original") + + +def test_exact_consume_filters_extraneous_payload(engine, tmp_path): + obj = consumed(engine) + result = engine.consume(obj.id, FULL, decision_id="decision:original") + result["notes"] = "discard" + confirmed = client(tmp_path, lambda *_a, **_k: Response(result)).consume_exact(obj.id, FULL, "decision:original") + assert "notes" not in confirmed + + +def test_denied_read_never_represents_confirmation(engine, app, tmp_path): + obj = consumed(engine) + def opener(req, timeout): + status, result = call(app, "GET", urlsplit(req.full_url).path, authorization=None) + assert status == 401 + raise HTTPError(req.full_url, status, "refused", {}, None) + with pytest.raises(ApprovalProtocolError, match="unauthorized"): + confirmer(client(tmp_path, opener), obj) diff --git a/workplans/ADHOC-2026-10-11.md b/workplans/ADHOC-2026-10-11.md index eb3fbba..7e8ead6 100644 --- a/workplans/ADHOC-2026-10-11.md +++ b/workplans/ADHOC-2026-10-11.md @@ -62,3 +62,23 @@ RPF-WP-0035-T02 retain their original statuses and authority boundaries. Formal review, if requested, must follow persistence of this work record in the actual owner checkout. This staged file is the proposed workrecord-before-review anchor; it does not establish a persisted anchor or approval by its presence. + + +## Formal source owner review after persisted preparation anchor + +Under the same bounded APPROVAL-WP-ADHOC-2026-10-11-T01 new-demand record, the +approval-engine owner worker performed formal source judgement only after +097a2f28340c0e768a327e3aff998835ff8226d0 persisted the original preparation +record (SHA256 6765012ef84045aa02cd68782ce2182149eaae0afbdd277c86d8ba7a6a545ad9). +The preceding proposed-anchor paragraph describes that earlier stage; the +persisted preparation evidence now supplies the workrecord-before-review anchor. + +Decision: approved for the unchanged exact provider sourceclient, tests and +source-only consumption contract. Review: docs/reviews/2026-10-11-task-execution- +consumption-source-owner-review.md. Exact hashes and INTENT basis are recorded +there. Original task status, validation and source-preparation outcome retained. +The review does not assert actual mediator identity/read scope, custody or +transport admission. Fresh claim/PDP validation, returned original consumption +correspondence and a separate one-shot journal remain caller obligations. +No grant, native acceptance, deployment, human approval, residual promotion or +full-adoption closure was recorded.