Skip to content

item 3: policy effect: and resource: templates - #4

Merged
arpanghoshal merged 3 commits into
mainfrom
item-3-policy-effect-resource
Sep 3, 2026
Merged

arpanghoshal merged 3 commits into
mainfrom
item-3-policy-effect-resource

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

Build-list item 3. SPEC-v0.2 §3, acceptance test T16.

schema: ctrlrun.policy/v2

An action entry now accepts effect:, resource: and mcp: alongside decision and rules. The gateway (item 6) has no decorator to carry an effect template, so the policy file has to.

schema: ctrlrun.policy/v2
actions:
  mcp.payments.create_refund:
    effect: "refund:{payment_id}"
    resource: "payment:{payment_id}"
    mcp:
      not_executed_on_error: true
    rules:
      - when: { amount_gte: 0, amount_lte: 50000 }
        decision: allow
      - decision: approve

v0.2 loads both schemas. Three refusals worth naming:

  • A v2 key in a v1 document is a PolicyError naming the key and the required schema. Not a warning, not silently honoured. A v0.1 kernel reading that file would ignore the effect template and execute with no duplicate protection at all — the schema string is the only thing standing between those two outcomes, so it is neither optional nor inferred.
  • Templates are validated at load, not at first traffic. A malformed template is a PolicyError when the policy loads, never an EffectKeyError at the moment an agent reaches the action. Same reason @protect checks its own at decoration time.
  • mcp.not_executed_on_error must be a bool, and 1 is not one. It is an assertion about a remote that CTRLRun cannot check (§6.8), so it is made deliberately or not at all — and it defaults to the fail-closed value.

New: Policy.effect_template, Policy.resource_template, Policy.mcp_options, McpOptions (§11). Not re-exported from ctrlrun/__init__.py — §11's re-export block does not list them.

Precedence (§3.2)

Where both the decorator and the policy declare a template, the decorator's is in force. The policy is a deployment artefact and the decorator is a statement in the code that will run; when they disagree, the code is what executes, and silently substituting the policy's template would change an effect identity without changing a line of the program.

The mismatch is a warning naming the action, both templates and which one won — once per decorated function, emitted when the function first resolves its Control: at decoration time when control= was passed, otherwise on the first call. A warning, not an error, because an operator adding templates to a policy for the gateway's sake must not break a running decorator-based deployment.

T16 also pins the thing the gateway will depend on: an Action built the way §6.6 will build one — resource and effect key from the policy's templates — has the same action_hash and the same effect key as the equivalent @protect(effect=…, resource=…) call. If those two ever diverge, an approval granted through one path would not authorize the other.

Two changes outside §3 that you should see

policy.py now imports from effect.py. ARCHITECTURE §6 said policy must not know about effects. Load-time template validation needs the grammar, and a second copy of a security-critical grammar is worse than the import. I took the import and amended §6 in this PR, per CLAUDE.md's rule about changing an architectural constraint in the same PR — policy still knows nothing of effect state, and effect.py does not import policy.py, so there is no cycle. Say the word if you would rather the grammar moved down into action.py instead.

One v0.1 test used ctrlrun.policy/v2 as its example of an unsupported schema, which §3.1 has now made supported. The rule under test is unchanged — an unsupported schema is refused — so the example moved to v3, with a comment saying why.

Mutation table — every MUST in §3

# Rule Result
M1 the entry key set grows to effect/resource/mcp red
M2 v0.2 loads ctrlrun.policy/v2 red
M3 a v1 document still loads red
M4 a v2 key in a v1 document is a PolicyError naming the schema red
M5 templates are validated at load time red
M6 a template must be a string red
M7 not_executed_on_error MUST be a bool red
M8 it defaults to the fail-closed value red
M9 the mcp key set is closed red
M10 mcp must be a mapping red
M11 the policy supplies a template the decorator omits red
M12 the decorator wins a mismatch red
M13 a mismatch is warned about red
M14 the warning names both templates and the action red
M15 at most one warning per decorated function red
M16 matching templates do not warn red
M17 the warning is emitted at decoration time when control= was passed red

Verification

839 tests pass (812 → 839), mypy --strict src/, ruff check and ruff format --check clean. Every SPEC-v0.1 §7 acceptance test still passes, and ctrlrun.example.yaml — a v1 document — loads unchanged.

arpanghoshal and others added 3 commits September 3, 2026 19:54
SPEC-v0.2 §3, acceptance test T16. `schema: ctrlrun.policy/v2` adds `effect:`,
`resource:` and `mcp:` to an action entry, because the gateway (item 6) has no
decorator to carry an effect template and the policy file has to.

v0.2 loads both schemas. A v2 key in a v1 document is a PolicyError naming the
key and the required schema: v0.1 would ignore the template and execute with
no duplicate protection at all, so a document that depends on one has to say
so in a way v0.1 refuses. Templates are validated at load, not at first
traffic — the same reason @Protect checks its own at decoration time — and
`mcp.not_executed_on_error` must be a bool, defaulting to the fail-closed
value.

Precedence (§3.2): where both the decorator and the policy declare a template,
the decorator's is in force. The policy is a deployment artefact and the
decorator is a statement in the code that will run; substituting the policy's
would change an effect identity without changing a line of the program. The
mismatch is a warning naming both, once per decorated function, emitted at
decoration time when `control=` was passed — so an operator adding templates
for the gateway's sake hears about it at import and is not broken by it.

The decorator now resolves its Control before building the Action, since the
policy may be the only place a template is declared and `resource` is part of
the canonical form.

ARCHITECTURE §6 records the one new cross-module import: policy.py takes the
template grammar from effect.py to validate at load, and still knows nothing
of effect state. A second copy of a security-critical grammar is worse.

One v0.1 test used `ctrlrun.policy/v2` as its example of an unsupported
schema. The rule under test is unchanged, so the example moves to v3.
@arpanghoshal
arpanghoshal merged commit 6819dcc into main Sep 3, 2026
3 checks passed
@arpanghoshal
arpanghoshal deleted the item-3-policy-effect-resource branch September 3, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant