Skip to content

Enforce single-occupancy slot pinning with human-approved moves - #16

Open
josephschorr wants to merge 13 commits into
mainfrom
slot-pinning
Open

josephschorr wants to merge 13 commits into
mainfrom
slot-pinning

Conversation

@josephschorr

Copy link
Copy Markdown
Member

What this changes

The security model has long claimed that a slot, once filled, "cannot be reopened" and that the slot write "checks that the slot is still empty as a precondition." Neither was enforced: slot grants were plain TOUCH writes, and a session bound to resource A could silently bind resource B of the same type through any bind path. This PR makes the claim true by construction, with one honest amendment: a slot cannot be silently reopened — a different instance needs a human's approval.

How it works

Declaration. AuthzSlot gains two fields: occupancy: single | multi (default single) and rebind: approval | never (default approval). The installer completes both before apply so re-installs stay SSA no-ops, and class admission rejects a single-occupancy slot declaring multiple defaults.

Enforcement. The schema composes a non-expiring slot_pin relation onto every slot resource type. The first bind of a single-occupancy slot writes the pin under an atomic MUST_NOT_MATCH precondition at authz.GrantSlots — the single funnel every production slot-grant write already passes through — and grant writes for pinned slots carry a MUST_MATCH guard so a bind can never race a move. A different instance is refused with a message that names the pinned instance and the route out, routed by the slot's rebind policy. Grants keep their mandatory expiry and revocability; the pin persists for the session's life, so expiry never silently reopens the slot. Tool-authored writes to slot_pin/slot_grant_* relations are refused at tuple validation, and a guard test asserts no production write bypasses the gate.

The approved move. A plan amendment naming a different instance records the displaced instance on the approval card at build time. Approval executes the move atomically: the pin repoints under a MUST_MATCH guard and the displaced instance's actual grants are revoked in the same request. The card shows the move as a move — current instance, proposed instance, and the revocation — and a move whose starting point no longer matches the live pin fails the approval loudly instead of moving something the approver never saw.

Lifecycle. Fork/restart copies grant and pin tuples verbatim (a clone is not a new arrival); teardown deletes pins alongside grants; a new session's first reconcile sweeps stale tuples left under a reused name, with channelsd sweeping before its own mint-time binds so the two can never race.

Visibility. Pins mirror onto AgentSession.status.slotPins (display-only; SpiceDB stays the only enforcement input) and render in oap session show. Pin refusals annotate the model's next denial for that type with the pinned instance and the route out, and mint-time refusals record a session notice.

Documented limits

  • Pins are per resource type: a class exposing one real resource through two declared types pins each independently.
  • Pin identity is derived-ID string equality; identity-keyed resource types are recommended for single-occupancy slots.
  • Moving a pin requires the plan gate; a class without it gets a refusal whose remedy is a new session.
  • A slot whose tools bind a constant, class-fixed instance alongside the chosen instance under one type needs occupancy: multi (or split types).

Migration

Existing classes pick up the defaults on their next apply. Multi-instance classes must declare occupancy: multi. Pre-existing sessions have no pins; the first bind after upgrade writes one.

Verification

All three suites green at the tip (mage test:unit, mage test:integration, mage test:e2e, including all 82 scenario bundles). New coverage includes a real-SpiceDB end-to-end arc (pin → refusal → approved move → revocation), a concurrent first-bind race resolved by the precondition rather than process ordering, a red-first regression test for the fork/sweep interaction, and a scenario bundle asserting the move card on a real published approval.

@vercel

vercel Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
openagentprimitives Ignored Ignored Preview Oct 16, 2026 9:43pm UTC

Request Review

@samkim

samkim commented Oct 8, 2026

Copy link
Copy Markdown
Member

Review: slot pinning (verified against the branch head)

Each finding below was checked against the code, not just flagged. Fixes are in progress locally.

Should fix before merge

  1. rebind: never is not enforced on the approval path (pkg/authz/bind.go moveApprovedPins, pkg/authz/hooks/plangate.go stampMovedFrom). SlotRebindNever is only read to choose the refusal wording in GrantSlots. stampMovedFrom stamps a move whenever the pin differs, and moveApprovedPins executes it, so an approved plan can retarget a never slot and revoke the old instance. The CRD says this mode is "refused unconditionally".

  2. First-approval and whole-plan cards hide the move (pkg/authz/plangate/card.go, pkg/authz/hooks/plangate.go). threadMovedFrom only threads MovedFrom onto AddedSlots. A non-amendment card renders phase.Slots from the active plan, which never carry MovedFrom, yet the stamped covered records still drive PriorID and the move. A session pinned at mint time whose first plan names another instance shows a plain first-fill, and approving it revokes the pinned instance.

  3. Admission sweep is not fail-closed (pkg/controllers/agentsession/controller.go). The sweep only runs when EnsureFinalizer returns added=true, and the finalizer is persisted before the sweep. If DeleteSlotGrants fails, the requeue sees the finalizer and skips the sweep permanently. Affects CLI-created sessions reusing a name (channelsd-minted sessions have their own mint-time sweep).

  4. GrantSlots returns early on EnsurePin / WriteGrantsPinned errors (pkg/authz/slot_grant.go). This drops refusals already accumulated (so errors.Is(..., ErrSlotPinned) routing and the refusal notice are lost) and skips the remaining single-occupancy types and the plain multi-occupancy write. Contradicts the documented per-type partitioning.

  5. Approved move commits before the new grant (pkg/authz/bind.go BindApproved). MovePin repoints the pin and revokes the prior grants before bindSlots runs. If the grant then fails (for example the same approval also names a constant instance of the type, which GrantSlots refuses as two distinct ids), the old instance is revoked and the new one ungranted, while the approver notice says "nothing was granted… fault on our side". Not permanently stuck (the decision is recorded and a later per-call approval is a same-instance bind), but the notice is wrong about what happened.

Needs a decision

  1. Upgrade break from the occupancy: single default (pkg/controllers/agentclass/slot_declaration.go). Every existing AgentClass with 2+ defaults on a slot becomes SlotDeclarationInvalid after upgrade, and multi-instance thread-seed / trigger binds of one type are now refused by GrantSlots. Nothing in the PR's docs mentions upgrade impact. Options: document as breaking, default to multi when unset, or migrate.

Minor

  1. planGateSlotOccupancy (pkg/agent/runner/host_approval.go) drops the boundEntitySpecsForAutofill error without logging. In practice planGateSlotPreconditions logs the same error in the same approval, so this is a convention nit.
  2. bindSlots writes refused candidates into session_scope before GrantSlots refuses them. Deliberate scope-first ordering and not read at dispatch today, but it becomes a real gap once CheckScopeWithRefs is wired.
  3. mirrorSlotPinsFromSpiceDB does an apiserver GET on every successful promotion; the "steady-state zero reads" comment holds for SpiceDB only.
  4. DeleteSlotGrants lists grants and pins in two separate reads. Low-frequency paths, nit.

🤖 Generated with Claude Code

samkim added a commit that referenced this pull request Oct 8, 2026
Addresses the verified findings from the PR #16 review:

- rebind: never is enforced on the approval path. The plan gate no
  longer stamps a move for a never slot (new PlanGateDeps.SlotRebind),
  and moveApprovedPins refuses one as a backstop.
- First-approval and whole-plan cards show the move. The plan's own
  slots now carry MovedFrom, the whole-plan renderer prints the move and
  the revocation, and CardLine.MovedFrom becomes a separate revocation
  line on the wire.
- The AgentSession admission sweep runs before the finalizer is added,
  so a failed sweep is retried on requeue instead of skipped forever.
- moveApprovedPins refuses deterministic conflicts (a never slot, or a
  second instance of the moving type in the same approval) before any
  MovePin. A grant failure after a committed move returns
  ErrSlotMoveCommitted, and a MUST_MATCH failure returns
  ErrSlotMoveDrifted, so the approver notice says what actually changed.
- GrantSlots collects EnsurePin/WriteGrantsPinned errors instead of
  returning early, so later types and the multi-occupancy write land.
- The slot occupancy lookup logs the error it used to drop.
- The occupancy: single default is documented as an upgrade break
  (CRD UPGRADE NOTE, security model), with CRDs, install.yaml and CRD
  docs regenerated.

mage test:unit passes. mage test:integration and mage test:e2e were not
run: Docker was unavailable, so every SpiceDB-backed test failed to
start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@samkim

