fix(policy): bind the resolved safety policy to dispatched runs - #76
Merged
Conversation
The Cloud API, migration, dashboard, and `engine/policy.py`'s `resolve_effective_policy` all shipped, but step 3 of 3 -- carrying the resolved policy into execution -- never landed. On `origin/main`, `engine/policy.py` is imported only by `engine/dispatch.py` (the UI verb `get_effective_policy`); `engine/runner_loop.py` contains no reference to policy at all, and `_execute` handed Flow the operator's raw `deployment.json`. An admin toggling a safety-critical setting therefore changed nothing about how a run executed: the control rendered, reported success, and bound nothing. Wire it: * `engine/policy.py` gains `SAFETY_VALUE_DOMAINS` (the exact value domains from the cloud registry), `binding_safety()` -- the pre-run gate -- and `apply_safety_policy()`, a strengthen-only projection of the Tier-3 block onto a Flow deployment config: `pixel_verify.consequential_policy: required` arms `runtime.pixel_verify_enabled`; a prohibition on model calls forces `runtime.allow_model_grounding = false`; the effect verification / identity / halt-on-ambiguous / unverified-write keys require a production execution profile, escalating `demo` to `standard` while leaving an ABSENT profile absent (Flow defaults it to `regulated`, which is stricter than anything we would write). * `engine/runner_loop.py` resolves the policy fresh before every dispatched run -- after digest/authorization revalidation, before any GUI action -- and hands Flow a policy-bound config staged privately (0600, removed when the run ends) instead of the operator's file. Fail closed. The run REFUSES (ack `refused`, flow never invoked) when the policy is not authoritative (`source == "fail-closed-default"`: an org that strengthened a key would otherwise be silently run without it), when a safety key is missing or outside its domain, when resolution raises, or when there is no bindable deployment config. No path degrades to "run it anyway". Refusal reasons stay PHI-free. Tests assert the EFFECT, not the resolver: the same bundle dispatched twice across an admin toggle must produce a different deployment config at the flow bridge, and every unenforceable-policy path must refuse before execution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect: a governance control that rendered but did not bind
The unified settings/policy system shipped in three parts. Parts 1 and 2 landed
(Cloud API + migration + dashboard;
engine/policy.py's fetch/cache/hardenresolver). Part 3 — carrying the resolved policy into execution — never did.
Verified against
origin/mainrefs only (no working tree):engine/policy.py's only callers onmainare the two UI verbs indispatch.py(get_effective_policy,refresh_policy).engine/runner_loop.py— the module that actually executes a dispatched governed run — contains zero
references to policy, and
_executehanded Flow the operator's rawdeployment.json:Net: an administrator toggling a safety-critical setting in the dashboard changed
nothing about how a run executed. The control rendered, reported success, and
bound nothing — the exact failure the engine itself exists to prevent (reporting
success without confirming the effect), and the shape
AGENTS.md§4 forbids: asurface that looks like a safety boundary and is not one.
The existing 17 policy tests all passed, because every one of them tested
resolve_effective_policyrather than its effect.What is now wired
RunnerService.bind_effective_policy()resolves the org's effective policy freshbefore every dispatched run — after digest/authorization revalidation,
before any GUI action — and hands Flow a policy-bound config staged privately
(0600, removed when the run ends) instead of the operator's file:
apply_safety_policyis strengthen-only — it can tighten a run, never relaxone. No existing guard was weakened to make the wiring simpler, and the policy
schema and dashboard are untouched.
pixel_verify.consequential_policyrequired→runtime.pixel_verify_enabled = true.disabledis the platform baseline, not a prohibition, so a locally-armed check stays armed.model_calls.allowed_in_healthy_runfalse→runtime.allow_model_grounding = false(fully local).trueis a permission, not an instruction to enable.effect_verification.required_for_consequential,unverified_write.allow,identity_gate.strictness,halt_on_ambiguousdemoescalates tostandard; an absent profile is left absent, because Flow resolves an omitted profile toregulated— stricter than anything we would write, so writing one in would weaken the run.egress.artifact_policyengine/upload_manager.py,engine/hosted.py), not the replay runtime. Documented rather than silently claimed.The
grounding_modelprojection is deliberately not bound to runs: it is anegress capability, and auto-enabling a capability from a cached policy is the
opposite of failing closed.
Fail-closed behaviour
The run refuses (ack outcome
refused, evidence stream empty, flow enginenever invoked) when:
source == "fail-closed-default"— no authoritative policy was everobtained (no control plane, no cache). This is the important one. The safest
values are populated, but they are the engine's guess at the org's posture,
not the org's posture: an org that strengthened a key beyond baseline —
e.g.
pixel_verify.consequential_policy: required, whose safe default isdisabled— would be silently run without it. That is precisely"degrading to allow" wearing a different hat.
docs/POLICY_SYNC.mdrule 5 andthe cloud sync contract both already specified this refusal; nothing enforced
it. A cached policy is authoritative and still runs offline.
safetyblock is absent, is not an object, or is missing a key.SAFETY_VALUE_DOMAINS(mirroring the exact domains inopenadapt-cloud src/lib/settingsRegistry.ts).harden_safetydeliberatelypreserves whatever the server said — binding is where an unknown posture must
stop a run.
never to raise).
an execution profile that cannot be ranked (so the engine cannot prove it is
not weakening it).
No path degrades to "run it anyway". Refusal reasons stay PHI-free — they name
keys, classes, and domains, never config contents, paths, or exception text
(asserted).
The test that would have caught this
TestSafetyPolicyBinding::test_admin_toggle_changes_what_the_run_executesdispatches the same bundle twice with only the org's Tier-3 setting changed
between them, and asserts on the deployment config the flow bridge actually
received — not on what the resolver returned.
Demonstration that it fails without the fix. Reverting only the binding (the
policy is still resolved, exactly as on
main, but never applied) and re-running:with e.g.
The change was then restored; the suite is green below.
Gates (exact pinned versions,
uv sync --locked --extra dev)Baseline on
origin/mainwas 573 passed / 2 skipped; +37 new tests, zeroregressions. (This repo's CI gates are ruff + pytest; there is no mypy gate.)
Fixture note
The runner rig now writes a
deployment.jsonand injects a policy resolver.Both reflect reality — a runner that can execute at all has a deployment config
and a reachable-or-cached policy — and the refusal tests remove or corrupt them
deliberately. No assertion was relaxed.
Scope / lane
openadapt-desktop, disjoint from PR #75 (mobile decision portal + pairing),which owns
engine/dispatch.py,engine/config.py,engine/portal/*,src/*, andtests/test_portal/*. This PR touches onlyengine/policy.py,engine/runner_loop.py, their two test modules, anddocs/POLICY_SYNC.md.Known remaining gap, deliberately out of scope: the local governed-run path
(
engine/dispatch.py::_replay_or_run, therun=Truebranch atdispatch.py:794) is still unbound — it builds its config throughprepare_flow_config(base_config, target)and never consults the policy. Thatfile is owned by PR #75 this cycle; binding it is a follow-up that should reuse
apply_safety_policyat theprepare_flow_configseam so both execution pathsshare one projection.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM