Skip to content

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

Merged
josephschorr merged 19 commits into
mainfrom
slot-pinning
Oct 23, 2026
Merged

josephschorr merged 19 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 23, 2026 12:10am 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 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>
@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

@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).

josephschorr and others added 13 commits October 21, 2026 22:30
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.
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.

@samkim samkim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the latest fix commits (d8fa0b4..4982a52), focused on security and info leaks. Each finding below was reproduced with a test that asserts the correct behavior and fails at 4982a52. Three other candidates were checked and dropped: MovePin's no-op path (grant writes are pin-guarded, confirmed against real SpiceDB), card recipients (plan-gate cards resolve only agentsession#approve), and the %T writer error (both production writers implement SlotPinner).

Should fix, in priority order:

  1. forkedFrom (and a pre-stamped finalizer) skip the admission sweep: controller.go:1188
  2. Cross-phase moves to different targets commit half a move and report "nothing granted": slot_grant.go:371
  3. Fault copy claims nothing was granted after a partial grant: plangate_notice.go:90
  4. AllRebindNever is computed over all bindings, not the refused ones: host_approval.go:2577, backfill.go:308
  5. A store fault on one type hides a genuine pin refusal on another: plangate_notice.go:303 and three sibling sites
  6. The per-phase loop publishes one failure notice per phase: host_approval.go:2577

Inline comments have the repro and the suggested fixes.

// synchronously ahead of its mint-time binds (pipeline.go), so it never
// reaches this branch.
if !controllerutil.ContainsFinalizer(&sess, spiceboxv1alpha1.FinalizerAgentSession) &&
r.SpiceDBDeleter != nil && sess.Spec.ForkedFrom == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: the admission sweep can be skipped by anyone who can create the CR.

sess.Spec.ForkedFrom == "" is the only thing gating the sweep, and forkedFrom is user-written spec. Nothing checks that the named parent exists or forked this session. The field's doc says "Immutable", but no CEL rule or webhook enforces that. Slot tuples are keyed by (ns, name) with no UID, so a new session that reuses a dead predecessor's name and sets forkedFrom: anything inherits any slot_pin (never expires) or slot_grant that survived the predecessor's finalize().

Repro (envtest, -tags=integration): seed stale pin and grant tuples for ns/name, create the session with forkedFrom: "no-such-parent", then reconcile. DeleteSlotGrants is called 0 times (want 1) and the stale tuples survive. The control case with forkedFrom empty sweeps correctly. A separate update that changes spec.forkedFrom on an existing session is accepted.

Second route to the same skip: the sweep only runs when the finalizer is absent. channelsd pre-stamps it deliberately (pipeline.go:~1531, it sweeps itself before its mint-time binds), but any creator can put the finalizer in their manifest and get the same skip without touching forkedFrom.

Suggested fix:

  • Gate the fork exemption on something the parent proves, not the child's spec: the parent exists in the same namespace and points back at this session (status.supersededBy, or a pendingRestart.targetSessionName whose marker passes restartmarker.Verify).
  • Bind that proof to the child's UID, not just its name. status.supersededBy is never cleared (restart.go:86), so once child X has been deleted, a new X with forkedFrom: P would still match P's stale supersededBy: X and replay the skip. Record the child UID on the parent when BuildChildSession creates it (e.g. status.supersededByUID) and compare it against sess.UID.
  • Add a CEL rule making forkedFrom immutable (add, remove and repoint all refused).
  • For the pre-stamped finalizer: the cleanest option is a ValidatingAdmissionPolicy that refuses a CREATE carrying the agentsession finalizer unless request.userInfo.username is the channelsd service account. It runs in the apiserver and adds no webhook availability dependency. Alternatives: a channelsd-signed mint marker bound to the specific creation (with a nonce or expiry so a predecessor's annotation can't be replayed), or dropping the exemption and having channelsd wait for the operator's sweep before binding.

Exploitability is narrow (it needs direct create on AgentSession plus a tuple that survived finalize()), but this is a fail-open authz gate, and closing that window is the stated purpose of the sweep.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e28cd74, with follow-ups in 43aff91 and e1c8d3a. forkedFrom no longer gates the sweep and is now immutable (CEL). The only skip left is a pre-stamped finalizer. A new ValidatingAdmissionPolicy (config/admission/) lets only the operator and channelsd service accounts add that finalizer. Fork and subagent children are pre-stamped by the operator, which sweeps their name before creating them. Takeover children are not pre-stamped. channelsd now sweeps before Create, so a crash or redelivery cannot leave a session unswept. Both sweeps also delete data-slot grants. The policy test runs against envtest with impersonated users.

