|
| 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