Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions docs/SPEC-v0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <id>` 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 <id>`, 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 <id>`, 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
Expand Down
2 changes: 1 addition & 1 deletion examples/authority-escalation/ctrlrun.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/authority-escalation/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"""
)

Expand Down
2 changes: 1 addition & 1 deletion examples/authority/payments.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/cookbook/manager-and-worker/ctrlrun.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions examples/cookbook/manager-and-worker/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion examples/cookbook/payout-maker-checker/ctrlrun.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions examples/cookbook/payout-maker-checker/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
Expand Down
4 changes: 2 additions & 2 deletions src/ctrlrun/cli/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
"""
)

Expand Down
84 changes: 75 additions & 9 deletions src/ctrlrun/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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).

Expand All @@ -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,
Expand All @@ -952,32 +964,86 @@ 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,
json_rpc_error(
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,
json_rpc_error(
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:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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="<test>",
)
Expand Down
18 changes: 9 additions & 9 deletions tests/test_delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand All @@ -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
"""

Expand All @@ -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:
Expand Down Expand Up @@ -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")}),
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
"""

Expand Down Expand Up @@ -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
"""

Expand Down
Loading