samkim commented Oct 8, 2026

Copy link
Copy Markdown
Member

Fixes for the review findings (1db0c42)

Pushed fixes for the findings in the review above.

Finding Fix
rebind: never not enforced on approval The plan gate no longer stamps a move for a never slot (new PlanGateDeps.SlotRebind), and moveApprovedPins refuses one as a backstop, before anything is repointed.
First-approval / whole-plan cards hide the move The plan's own slots now carry MovedFrom (planWithMovedFrom). The whole-plan renderer prints old → new and the revocation sentence, and CardLine.MovedFrom becomes its own revocation line on the wire.
Admission sweep not fail-closed The sweep now runs while the finalizer is still absent; the finalizer is added only after it succeeds, so a failed sweep is retried on requeue. channelsd mints (which pre-stamp the finalizer) are unaffected.
GrantSlots returns early on store errors EnsurePin / WriteGrantsPinned errors are collected with the other refusals, so later types and the multi-occupancy write still land.
Move commits before the new grant Deterministic conflicts (a never slot, or a second instance of the moving type in the same approval) are refused before any MovePin. A grant failure after a committed move returns ErrSlotMoveCommitted, and the approver is told the old instance's access was removed. A drifted pin returns ErrSlotMoveDrifted and keeps the "try once more" copy.
Upgrade break from occupancy: single Documented as breaking: an UPGRADE NOTE on the field (CRD YAML, install.yaml, CRD docs regenerated) and a paragraph in docs/security-model.md.
Dropped occupancy-lookup error The lookup is shared between the plan gate and the approval path and now logs the error.
Mirror "zero reads" comment Corrected to note the AgentSession GET.

Not changed: scope-first ordering in bindSlots (deliberate, and not read at dispatch today) and the double read in DeleteSlotGrants.

Tests

New regression tests: TestBindApproved_moveRefusedBeforeAnyMove, TestBindApproved_grantFailureAfterMoveReportsCommittedMove, TestGrantSlots_PinStoreErrorDoesNotStrandOtherTypes, TestRequestPhaseApproval_firstApprovalCardShowsTheMove, TestRequestPhaseApproval_rebindNeverRecordsNoMove, TestBuildCard_WholePlanResourceLineShowsAMove, TestPlanGateItems_MoveCarriesARevocationLine, TestClassifyApprovalBindFailure, and the integration test TestReconcile_AdmissionSweepRetriesAfterFailure.

  • mage test:unit: passes.
  • The admission-sweep integration tests (new and existing) pass when run on their own.
  • mage test:integration and mage test:e2e have not been run. Docker was unavailable, so every SpiceDB-backed test failed to start. Per the ship gate, this should not merge until both are green.

🤖 Generated with Claude Code

josephschorr pushed a commit that referenced this pull request Oct 14, 2026
Addresses the verified findings from the PR #16 review:

- rebind: never is enforced on the approval path. The plan gate no
  longer stamps a move for a never slot (new PlanGateDeps.SlotRebind),
  and moveApprovedPins refuses one as a backstop.
- First-approval and whole-plan cards show the move. The plan's own
  slots now carry MovedFrom, the whole-plan renderer prints the move and
  the revocation, and CardLine.MovedFrom becomes a separate revocation
  line on the wire.
- The AgentSession admission sweep runs before the finalizer is added,
  so a failed sweep is retried on requeue instead of skipped forever.
- moveApprovedPins refuses deterministic conflicts (a never slot, or a
  second instance of the moving type in the same approval) before any
  MovePin. A grant failure after a committed move returns
  ErrSlotMoveCommitted, and a MUST_MATCH failure returns
  ErrSlotMoveDrifted, so the approver notice says what actually changed.
- GrantSlots collects EnsurePin/WriteGrantsPinned errors instead of
  returning early, so later types and the multi-occupancy write land.
- The slot occupancy lookup logs the error it used to drop.
- The occupancy: single default is documented as an upgrade break
  (CRD UPGRADE NOTE, security model), with CRDs, install.yaml and CRD
  docs regenerated.

mage test:unit passes. mage test:integration and mage test:e2e were not
run: Docker was unavailable, so every SpiceDB-backed test failed to
start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
josephschorr and others added 11 commits October 14, 2026 13:16
AuthzSlot gains occupancy (single|multi, default single) and rebind
(approval|never, default approval). The installer completes both before
SSA apply, the same way membership is completed, so a byte-identical
re-install stays a no-op. Class admission rejects a single-occupancy
slot declaring more than one default. AgentSession status gains a
display-only slotPins mirror type; SpiceDB remains the only
enforcement source of truth.
The schema composer adds a non-expiring slot_pin relation to every slot
resource definition. GrantSlots — the one funnel every slot-grant write
passes through — gates single-occupancy bindings: the first bind writes
the pin under a MUST_NOT_MATCH precondition, grant writes carry a
MUST_MATCH guard so a bind can never race an approved move, a different
instance is refused with a message routed by the slot's rebind policy,
and a single call naming two instances of one single-occupancy type
binds nothing for that type while other types still land. Grants keep
their mandatory expiry; the pin does not expire. Tuple validation
refuses tool-authored writes to slot_pin and slot_grant relations, and
a guard test over pkg/, internal/ and cmd/ asserts no production write
bypasses the gate. Pinned grant writes and moves advance the
read-your-writes freshness floor.
…d amendment

Every producer that reaches GrantSlots — defaults, extracted and
observed promotion, thread seeding, trigger binding, tool approval and
the plan gate — copies the slot's occupancy and rebind onto its
bindings. A plan amendment that names a different instance for a filled
single-occupancy slot records the displaced instance on the approval
card at build time; the approved move executes atomically (the pin
repoints under a MUST_MATCH guard and the displaced instance's actual
grants are revoked in the same request), and the card shows the move as
a move: current instance, proposed instance, and the revocation. A move
whose recorded starting point no longer matches the live pin fails the
approval loudly rather than moving something the approver never saw.
The runner mirrors pins onto AgentSession.status.slotPins — derived from
SpiceDB reads after a successful bind, never from candidate lists, so
the mirror cannot claim a pin that was never written — and oap session
show renders them. Pin refusals from the promotion paths are recorded
per resource type (newest wins, cleared when that type later binds or
moves) and annotate the next denied tool call for that type, so the
model is told the pinned instance and the route out rather than reading
a bare denial as a system fault. A pin failure after a plan-gate
approval tells the approver whether the slot had drifted or was already
committed elsewhere, with accurate claims about what was granted.
channelsd pre-stamps the session finalizer at its create site and sweeps
stale slot tuples itself before trigger and thread-seed binding, so the
operator's admission sweep can never race the mint and silently delete
legitimate authority; a sweep failure fails the session start rather
than proceeding past a possible leak. Mint-time pin refusals record a
SlotBindRefused session notice with the refusal text, with the suggested
next step routed by the slot's rebind policy. A tool approval that lands
on a pinned slot in a class with no plan gate is rejected naming the
pinned instance and the new-session route.
Fork and restart copy grant and pin tuples verbatim — a lifecycle clone
is not a new arrival and never re-enters the gate — while session
teardown deletes pins alongside grants (pins never expire, so GC is the
only thing that removes them). A new session's first reconcile sweeps
stale slot tuples left under its name by a dead predecessor, except for
fork children, whose tuples are the parent's legitimate copy and whose
generated names make name reuse moot. The status webhook waives slotPins
for the runner: it is a display-only mirror no authorization reads.
A scenario suite drives the full arc on the in-process harness: the
first bind pins, a second instance is refused and never written, an
approved amendment moves the pin and revokes the displaced instance's
grants, rebind: never admits no move, and two concurrent first binds
resolve to exactly one winner through the precondition rather than
process ordering. A scenario bundle proves the move card on a real
published approval; the existing re-point bundle keeps its re-ask
coverage, and two fixtures that bind a constant workspace instance
alongside the chosen repository under one resource type now declare
that slot multi-occupancy, which is what they are.
The security model's slot section now describes what the code enforces:
the commitment is a non-expiring pinned relationship written under an
atomic precondition; a filled single-occupancy slot cannot be silently
reopened — a different instance needs a human-approved plan amendment
that moves the pin and revokes the old instance's access, and
rebind: never admits no move. The stated limits are documented plainly:
pins are per resource type, pin identity is derived-ID string equality,
moving a pin requires the plan gate, and a slot binding a constant
instance alongside the chosen one needs multi occupancy or split types.
Addresses the verified findings from the PR #16 review:

