From 6844a05e56a52614f3dde63f9b35b73c28130bb6 Mon Sep 17 00:00:00 2001 From: Arpan Ghoshal Date: Tue, 6 Oct 2026 06:07:25 +0530 Subject: [PATCH 1/3] Move the hop fixtures' expiry out of the past NARROW and RACE_CHILD expired at 2026-10-01T00:00:00Z, so twelve tests in test_hop.py have refused with authority_expired since that date. The children must expire before their parents, so both move together: parents to 2100-01-01, children to 2099-10-01. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Arpan Ghoshal --- tests/test_hop.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_hop.py b/tests/test_hop.py index 84e5dd8..657e219 100644 --- a/tests/test_hop.py +++ b/tests/test_hop.py @@ -44,7 +44,7 @@ environments: ["production"] constraints: {{ amount_lte: 100000 }} delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" tasks: ["refund-run:*"] budgets: - {{ metric: amount, limit: 100000, window: PT24H }} @@ -63,7 +63,7 @@ resources: ["payment:EU-1"] environments: ["production"] constraints: { amount_lte: 5000 } -expires_at: "2026-10-01T00:00:00Z" +expires_at: "2099-10-01T00:00:00Z" tasks: ["refund-run:7"] budgets: - { metric: amount, limit: 20000, window: P30D } @@ -521,7 +521,7 @@ def test_T488b_the_context_variable_is_evidence_only(tmp_path): resources: ["payment:*"] environments: ["production"] delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" budgets: - {{ metric: amount, limit: {limit}, window: PT24H }} """ @@ -531,7 +531,7 @@ def test_T488b_the_context_variable_is_evidence_only(tmp_path): actions: ["stripe.refund"] resources: ["payment:EU-{index}"] environments: ["production"] -expires_at: "2026-10-01T00:00:00Z" +expires_at: "2099-10-01T00:00:00Z" budgets: # Contained on both axes (§2.6): the same limit over the same window. A child whose limit # exceeded its parent's is refused at creation, which is what an earlier draft of this From 3ebdf45fa76790ea83ffb8afdc188fac65e07139 Mon Sep 17 00:00:00 2001 From: Arpan Ghoshal Date: Tue, 6 Oct 2026 06:25:43 +0530 Subject: [PATCH 2/3] Move the demo's and the fixtures' grant expiry out of the near future `ctrlrun demo` delegates a grant that expires 2026-12-01 under a root grant that expires 2027-01-01, so from 1 December its fifth scenario refuses with authority_expired instead of showing what it is there to show. The same dates sit in examples/authority/payments.yaml (read by verify's T100 and T118), examples/authority-escalation, the manager-and-worker and payout-maker-checker recipes, and the fixtures of every test that runs the CLI on the real clock. Running the whole suite with the clock moved to 2031-01-01 found each of them. They move together, each child still expiring before its parent: 2027-01-01 to 2100-01-01, 2026-12-31 to 2099-12-31, 2026-12-01 to 2099-12-01, 2026-10-01 to 2099-10-01. The two recipes are re-extracted from their pages. A test on the injected clock (2026-09-03) keeps dates relative to that clock: t76b's brought-forward root stays 2026-10-01, because the test advances the clock 45 days past it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Arpan Ghoshal --- examples/authority-escalation/ctrlrun.yaml | 2 +- examples/authority-escalation/main.py | 2 +- examples/authority/payments.yaml | 2 +- .../cookbook/manager-and-worker/ctrlrun.yaml | 2 +- examples/cookbook/manager-and-worker/main.py | 4 ++-- .../cookbook/payout-maker-checker/ctrlrun.yaml | 2 +- examples/cookbook/payout-maker-checker/main.py | 4 ++-- src/ctrlrun/cli/demo.py | 4 ++-- tests/test_adapter.py | 4 ++-- tests/test_delegation.py | 18 +++++++++--------- tests/test_hop_surfaces.py | 6 +++--- tests/test_inspect.py | 4 ++-- tests/test_otel.py | 4 ++-- tests/test_revoke_selector.py | 10 +++++----- tests/test_upgrade_0_9_to_0_10.py | 4 ++-- 15 files changed, 36 insertions(+), 36 deletions(-) diff --git a/examples/authority-escalation/ctrlrun.yaml b/examples/authority-escalation/ctrlrun.yaml index 578145f..fcc945b 100644 --- a/examples/authority-escalation/ctrlrun.yaml +++ b/examples/authority-escalation/ctrlrun.yaml @@ -32,7 +32,7 @@ authority: constraints: { amount_lte: 10000000 } # €100,000.00 in integer minor units environments: ["production"] delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" actions: # The autonomy policy is a **separate axis** and cannot see the principal. It says how much diff --git a/examples/authority-escalation/main.py b/examples/authority-escalation/main.py index 739faeb..09218a2 100644 --- a/examples/authority-escalation/main.py +++ b/examples/authority-escalation/main.py @@ -84,7 +84,7 @@ def narrower(agent: str, ceiling: int) -> Grant: constraints: {{ amount_lte: {ceiling} }} environments: ["production"] delegable: true -expires_at: "2026-12-01T00:00:00Z" +expires_at: "2099-12-01T00:00:00Z" """ ) diff --git a/examples/authority/payments.yaml b/examples/authority/payments.yaml index 35f9aed..27ed203 100644 --- a/examples/authority/payments.yaml +++ b/examples/authority/payments.yaml @@ -34,7 +34,7 @@ authority: constraints: { amount_gte: 0, amount_lte: 10000000 } # €0 to €100,000.00 environments: ["production"] delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" # SPEC-v0.9 §6. The unit of work this authority is for. A grant that omits `tasks:` # authorises any task (§6.5), which is what every grant below does and why they are # unchanged; naming one here binds this authority, and every delegation beneath it, to diff --git a/examples/cookbook/manager-and-worker/ctrlrun.yaml b/examples/cookbook/manager-and-worker/ctrlrun.yaml index d1832e9..6366a55 100644 --- a/examples/cookbook/manager-and-worker/ctrlrun.yaml +++ b/examples/cookbook/manager-and-worker/ctrlrun.yaml @@ -12,7 +12,7 @@ authority: constraints: { replicas_gte: 0, replicas_lte: 50 } environments: ["production"] delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" actions: k8s.rollout_restart: diff --git a/examples/cookbook/manager-and-worker/main.py b/examples/cookbook/manager-and-worker/main.py index f2839f2..9a560e1 100644 --- a/examples/cookbook/manager-and-worker/main.py +++ b/examples/cookbook/manager-and-worker/main.py @@ -57,7 +57,7 @@ def scale(who: Principal, cluster: str, deployment: str, replicas: int) -> None: resources: ["cluster:prod-eu"] constraints: { replicas_gte: 0, replicas_lte: 10 } environments: ["production"] -expires_at: "2026-12-01T00:00:00Z" +expires_at: "2099-12-01T00:00:00Z" """), by=MANAGER, ) @@ -88,7 +88,7 @@ def scale(who: Principal, cluster: str, deployment: str, replicas: int) -> None: actions: ["k8s.scale"] resources: ["cluster:prod-eu"] environments: ["production"] -expires_at: "2026-12-01T00:00:00Z" +expires_at: "2099-12-01T00:00:00Z" """), by=MANAGER, ) diff --git a/examples/cookbook/payout-maker-checker/ctrlrun.yaml b/examples/cookbook/payout-maker-checker/ctrlrun.yaml index 1a7c4bb..ed99a6b 100644 --- a/examples/cookbook/payout-maker-checker/ctrlrun.yaml +++ b/examples/cookbook/payout-maker-checker/ctrlrun.yaml @@ -11,7 +11,7 @@ authority: constraints: { amount_gte: 0, amount_lte: 10000000 } environments: ["production"] delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" actions: bank.payout: diff --git a/examples/cookbook/payout-maker-checker/main.py b/examples/cookbook/payout-maker-checker/main.py index e336415..c274d16 100644 --- a/examples/cookbook/payout-maker-checker/main.py +++ b/examples/cookbook/payout-maker-checker/main.py @@ -60,7 +60,7 @@ def proposal(who: Principal, account: str, reference: str, amount: int) -> Actio constraints: { amount_gte: 0, amount_lte: 2500000 } environments: ["production"] delegable: true -expires_at: "2026-12-31T00:00:00Z" +expires_at: "2099-12-31T00:00:00Z" """) delegation = control.delegate("treasury-lead", slice_, by=LEAD) print("delegated to the payout agent:", delegation.delegation_id) @@ -115,7 +115,7 @@ def proposal(who: Principal, account: str, reference: str, amount: int) -> Actio resources: ["account:*"] constraints: { amount_gte: 0, amount_lte: 9000000 } environments: ["production"] -expires_at: "2026-12-31T00:00:00Z" +expires_at: "2099-12-31T00:00:00Z" """), by=AGENT, ) diff --git a/src/ctrlrun/cli/demo.py b/src/ctrlrun/cli/demo.py index aeaf759..da63c17 100644 --- a/src/ctrlrun/cli/demo.py +++ b/src/ctrlrun/cli/demo.py @@ -296,7 +296,7 @@ def present_it_again() -> None: actions: ["stripe.refund"] constraints: {{ amount_lte: {HEAD_OF_SUPPORT_CEILING} }} delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" actions: stripe.refund: rules: @@ -321,7 +321,7 @@ def _delegated(ceiling: int, agent: str, user: str) -> Grant: actions: ["stripe.refund"] constraints: {{ amount_lte: {ceiling} }} delegable: true -expires_at: "2026-12-01T00:00:00Z" +expires_at: "2099-12-01T00:00:00Z" """ ) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index d2e3519..1d39d23 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -1218,7 +1218,7 @@ def issue_refund(payment_id: str, amount: int, mode: str = "live") -> str: resources: ["payment:*"] environments: [production] delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" actions: stripe.refund: resource: "payment:{payment_id}" @@ -1258,7 +1258,7 @@ def test_T128c_needs_approval_under_a_hop_decides_what_execute_decides(tmp_path) actions: ["stripe.refund"] resources: ["payment:US-*"] environments: [production] -expires_at: "2026-12-01T00:00:00Z" +expires_at: "2099-12-01T00:00:00Z" """, source="", ) diff --git a/tests/test_delegation.py b/tests/test_delegation.py index 06705e6..55f39c5 100644 --- a/tests/test_delegation.py +++ b/tests/test_delegation.py @@ -98,7 +98,7 @@ resources: ["payment:EU-*"] constraints: { amount_gte: 0, amount_lte: 10000000 } environments: ["production", "staging"] - expires_at: "2027-01-01T00:00:00+00:00" + expires_at: "2100-01-01T00:00:00+00:00" delegable: true """ @@ -115,7 +115,7 @@ region_neq: "us-east-1" cluster_in: ["eu-1", "eu-2", "eu-3"] environments: ["production", "staging"] - expires_at: "2027-01-01T00:00:00+00:00" + expires_at: "2100-01-01T00:00:00+00:00" delegable: true """ @@ -132,8 +132,8 @@ FINANCE_AGENT = Principal(agent="finance-agent", user="cfo@example.com") SUPPORT_AGENT = Principal(agent="support-agent", user="cfo@example.com") -EXPIRES = datetime(2026, 12, 1, tzinfo=UTC) -PARENT_EXPIRES = datetime(2027, 1, 1, tzinfo=UTC) +EXPIRES = datetime(2099, 12, 1, tzinfo=UTC) +PARENT_EXPIRES = datetime(2100, 1, 1, tzinfo=UTC) class _Clock: @@ -386,7 +386,7 @@ def test_t75b_only_the_parents_subject_may_delegate(store, clock, by): ("actions", {"actions": ("**",)}), ("resources", {"resources": ("payment:*",)}), ("environments", {"environments": ("production", "dev")}), - ("expires_at", {"expires_at": datetime(2027, 6, 1, tzinfo=UTC)}), + ("expires_at", {"expires_at": datetime(2100, 6, 1, tzinfo=UTC)}), ("subject", {"subject": Subject(agent="*", user="cfo@example.com")}), ("subject", {"subject": Subject(user="cfo@example.com")}), ("subject", {"subject": Subject(agent="finance-agent")}), @@ -590,7 +590,7 @@ def test_t76b_rule_3_an_ancestor_whose_expiry_was_brought_forward(store, clock): control = _control(_document(), store, clock) first = control.delegate("head-of-finance", _child(), by=CFO) - brought_forward = FINANCE.replace("2027-01-01T00:00:00+00:00", "2026-10-01T00:00:00+00:00") + brought_forward = FINANCE.replace("2100-01-01T00:00:00+00:00", "2026-10-01T00:00:00+00:00") edited = _control(_document(brought_forward), store, clock) clock.advance(timedelta(days=45)) # past the root grant, before `dlg_1` @@ -739,7 +739,7 @@ def test_t77b_a_narrowed_parent_narrows_its_children(store, clock): subject: { agent: "*", user: "alice@example.com" } actions: ["stripe.**"] constraints: { amount_lte: 100000 } - expires_at: "2027-01-01T00:00:00+00:00" + expires_at: "2100-01-01T00:00:00+00:00" delegable: true """ alice = Principal(agent="orchestrator", user="alice@example.com") @@ -1203,7 +1203,7 @@ def test_t80b_resume_records_the_denial_and_does_not_re_decide(signature, store, resources: ["payment:EU-4*"] constraints: { amount_gte: 0, amount_lte: 2500000 } environments: ["production"] -expires_at: "2026-12-01T00:00:00+00:00" +expires_at: "2099-12-01T00:00:00+00:00" delegable: true """ @@ -1324,7 +1324,7 @@ def test_ctrlrun_revoke_is_idempotent_and_refuses_an_unknown_id(cli_workspace): resources: ["payment:EU-*"] constraints: { amount_lte: 25000 } environments: ["production"] - expires_at: "2027-01-01T00:00:00+00:00" + expires_at: "2100-01-01T00:00:00+00:00" delegable: true """ diff --git a/tests/test_hop_surfaces.py b/tests/test_hop_surfaces.py index 5496ffe..88c8671 100644 --- a/tests/test_hop_surfaces.py +++ b/tests/test_hop_surfaces.py @@ -40,7 +40,7 @@ resources: ["payment:*"] environments: ["production", "staging"] delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" """ MIDDLE = """ @@ -49,7 +49,7 @@ resources: ["payment:EU-*"] environments: ["production"] delegable: true -expires_at: "2026-12-01T00:00:00Z" +expires_at: "2099-12-01T00:00:00Z" """ LEAF = """ @@ -57,7 +57,7 @@ actions: ["stripe.refund"] resources: ["payment:EU-1"] environments: ["production"] -expires_at: "2026-10-01T00:00:00Z" +expires_at: "2099-10-01T00:00:00Z" """ diff --git a/tests/test_inspect.py b/tests/test_inspect.py index 3dd1ea3..ea102e3 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -115,7 +115,7 @@ def _with_claims(control): agent="refund-agent", claims={"employee_no": 4471, "case": "CASE-9"}, issuer="https://issuer.example/", - expires_at=datetime(2027, 1, 1, tzinfo=UTC), + expires_at=datetime(2100, 1, 1, tzinfo=UTC), ), ) control.execute(action, lambda: "re_1", "refund:txn_c") @@ -137,7 +137,7 @@ def test_inspect_shows_the_issuer_the_expiry_and_the_claim_names(control): readable = _re.sub(r"\b(?:act|apr|dlg|ctr)_[0-9a-f]+", "", output) assert "issuer https://issuer.example/" in output - assert "expires 2027-01-01T00:00:00.000Z" in output + assert "expires 2100-01-01T00:00:00.000Z" in output assert "claims case, employee_no" in output assert "4471" not in readable assert "CASE-9" not in readable diff --git a/tests/test_otel.py b/tests/test_otel.py index 3625d78..9da0bbc 100644 --- a/tests/test_otel.py +++ b/tests/test_otel.py @@ -62,7 +62,7 @@ subject: { agent: "support-lead", user: "alice@example.com" } actions: ["stripe.**"] constraints: { amount_lte: 5000 } - expires_at: "2027-01-01T00:00:00+00:00" + expires_at: "2100-01-01T00:00:00+00:00" delegable: true actions: stripe.refund: @@ -600,7 +600,7 @@ def test_a_delegation_event_becomes_a_standalone_span(store, exporter): subject=Subject(agent="refund-agent", user="alice@example.com"), actions=("stripe.refund",), constraints=parse_conditions({"amount_lte": 500}, where=""), - expires_at=datetime(2026, 12, 1, tzinfo=UTC), + expires_at=datetime(2099, 12, 1, tzinfo=UTC), ), by=Principal(agent="support-lead", user="alice@example.com"), ) diff --git a/tests/test_revoke_selector.py b/tests/test_revoke_selector.py index 96cfaba..5a83c98 100644 --- a/tests/test_revoke_selector.py +++ b/tests/test_revoke_selector.py @@ -42,7 +42,7 @@ resources: ["payment:EU-*"] constraints: { amount_gte: 0, amount_lte: 10000000 } environments: ["production"] - expires_at: "2027-01-01T00:00:00+00:00" + expires_at: "2100-01-01T00:00:00+00:00" delegable: true - id: ops-lead subject: { agent: "human-ops" } @@ -50,7 +50,7 @@ resources: ["service:EU-*"] constraints: { replicas_gte: 0, replicas_lte: 100 } environments: ["production"] - expires_at: "2027-01-01T00:00:00+00:00" + expires_at: "2100-01-01T00:00:00+00:00" delegable: true actions: stripe.refund: @@ -64,7 +64,7 @@ resources: ["payment:EU-4*"] constraints: { amount_gte: 0, amount_lte: 2500000 } environments: ["production"] -expires_at: "2026-12-01T00:00:00+00:00" +expires_at: "2099-12-01T00:00:00+00:00" delegable: true """ @@ -73,7 +73,7 @@ resources: ["payment:EU-42"] constraints: { amount_gte: 0, amount_lte: 1000 } environments: ["production"] -expires_at: "2026-12-01T00:00:00+00:00" +expires_at: "2099-12-01T00:00:00+00:00" delegable: true """ @@ -82,7 +82,7 @@ resources: ["service:EU-4*"] constraints: { replicas_gte: 0, replicas_lte: 10 } environments: ["production"] -expires_at: "2026-12-01T00:00:00+00:00" +expires_at: "2099-12-01T00:00:00+00:00" delegable: true """ diff --git a/tests/test_upgrade_0_9_to_0_10.py b/tests/test_upgrade_0_9_to_0_10.py index 273076c..e8bd62b 100644 --- a/tests/test_upgrade_0_9_to_0_10.py +++ b/tests/test_upgrade_0_9_to_0_10.py @@ -45,7 +45,7 @@ resources: ["payment:*"] environments: ["production"] delegable: true - expires_at: "2027-01-01T00:00:00Z" + expires_at: "2100-01-01T00:00:00Z" - id: unrelated subject: { agent: "other" } actions: ["stripe.refund"] @@ -58,7 +58,7 @@ actions: ["stripe.refund"] resources: ["payment:EU-1"] environments: ["production"] -expires_at: "2026-10-01T00:00:00Z" +expires_at: "2099-10-01T00:00:00Z" """ From e351171c07afaeb76d1f0ef52badd24df38899df Mon Sep 17 00:00:00 2001 From: Mehdi Zare <8142364+MehdiZare@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:19:52 +0000 Subject: [PATCH 3/3] Record a receipt when the gateway refuses before asking a human. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _before_asking_a_human returned -41003, -41004 and -41005 before Control.execute and wrote nothing, so a call that reached a decision left no receipt. The pre-check now writes the same events and receipt the kernel writes for that refusal. SPEC-v0.2 §6.10 and T25 say so. Signed-off-by: Mehdi Zare <8142364+MehdiZare@users.noreply.github.com> --- CHANGELOG.md | 11 ++++ docs/SPEC-v0.2.md | 16 ++++- src/ctrlrun/gateway/server.py | 84 ++++++++++++++++++++++--- tests/test_gateway_server.py | 111 +++++++++++++++++++++++++++++++++- 4 files changed, 209 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 041cdd3..6a85720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,17 @@ any change to one appears here. --stdio` with `CTRLRUN_CONFIG` pointing at your policy, which is a config block a desktop client can install. +### Fixed + +- **A gateway pre-check refusal now leaves a receipt.** `_before_asking_a_human` answered + `-41003`, `-41004` and `-41005` before `Control.execute` and wrote nothing, so a third + identical call on an effect that had already committed, a retry of one whose outcome was + unknown, or a resend of a call a human had denied, reached a decision the store could not + show. The refusal is recorded the way the kernel records the same one: `ACTION_PROPOSED`, + `POLICY_EVALUATED`, then a `blocked` receipt for a duplicate or an ambiguous retry and a + `denied` receipt for a human's no. The codes the client sees are unchanged, and observe mode + is untouched. `SPEC-v0.2` §6.10 and T25 say so. + ## [0.12.2] — the operator's tool descriptions, and the marker the registry reads A patch release with no change to the enforcement path. Two things sat on `main` with no way for diff --git a/docs/SPEC-v0.2.md b/docs/SPEC-v0.2.md index 07505e7..d1f5c3d 100644 --- a/docs/SPEC-v0.2.md +++ b/docs/SPEC-v0.2.md @@ -1000,6 +1000,16 @@ of service so much as a way to wear an approver down until one of them clicks th button. A denial holds for the life of the request that carried it; after it expires, asking again is legitimate. +A pre-check refusal is a decision, and it is recorded. Both refusals above reach one before +`Control.execute`: `-41003`, where a denial still holds, and `-41004` or `-41005`, where the +effect key would refuse the action anyway. Each writes `ACTION_PROPOSED` and `POLICY_EVALUATED`, +then the same record the kernel writes when the call reaches it. A human's no is `ACTION_DENIED` +and a `denied` receipt. A duplicate or an ambiguous retry is `EFFECT_RESERVATION_REFUSED` and a +`blocked` receipt. Those are different answers: T23 already writes `blocked` for an identical +`-41005` on an allow-path call, and `ReceiptResult` defines `blocked` as covering duplicate and +ambiguous-retry refusals, so a `blocked` receipt is not a human saying no and a `denied` one is +not a reservation refusing a retry. The wire response is unchanged. + `ctrlrun approve` and `ctrlrun deny` work on gateway requests unchanged: same store. ### 6.11 What the gateway refuses, in one list @@ -1280,9 +1290,9 @@ own response reaches the client unchanged apart from `_meta["com.ctrlrun/receipt A tool whose policy says `approve`. The first call returns `-41002` with a `request_id` and writes no receipt. `ctrlrun approve ` runs against the same store. The identical call then executes, consumes that approval, and commits. A third identical call returns `-41006` -(`consumed`) or `-41004`, and the upstream is called exactly once. Separately: after -`ctrlrun deny `, resending the identical call returns `-41003` and creates **no** second -approval request, until the denied one expires. +(`consumed`) or `-41004`, writes a `blocked` receipt, and the upstream is called exactly once. +Separately: after `ctrlrun deny `, resending the identical call returns `-41003`, writes +one `denied` receipt and creates **no** second approval request, until the denied one expires. ### T26 — A held reservation across an elicitation With `requestState`: an `input_required` result leaves the effect `EXECUTING` with an extended diff --git a/src/ctrlrun/gateway/server.py b/src/ctrlrun/gateway/server.py index 85b60ff..5954159 100644 --- a/src/ctrlrun/gateway/server.py +++ b/src/ctrlrun/gateway/server.py @@ -28,6 +28,7 @@ from typing import Any, Final, Protocol, TypeAlias from ..action import Action, Principal +from ..approval import APPROVAL_DENIED as APPROVAL_DENIED_REASON from ..control import Control from ..effect import EffectState, resolve_effect_key, resolve_resource from ..errors import ( @@ -50,8 +51,8 @@ IdentityProvider, StaticIdentityProvider, ) -from ..policy import OBSERVE, UPSTREAM_MISMATCH, UPSTREAM_UNVERIFIED, Policy -from ..receipt import Receipt +from ..policy import OBSERVE, UPSTREAM_MISMATCH, UPSTREAM_UNVERIFIED, Evaluation, Policy +from ..receipt import EventType, Receipt, ReceiptResult from .mcp import ( ACCEPTED_REVISIONS, DEFAULT_MAX_BODY_BYTES, @@ -781,9 +782,8 @@ def _through_control( # it at all: without that a hop-selected grant is decided one way for the approval # pre-check and another for the call, and a human is asked about an action the hop admits # or not asked about one it refuses. - if self._control.evaluate(action, task=parsed.task, hop=parsed.hop).decision.value == ( - "approve" - ): + evaluation = self._control.evaluate(action, task=parsed.task, hop=parsed.hop) + if evaluation.decision.value == "approve": approval = self._control.store.find_granted_approval(action.action_hash) if approval is None and self._control.policy.mode != OBSERVE: # SPEC-v0.3 §6.2 — §6.10's pre-check exists to spare a human, and it spares @@ -792,7 +792,7 @@ def _through_control( # §9's ⚠ rows in the one mode that enforces none of them, and an operator # measuring what enforcement would cost would be reading a number the # gateway had already changed. - refusal = self._before_asking_a_human(action, effect_key, request_id) + refusal = self._before_asking_a_human(action, effect_key, request_id, evaluation) if refusal is not None: return refusal try: @@ -918,7 +918,11 @@ def _through_control( return self._upstream_response(action, held, effect_key, receipt) def _before_asking_a_human( - self, action: Action, effect_key: str | None, request_id: JsonRpcId + self, + action: Action, + effect_key: str | None, + request_id: JsonRpcId, + evaluation: Evaluation, ) -> _Response | None: """Two reasons not to create another approval request (SPEC-v0.2 §6.10). @@ -931,9 +935,17 @@ def _before_asking_a_human( wears the same approver down for the same reason, and the answer could not be acted on if they said yes. So the refusal the reservation would give is given now, before anyone is troubled by it. It only ever refuses more. + + A pre-check refusal is a decision, and it is recorded the way `Control.execute` + records the same one: `ACTION_PROPOSED`, `POLICY_EVALUATED`, then `ACTION_DENIED` + and a `denied` receipt, or `EFFECT_RESERVATION_REFUSED` and a `blocked` receipt. + The wire response is the one this returned before the record existed. Nothing here + catches a store error: a decision that was not recorded is not one this may report, + same as `_record_unrepresentable`. """ denied = self._control.store.find_denied_request(action.action_hash) if denied is not None: + self._record_denied_before_asking(action, evaluation, effect_key, denied.request_id) code, token, status = APPROVAL_DENIED return _json( status, @@ -952,6 +964,15 @@ def _before_asking_a_human( if record is None: return None if record.state is EffectState.COMMITTED: + message = f"effect {effect_key!r} was already committed by {record.action_id}" + self._proposed_and_evaluated(action, evaluation, effect_key) + self._control._refused( + action, + evaluation, + self._control._clock(), + effect_key, + DuplicateEffect(message, state="committed", effect_key=effect_key), + ) code, token, status = DUPLICATE_EFFECT return _json( status, @@ -959,12 +980,21 @@ def _before_asking_a_human( request_id, code, token, - f"effect {effect_key!r} was already committed by {record.action_id}", + message, effect_key=effect_key, state="committed", ), ) if record.state is EffectState.AMBIGUOUS: + message = f"effect {effect_key!r} has an unknown outcome from {record.action_id}" + self._proposed_and_evaluated(action, evaluation, effect_key) + self._control._refused( + action, + evaluation, + self._control._clock(), + effect_key, + AmbiguousEffect(message, effect_key=effect_key, action_id=record.action_id), + ) code, token, status = AMBIGUOUS_EFFECT return _json( status, @@ -972,12 +1002,48 @@ def _before_asking_a_human( request_id, code, token, - f"effect {effect_key!r} has an unknown outcome from {record.action_id}", + message, effect_key=effect_key, ), ) return None + def _proposed_and_evaluated( + self, action: Action, evaluation: Evaluation, effect_key: str | None + ) -> None: + """The two events `Control.execute` writes before any refusal of its own.""" + control = self._control + control._append( + EventType.ACTION_PROPOSED, action, {"action_hash": action.action_hash}, effect_key + ) + control._append( + EventType.POLICY_EVALUATED, + action, + {"decision": str(evaluation.decision), "reason": evaluation.reason}, + effect_key, + ) + + def _record_denied_before_asking( + self, action: Action, evaluation: Evaluation, effect_key: str | None, request_id: str + ) -> None: + """SPEC-v0.2 §6.10 — the `-41003` pre-check refusal, recorded.""" + control = self._control + self._proposed_and_evaluated(action, evaluation, effect_key) + control._append( + EventType.ACTION_DENIED, + action, + {"reason": APPROVAL_DENIED_REASON, "request_id": request_id}, + effect_key, + ) + control._record( + action, + evaluation, + ReceiptResult.DENIED, + control._clock(), + error=f"a human already refused this exact action in {request_id}", + effect_key=effect_key, + ) + def _over_the_round_bound( self, action: Action, effect_key: str | None, request_id: JsonRpcId ) -> _Response | None: diff --git a/tests/test_gateway_server.py b/tests/test_gateway_server.py index becaa4e..cffb018 100644 --- a/tests/test_gateway_server.py +++ b/tests/test_gateway_server.py @@ -28,7 +28,7 @@ build_server, httpx_forwarder, ) -from ctrlrun.receipt import ReceiptResult +from ctrlrun.receipt import ReceiptResult, verify_chain httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") @@ -766,6 +766,115 @@ def test_T25_a_denied_request_is_not_re_asked_until_it_expires(client, upstream, assert upstream.calls == [] +def test_T25_a_refusal_before_asking_for_a_committed_effect_writes_a_blocked_receipt( + client, upstream, store +): + """§6.10 — the `-41004` pre-check is a decision, and it leaves a receipt. + + Approve, grant, commit, then the identical call a third time. The third call is refused + before `Control.execute`. The store holds the committed receipt and a blocked one. Before + the fix it held the committed receipt only, and nine events. + """ + upstream.respond({"resultType": "complete", "content": []}) + body = _needs_approval() + first = client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + store.grant_approval(_error(first)["data"]["request_id"], "cli:local") + client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + + third = client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + + assert third.status_code == 409, third.text + assert _error(third)["code"] == -41004, third.text + receipts = store.receipts() + assert len(receipts) == 2, [receipt.result for receipt in receipts] + blocked = receipts[-1] + assert blocked.result is ReceiptResult.BLOCKED + assert blocked.effect_key == "refund:txn_2" + assert blocked.decision_reason == "rule[1]" + tail = store.events()[-3:] + assert [event.type.value for event in tail] == [ + "ACTION_PROPOSED", + "POLICY_EVALUATED", + "EFFECT_RESERVATION_REFUSED", + ] + assert dict(tail[-1].data) == {"reason": "duplicate", "state": "committed"} + assert len(upstream.calls) == 1 + assert verify_chain(store).ok, verify_chain(store) + + +def test_T25_a_refusal_before_asking_for_an_ambiguous_effect_writes_a_blocked_receipt( + client, upstream, store +): + """§6.10 — the same pre-check, on an effect whose outcome is unknown. + + Approve, grant, then the executing call loses the upstream's response (`-41010`). The + third call is `-41005` and writes a blocked receipt whose refusal reason is `ambiguous`. + The upstream was called once. A success on the third call would mean the pre-check let + a retry of an unknown outcome through. + """ + body = _needs_approval() + first = client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + store.grant_approval(_error(first)["data"]["request_id"], "cli:local") + upstream.drop_after_request = True + lost = client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + assert _error(lost)["code"] == -41010, lost.text + upstream.drop_after_request = False + upstream.respond({"resultType": "complete", "content": []}) + + third = client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + + assert third.status_code == 409, third.text + assert _error(third)["code"] == -41005, third.text + receipts = store.receipts() + assert len(receipts) == 2, [receipt.result for receipt in receipts] + blocked = receipts[-1] + assert blocked.result is ReceiptResult.BLOCKED + assert blocked.effect_key == "refund:txn_2" + assert blocked.decision_reason == "rule[1]" + tail = store.events()[-3:] + assert [event.type.value for event in tail] == [ + "ACTION_PROPOSED", + "POLICY_EVALUATED", + "EFFECT_RESERVATION_REFUSED", + ] + assert dict(tail[-1].data) == {"reason": "ambiguous"} + assert len(upstream.calls) == 1 + assert verify_chain(store).ok, verify_chain(store) + + +def test_T25_a_resent_denied_call_writes_a_denied_receipt_and_asks_no_one(client, upstream, store): + """§6.10 — a resend after a deny is a decision too, and it asks nobody again. + + The receipt is `denied`, not `blocked`: a human said no, which is a different answer + from a reservation refusing a retry. The events end with `ACTION_DENIED` carrying + `approval_denied` and the request that held the denial. + """ + body = _needs_approval() + first = client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + request_id = _error(first)["data"]["request_id"] + store.deny_approval(request_id, "cli:local") + + second = client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + + assert second.status_code == 403, second.text + assert _error(second)["code"] == -41003, second.text + receipts = store.receipts() + assert len(receipts) == 1, [receipt.result for receipt in receipts] + assert receipts[-1].result is ReceiptResult.DENIED + assert receipts[-1].effect_key == "refund:txn_2" + assert receipts[-1].decision_reason == "rule[1]" + tail = store.events()[-3:] + assert [event.type.value for event in tail] == [ + "ACTION_PROPOSED", + "POLICY_EVALUATED", + "ACTION_DENIED", + ] + assert dict(tail[-1].data) == {"reason": "approval_denied", "request_id": request_id} + assert len(store.approvals_for(store.get_approval(request_id).action_hash)) == 1 + assert upstream.calls == [] + assert verify_chain(store).ok, verify_chain(store) + + # --- §6.3: everything else is relayed --------------------------------------------------