Comment thread pkg/authz/slot_grant.go Outdated
// card showed nor the target (MovePin's own no-op already
// absorbed the already-at-target case). Nothing moved, nothing
// revoked; re-approving rebuilds the card from the current pin.
refusals = append(refusals, fmt.Errorf("%w: %w: execute approved slot move for %s", ErrSlotMoveDrifted, err, rt))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: one approval whose phases move the same pin to different instances commits the first move, then reports "nothing was granted".

stampMovedFrom (hooks/plangate.go) stamps MovedFrom=<current pin> on every covered ref that differs from the pin, so a plan where phase 1 names repo B and phase 2 names repo C (pin = A) yields a card with {B: A, C: A}. Nothing rejects that at card build, and the only distinct-instance guard (len(grp.ids) > 1, ~L322) applies within a single BindApproved call. The approval loop (recordPlanGateDecision, host_approval.go ~L2385) calls it once per phase. The old cross-phase "two distinct move targets" check went away in d8fa0b4.

So the sequence is:

  1. Phase 1: MovePin(A→B) commits, A's grants are revoked and B's grant is written.
  2. Phase 2: MovePin(A→C) fails its precondition, ReadPin returns B ≠ C, and the error is wrapped here as ErrSlotMoveDrifted.
  3. classifyApprovalBindFailure maps that to approvalFault, and the approver is told "nothing was granted… try approving once more".
  4. Retrying rebuilds the card as B→C and revokes B.

Repro (driving the real recordPlanGateDecision loop): result was pin=repoB realMoves=1 grantWrittenOnRepoB=true, with one notice category=internal_error lead="Your approval could not be applied, so nothing was granted…". A real two-phase plan through requestPhaseApproval produces this exact card.

Suggested fix: before the per-phase BindApproved loop, collect the distinct move targets per single-occupancy type across approvedPhaseRecords(pg). If any type has more than one, refuse the whole approval before any write, with a pinned-refusal-style notice ("this plan moves to more than one instance; a session can hold only one"), not approvalFault. Optionally flag it at card build in stampMovedFrom too. Separately, a drift that follows a move this same approval committed shouldn't be classified as "nothing granted".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5594096 and 7cafaf2. All covered phases are resolved before anything is written. conflictingMoveTargets refuses the whole approval when a single-occupancy type would end up on more than one instance and at least one phase moves it. Nothing is moved, granted or recorded as approved, and the approver gets a SlotBindRefused notice. That also covers one phase staying on the pin while another moves off it.

Comment thread pkg/agent/runner/plangate_notice.go Outdated
// Recoverable-fault copy (the default): nothing bound, and re-approving may
// take. Bounded rather than "retry"/"don't retry" — one more attempt
// distinguishes a transient blip from a persistent one; a third never does.
lead := "Your approval could not be applied, so nothing was granted and the plan " +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approver is told the wrong thing about what access the session holds.

The approvalFault copy says "nothing was granted and the plan has not started… nothing has been granted either way". But GrantSlots deliberately keeps going after a per-type failure ("so the remaining types still land", slot_grant.go ~L306): each single-occupancy type does its own EnsurePin/WriteGrantsPinned, and multi-occupancy types are written in a separate WriteRelationships. So when type Y's write fails with ErrSlotStore, types X and Z are already granted. The pinned-refusal copy in this same function already acknowledges that "other instances may have bound". This branch is the one that contradicts the code.

Repro: a writer where git_repo:repoB push succeeds and another type's WriteGrantsPinned returns an ErrSlotStore-wrapped error. The require that repoB was granted passes, and the notice is still internal_error with "…so nothing was granted…". The cross-phase drift case on slot_grant.go:371 lands on this same copy after a real revoke and grant.

Suggested fix: change the copy to "could not be fully applied, so some or all of the access it covers was not granted", and drop the "nothing has been granted either way" next step. If it's cheap, name the types that did and didn't bind.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5594096. Bind-time faults now say the approval was recorded but could not be fully applied, and name the types that were not granted. The "nothing was granted, try once more" wording is kept only for the standing-lookup failure, where nothing is recorded and it is true.

Comment thread pkg/agent/runner/host_approval.go Outdated
// only be worked in a new session — no amendment moves it — so the
// notice must route there, not to a widening that would be refused
// every time. Same split channelsd makes.
h.publishPlanGateApprovalFailed(ctx, err, kind, authz.AllRebindNever(bindings))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues on this line.

