Skip to content

refactor(core): split permission policy from global ledger - #46706

Open
kitlangton wants to merge 1 commit into
v2from
permission-ledger
Open

refactor(core): split permission policy from global ledger#46706
kitlangton wants to merge 1 commit into
v2from
permission-ledger

Conversation

@kitlangton

Copy link
Copy Markdown
Contributor

Why

Permission fused two kinds of state in one Location node: policy inputs that legitimately belong to the instance (Location, Agent, PluginHooks, PermissionSaved) and const pending = new Map<ID, Pending>(), transient runtime state keyed by request and Session that clients need host-wide. Because the map lived in the instance, GET /api/session/:sessionID/permission (and get/reply) ran sessionLocationMiddleware, which called locations.get(...) and booted the whole instance (config, plugins, MCP) just to read a map that is empty by construction for an unloaded instance. The TUI does this for every descendant subagent Session, which reactivated ~70 idle locations at boot.

The rule adopted here: config-derived state stays in the instance; transient state keyed by a Session, or that clients read host-wide, goes in a global node so reads never boot an instance.

What Changes

flowchart LR
  subgraph before["Before: one Location node"]
    P0["Permission<br/>evaluate + pending Map"]
  end
  subgraph after["After"]
    subgraph inst["Location node (per instance)"]
      F["Permission (facade)<br/>ask / assert"]
      PP["PermissionPolicy<br/>saved rules · agent policy · hooks"]
    end
    subgraph glob["Global node (process-wide)"]
      L["PermissionLedger<br/>pending Map · register · reply · get · forSession · list"]
    end
    F -->|evaluate| PP
    F -->|register when ask| L
    HTTP["server.permission handlers"] -->|list / get / reply| L
    HTTP -->|create| F
  end
Loading
Surface Before After
GET /api/session/:id/permission, GET …/:requestID, POST …/reply boot the Session's instance via sessionLocationMiddleware read the global ledger; only a Session existence check (DB read)
POST /api/session/:id/permission (manual ask) instance-scoped unchanged, still needs policy
GET /api/permission/request location-scoped, { location, data }, booted the location { data } host-wide; ?location[directory]=… filters to requests asked from that location, no boot
Pending request for Session B asked from instance A visible only inside instance A visible to any client via Session B's routes
Tools (permission.assert) and plugin host (list/get/reply) Permission.Service unchanged call sites; the facade delegates to policy + ledger

Permission keeps its public name and Interface so the ~15 tool call sites, plugin/host.ts, and plugin/internal.ts are untouched. PermissionSaved stays where it is (project-keyed, already global).

Ledger Ownership

The instance-layer finalizer that failed every pending Deferred on instance close is gone; a global service has no instance lifetime to hook. Instead the awaiting fiber owns its entry:

  • ledger.register returns { request, await, cancel }. The facade attaches cancel with Effect.ensuring inside the same uninterruptible region that registered. This matters: Effect.interruptible checks for a pending interrupt the moment restore opens, so a guard attached after that point would never run when the interrupt landed during registration.
  • Interrupting an asker (user interrupt, drain teardown, instance close) removes the entry and publishes permission.replied with reply: "reject" so every client drops the prompt. Previously the entry vanished silently and clients kept a stale prompt until a refetch.
  • Detached asks (Permission.ask returning "ask", used by session.permission.create) park a waiter fiber in the instance scope, so closing the instance still evicts them and notifies clients.
  • Events carry the asking instance's location explicitly (bus.publish(..., { location })) since the ledger has no ambient Location.Service. No Bus routing changes.
  • reply: "always" needs the asker's project for saved.add and the asker's policy to auto-approve other pending requests; both are captured at registration (projectID, reevaluate), so the ledger never resolves an instance.

Scope

Permission only. Form keeps its instance-scoped map and formLocationMiddleware; a sibling change handles that. The app's permission.request.list sweep still lists per location and works unchanged aside from the unwrapped response.

Verification

cd packages/core && bun run test test/permission.test.ts           # 107 pass
cd packages/core && bun run test test/tool-*.test.ts test/mcp*.test.ts test/plugin test/session-runner*.test.ts
cd packages/server && bun run test                                  # 48 pass
cd packages/client && bun run generate && bun run test              # 144 pass
bun typecheck                                                       # 33/33 packages

New coverage in packages/core/test/permission.test.ts:

  • shares pending requests through the host-wide ledger: a request registered through the facade is listable and answerable via PermissionLedger.Service, including the location filter.
  • cancels an interrupted asker and tells clients: interrupting the asker removes the entry and emits permission.replied (reject) stamped with the instance location.
  • cancels detached requests when their instance closes: a second facade built on the same globals is closed and its detached ask is evicted with a reject event.
  • lets plugin hooks override the evaluated effect: hook chain still runs in PermissionPolicy.

packages/server/test/session-instances.test.ts now seeds requests for a third Session that has no instance configuration (booting it would throw), then lists and replies to them over HTTP and asserts boots is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant