On June 5, commit commit-9547 closed an authority hole that had given every agent the same answer when it asked for permission: skip the checks.
I had built a proposal store so agents could take approved work and dispatch it without waiting for me to manually re-enter the same instruction. A proposal had a handoff, required capabilities, a trust context, and a dispatch posture. Approval turned that proposal into a bridge session.
The problem was not in the proposal store. It was in a default lower down the stack.
The bridge engine represented posture as posture | null. That looked harmless while I was wiring the first paths. null meant that no explicit posture had been chosen yet. But the code that turned a posture into permission arguments treated null as the unrestricted CLI path:
--dangerously-skip-permissionsThat was backwards. Missing authority was not a temporary state. It was an authorization failure.
It was worse on resume. A fresh spawn could carry posture in from the caller. A recreated session had no equivalent guarantee. When send() rebuilt a session, it could do so with no posture at all, and the bridge silently used the unrestricted argument again. I had a permission system on paper, and a bypass on the two paths that mattered most: autonomous dispatch and resume.
The diagnostic was a type with an unsafe meaning
The first symptom was not an error. It was the absence of one.
I expected an autonomous act with no resolved posture to be rejected before a session existed. Instead, the bridge ran. Tracing the call chain showed the sequence: approveAndDispatch assembled its own capability gate and posture, spawnFresh() accepted an optional posture, and permissionArgs() accepted null. Once that null reached the argument builder, the engine selected unrestricted mode.
The proposal store knew whether a proposal was approved. The bridge knew how to launch or resume a process. Neither one owned the complete question: may this act run with these capabilities, under this trust context, in this area?
My first option was to tighten the proposal store. I could have made approveAndDispatch require a posture and added another guard before it invoked the bridge. That would have fixed autonomous dispatch, but not interactive calls from /api/bridge/spawn, message sends through /:sid/message, or session recreation. It also would have left several places responsible for assembling the same policy.
The second option was to preserve the nullable field and change its meaning. null could have mapped to an enforced posture instead of unrestricted mode. That was better, but absence would still look valid. A later caller could forget to resolve authority, receive an implicit posture, and continue. I did not want a safer fallback. I wanted no fallback.
The third option was the one I shipped: introduce a single authority object and make every act obtain one before it can run.
Authority is now an object, not an optional hint
The new server/actAuthority.ts exports one function:
authorizeAct({ act, trust, required, areaKey })It composes the existing capability gate with dispatch posture and returns an explicit ActAuthority. It has two valid outcomes.
An interactive act, with the operator present and authenticated through the vault key, receives explicit trusted authority. An autonomous act receives an enforced posture only after its requested capabilities pass the gate. Otherwise authorizeAct() refuses the act.
There is no result that means “run unrestricted because nobody specified anything.”
The old type made omission easy to pass through several functions. The new type forces resolution at the boundary. The caller either holds an ActAuthority, or it does not get to cross into the bridge.
In bridgeEngine.ts, permissionArgs() now throws if it receives no authority. spawnFresh() and send() guard up front as well. The change to send() was important. It now accepts the authority explicitly, so re-creating a session cannot silently forget the authority that launched it.
The API routes resolve interactive authority before touching the bridge. Both /api/bridge/spawn and /:sid/message call authorizeAct() and pass the returned object through. The proposal store resolves autonomous authority through that same function in approveAndDispatch, rather than inlining its own gate and posture logic.
interactive API route -> authorizeAct(...) -> ActAuthority -> bridgeproposal approval -> authorizeAct(...) -> ActAuthority -> bridgeEvery spawn and every resume needs an authority resolved for the act in front of it.
I kept the handoff contract and changed the invariant
I did not use this fix as an excuse to rewrite dispatch. The existing SpawnFn(handoff, posture) contract stayed intact. That preserved the proposal store’s tested handoff behavior while moving the policy decision out of the store and into the authority module.
The existing lock layer already funnels mutation routes. authorizeAct() exposes act: "mutation" as the extension point for the same pattern. The immediate hole was spawn and resume. The new boundary gives mutations a place to attach without pretending they were already covered.
The implementation touched four files: 175 insertions and 29 deletions. The new authority module accounts for 101 lines. That is safer than scattered guards because duplicated policy invites regression. A future dispatch path makes one explicit call, not a checklist of posture, capability, and trust conditions.
The verification was unglamorous, which is exactly what I wanted. tsc --noEmit reported the same nine pre-existing errors and no new ones. proposalStore.test.ts passed all 6 of 6 cases, preserving the existing gate behavior. The full suite still had eight failures, all reproduced at commit commit-58C7: stale nodes.test.ts cases and two live OpenAI tests with no key. tsx-watch reloaded cleanly on port 3010.
The original bug was a silent default. The fix is a loud invariant. If an act has not been authorized, the bridge cannot turn that absence into permission to run.