@gotgenes/pi-permission-system 28.0.0 → 29.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [29.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v28.0.1...pi-permission-system-v29.0.0) (2026-08-30)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** `getRootPermissionsService()`, `publishRootPermissionsService()`, and `unpublishRootPermissionsService()` are removed. Resolve a node's service with `getPermissionsService(sessionId)`, taking `sessionId` from the `permissions:ready` payload (or from `ctx.sessionManager.getSessionId()` inside your own session handler). See docs/migration/0796-remove-process-root-slot.md.
14
+
15
+ ### Features
16
+
17
+ * **pi-permission-system:** remove the deprecated process-root service accessor ([65cbbee](https://github.com/gotgenes/pi-packages/commit/65cbbee5b4d98a8f34e34b831ad4a33250735f7e)), closes [#796](https://github.com/gotgenes/pi-packages/issues/796)
18
+
19
+
20
+ ### Documentation
21
+
22
+ * **pi-permission-system:** retire the process-root service slot from the docs ([649b1f0](https://github.com/gotgenes/pi-packages/commit/649b1f063a840e9a7c1f259ed03620a71624e55f)), closes [#796](https://github.com/gotgenes/pi-packages/issues/796)
23
+
24
+ ## [28.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v28.0.0...pi-permission-system-v28.0.1) (2026-08-30)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **pi-permission-system:** carry a serving session's deny reason to the requesting agent ([b2010d7](https://github.com/gotgenes/pi-packages/commit/b2010d7bfbf766592b4ae0b98f16a612f96ff706)), closes [#844](https://github.com/gotgenes/pi-packages/issues/844)
30
+ * **pi-permission-system:** name the rule that refused a forwarded call instead of blaming the user ([3d83641](https://github.com/gotgenes/pi-packages/commit/3d83641ad9bf6b045a86a4fb2879a7761c0aefc3)), closes [#844](https://github.com/gotgenes/pi-packages/issues/844)
31
+ * **pi-permission-system:** tell the agent when the permission authority failed to answer ([8f70380](https://github.com/gotgenes/pi-packages/commit/8f70380005733546fa2df1d8694c50985332de19)), closes [#844](https://github.com/gotgenes/pi-packages/issues/844)
32
+
8
33
  ## [28.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.3...pi-permission-system-v28.0.0) (2026-08-30)
9
34
 
10
35
 
package/README.md CHANGED
@@ -209,6 +209,7 @@ Approve-and-steer, edit diffs, and risk explanations → a downstream package ov
209
209
  | [docs/migration/0745-prompt-payload-contracts.md](docs/migration/0745-prompt-payload-contracts.md) | Prompt payload contracts (breaking) — the forwarded wire, the `ui_prompt` broadcast, and the deprecated preview caps |
210
210
  | [docs/migration/0746-review-log-fields.md](docs/migration/0746-review-log-fields.md) | Review-log fields (breaking) — `message` replaced by request facts, and the `reviewLogFieldMaxWidth` bound |
211
211
  | [docs/migration/0794-keyed-service-locator.md](docs/migration/0794-keyed-service-locator.md) | Keyed service locator (breaking) — `getPermissionsService(sessionId)`, and the repeating ready event |
212
+ | [docs/migration/0796-remove-process-root-slot.md](docs/migration/0796-remove-process-root-slot.md) | Process-root slot removed (breaking) — `getRootPermissionsService()` and its publish/unpublish pair are gone |
212
213
 
213
214
  ## Development
214
215
 
package/dist/public.d.ts CHANGED
@@ -465,15 +465,12 @@ type ToolInputFormatter = (input: Record<string, unknown>) => string | undefined
465
465
  * accessors here read from the same `globalThis` slots the provider wrote to —
466
466
  * enabling direct, synchronous, type-safe function calls.
467
467
  *
468
- * There are two slots, because one process can host several **nodes** (one Pi
469
- * session runtime each — a root session and its in-process subagent children
470
- * all load their own instance of this extension):
471
- *
472
- * - A session-keyed map, written by every node under its own session id.
473
- * `getPermissionsService(sessionId)` resolves the service whose
474
- * registries that node's own gates and chain read (ADR 0012 decision 2).
475
- * - A single legacy slot holding the process root's service, read by the
476
- * deprecated `getRootPermissionsService()`.
468
+ * The slot is a session-keyed map, because one process can host several
469
+ * **nodes** (one Pi session runtime each — a root session and its in-process
470
+ * subagent children all load their own instance of this extension). Every node
471
+ * writes under its own session id, and `getPermissionsService(sessionId)`
472
+ * resolves the service whose registries that node's own gates and chain read
473
+ * (ADR 0012 decision 2).
477
474
  *
478
475
  * Best practice: resolve per use rather than caching the reference — this
479
476
  * ensures resilience across `/reload` and load-order edge cases.
@@ -529,8 +526,7 @@ interface PermissionQuery {
529
526
  }
530
527
  /**
531
528
  * Public interface exposed to other extensions via
532
- * {@link getPermissionsService} (or the deprecated
533
- * {@link getRootPermissionsService}).
529
+ * {@link getPermissionsService}.
534
530
  *
535
531
  * Each instance belongs to one node, and its three registration surfaces are
536
532
  * read by that node alone: extractors and formatters by its own gates, chain
@@ -600,26 +596,6 @@ interface PermissionsService extends PermissionQuery {
600
596
  */
601
597
  registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;
602
598
  }
603
- /**
604
- * Store a `PermissionsService` in the legacy process-root slot, read by
605
- * `getRootPermissionsService()`.
606
- *
607
- * Called at `session_start` by the top-level (parent) instance only — an
608
- * in-process subagent child skips publishing so it cannot clobber the parent's
609
- * service. Overwrites any previously published service, which keeps `/reload`
610
- * working: a reloaded parent re-publishes its fresh service.
611
- */
612
- declare function publishRootPermissionsService(service: PermissionsService): void;
613
- /**
614
- * Retrieve the process root's published `PermissionsService`, or `undefined`
615
- * if the permission-system extension has not loaded (or has been unloaded).
616
- *
617
- * @deprecated Use {@link getPermissionsService} with the `sessionId`
618
- * from the `permissions:ready` payload. This accessor answers "the process
619
- * root's service", which is the wrong question in every node but the root.
620
- * Removal is deferred to a future major (ADR 0012 decision 7).
621
- */
622
- declare function getRootPermissionsService(): PermissionsService | undefined;
623
599
  /**
624
600
  * Publish `service` as the service of the node whose session is `sessionId`
625
601
  * (ADR 0012 decision 2 — node-locality).
@@ -647,27 +623,14 @@ declare function publishPermissionsService(sessionId: string, service: Permissio
647
623
  declare function getPermissionsService(sessionId: string): PermissionsService | undefined;
648
624
  /**
649
625
  * Remove the `sessionId` entry, but only when it still holds `service`
650
- * (identity compare-and-delete, like {@link unpublishRootPermissionsService}).
626
+ * (identity compare-and-delete).
651
627
  *
652
- * Scoping the delete to the publishing instance keeps a superseded `/reload`
653
- * generation's late shutdown from wiping the new generation's freshly
628
+ * Called during `session_shutdown` to avoid stale references after the node is
629
+ * torn down. Scoping the delete to the publishing instance keeps a superseded
630
+ * `/reload` generation's late shutdown from wiping the new generation's freshly
654
631
  * published service.
655
632
  */
656
633
  declare function unpublishPermissionsService(sessionId: string, service: PermissionsService): void;
657
- /**
658
- * Remove `service` from `globalThis`, but only when the current slot still
659
- * holds it (identity compare-and-delete).
660
- *
661
- * Called during `session_shutdown` to avoid stale references after the
662
- * extension is torn down. Scoping the delete to the publishing instance keeps
663
- * two cases correct:
664
- *
665
- * - An in-process subagent child never published the parent's service, so its
666
- * shutdown is a no-op and the parent's slot survives.
667
- * - A superseded `/reload` generation no longer owns the slot, so its late
668
- * shutdown cannot wipe the new generation's freshly published service.
669
- */
670
- declare function unpublishRootPermissionsService(service: PermissionsService): void;
671
634
 
672
- export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, getRootPermissionsService, publishPermissionsService, publishRootPermissionsService, unpublishPermissionsService, unpublishRootPermissionsService };
635
+ export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, publishPermissionsService, unpublishPermissionsService };
673
636
  export type { Authorizer, AuthorizerLog, AuthorizerVerdict, ForwardedPromptContext, PermissionCheckResult, PermissionDecisionEvent, PermissionQuery, PermissionState, PermissionUiPromptEvent, PermissionUiPromptSource, PermissionsReadyEvent, PermissionsService, PromptAnnotation, PromptEvidence, PromptPayload, PromptPayloadKind, PromptPermissionDetails, PromptRequestFacts, PromptRequester, ToolInputFormatter };
@@ -49,11 +49,9 @@ Each node therefore publishes its own service at `session_start`, into a `global
49
49
  Consumers call `getPermissionsService(sessionId)` to retrieve it — even though their `import()` loads a fresh module copy, the accessor reads from the shared `globalThis` slot.
50
50
  The session id arrives as a field on the `permissions:ready` broadcast, which each node emits at its own `session_start`, right after publishing — and again at that node's first `before_agent_start`, so a consumer whose own `session_start` ran later still hears it.
51
51
 
52
- `getRootPermissionsService()` remains, reading a separate legacy slot that holds the **process root's** service, but it is deprecated: in any node but the root it answers the wrong question, handing an in-process child the parent's service.
53
- Calling it emits a once-guarded Node `DeprecationWarning` (code `PI_PERMISSION_SYSTEM_DEP0001`); run with `--trace-deprecation` to locate your call site, or `--no-deprecation` to silence it.
54
- Removal is deferred to a future major release.
55
-
56
- Both accessors were renamed in the major that reclaimed `getPermissionsService` for the keyed locator; if you are upgrading from a release whose `getPermissionsService()` took no argument, see [migration/0794-keyed-service-locator.md](migration/0794-keyed-service-locator.md).
52
+ That keyed map is the only service slot.
53
+ A separate legacy slot once held the process root's service, read by a deprecated `getRootPermissionsService()`; both were removed, because in any node but the root that accessor answered the wrong question — handing an in-process child the parent's service.
54
+ If you are upgrading from a release that had it, see [migration/0796-remove-process-root-slot.md](migration/0796-remove-process-root-slot.md); if you are upgrading from one whose `getPermissionsService()` took no argument, start with [migration/0794-keyed-service-locator.md](migration/0794-keyed-service-locator.md).
57
55
 
58
56
  All types below are directly importable and type-check with `tsc` out of the box.
59
57
  `@gotgenes/pi-permission-system`'s published `exports` resolve `import type { … }` to a self-contained, bundled declaration file with no internal module references, so a downstream `tsconfig.json` needs no special path configuration.
@@ -301,7 +299,7 @@ Best practice: resolve the service per use rather than caching the reference.
301
299
  The `import()` throws if the package is not installed.
302
300
  Wrap both in `try/catch` + `if` guard as shown in the Quick Start example.
303
301
 
304
- It also returns `undefined` when called with no session id at all — a shape TypeScript rejects but JavaScript reaches — rather than falling back to the process root's service, since answering with another node's service is the defect the keyed locator exists to prevent.
302
+ It also returns `undefined` when called with no session id at all — a shape TypeScript rejects but JavaScript reaches — rather than guessing a node, since answering with another node's service is the defect the keyed locator exists to prevent.
305
303
  That call emits a once-guarded Node warning (code `PI_PERMISSION_SYSTEM_WARN0001`), because the guard above turns the missing service into a registration that silently never happens.
306
304
  It is deliberately not a `DeprecationWarning`: `--no-deprecation` does not silence it.
307
305
 
@@ -149,7 +149,7 @@ try {
149
149
 
150
150
  Query the service belonging to the session whose policy you mean.
151
151
  Each session that loads the permission system publishes its own — a subagent child's config (and cwd) may differ from its parent's, so the parent's service is not a stand-in for the child's.
152
- The deprecated `getRootPermissionsService()` always answers with the process root's service, which is why it is the wrong call inside a child.
152
+ That is why the session id is required: an accessor answering "the process root's service" would hand a child the parent's, and the one that did was removed.
153
153
 
154
154
  If `pi-permission-system` is not installed, `import()` throws; if that session has published no service yet (or has been unloaded), `getPermissionsService(sessionId)` returns `undefined`.
155
155
  Guard both cases as shown above.
@@ -22,7 +22,7 @@ Each node now publishes its own service under its own session id, and you resolv
22
22
  | Before | After |
23
23
  | -------------------------------------- | ------------------------------------------------------------------ |
24
24
  | `getPermissionsService()` | `getPermissionsService(sessionId)` |
25
- | — (no equivalent) | `getRootPermissionsService()` — the old behavior, still deprecated |
25
+ | — (no equivalent) | `getRootPermissionsService()` — the old behavior, deprecated |
26
26
  | `publishPermissionsService(service)` | `publishRootPermissionsService(service)` |
27
27
  | `unpublishPermissionsService(service)` | `unpublishRootPermissionsService(service)` |
28
28
 
@@ -89,3 +89,8 @@ Guard it with a stored dispose handle, as shown above, and release the handle on
89
89
  - The `permissions:ready`, `permissions:ui_prompt`, and `permissions:decision` payload shapes.
90
90
  - The process-root slot itself: a node that is not an in-process subagent child still publishes to it, so `getRootPermissionsService()` answers exactly as the old zero-arg accessor did.
91
91
  It remains deprecated (`PI_PERMISSION_SYSTEM_DEP0001`), and its removal is deferred to a later major.
92
+
93
+ > [!NOTE]
94
+ > That last bullet held only for this release.
95
+ > The process-root slot, its accessor, and its publish/unpublish pair were removed in a later major — see [0796-remove-process-root-slot.md](0796-remove-process-root-slot.md).
96
+ > If you are migrating from a pre-27.0.0 release, work through this guide first and that one second.
@@ -0,0 +1,68 @@
1
+ # Migration guide: the process-root service slot is removed
2
+
3
+ Starting with the release that closes #796, the deprecated process-root service accessor and the `globalThis` slot behind it no longer exist.
4
+
5
+ This is a **breaking change**.
6
+ If your extension does not import `getRootPermissionsService`, `publishRootPermissionsService`, or `unpublishRootPermissionsService` from `@gotgenes/pi-permission-system`, nothing here affects you.
7
+
8
+ ## What was removed
9
+
10
+ | Removed | Replacement |
11
+ | -------------------------------------------------- | ------------------------------------------------ |
12
+ | `getRootPermissionsService()` | `getPermissionsService(sessionId)` |
13
+ | `publishRootPermissionsService(service)` | — (internal; a node publishes its own service) |
14
+ | `unpublishRootPermissionsService(service)` | — (internal; a node unpublishes its own service) |
15
+ | `PI_PERMISSION_SYSTEM_DEP0001` deprecation warning | — (the deprecated path is gone) |
16
+
17
+ The `Symbol.for("@gotgenes/pi-permission-system:service")` slot is no longer written by any node.
18
+ `Symbol.for("@gotgenes/pi-permission-system:session-services")` — the session-keyed map — is the only service slot.
19
+
20
+ ## What to change
21
+
22
+ Resolve the service of the node whose behavior you mean to affect, keyed by its session id.
23
+ The id arrives as a field on the `permissions:ready` payload; inside your own session handler, `ctx.sessionManager.getSessionId()` is the same value.
24
+
25
+ ```typescript
26
+ import {
27
+ getPermissionsService,
28
+ PERMISSIONS_READY_CHANNEL,
29
+ type PermissionsReadyEvent,
30
+ } from "@gotgenes/pi-permission-system";
31
+
32
+ let dispose: (() => void) | undefined;
33
+
34
+ pi.events.on(PERMISSIONS_READY_CHANNEL, (event) => {
35
+ const { sessionId } = event as PermissionsReadyEvent;
36
+ // Idempotent: ready may repeat, so a second emission must be a no-op.
37
+ if (dispose || !sessionId) return;
38
+ dispose = getPermissionsService(sessionId)?.registerAuthorizer(
39
+ "my-link",
40
+ authorize,
41
+ );
42
+ });
43
+ ```
44
+
45
+ If you are still on a zero-argument `getPermissionsService()` from a release before 27.0.0, migrate through [0794-keyed-service-locator.md](0794-keyed-service-locator.md) first — that release is where the accessor's signature changed.
46
+
47
+ ## Why the deprecation window closed
48
+
49
+ [ADR 0012](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0012-cross-node-extension-contract.md) decision 7 deferred the removal until downstream consumers had migrated.
50
+ `pi-permission-model-judge` 2.0.0 — the named migration case — registers through the keyed locator and floors its peer range at `>=27.0.0`, and no other known consumer reads the root slot.
51
+
52
+ The removal is also narrower than a deprecation window's usual population suggests.
53
+ `getRootPermissionsService` did not exist before 27.0.0: it is the name that release gave the old behavior when it reclaimed `getPermissionsService` for the keyed locator.
54
+ So no consumer predating the deprecation can be calling it — every caller adopted the name after it was already marked deprecated, in preference to the keyed locator this guide's predecessor recommends.
55
+
56
+ ## What you will see if you miss a call site
57
+
58
+ The export is gone, so TypeScript reports `TS2724` at your import and plain JavaScript throws `TypeError: getRootPermissionsService is not a function`.
59
+
60
+ Calling `getPermissionsService()` with **no** argument still answers `undefined` and emits a once-guarded Node warning under code `PI_PERMISSION_SYSTEM_WARN0001`, naming the keyed call and the ready payload.
61
+ That warning is deliberately not a `DeprecationWarning`, so `--no-deprecation` does not silence it: a registration that never landed is not something to hide.
62
+
63
+ ## What has not changed
64
+
65
+ - The `PermissionsService` interface — the five methods and their signatures are untouched.
66
+ - The keyed accessors `getPermissionsService` / `publishPermissionsService` / `unpublishPermissionsService`.
67
+ - The `permissions:ready`, `permissions:ui_prompt`, and `permissions:decision` payload shapes and cadence.
68
+ - Node-locality: a registration is still read only by the node it was made in, and an in-process subagent child still gets its own service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "28.0.0",
3
+ "version": "29.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,7 +5,10 @@ import {
5
5
  type ForwarderContext,
6
6
  getSessionId,
7
7
  } from "#src/authority/forwarder-context";
8
- import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
8
+ import {
9
+ createDeniedPermissionDecision,
10
+ type PermissionPromptDecision,
11
+ } from "#src/authority/permission-dialog";
9
12
  import {
10
13
  type ForwardedAccessFacts,
11
14
  type ForwardedAccessIntent,
@@ -456,9 +459,12 @@ export class ForwardedRequestServer implements InboxProcessor {
456
459
  : "forwarded_permission.auto_denied",
457
460
  { ...logDetails, decidedBy },
458
461
  );
462
+ // A deny-with-reason rule's text is the operator's own explanation, and
463
+ // the requesting session relays it to its agent — so it travels with the
464
+ // verdict rather than stopping at the node that holds the config (#844).
459
465
  return approved
460
466
  ? { approved: true, state: "approved", decidedBy }
461
- : { approved: false, state: "denied", decidedBy };
467
+ : { ...createDeniedPermissionDecision(check.reason), decidedBy };
462
468
  }
463
469
 
464
470
  this.logger.review("forwarded_permission.prompted", logDetails);
@@ -1,5 +1,4 @@
1
1
  import {
2
- isRegisteredSubagentChild,
3
2
  isSubagentExecutionContext,
4
3
  type SubagentDetectionContext,
5
4
  } from "#src/authority/subagent-context";
@@ -17,17 +16,6 @@ export interface SubagentDetector {
17
16
  isSubagent(ctx: SubagentDetectionContext): boolean;
18
17
  }
19
18
 
20
- /**
21
- * Narrow seam for the service-publication guard (#302): "is the current
22
- * session a registered in-process child?"
23
- *
24
- * `PermissionServiceLifecycle` depends on this single-method view so a
25
- * registered child never publishes over its parent's process-global slot.
26
- */
27
- export interface RegisteredChildDetector {
28
- isRegisteredChild(ctx: SubagentDetectionContext): boolean;
29
- }
30
-
31
19
  /** Composition-root inputs for {@link SubagentDetection}. */
32
20
  export interface SubagentDetectionDeps {
33
21
  subagentSessionsDir: string;
@@ -44,9 +32,7 @@ export interface SubagentDetectionDeps {
44
32
  * individually. Delegates to the pure detection functions in
45
33
  * {@link ./subagent-context}, holding only the deps.
46
34
  */
47
- export class SubagentDetection
48
- implements SubagentDetector, RegisteredChildDetector
49
- {
35
+ export class SubagentDetection implements SubagentDetector {
50
36
  constructor(private readonly deps: SubagentDetectionDeps) {}
51
37
 
52
38
  isSubagent(ctx: SubagentDetectionContext): boolean {
@@ -57,10 +43,4 @@ export class SubagentDetection
57
43
  this.deps.registry,
58
44
  );
59
45
  }
60
-
61
- isRegisteredChild(ctx: SubagentDetectionContext): boolean {
62
- return this.deps.registry
63
- ? isRegisteredSubagentChild(ctx, this.deps.registry)
64
- : false;
65
- }
66
46
  }
package/src/index.ts CHANGED
@@ -266,15 +266,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
266
266
  );
267
267
 
268
268
  // PermissionServiceLifecycle owns the process-global service publication:
269
- // activate() publishes this node's service under its own session id (and to
270
- // the legacy root slot unless this is a registered subagent child — see
271
- // #302), then announces the node's session id and chain role on the ready
272
- // channel; teardown() unsubscribes all session listeners and unpublishes.
269
+ // activate() publishes this node's service under its own session id, then
270
+ // announces the node's session id and chain role on the ready channel;
271
+ // teardown() unsubscribes all session listeners and unpublishes.
273
272
  // Deferred to session_start because both facts come from ctx, unavailable at
274
273
  // factory-init time.
275
274
  const serviceLifecycle = new PermissionServiceLifecycle(
276
275
  permissionsService,
277
- subagentDetection,
278
276
  authorizerSelection,
279
277
  pi.events,
280
278
  [unsubSubagentLifecycle],
@@ -14,6 +14,7 @@ import {
14
14
  findEvidence,
15
15
  type PromptPayload,
16
16
  } from "#src/presentation/prompt-payload";
17
+ import type { BashCommandContext } from "#src/types";
17
18
 
18
19
  /**
19
20
  * The agent-facing render of a refused permission ask (ADR 0011 §7).
@@ -68,6 +69,9 @@ export function renderRefusal(
68
69
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
69
70
  ): string {
70
71
  const decider = effectiveDecider(decidedBy);
72
+ // The hop the unwrap discarded: *where* the decision was made. Kept as a
73
+ // bare fact rather than the responder's identity, which stays undisclosed.
74
+ const decidedElsewhere = decidedBy.kind === "forwarded";
71
75
  switch (decider.kind) {
72
76
  case "authorizer":
73
77
  return renderAuthorizerDenial(
@@ -78,14 +82,26 @@ export function renderRefusal(
78
82
  );
79
83
  case "unavailable":
80
84
  return renderUnavailableDenial(payload, denialReason, budget);
81
- // A `rule` here is a rule in the *serving* session, whose pattern and
82
- // origin are not on this payload — `matchedPattern` is the pattern that
83
- // raised this session's own ask, so naming a rule would name the wrong
84
- // one. That render, and the `gate_error` one beside it, are #844. The
85
- // remaining kinds never refuse: they only ever allow.
86
- case "user":
87
85
  case "rule":
86
+ return renderEscalatedPolicyDenial(
87
+ payload,
88
+ { pattern: decider.pattern, decidedElsewhere },
89
+ denialReason,
90
+ budget,
91
+ );
88
92
  case "gate_error":
93
+ return renderGateErrorDenial(payload, {
94
+ reason: decider.reason,
95
+ decidedElsewhere,
96
+ });
97
+ // Three different reasons share this arm. `user` is the render's true
98
+ // subject: a human refused, and the sentence says so. The three below it
99
+ // never refuse at all — a session grant, an infrastructure read, and yolo
100
+ // only ever allow — so they are unreachable here and listed only to keep
101
+ // the switch exhaustive. A `forwarded` reaching this far named no inner
102
+ // decider (an older responder), so the hop is all that is known, and the
103
+ // user render is the fail-soft answer rather than the accurate one.
104
+ case "user":
89
105
  case "session_approval":
90
106
  case "infrastructure_read":
91
107
  case "yolo":
@@ -101,7 +117,7 @@ export function renderPolicyDenial(
101
117
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
102
118
  ): string {
103
119
  return tagged(
104
- `Denied by policy: ${identification(payload, budget, "")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
120
+ `Denied by policy: ${identification(payload, budget, "", askRuleClause(payload))}${boundaryClause(payload)}${provenanceClause(payload)}.`,
105
121
  ruleReason,
106
122
  );
107
123
  }
@@ -113,7 +129,7 @@ export function renderUserDenial(
113
129
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
114
130
  ): string {
115
131
  return tagged(
116
- `The user denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
132
+ `The user denied this ${identification(payload, budget, "call", askRuleClause(payload))}${boundaryClause(payload)}${provenanceClause(payload)}.`,
117
133
  denialReason,
118
134
  );
119
135
  }
@@ -133,11 +149,76 @@ export function renderAuthorizerDenial(
133
149
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
134
150
  ): string {
135
151
  return tagged(
136
- `The '${linkName}' authorizer denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
152
+ `The '${linkName}' authorizer denied this ${identification(payload, budget, "call", askRuleClause(payload))}${boundaryClause(payload)}${provenanceClause(payload)}.`,
153
+ denialReason,
154
+ );
155
+ }
156
+
157
+ /** The rule that refused an escalated ask, and where it sat. */
158
+ export interface EscalatedRule {
159
+ /** The pattern the deciding node's rule matched; `null` when none was recorded. */
160
+ readonly pattern: string | null;
161
+ /** Whether that node was reached through a forwarding hop. */
162
+ readonly decidedElsewhere: boolean;
163
+ }
164
+
165
+ /**
166
+ * The agent-facing render of a policy denial an escalation came back with.
167
+ *
168
+ * Distinct from {@link renderPolicyDenial} in which rule it names. That one
169
+ * renders the rule on this session's payload, which is the rule that decided
170
+ * when recorded authority answered locally. Here the ask was escalated, so the
171
+ * payload's rule is the one that raised the *ask* and the rule that *denied*
172
+ * lives on the decision — naming the payload's would name the wrong rule while
173
+ * looking correct (ADR 0011 §10, #844).
174
+ */
175
+ export function renderEscalatedPolicyDenial(
176
+ payload: PromptPayload,
177
+ rule: EscalatedRule,
178
+ denialReason: string | null,
179
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
180
+ ): string {
181
+ const decidedRule = ruleClause(rule.pattern, payload.request.commandContext);
182
+ return tagged(
183
+ `A policy rule${servingClause(rule.decidedElsewhere)} denied this ${identification(payload, budget, "call", decidedRule)}${boundaryClause(payload)}${provenanceClause(payload)}.`,
137
184
  denialReason,
138
185
  );
139
186
  }
140
187
 
188
+ /** The escalation failure that blocked an ask fail-closed, and where it happened. */
189
+ export interface GateFailure {
190
+ /** The error text stamped on the decision, which carries the detail here. */
191
+ readonly reason: string;
192
+ /** Whether the failing authority was reached through a forwarding hop. */
193
+ readonly decidedElsewhere: boolean;
194
+ }
195
+
196
+ /**
197
+ * The agent-facing render of an escalation that threw rather than ruling.
198
+ *
199
+ * Nobody denied this call — the authority that would have answered broke, and
200
+ * the boundary blocked rather than allowed. Saying so is what distinguishes it
201
+ * from a verdict the agent should respect: a failure is worth surfacing to the
202
+ * operator, where a denial is worth working around (#844).
203
+ *
204
+ * The reason comes from the decision's own stamp rather than a denial reason,
205
+ * because that is the field the failing site writes and the field the review
206
+ * log records — a second source would be a second story about one failure.
207
+ *
208
+ * Like {@link renderUnavailableDenial}, it omits the escaped boundary: the
209
+ * ask never reached a rule, so no retry shape would change the outcome.
210
+ */
211
+ export function renderGateErrorDenial(
212
+ payload: PromptPayload,
213
+ failure: GateFailure,
214
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
215
+ ): string {
216
+ return tagged(
217
+ `The permission authority${servingClause(failure.decidedElsewhere)} failed to answer this ${identification(payload, budget, "call", askRuleClause(payload))}, so it was blocked (fail-closed).`,
218
+ failure.reason,
219
+ );
220
+ }
221
+
141
222
  /** The agent-facing render when no live authority could answer the ask. */
142
223
  export function renderUnavailableDenial(
143
224
  payload: PromptPayload,
@@ -145,13 +226,24 @@ export function renderUnavailableDenial(
145
226
  budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
146
227
  ): string {
147
228
  return tagged(
148
- `This ${identification(payload, budget, "call")} requires approval, but no interactive UI is available.`,
229
+ `This ${identification(payload, budget, "call", askRuleClause(payload))} requires approval, but no interactive UI is available.`,
149
230
  denialReason,
150
231
  );
151
232
  }
152
233
 
153
234
  // ── Sentence assembly ──────────────────────────────────────────────────────
154
235
 
236
+ /**
237
+ * Where the deciding authority sat, when it was not this session.
238
+ *
239
+ * Says that another session decided and never which one: the responder's
240
+ * identity answers a question the requesting agent cannot act on, and §6 keeps
241
+ * it off the render (ADR 0011 §10).
242
+ */
243
+ function servingClause(decidedElsewhere: boolean): string {
244
+ return decidedElsewhere ? " in the session serving this request" : "";
245
+ }
246
+
155
247
  function tagged(sentence: string, reason: string | null): string {
156
248
  return `${EXTENSION_TAG} ${sentence}${reasonClause(reason)}`;
157
249
  }
@@ -164,11 +256,16 @@ function tagged(sentence: string, reason: string | null): string {
164
256
  * `callWord` is the noun the verdict needs after the surface — a user or
165
257
  * unavailable verdict refuses a *call*, while a policy deny refuses the
166
258
  * surface itself.
259
+ *
260
+ * `ruleText` is supplied rather than read off the payload, because an escalated
261
+ * refusal names the rule that *decided* it, which is not always this session's
262
+ * own (#844).
167
263
  */
168
264
  function identification(
169
265
  payload: PromptPayload,
170
266
  budget: AgentRenderBudget,
171
267
  callWord: string,
268
+ ruleText: string,
172
269
  ): string {
173
270
  return [
174
271
  `'${payload.request.surface}'`,
@@ -177,7 +274,7 @@ function identification(
177
274
  toolClause(payload),
178
275
  agentClause(payload),
179
276
  flaggedClause(payload, budget),
180
- ruleClause(payload),
277
+ ruleText,
181
278
  ]
182
279
  .filter((clause) => clause !== "")
183
280
  .join(" ");
@@ -244,12 +341,29 @@ function resolvedAlias(payload: PromptPayload, element: string): string {
244
341
  return resolved ? ` (resolves to '${resolved}')` : "";
245
342
  }
246
343
 
247
- /** The rule that fired, with the nested context that makes it intelligible. */
248
- function ruleClause(payload: PromptPayload): string {
249
- const { matchedPattern, commandContext } = payload.request;
344
+ /** The rule that raised this session's own ask. */
345
+ function askRuleClause(payload: PromptPayload): string {
346
+ return ruleClause(
347
+ payload.request.matchedPattern,
348
+ payload.request.commandContext,
349
+ );
350
+ }
351
+
352
+ /**
353
+ * The rule that fired, with the nested context that makes it intelligible.
354
+ *
355
+ * The pattern is a parameter rather than a payload read: an escalated refusal
356
+ * renders the rule that decided it, which for a forwarded ask lives on the
357
+ * response's decider and never on this session's payload. The command context
358
+ * is a fact about the call either way, so it always comes from the payload.
359
+ */
360
+ function ruleClause(
361
+ pattern: string | null,
362
+ commandContext: BashCommandContext | null,
363
+ ): string {
250
364
  const parts: string[] = [];
251
- if (matchedPattern !== null) {
252
- parts.push(`rule '${matchedPattern}'`);
365
+ if (pattern !== null) {
366
+ parts.push(`rule '${pattern}'`);
253
367
  }
254
368
  const context = describeBashCommandContext(commandContext);
255
369
  if (context !== undefined) {
@@ -1,13 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AdjudicationRole } from "./authority/authorizer-selection";
3
- import type { RegisteredChildDetector } from "./authority/subagent-detection";
4
3
  import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
5
4
  import {
6
5
  type PermissionsService,
7
6
  publishPermissionsService,
8
- publishRootPermissionsService,
9
7
  unpublishPermissionsService,
10
- unpublishRootPermissionsService,
11
8
  } from "./service";
12
9
  import { readSessionId } from "./session-identity";
13
10
 
@@ -35,18 +32,16 @@ export interface ReadyAnnouncer {
35
32
  *
36
33
  * - `activate` publishes the service under this node's own session id, so a
37
34
  * sibling extension loaded into this node registers into the registries this
38
- * node's gates and chain read. It additionally publishes to the legacy
39
- * process-root slot unless this is a registered subagent child, which must
40
- * not clobber its parent's slot (#302). Then it announces both facts a
41
- * consumer needs — the session id and the chain role — on the ready channel,
42
- * and re-arms the latch so the turn about to start announces once more.
35
+ * node's gates and chain read. Then it announces both facts a consumer needs
36
+ * the session id and the chain role — on the ready channel, and re-arms the
37
+ * latch so the turn about to start announces once more.
43
38
  * - `announceReady` is that second announcement: it fires at most once per
44
39
  * activation cycle, so `permissions:ready` reaches a consumer whose own
45
40
  * `session_start` ran after this node's (ADR 0012 decision 3). The channel's
46
41
  * contract is therefore "at least once per session, and may repeat" —
47
42
  * handlers must be idempotent.
48
43
  * - `teardown` runs all session-scoped subscription cleanups in order, then
49
- * unpublishes from both slots. Each unpublish is identity-scoped, so a
44
+ * unpublishes this node's entry. The unpublish is identity-scoped, so a
50
45
  * superseded `/reload` generation cannot evict the fresh one.
51
46
  */
52
47
  export class PermissionServiceLifecycle
@@ -60,7 +55,6 @@ export class PermissionServiceLifecycle
60
55
 
61
56
  constructor(
62
57
  private readonly service: PermissionsService,
63
- private readonly detection: RegisteredChildDetector,
64
58
  private readonly role: AdjudicationRole,
65
59
  private readonly events: PermissionEventBus,
66
60
  private readonly subscriptions: readonly (() => void)[],
@@ -75,9 +69,6 @@ export class PermissionServiceLifecycle
75
69
  publishPermissionsService(sessionId, this.service);
76
70
  this.publishedSessionId = sessionId;
77
71
  }
78
- if (!this.detection.isRegisteredChild(ctx)) {
79
- publishRootPermissionsService(this.service);
80
- }
81
72
  this.emitReady(ctx);
82
73
  }
83
74
 
@@ -97,7 +88,6 @@ export class PermissionServiceLifecycle
97
88
  unpublishPermissionsService(this.publishedSessionId, this.service);
98
89
  this.publishedSessionId = null;
99
90
  }
100
- unpublishRootPermissionsService(this.service);
101
91
  }
102
92
 
103
93
  /**
package/src/service.ts CHANGED
@@ -7,15 +7,12 @@
7
7
  * accessors here read from the same `globalThis` slots the provider wrote to —
8
8
  * enabling direct, synchronous, type-safe function calls.
9
9
  *
10
- * There are two slots, because one process can host several **nodes** (one Pi
11
- * session runtime each — a root session and its in-process subagent children
12
- * all load their own instance of this extension):
13
- *
14
- * - A session-keyed map, written by every node under its own session id.
15
- * `getPermissionsService(sessionId)` resolves the service whose
16
- * registries that node's own gates and chain read (ADR 0012 decision 2).
17
- * - A single legacy slot holding the process root's service, read by the
18
- * deprecated `getRootPermissionsService()`.
10
+ * The slot is a session-keyed map, because one process can host several
11
+ * **nodes** (one Pi session runtime each — a root session and its in-process
12
+ * subagent children all load their own instance of this extension). Every node
13
+ * writes under its own session id, and `getPermissionsService(sessionId)`
14
+ * resolves the service whose registries that node's own gates and chain read
15
+ * (ADR 0012 decision 2).
19
16
  *
20
17
  * Best practice: resolve per use rather than caching the reference — this
21
18
  * ensures resilience across `/reload` and load-order edge cases.
@@ -72,9 +69,6 @@ export type {
72
69
  } from "./presentation/prompt-payload";
73
70
  export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
74
71
 
75
- /** Process-global key for the legacy (process-root) service slot. */
76
- const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
77
-
78
72
  /** Process-global key for the session-keyed service map (ADR 0012 decision 2). */
79
73
  const SESSION_SERVICES_KEY = Symbol.for(
80
74
  "@gotgenes/pi-permission-system:session-services",
@@ -120,8 +114,7 @@ export interface PermissionQuery {
120
114
 
121
115
  /**
122
116
  * Public interface exposed to other extensions via
123
- * {@link getPermissionsService} (or the deprecated
124
- * {@link getRootPermissionsService}).
117
+ * {@link getPermissionsService}.
125
118
  *
126
119
  * Each instance belongs to one node, and its three registration surfaces are
127
120
  * read by that node alone: extractors and formatters by its own gates, chain
@@ -203,77 +196,13 @@ export interface PermissionsService extends PermissionQuery {
203
196
  ): () => void;
204
197
  }
205
198
 
206
- /**
207
- * Store a `PermissionsService` in the legacy process-root slot, read by
208
- * `getRootPermissionsService()`.
209
- *
210
- * Called at `session_start` by the top-level (parent) instance only — an
211
- * in-process subagent child skips publishing so it cannot clobber the parent's
212
- * service. Overwrites any previously published service, which keeps `/reload`
213
- * working: a reloaded parent re-publishes its fresh service.
214
- */
215
- export function publishRootPermissionsService(
216
- service: PermissionsService,
217
- ): void {
218
- (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
219
- }
220
-
221
- /**
222
- * Warned at most once per module copy, so a consumer hears it on its first
223
- * deprecated call and never again. Under jiti isolation each consumer
224
- * extension holds its own copy, so each hears it for its own call site.
225
- */
226
- let warnedDeprecatedAccessor = false;
227
-
228
- const DEPRECATED_ACCESSOR_WARNING =
229
- "getRootPermissionsService() is deprecated: it answers with the process root's " +
230
- "service, which is the wrong node in every node but the root — inside an " +
231
- "in-process subagent child it hands back the parent's service, so a " +
232
- "registration lands where the child's gates never read it and a policy " +
233
- "query answers against the parent's config. Use " +
234
- "getPermissionsService(sessionId) with the sessionId from the " +
235
- "permissions:ready payload. See " +
236
- "https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/cross-extension-api.md";
237
-
238
- /**
239
- * Retrieve the process root's published `PermissionsService`, or `undefined`
240
- * if the permission-system extension has not loaded (or has been unloaded).
241
- *
242
- * @deprecated Use {@link getPermissionsService} with the `sessionId`
243
- * from the `permissions:ready` payload. This accessor answers "the process
244
- * root's service", which is the wrong question in every node but the root.
245
- * Removal is deferred to a future major (ADR 0012 decision 7).
246
- */
247
- export function getRootPermissionsService(): PermissionsService | undefined {
248
- if (!warnedDeprecatedAccessor) {
249
- warnedDeprecatedAccessor = true;
250
- process.emitWarning(DEPRECATED_ACCESSOR_WARNING, {
251
- type: "DeprecationWarning",
252
- code: "PI_PERMISSION_SYSTEM_DEP0001",
253
- });
254
- }
255
- return readRootService();
256
- }
257
-
258
- /**
259
- * The undeprecated read of the root slot, for this package's own lifecycle.
260
- *
261
- * `unpublishRootPermissionsService` must compare identities without warning the
262
- * host about a call the host did not make.
263
- */
264
- function readRootService(): PermissionsService | undefined {
265
- return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
266
- | PermissionsService
267
- | undefined;
268
- }
269
-
270
199
  /**
271
200
  * The process-global map of session id → that node's service, created on first
272
201
  * use.
273
202
  *
274
- * Backed by `globalThis` + `Symbol.for()` for the same reason the single slot
275
- * above is: each session's `ResourceLoader` builds its own jiti instance, so a
276
- * parent and its in-process child share no module state — only process globals.
203
+ * Backed by `globalThis` + `Symbol.for()` because each session's
204
+ * `ResourceLoader` builds its own jiti instance, so a parent and its in-process
205
+ * child share no module state — only process globals.
277
206
  */
278
207
  function sessionServices(): Map<string, PermissionsService> {
279
208
  const store = globalThis as Record<symbol, unknown>;
@@ -331,9 +260,7 @@ export function getPermissionsService(
331
260
  const MISSING_SESSION_ID_WARNING =
332
261
  "getPermissionsService() was called without a session id and answered " +
333
262
  "undefined. It resolves the service of one node, so it needs the sessionId " +
334
- "from the permissions:ready payload: getPermissionsService(sessionId). To " +
335
- "read the process root's service — what the zero-arg call used to do — use " +
336
- "the deprecated getRootPermissionsService(). See " +
263
+ "from the permissions:ready payload: getPermissionsService(sessionId). See " +
337
264
  "https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/cross-extension-api.md";
338
265
 
339
266
  /**
@@ -358,10 +285,11 @@ function warnMissingSessionId(): void {
358
285
 
359
286
  /**
360
287
  * Remove the `sessionId` entry, but only when it still holds `service`
361
- * (identity compare-and-delete, like {@link unpublishRootPermissionsService}).
288
+ * (identity compare-and-delete).
362
289
  *
363
- * Scoping the delete to the publishing instance keeps a superseded `/reload`
364
- * generation's late shutdown from wiping the new generation's freshly
290
+ * Called during `session_shutdown` to avoid stale references after the node is
291
+ * torn down. Scoping the delete to the publishing instance keeps a superseded
292
+ * `/reload` generation's late shutdown from wiping the new generation's freshly
365
293
  * published service.
366
294
  */
367
295
  export function unpublishPermissionsService(
@@ -373,26 +301,3 @@ export function unpublishPermissionsService(
373
301
  services.delete(sessionId);
374
302
  }
375
303
  }
376
-
377
- /**
378
- * Remove `service` from `globalThis`, but only when the current slot still
379
- * holds it (identity compare-and-delete).
380
- *
381
- * Called during `session_shutdown` to avoid stale references after the
382
- * extension is torn down. Scoping the delete to the publishing instance keeps
383
- * two cases correct:
384
- *
385
- * - An in-process subagent child never published the parent's service, so its
386
- * shutdown is a no-op and the parent's slot survives.
387
- * - A superseded `/reload` generation no longer owns the slot, so its late
388
- * shutdown cannot wipe the new generation's freshly published service.
389
- */
390
- export function unpublishRootPermissionsService(
391
- service: PermissionsService,
392
- ): void {
393
- if (readRootService() !== service) {
394
- return;
395
- }
396
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
397
- delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
398
- }