- rebind: never is enforced on the approval path. The plan gate no
  longer stamps a move for a never slot (new PlanGateDeps.SlotRebind),
  and moveApprovedPins refuses one as a backstop.
- First-approval and whole-plan cards show the move. The plan's own
  slots now carry MovedFrom, the whole-plan renderer prints the move and
  the revocation, and CardLine.MovedFrom becomes a separate revocation
  line on the wire.
- The AgentSession admission sweep runs before the finalizer is added,
  so a failed sweep is retried on requeue instead of skipped forever.
- moveApprovedPins refuses deterministic conflicts (a never slot, or a
  second instance of the moving type in the same approval) before any
  MovePin. A grant failure after a committed move returns
  ErrSlotMoveCommitted, and a MUST_MATCH failure returns
  ErrSlotMoveDrifted, so the approver notice says what actually changed.
- GrantSlots collects EnsurePin/WriteGrantsPinned errors instead of
  returning early, so later types and the multi-occupancy write land.
- The slot occupancy lookup logs the error it used to drop.
- The occupancy: single default is documented as an upgrade break
  (CRD UPGRADE NOTE, security model), with CRDs, install.yaml and CRD
  docs regenerated.

mage test:unit passes. mage test:integration and mage test:e2e were not
run: Docker was unavailable, so every SpiceDB-backed test failed to
start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
moveApprovedPins' ListGrantsFor error path returned a bare error even after
an earlier type's move had already committed (pin repointed, prior grants
revoked), so BindApproved reported it as an ordinary failure. The approver
was then told nothing was granted while access had in fact been removed —
the exact case ErrSlotMoveCommitted exists to flag, and which the sibling
MovePin error path already wraps. Wrap it the same way once a move landed.
A plan-scoped approval is resolved one BindApproved call per covered phase,
and stampMovedFrom stamps the same pin move onto every covered phase that
names the moved-to instance. A single-occupancy pin moves at most once per
approval: the first phase repointed repoA->repoB, and each later phase then
re-attempted the identical move with a now-stale PriorID=repoA whose
MUST_MATCH drift-failed — silently dropping that phase's grant while still
recording it approved, and making a re-approval drift-fail every phase.

dedupeCoveredMoves keeps the move on only the first covered record per type;
the rest bind same-instance against the pin the first move already set. Run
before the delegable lookup, since a SlotRef's identity includes MovedFrom
and clearing it afterward would leave a later phase's request ref no longer
matching the delegable key looked up for it.
…rror

A plain pinned refusal on the approval path — the slot is committed to a
different instance and no approved move applies — was published to the
approver under categories.InternalError, whose meaning is "we broke, and
retrying is the remedy." It is neither: re-approving the same card cannot
change it, as the notice copy itself says. Publish it under the
refusal-flavored SlotBindRefused instead (same ToneDegraded weight; the
category is what a surface reads to tell a fault from an answer). The fault
and committed-move cases keep InternalError.
@samkim

samkim commented Oct 15, 2026

Copy link
Copy Markdown
Member

Review of the fix commits (afbc1d8, b5f447d, 48f371e, 05928fe)

Every finding below was checked against 05928fe. Eight exist as described; one (item 6) is a design suggestion rather than a defect. No fixes yet.

Security summary: none of these lets a session reach an instance nobody approved. The pin still fails closed, and every path ends with less access, not more. The weak spot is the human's consent and the information they get:

  • an approver can authorize a revocation they never saw (item 1)
  • an approver can approve a grant the card implies will happen but never can (item 4)
  • approvers can be told the opposite of what happened (items 2, 3)
  • approvers can be pointed toward amendments that can't succeed (item 5)
  • raw SpiceDB error text can reach the model and chat users (item 2)
# Issue Effect on security
1 Merged whole-plan line hides a revocation Approver doesn't see a revocation their approval carries out
2 Store errors folded into pin refusals Raw store error text leaks; a temporary failure is reported as final
3 "Move committed" names the wrong failure Approver told the new target wasn't granted when it was
4 rebind: never slot renders as a plain grant Card shows a grant that is guaranteed to be refused
5 Refusal advice can't work for 3 cases Approver pointed to amendments that always fail
6 Failed later move drops an earlier move's grant Access lost on both instances (fail-closed)
7 Pre-move refusals block unrelated types Access lost on other types (fail-closed)
8 Dedupe makes later phases depend on phase 1 Access lost; later approval records lose MovedFrom
9 Make the move a no-op when the pin already names the target Design suggestion

Weakens the approval guarantee

1. A merged whole-plan line hides the revocation (pkg/authz/plangate/card.go:487)
The whole-plan renderer drops any slot whose derived id it already drew (slotSeen[id]). That check ignores type, and slots are sorted by type. gh_repo sorts before git_repo, so a plain gh_repo:org/r line wins and the moving git_repo:https://github.com/org/r line is skipped, along with its old → new arrow and "revokes this session's access to …" line. The git/gh pair is exactly the case the merge exists for.
Effect: approving executes a revocation the human was never shown. It only ever removes access, but it breaks the rule that the approver gets exactly the move they saw. This is the most serious gap here.

2. Store errors are folded into pin refusals (pkg/authz/slot_grant.go, introduced by afbc1d8)
EnsurePin / WriteGrantsPinned failures are now joined with ErrSlotPinned refusals, so errors.Is(ErrSlotPinned) is true whenever both happen in one call.

  • The plan-gate approver gets "Re-approving this card will not move it" even though a retry would bind the type that timed out.
  • The joined text, raw store error included (e.g. a gRPC Unavailable … dial tcp …), is passed along as-is. recordSlotPinRefusal attaches it to the model's next denial (loop_dispatch.go:799, loop_autofill.go:388), and channelsd quotes it in the user-visible notice (backfill.go:298).
  • Before afbc1d8, a store error came back alone and was only logged.

Effect: a small information leak (internal error detail and endpoints reach the model and chat users) and a misleading answer to the approver.

3. "Move committed" can name the wrong failure (pkg/authz/bind.go:567)
After a move, any bindSlots error is wrapped as ErrSlotMoveCommitted, and the classifier checks that first. Because GrantSlots handles each type separately, the moved type's grant on B can succeed while another type fails. The approver is told "access to the new target could not be granted", which is false, and the type that actually failed is never named.
Effect: the approver's picture of what the session can reach is wrong. Still fail-closed.

4. A rebind: never slot renders as an ordinary grant (pkg/authz/hooks/plangate.go, introduced by afbc1d8)
stampMovedFrom skips never slots, so the card shows "reaches B" with no warning. The bind is then refused by GrantSlots, but the phase is still recorded as approved. Traced through: later calls on B pass the plan gate, find no grant at the instance check, and the per-call approval is refused again with the never message. Nothing is granted.
Effect: the card claims more than will happen, so the human consents to something that can't occur. That leans the safe way, but the card is still inaccurate. The card should say B can't be granted in this session, or the gate should refuse before asking.

5. Refusal advice that can't work (pkg/agent/runner/plangate_notice.go)
The pinned-refusal notice always says "the plan must name it so an approved amendment can move the commitment". That can never work for three refusals that now reach this path:

  • a rebind: never slot
  • "would hold both" (a constant instance of the moving type in the same approval)
  • two move targets for one type

"To target the committed instance" also points at the wrong instance; it should be the new target. channelsd already changes its wording for never (allRebindNever); the plan-gate notice doesn't.
Effect: an approver can be led into repeated amendments that are refused every time. Nothing is granted.

Fail-closed availability bugs (access lost, never gained)

6. A failed later move drops an earlier move's new grant (pkg/authz/bind.go:693)
If type 1's move commits and type 2's ListGrantsFor or MovePin then fails, moveApprovedPins returns and BindApproved exits before bindSlots runs. Type 1 ends up pinned to B, A revoked, B never granted, and no scope entry written.
Effect: no access to the old or new instance of type 1 until a per-call approval re-grants B. The approver is correctly told a move landed.

7. Pre-move refusals block unrelated types (pkg/authz/bind.go:630 for never, :672 for "would hold both")
Both return before bindSlots, so first-fill grants on other types in the same approval are never written, while the phase is still recorded as approved. The notice then says "Other instances … may have been granted", which isn't true here. In practice, "would hold both" mostly hits a class that should have declared occupancy: multi.
Effect: access lost and a misleading notice; calls escalate per call.

8. Later phases depend on phase 1's move (pkg/agent/runner/host_approval.go:2361, dedupeCoveredMoves, from 48f371e)
Only the first covered record keeps MovedFrom. Phase 1 can fail before moving: a ListGrantsFor blip, a "would hold both" refusal triggered only by phase 1's ceiling, or its bindings filtered to nothing. When it does, every later phase binds B against pin A and gets ErrSlotPinned. The approver then receives a "fault, try once more" notice followed by a contradictory "re-approving won't move it" notice. Later phases' approved records are written without MovedFrom, even though their card showed the move.
Effect: access lost and a weaker audit record. The move is still recorded on phase 1's record and in the card JSON.

9. Suggestion: a simpler fix for item 8 (pkg/agent/runner/host_approval.go:2710)
MovePin requires the pin to still equal fromID, so repeating a move to the same target fails. Treating "the pin already names the target" as a no-op in moveApprovedPins / MovePin would make retries and multi-phase approvals idempotent, with no dependence on record order and no clearing of MovedFrom from the audit records.

Suggested order

Fix items 1 and 2 first. Most of 3, 6, 7 and 8 go away if BindApproved handles moves and grants per type, the same way GrantSlots already does, together with the no-op from item 9.

🤖 Generated with Claude Code

Fixes the nine findings from the review of the fix commits:

- Whole-plan card: pre-scan for a move per derived-id so the merged
  (git/gh) line still shows the revocation when the moving type loses
  the dedup, instead of rendering it as a plain first-fill.
- GrantSlots store failures carry a distinct ErrSlotStore fault sentinel:
  a transient blip joined with a real pin refusal now classifies as a
  retryable fault, and its raw store text no longer leaks to the model or
  the chat notice.
- Fold the approved pin move into GrantSlots' per-type loop and delete
  moveApprovedPins, so one type's move failure can no longer mislabel,
  strand, or block a sibling type's grant.
- MovePin is idempotent at the target (pin already names toID -> no-op,
  not drift); dedupeCoveredMoves removed, so multi-phase approvals are
  order-independent and every covered phase keeps its MovedFrom audit.
- A rebind:never slot pinned elsewhere renders as a blocked line
  (new PinnedImmovable field) rather than a grantable-looking first-fill.
- Pinned-refusal notice routes by rebind policy (new session for never,
  amendment for movable) and names the instance the approval wanted, not
  the committed one. allRebindNever lifted to shared authz.AllRebindNever.

All three suites green: mage test:unit, test:integration, test:e2e.
@josephschorr

Copy link
Copy Markdown
Member Author

Fixes for the second-round findings (b1910e0)

All nine addressed. The consent-accuracy and leak issues are fixed at the surface; 3/6/7/8/9 are gone by construction — the approved move now runs inside GrantSlots' per-type loop (moveApprovedPins deleted) and MovePin is idempotent at the target.

# Fix
1 Whole-plan card pre-scans for a move per derived-id, so the merged git/gh line shows the revocation whichever type loses the dedup.
2 New ErrSlotStore fault sentinel: a store blip joined with a pin refusal classifies as a retryable fault and no longer leaks raw store text to the model or the chat notice.
3 Move folded into GrantSlots per type — a committed move is reported only for the type that actually moved.
4 A rebind: never slot pinned elsewhere renders as a blocked line (new PinnedImmovable), not a grantable first-fill.
5 Pinned-refusal notice routes by rebind policy (new session for never, amendment for movable) and names the instance you wanted, not the committed one. allRebindNever lifted to shared authz.AllRebindNever.
6 Per-type handling: one type's failure no longer strands another type's committed move + grant.
7 Pre-move refusals (never, would-hold-both) are per-type now, so they don't block a sibling type's first-fill.
8 dedupeCoveredMoves removed; multi-phase approvals are order-independent and every covered phase keeps its MovedFrom audit.
9 MovePin no-ops when the pin already names the target.

mage test:unit, test:integration, test:e2e all green (incl. the slot/move bronze bundles and steelthread).

This branch was successfully deployed

1 active (outdated) deployment
Preview — 05928fe2 Deployed Oct 14, 2026 by vercel[bot]
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.

2 participants