--- issue: 145 issue_title: "Add Symbol.for()-backed service accessor, deprecate permissions:rpc:check" --- # Symbol.for()-backed service accessor ## Problem Statement The current cross-extension API for policy queries (`permissions:rpc:check`) wraps a synchronous `checkPermission()` call in async RPC ceremony: `requestId` → scoped reply channel → timeout handling → `as`-cast deserialization. This works, but the ergonomics are poor for what is fundamentally a direct function call. `Symbol.for()` is process-global by spec and survives jiti's per-extension module isolation. A service object stored on `globalThis` via `Symbol.for()` enables direct, type-safe, synchronous function calls from any extension — eliminating the RPC envelope entirely. ## Goals 1. Add `src/service.ts` with a `PermissionsService` interface and `Symbol.for()`-backed accessor functions (`getPermissionsService`, `publishPermissionsService`, `unpublishPermissionsService`). 2. Add an `exports` field to `package.json` so `import("@gotgenes/pi-permission-system")` resolves to the service module. 3. Publish the service during the extension factory and clear it on shutdown. 4. Deprecate `permissions:rpc:check` — keep the handler working but document the service accessor as the preferred path. ## Non-Goals - **Remove `permissions:rpc:check`** — it stays as a zero-dependency fallback for consumers who do not want to add an optional peer dep. - **Move `permissions:rpc:prompt` to the service** — prompt forwarding is genuinely async and the event bus is a reasonable fit. - **Move `permissions:decision` broadcasts** — fire-and-forget observation belongs on the event bus. - **Add a Proxy delegate for reload safety** — during `/reload`, all extensions re-initialize; both provider and consumer call their factories anew, so captured references are naturally refreshed. Document the "call per use, don't cache" pattern as a best practice. - **Add a JS build step** — consumers are Pi extensions that use jiti; pointing `exports` to `.ts` source is sufficient. - **Upstream `registerService`/`getService`** — tracked in earendil-works/pi#4207; this plan works independently. ## Background ### Dependency status | Issue | Description | Status | | ---------------------- | ----------------------------------- | ---------------------------------------------- | | #29 | Permission event channel with RPC | ✅ Implemented | | earendil-works/pi#4207 | Upstream registerService/getService | Open — independent; this plan works without it | ### jiti isolation model Pi's extension loader creates a fresh jiti instance per extension with `moduleCache: false`. Module-scoped state is invisible across extensions. The only shared channels are: - `pi.events` — the event bus, explicitly passed by the loader. - `globalThis` + `Symbol.for()` — process-global by spec, survives jiti isolation. When a consumer does `import("@gotgenes/pi-permission-system")`, their jiti loads a fresh module copy. That copy's `getPermissionsService()` reads `globalThis[Symbol.for(...)]`, which was set by the provider's factory running in a different jiti instance. The accessor works because `globalThis` and `Symbol.for()` are both process-global. ### Affected permission surfaces This change adds a new **cross-extension access layer**. It does not alter any allow/deny/ask decision logic. All six surfaces (tools, bash, mcp, skills, special, external\_directory) are queryable through the service's `checkPermission` method. ### Existing `buildInputForSurface` utility `src/permission-event-rpc.ts` contains a non-exported `buildInputForSurface()` helper that translates `(surface, value)` into the input object `PermissionManager.checkPermission()` expects. The new service adapter needs the same logic. The plan extracts it to `src/input-normalizer.ts` so both the RPC handler and the service factory can import it. ## Design Overview ### Service interface ```typescript import type { PermissionCheckResult } from "./types"; export interface PermissionsService { /** * Query the permission policy for a surface and value. * Returns the full check result including state, matched pattern, and origin. * Session rules are included automatically. */ checkPermission( surface: string, value?: string, agentName?: string, ): PermissionCheckResult; } ``` The interface exposes a single method matching the simplified RPC signature. Internally it delegates to `PermissionManager.checkPermission()` with the current session rules, mirroring the existing `permissions:rpc:check` handler logic. The return type is the existing `PermissionCheckResult` — re-exported from the service module so consumers get full type safety without importing internal modules. ### globalThis accessor ```typescript const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service"); export function publishPermissionsService(service: PermissionsService): void { (globalThis as Record)[SERVICE_KEY] = service; } export function getPermissionsService(): PermissionsService | undefined { return (globalThis as Record)[SERVICE_KEY] as | PermissionsService | undefined; } export function unpublishPermissionsService(): void { delete (globalThis as Record)[SERVICE_KEY]; } ``` - `publishPermissionsService` overwrites the slot — safe for `/reload`. - `unpublishPermissionsService` clears the slot — called during `session_shutdown` to avoid stale references after the extension is torn down. - `getPermissionsService` returns `undefined` when the extension has not loaded (or has been unloaded). Consumers handle this with a `try/catch` around the dynamic import plus an `if` guard. ### Consumer usage ```typescript try { const { getPermissionsService } = await import( "@gotgenes/pi-permission-system" ); const permissions = getPermissionsService(); if (permissions) { const result = permissions.checkPermission("bash", "git push"); // Direct call, full type safety, no async envelope } } catch { // Not installed — graceful degradation } ``` ### Reload safety During `/reload`, the Pi extension loader: 1. Fires `session_shutdown` to all extensions (provider calls `unpublishPermissionsService()`). 2. Tears down all extension runtimes. 3. Creates fresh jiti instances and calls each extension factory anew. 4. Provider's factory calls `publishPermissionsService(newImpl)`. 5. Consumer's factory calls `getPermissionsService()` and gets the new impl. Both sides re-initialize, so there is no stale-reference window in the normal flow. The plan documents "call `getPermissionsService()` per use, do not cache the reference" as a best practice for resilience against load-order edge cases. ### `package.json` exports ```json { "exports": { ".": "./src/service.ts" } } ``` Points to the TypeScript source — jiti consumers resolve it natively. TypeScript consumers with `moduleResolution: "Bundler"` get full type inference from the source. No build step is required. ### Deprecation of `permissions:rpc:check` - Add `@deprecated` JSDoc annotations to `PERMISSIONS_RPC_CHECK_CHANNEL`, `PermissionsCheckRequest`, and `PermissionsCheckReplyData` in `src/permission-events.ts`. - The RPC handler in `src/permission-event-rpc.ts` continues to function — no runtime change. - README and architecture docs note the service accessor as the preferred API. ## Module-Level Changes | File | Action | Detail | | ------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/service.ts` | **new** | `PermissionsService` interface, `SERVICE_KEY` constant, `publishPermissionsService()`, `getPermissionsService()`, `unpublishPermissionsService()`. Re-exports `PermissionCheckResult` and `PermissionState` from `src/types.ts`. | | `src/input-normalizer.ts` | changed | Export new `buildInputForSurface(surface, value)` function (moved from `src/permission-event-rpc.ts`). | | `src/permission-event-rpc.ts` | changed | Remove local `buildInputForSurface`; import from `src/input-normalizer.ts`. | | `src/permission-events.ts` | changed | Add `@deprecated` JSDoc to `PERMISSIONS_RPC_CHECK_CHANNEL`, `PermissionsCheckRequest`, `PermissionsCheckReplyData`. | | `src/index.ts` | changed | Build service adapter object, call `publishPermissionsService()` after RPC registration. Pass `unpublishPermissionsService` to `SessionLifecycleHandler` cleanup. | | `src/handlers/lifecycle.ts` | changed | Call the additional cleanup function (unpublish) alongside `cleanupRpc()`. | | `package.json` | changed | Add `"exports": { ".": "./src/service.ts" }`. | | `tests/service.test.ts` | **new** | Unit tests for accessor functions and service delegation. | | `tests/permission-event-rpc.test.ts` | unchanged | Existing RPC tests remain valid — the handler still works. | | `docs/architecture/architecture.md` | changed | Add "Cross-extension service accessor" section describing the `Symbol.for()` pattern. | | `README.md` | changed | Add "Service API" section; mark RPC check as deprecated in the event API section. | ## Test Impact Analysis 1. **New unit tests enabled**: `tests/service.test.ts` tests the `globalThis` accessor in isolation — publish, get, unpublish, overwrite. Also tests the service adapter's `checkPermission` delegation via a mock `PermissionManager`. 2. **No existing tests become redundant**: the RPC handler tests cover the event-bus path which remains the fallback API. 3. **Existing tests that must stay**: `tests/permission-event-rpc.test.ts` — the RPC handler is not removed, only deprecated. 4. **`buildInputForSurface` extraction**: no test changes needed — the function is non-exported today and tested only indirectly through the RPC handler tests, which continue to exercise it after the move. ## TDD Order ### Step 1 — Service accessor module - **Red**: `tests/service.test.ts` — assert `getPermissionsService()` returns `undefined` by default; assert `publishPermissionsService(mock)` makes it retrievable; assert `unpublishPermissionsService()` clears it; assert a second publish overwrites the first. - **Green**: implement `src/service.ts` with the `PermissionsService` interface, `SERVICE_KEY`, and the three accessor functions. Re-export `PermissionCheckResult` and `PermissionState`. - **Commit**: `feat: add Symbol.for()-backed service accessor module (#145)` ### Step 2 — Extract `buildInputForSurface` - **Green**: move `buildInputForSurface` from `src/permission-event-rpc.ts` to `src/input-normalizer.ts` as a named export. Update `src/permission-event-rpc.ts` to import it. Run existing tests to confirm no breakage. - **Commit**: `refactor: extract buildInputForSurface to input-normalizer (#145)` ### Step 3 — Service adapter and lifecycle wiring - **Red**: `tests/service.test.ts` — add tests that construct a service adapter object using a mock `PermissionManager` and mock `SessionRules`, call `checkPermission("bash", "git push")`, and assert it delegates correctly with the right input shape and session rules. - **Red**: verify that `getPermissionsService()` returns `undefined` after the shutdown cleanup runs (test the cleanup callback separately or via the `SessionLifecycleHandler` test). - **Green**: in `src/index.ts`, build the service adapter object and call `publishPermissionsService()`. Pass `unpublishPermissionsService` into the lifecycle handler's cleanup callback. Update `src/handlers/lifecycle.ts` to accept and call the additional cleanup. - **Build**: run `pnpm run build` to verify the `handlers/lifecycle.ts` signature change compiles. - **Commit**: `feat: publish permissions service on startup, clear on shutdown (#145)` ### Step 4 — Package exports - Add `"exports": { ".": "./src/service.ts" }` to `package.json`. - **Verify**: `pnpm run build` passes; `node -e "import('@gotgenes/pi-permission-system').then(m => console.log(Object.keys(m)))"` lists the exported names (or verify via a simpler smoke test). - **Commit**: `feat: add package.json exports field for cross-extension import (#145)` ### Step 5 — Deprecate `permissions:rpc:check` - Add `@deprecated` JSDoc to `PERMISSIONS_RPC_CHECK_CHANNEL`, `PermissionsCheckRequest`, and `PermissionsCheckReplyData` in `src/permission-events.ts`. - **Commit**: `docs: deprecate permissions:rpc:check types in favor of service accessor (#145)` ### Step 6 — Documentation - Update `README.md`: add "Service API" section documenting the `Symbol.for()` accessor, consumer usage pattern, and reload behavior. Mark `permissions:rpc:check` as deprecated in the existing event API section. - Update `docs/architecture/architecture.md`: add cross-extension service accessor description. - **Commit**: `docs: document service accessor and deprecate RPC check (#145)` ## Risks and Mitigations | Risk | Mitigation | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Could this silently weaken a permission? | No. The service delegates to the same `PermissionManager.checkPermission()` and `SessionRules` that the event-bus RPC and tool-call handler use. No decision logic changes. | | Stale service reference after `/reload` | Both provider and consumer re-initialize during reload. Document "call per use, don't cache" as best practice. `unpublishPermissionsService()` on shutdown clears the slot as extra safety. | | `exports` field breaks Pi's jiti loader resolution | Pi's loader uses `pi.extensions` (not `exports`) to find the extension factory. The `exports` field only affects bare-specifier `import()` from other extensions. Verify with `pnpm run build` + smoke test. | | Consumer calls `getPermissionsService()` before provider has loaded | Returns `undefined` — the consumer's `if (permissions) { ... }` guard handles this. Same as the RPC fallback path's timeout. Document load-order independence. | | `buildInputForSurface` extraction breaks RPC handler | The function body is unchanged; only its location moves. Existing `permission-event-rpc.test.ts` tests pass as-is. | | `globalThis` pollution across unrelated processes | `Symbol.for()` keys are scoped by the full string name (`"@gotgenes/pi-permission-system:service"`). Collision with other packages is infeasible. Cleanup on shutdown removes the slot. | ## Open Questions 1. **Should the service expose `getToolPermission()` for tool-filtering queries?** The current RPC only exposes `checkPermission`. Adding `getToolPermission` would let consumers replicate before\_agent\_start filtering. Deferred — add when a consumer needs it. 2. **Should additional event types be re-exported from `src/service.ts`?** Consumers using the service accessor for policy queries may also want `PermissionDecisionEvent` for observation. Subpath exports (`"./events"`) can be added later without breaking changes. 3. **Should the `exports` field include a `"types"` condition?** Since the entry point is a `.ts` file and jiti consumers resolve types natively, a `"types"` condition adds no value today. Revisit if a JS build step is added.