Skip to content

Commit 84a48a6

Browse files
Merge pull request #825 from corbitsdev/cl-5683-ask-authz-suspend-rfc
Reconcile ask-authz suspend with unified message bus (RFC)
2 parents 3bd2972 + 9b910e1 commit 84a48a6

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

‎docs/RFC-ask-authz-suspend.md‎

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# RFC: Reconcile ask-authz suspend with unified message bus
2+
3+
**Status:** Draft
4+
**Author:** Corbits Code
5+
**Ticket:** [CL-5683](https://linear.app/abklabs/issue/CL-5683/rfc-reconcile-ask-authz-suspend-with-unified-message-bus)
6+
**Blocks:** CL-5699 (adopt reactor approval-suspend primitive)
7+
8+
## Summary
9+
10+
The current permission gate parks tool calls on in-memory `resolve()`
11+
closures (the `resolve` field of `PermissionGateEvent` in
12+
`src/tui/gate-events.ts`) held open by the gate-wire
13+
overlay. This RFC decides how Corbits Code adopts the upstream reactor's
14+
approval-suspend primitive instead: a before-tool authz hook that returns
15+
a `suspend` effect carrying an approval gate and a persisted
16+
`PendingOperation`, with resumption driven by the reactor's signal
17+
dispatch — not by any callback we invent.
18+
19+
This RFC resolves the four decisions CL-5683 requires:
20+
21+
- **(a)** The gate's pending-record bookkeeping maps onto the upstream
22+
`PendingOperation`/`correlationId` flow by adopting the upstream
23+
`correlationId` as the single identity for a parked call and reusing the
24+
existing `pendingOperations` persistence in
25+
`src/session/optimized-context-store.ts` — no parallel queue.
26+
- **(b)** Director ask-handling consumes the reactor's suspend action and
27+
`gate.cleared` resume dispatch; the director stops owning approval
28+
queues. The wiring seam is the `requestApproval` field of
29+
`SessionGateArgs` and its pass into `createPermissionGate` in
30+
`assembleSessionGate` (`src/session/assemble-runtime.ts`),
31+
not the director.
32+
- **(c)** `src/permission/classify.ts` allow/ask tiering stays a
33+
pre-filter above authz grants; it is not authz policy.
34+
- **(d)** Headless denial and the stricter chained-command deny are
35+
re-homed as `block` effects in the authz extension's before-tool hook,
36+
where upstream already has a block channel.
37+
38+
No code changes in this cut — design decision only. It blocks only
39+
CL-5699.
40+
41+
## Motivation
42+
43+
### Current architecture
44+
45+
- `PermissionGateEvent` (`src/tui/gate-events.ts`) carries a
46+
`PermissionRequest`, a `resolve(outcome)` callback, optional
47+
`timeoutMs`, and an `AbortSignal`.
48+
- `OperatorGateEvent` (`src/tui/gate-events.ts`) is the question
49+
analogue.
50+
- The gate-wire overlay (`src/tui/gate-wire.ts`) connects these events to
51+
the TUI and holds `resolve()` open until the operator interacts; it also
52+
owns the pending-display overlay that serializes what the operator sees
53+
(`wireGates` in `src/tui/gate-wire.ts`).
54+
- `src/permission/queue.ts` is an in-memory `Map` of concurrent pending
55+
entries keyed by id (`createPermissionRequestQueue` in
56+
`src/permission/queue.ts`) used to reconcile
57+
grants through the approval store; `src/permission/store.ts` persists
58+
grants only, not pending requests.
59+
60+
### The gap
61+
62+
The "suspend" is implicit: the `resolve()` closure sits in memory, pinned
63+
by the gate-wire's pending overlay. If the process dies, the suspend is
64+
lost — the LLM's tool call returns a hung future with no recovery path.
65+
Today there is **no durability for pending approvals**: the queue is an
66+
in-memory Map and the store holds grants only. Nothing in this RFC claims
67+
restart recovery for pending approvals until that changes (see the
68+
durability decision under (a)).
69+
70+
Meanwhile the upstream reactor (vendored at `vendor/intx-inference`)
71+
already carries the primitive:
72+
73+
- The before-tool authz hook returns, for an `ask` effect,
74+
`{ type: "suspend", gate: { type: "approval", gateId, correlationId,
75+
timeoutAt }, pendingOp }` (`authz-extension.ts:263-267` upstream; the
76+
reactor persists `pendingOp`, minting `correlationId` at :223 and
77+
`gateId = pending-${correlationId}` at :226).
78+
- `DEFAULT_APPROVAL_TIMEOUT_MS = 3_600_000` (:36) — one hour.
79+
- Resume is signal-driven dispatch in the reactor: a suspended call parks
80+
on the reserved `signalName(correlationId)` channel (see the park-kind
81+
prose in `packages/types/src/signals.ts:67` and `runtime.ts:663`
82+
upstream), and a cleared gate enqueues a `reactor.gate.cleared` event
83+
the director decides on (`reactor.ts:8-10, 68-72` upstream).
84+
- A re-dispatch of an already-approved call bypasses the gate via a
85+
delete-on-read in-memory `approvedOnce` token
86+
(`authz-extension.ts:211-218`).
87+
88+
Corbits Code does not use this yet because the closure-based approach
89+
predates the vendoring.
90+
91+
## Decisions
92+
93+
### (a) Gate pending records map onto PendingOperation / correlationId
94+
95+
**Decision:** adopt the upstream `correlationId` as the single identity of
96+
a parked permission request. The gate's current per-request ids and
97+
resolve closures are replaced by the upstream flow: the authz hook mints
98+
`correlationId`, wraps the request into a `pendingOp`
99+
(`PendingOperation` from `@intx/types/runtime`, with `approvalSnapshot`
100+
and `suspendedCall`), and returns the `suspend` effect. The reactor
101+
persists the operation; resume is addressed by `correlationId` via the
102+
signal channel, not by holding a callback.
103+
104+
Rationale: today `src/session/optimized-context-store.ts` already
105+
persists `PendingOperation[]` from `@intx/types/runtime`
106+
(the `pendingOperations` field of its `SessionMetadata`), and upstream
107+
persists the operation
108+
precisely so the id survives a restart (comment at
109+
`authz-extension.ts:219-221`). Using that existing surface means the
110+
gate's bookkeeping collapses into one identity and one store instead of a
111+
parallel `Map<string, SuspendToken>` in the gate-wire.
112+
113+
**Durability, stated honestly:** as of this RFC, restart recovery for
114+
pending approvals is **not delivered and remains out of CL-5699 scope**.
115+
`src/permission/queue.ts` is an in-memory `Map` and
116+
`src/permission/store.ts` persists grants only, so a crashed session
117+
still loses the pending approval. The mapping above is what makes
118+
recovery _possible later_ (the persisted `pendingOperations` plus
119+
`correlationId`-addressed resume), but wiring snapshot-and-restore is
120+
separate work and is not claimed by CL-5699.
121+
122+
### (b) Director consumes suspend actions; requestApproval is the seam
123+
124+
**Decision:** director ask-handling consumes the reactor's suspend action
125+
and the `gate.cleared`-driven resume dispatch, and stops managing its own
126+
approval queue. The `requestApproval` hook stays wired where it is today
127+
— declared on `SessionGateArgs` and passed into `createPermissionGate`
128+
by `assembleSessionGate` (both in `src/session/assemble-runtime.ts`) —
129+
and its job
130+
narrows to feeding the operator-facing surface. The director never sees
131+
the gate itself; it sees outcomes only as tool-result text today
132+
(`isOperatorDeclinedToolResult` in `src/agent/director.ts` matches
133+
"Blocked by permission policy: Operator declined:") and, after adoption,
134+
additionally sees the suspend
135+
as a parked tool call and the clear as a `reactor.gate.cleared` event it
136+
decides on.
137+
138+
Rationale: upstream deliberately separates "the call is parked" (reactor,
139+
gate, persisted operation) from "the director decides what happens when
140+
the gate clears" (resume dispatch reaches the director as a normal
141+
event). Putting the queue in the director would duplicate the reactor's
142+
park/bookkeeping role; putting it nowhere loses the operator surface.
143+
The seam ownership follows the existing wiring: the session assembles the
144+
gate dependencies, the reactor owns the suspend lifecycle, the director
145+
only reacts to events.
146+
147+
### (c) classify.ts tiering stays a pre-filter above authz grants
148+
149+
**Decision:** `src/permission/classify.ts`'s allow/ask `Tier`
150+
remains a pre-filter that decides _whether and how_
151+
the authz path is consulted; it does not become authz policy. Read-only
152+
tools classify `allow` and short-circuit; everything else classifies
153+
`ask` and flows through the authz grant path, where grants, denies, and
154+
the suspend effect live.
155+
156+
Rationale: the classifier encodes Corbits' tool-level defaults (which
157+
tools are safe to auto-run) — knowledge that lives on our side of the
158+
boundary and that upstream authz has no way to express. Authz grants
159+
encode per-project operator intent (patterns, scopes, persistence).
160+
Collapsing the two would either push Corbits tool defaults into
161+
grant-matching (wrong layer, wrong persistence) or force the grant store
162+
to re-implement tiering. Keeping the tier as a pre-filter preserves both
163+
and gives the suspend primitive a clean trigger: `ask` tier is exactly
164+
the condition under which the before-tool hook can return `suspend`.
165+
166+
### (d) Headless denial and stricter command-deny re-home as block effects
167+
168+
**Decision:** the two Corbits-only deny paths move into the authz
169+
extension's before-tool hook as `block` effects, which upstream already
170+
supports (`{ type: "block", reason }` is a first-class hook return in
171+
`authz-extension.ts:205-208`):
172+
173+
- **Headless denial** — today in the two `!interactive` deny branches of
174+
`evaluate` in `src/permission/gate.ts`. When the run is non-interactive
175+
there is no operator to
176+
approve, so instead of reaching the `ask` effect the hook returns
177+
`block` with the existing denial reasons.
178+
- **Stricter chained-command deny** — today the
179+
`runShellAuthzBlockReason` check in `evaluate`, owned by
180+
`preGrantGuardReason` (both in `src/permission/gate.ts`), which
181+
hard-denies chained shell commands whose
182+
segments target restricted or sensitive paths even when a grant
183+
exists. This stays a pre-grant guard but is expressed as a `block`
184+
effect in the hook rather than gate-internal bookkeeping.
185+
186+
Neither path has an upstream equivalent, so both are ours to carry; the
187+
decision is only _where_ they live. Putting them in the hook means the
188+
gate's `ask` path is the only path that can suspend, and denial never
189+
needs a parked operation, a correlation id, or a resume.
190+
191+
Rationale: upstream's hook contract already distinguishes
192+
allow / block / ask / suspend. Denial-without-interaction is exactly
193+
`block`; approval-requiring is exactly `ask`→`suspend`. Re-homing keeps
194+
`gate.ts`'s remaining job limited to interactive outcome routing while
195+
the vendored reactor owns the lifecycle.
196+
197+
## Transport: what exists, not `Channel<T>`
198+
199+
`@intx/types` has no `Channel<T>`. An earlier draft of this RFC designed
200+
a bus around one; that interface does not exist upstream or in our tree.
201+
The real mechanisms are:
202+
203+
- **Upstream:** the reserved signal channel — a suspended step parks on
204+
`signalName(correlationId)` and resume is the reactor's signal-driven
205+
dispatch (`packages/types/src/signals.ts:67`,
206+
`packages/types/src/runtime.ts:663`, `reactor.ts:8-10, 68-72`).
207+
This is the transport the suspend primitive is designed against, so it
208+
is the one we adopt: gate clears are delivered by enqueueing a signal
209+
on the correlation-id channel, and the reactor's existing resume
210+
dispatch does the rest.
211+
- **Ours:** the TUI gate wire (`src/tui/gate-wire.ts` and
212+
`src/tui/gate-events.ts`, exercised end to end by the harness modules
213+
under `tests/integration/`) is EventEmitter-based — a display-plane
214+
mechanism, suited to
215+
surfacing the pending operation to the operator, not to resuming a
216+
parked reactor step.
217+
218+
Decision: resume transport is the upstream signal channel (it is what
219+
the reactor dispatches on); the EventEmitter gate-wire events stay on
220+
the display plane and carry the approval snapshot to the overlay. No new
221+
message type is introduced.
222+
223+
## Alternatives considered
224+
225+
1. **Keep closure-based suspend.** Zero migration cost but leaves the
226+
reactor's primitive unused, keeps `resolve()` pinned in memory, and
227+
forfeits the persisted-`pendingOp` identity that any future restart
228+
recovery needs.
229+
230+
2. **Invent a `SuspendToken` with `resume()`/`cancel()` handed to the
231+
reactor.** Rejected: corresponds to nothing upstream. The reactor's
232+
resume is signal-driven dispatch; a callback-based token would be a
233+
second resume mechanism racing the first.
234+
235+
3. **Serialize the full `PermissionRequest` into the store "because the
236+
queue can reconstruct it."** Rejected as stated: the queue is an
237+
in-memory `Map` (`createPermissionRequestQueue` in
238+
`src/permission/queue.ts`), so after process
239+
death there are no pending entries to reconstruct from, and
240+
`src/permission/store.ts` persists grants only. Snapshot/restore of
241+
pending operations is real future work, decided out of scope in (a).
242+
243+
4. **Skip adoption until a cross-process surface exists.** Rejected: the
244+
closure-based path already breaks down on single-process restart, and
245+
adoption is a prerequisite for CL-5699 regardless.
246+
247+
## Migration path
248+
249+
1. CL-5683 (this RFC) — design complete; decisions (a)-(d) above.
250+
2. CL-5699 — implement: route `ask`-tier calls through the authz hook's
251+
suspend effect; persist `PendingOperation` via the existing
252+
`optimized-context-store` surface; deliver gate clears on the
253+
correlation-id signal channel; re-home the two deny paths as `block`
254+
effects; narrow `requestApproval` to the operator-facing seam.
255+
Gate-wire display behavior unchanged from the operator's perspective.
256+
Explicitly out of scope: snapshot-and-restore of pending approvals
257+
across restart.
258+
3. Monitor adoption; revisit restart recovery as a separate ticket with
259+
its own scope.
260+
261+
## Open questions
262+
263+
- Should the approval `approvalSnapshot` → TUI payload mapping live in
264+
the gate-wire overlay or in a new adapter beside
265+
`src/session/optimized-context-store.ts`? Leaning gate-wire, since it
266+
already owns the pending-display overlay.
267+
- Exact timeout policy: upstream defaults to one hour
268+
(`DEFAULT_APPROVAL_TIMEOUT_MS`, `authz-extension.ts:36`); whether
269+
unattended auto-continue runs should pass a shorter
270+
`approvalTimeoutMs` per run.

0 commit comments

Comments
 (0)