(a) AllRebindNever(bindings) is passed every binding in the phase, not the refused ones. The doc on AllRebindNever (slot_grant.go:175) and the nextStep parameter both say "every refused binding". With a mix (ticket rebind default binds fine, git_repo rebind never is refused because it's pinned elsewhere) the result is false, so the approver is told "an approved amendment moves the commitment to it". GrantSlots refuses that amendment every time (priorID != id && rebind == never, ~L349). channelsd has the same bug at backfill.go:308.

Repro (real driveDecision → narrowToApproved → BindApproved path, class with git_repo single/never and ticket single/default): the ticket grant is written, and NextStep is "…an approved amendment moves the commitment…" when it should say "start a new session". The channelsd publishSlotBindRefusedNotice path gives "propose an updated plan… moves the commitment" in the same scenario.

Fix: GrantSlots joins fmt.Errorf strings, so the call site can't tell which types were refused. Return a typed refusal per type, e.g. *SlotPinRefusal{ResourceType, Rebind, Reason} that unwraps to ErrSlotPinned, collect them with errors.As over the joined error, and compute the rebind-never predicate over those only. The same type fixes the store-fault masking on plangate_notice.go:303.

(b) This publishes once per covered phase. recordPlanGateDecision loops approvedPhaseRecords(pg) and calls narrowToApproved per record. Each runs its own BindApproved and publishes on failure here, and the return only exits narrowToApproved, so the loop moves on. Each notice gets a fresh newRequestID(), so nothing dedupes. Repro: one approval covering 3 phases that each name a refused pinned instance publishes 3 SlotBindRefused notices (want 1). The same structure can make notices contradict each other: phase 1 reports a committed move whose new grant failed, and a later phase's no-op move then writes that grant.

Fix: have narrowToApproved return its classified outcome instead of publishing it. Aggregate across the loop and publish once at the end. A later per-type success should supersede an earlier fault or committed-move outcome for the same type.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in 5594096. (a) GrantSlots returns a typed *authz.SlotBindFailure per type, and AllRebindNever now takes SlotPinRefusals(err), here and in channelsd. (b) Outcomes are folded per (type, permission) across phases and published once. A later bind of the same grant supersedes an earlier fault or committed move, and a refusal is sticky.

Comment thread pkg/agent/runner/plangate_notice.go Outdated
return approvalMoveCommitted, true
case errors.Is(err, authz.ErrSlotMoveDrifted):
return approvalFault, true
case errors.Is(err, authz.ErrSlotStore):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A transient store fault on one type hides a genuine pin refusal on another.

GrantSlots returns errors.Join of all per-type outcomes, so one call can carry an ErrSlotPinned for type X (pinned elsewhere, permanent) and an ErrSlotStore for type Y (EnsurePin timeout). All four consumers check the store fault first:

  • here: classified approvalFault, so the approver is told to "try once more", and that can never succeed for X
  • loop_autofill.go:380 and loop_dispatch.go:789: ErrSlotPinned && !ErrSlotStore is false, so recordSlotPinRefusal is skipped and the model gets a bare denial for X with no explanation
  • channelsd backfill.go:294: the SlotBindRefused notice is suppressed entirely

I get that the ordering is intentional, to keep raw store detail out of user and model text. But it currently drops the refusal along with the store detail. Repros: the classifier returns fault for the mixed error. The real dispatch/observed-promotion path logs git_commit refusal recorded=false, though the joined error contains "this session is pinned to git_commit:…". channelsd publishes 0 notices (want 1).

Fix: with the typed refusal suggested on host_approval.go:2577, walk the joined error, surface only the SlotPinRefusal members to the model and user (and classify as a refusal), and keep the ErrSlotStore members in logs. Update the comments here and at the sibling sites that describe the old ordering. TestBindTriggerSlots_PinRefusalWithStoreFault_NoNotice asserts the current suppression and would need to flip.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5594096. All four sites now surface only the refusals, quoted via SlotRefusalText, so no store text reaches the model or users. Faults stay in the logs. TestBindTriggerSlots_PinRefusalWithStoreFault_NoNotice is replaced by ..._NoticeQuotesOnlyTheRefusal.

GrantSlots now returns a typed *authz.SlotBindFailure per resource type
(refused, fault, drifted, move-committed), so callers can separate a
permanent pin refusal from a transient store fault in the same call.

- Model- and user-facing surfaces quote only the refusals
  (SlotPinRefusals / SlotRefusalText); faults stay in operator logs and
  no longer suppress a refusal on another type.
- AllRebindNever is computed over the refused types only, in the runner
  and in channelsd.
- A plan-gate approval resolves every covered phase before writing,
  refuses the whole approval when phases move one pin to two different
  instances, and publishes one notice aggregated per type across phases.
- Bind-time fault copy no longer claims nothing was granted.
…alizer

- The sweep is skipped only when the session finalizer was present at
  first reconcile; spec.forkedFrom no longer gates it.
- A ValidatingAdmissionPolicy refuses any create/update that adds the
  agentsession finalizer unless the caller is the operator or channelsd
  service account.
- BuildChildSession pre-stamps the finalizer so a fork child keeps the
  grants and pin its parent copies onto it.
- spec.forkedFrom is immutable after create (CEL).
- oap install knows the two admission kinds are cluster-scoped.
…plans

- An amendment card renders any covered slot whose pin it moves or
  blocks, not only its delta, so approving never revokes an instance the
  approver was not shown.
- The whole-approval conflict check counts every instance of a moving
  single-occupancy type, so a phase staying on the pin beside one moving
  off it is refused instead of silently revoking the stay's grant.
- Approval outcomes are tracked per (type, permission): a later phase's
  grant no longer hides an earlier phase's failure on the same type.
- Occupancy and rebind are read straight off the spec, so a precondition
  compile failure can no longer make a rebind:never slot movable.
- GrantSlots lets rebind:never win a mixed merge and keeps the first
  PriorID, as its comment always said.
- CopySlotGrants skips any type the fork child already pins and guards
  each copied type with MUST_NOT_MATCH, so a re-run of restart step 6c
  cannot give the child two pins.
- Takeover children are no longer pre-stamped with the finalizer; other
  fork modes sweep the target name before creating the child.
- Admission sweeps (operator and channelsd) also delete data-slot grants.
- channelsd sweeps before Create, failing closed, so a crash or redelivery
  can no longer leave a minted session unswept.
- DeleteSlotGrants deletes pins before revoking grants.
Before any class becomes valid SpiceDB has no agentsession definition,
so the reads behind the admission and mint sweeps fail
FailedPrecondition. Nothing can be granted against an absent definition,
so the sweep is satisfied; failing closed there refused every first
session on a fresh install.
The admission sweep now also deletes data-slot grants, and a subagent
child's first reconcile raced the data-slot grants the SubagentRequest
controller writes onto it right after Create, wiping them. The controller
now sweeps the child's (ns, name) itself before Create, fail-closed, and
pre-stamps the session finalizer so the child's own reconcile does not
sweep again. An existing child (a retry) is never swept.
@josephschorr

Copy link
Copy Markdown
Member Author

Third-round fixes + pre-merge audit (5594096..e1c8d3a)

All six findings from the last review are addressed; replies are inline.

I also ran the repo's ten-lens audit (mage audit:recent) over the branch. Each finding was checked by four skeptics. Result: 0 blocker, 3 major, 19 minor, 15 nit.

Fixed

  • Amendment card hid a move. The card now renders every covered slot whose pin it moves or blocks, not only its delta.
  • Stay-beside-move went undetected. The conflict check now counts every instance of a moving type.
  • Fork step 6c re-run could double-pin the child. CopySlotGrants skips types the child already pins, and each copied type is guarded with MUST_NOT_MATCH. The window was real: the child's runner can start while the parent waits on restore.
  • Minors:
    • Admission sweeps now also delete data-slot grants.
    • The channelsd mint sweep runs before Create.
    • Takeover children are not pre-stamped.
    • DeleteSlotGrants deletes pins before grants.
    • rebind: never no longer fails open when slot preconditions don't compile.
    • Outcomes are tracked per (type, permission).
    • Stale comments and doc paths are corrected.
  • Two regressions from these fixes, caught by the gate and fixed:
    • The pre-Create sweep refused sessions before any schema existed. Fixed in a40740e: an absent definition now means there is nothing to sweep.
    • A subagent child's data-slot grants were wiped by its own admission sweep. Fixed in e1c8d3a: the controller sweeps the child's name before Create and pre-stamps the finalizer.

Not fixed in this PR (follow-ups)

  • Grants written during a move. The revoke list is read before MovePin. A concurrent grant of a new permission on the old instance can survive the move until its grant expires.
  • Simultaneous mints. Two channelsd replicas minting the same session at once can strip the winner's mint-time binds. This only removes access, never adds it.
  • Single/multi occupancy changes mid-session. A class edit can make a live pin non-authoritative.
  • Precondition waiver advice. The waiver handler still gives "propose an updated plan" advice to classes with no plan gate.
  • Refactors. SlotPolicy/PinState value types; these should land before the plangateaudit record shape freezes.
  • Docs and tests: the AGENTS.md interaction-category count (actually 47, already stale before this branch), and the extra bundle and test coverage the audit lists.

Deploy note: the new admission policy needs Kubernetes ≥ 1.30. It hard-codes the agentprimitives-system service-account names.

Gate: mage test:unit, mage test:integration and mage test:e2e (including all bronze and steel bundles) are all green at e1c8d3a.

@josephschorr
josephschorr merged commit 7cd9d18 into main Oct 23, 2026
10 checks passed
@josephschorr
josephschorr deleted the slot-pinning branch October 23, 2026 01:23
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