@gotgenes/pi-permission-system 29.3.0 → 30.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,18 @@ 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
+ ## [30.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.3.0...pi-permission-system-v30.0.0) (2026-09-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** **breaking:** record a session approval's surface per pattern ([499444d](https://github.com/gotgenes/pi-packages/commit/499444df5a21e289409cbf3b360033a13d0eba56)), closes [#810](https://github.com/gotgenes/pi-packages/issues/810)
14
+ * **pi-permission-system:** grant each external path only the direction its command proved ([aa38c74](https://github.com/gotgenes/pi-packages/commit/aa38c746bf9b82ed0dc96217cf4789aae9f090cd)), closes [#810](https://github.com/gotgenes/pi-packages/issues/810)
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** document per-pattern approval surfaces and mark Phase 14 Step 10 ([5511450](https://github.com/gotgenes/pi-packages/commit/551145024364cc794b3c48256362d69971aa69de)), closes [#810](https://github.com/gotgenes/pi-packages/issues/810)
19
+
8
20
  ## [29.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.2.0...pi-permission-system-v29.3.0) (2026-09-02)
9
21
 
10
22
 
package/README.md CHANGED
@@ -210,6 +210,7 @@ Approve-and-steer, edit diffs, and risk explanations → a downstream package ov
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
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 |
213
+ | [docs/migration/0810-per-pattern-approval-surfaces.md](docs/migration/0810-per-pattern-approval-surfaces.md) | Per-pattern approval surfaces (breaking) — `ForwardedSessionApproval.grants` replaces `surface` + `patterns` |
213
214
 
214
215
  ## Development
215
216
 
package/dist/public.d.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ /**
4
+ * One session-approval grant: a wildcard pattern approved on one surface.
5
+ *
6
+ * A gate proves a direction per accessed path (ADR 0013 §7), so an ask whose
7
+ * paths disagree records a surface per pattern rather than one for all of them
8
+ * (#810). This lives in its own module because both the {@link SessionApproval}
9
+ * value object and the forwarded wire type name it, and those two already
10
+ * import in one direction.
11
+ */
12
+ interface ApprovalGrant {
13
+ readonly surface: string;
14
+ readonly pattern: string;
15
+ }
16
+
3
17
  declare const permissionStateSchema: z.ZodUnion<readonly [z.ZodLiteral<"allow">, z.ZodLiteral<"deny">, z.ZodLiteral<"ask">]>;
4
18
  /** A permission decision. */
5
19
  type PermissionState = z.infer<typeof permissionStateSchema>;
@@ -316,16 +330,21 @@ interface PermissionDecisionEvent {
316
330
 
317
331
  /**
318
332
  * The child's session-approval suggestion, relayed to the serving node so a
319
- * human who grants "the whole session" records the same pattern the child
320
- * would have recorded locally.
333
+ * human who grants "the whole session" records the same grants the child would
334
+ * have recorded locally.
321
335
  *
322
336
  * A plain data shape (not the `SessionApproval` value object) so it serializes
323
337
  * onto the forwarded request; the serving node rebuilds a `SessionApproval`
324
- * from it via `SessionApproval.multiple`.
338
+ * from it via `SessionApproval.forGrants`.
339
+ *
340
+ * Each grant carries its own surface (#810). The pre-#810 shape — one
341
+ * `surface` plus a `patterns` list — is rejected by the reader rather than
342
+ * normalized, so a version-skewed pair drops the suggestion and the serving
343
+ * dialog offers no whole-session scope; the requesting child still records its
344
+ * own grant, so the failure is narrow in both directions.
325
345
  */
326
346
  interface ForwardedSessionApproval {
327
- surface: string;
328
- patterns: readonly string[];
347
+ grants: readonly ApprovalGrant[];
329
348
  }
330
349
  /**
331
350
  * The child-fixed facts a gate emits: the surface it evaluated and the match
@@ -0,0 +1,76 @@
1
+ # Migration guide: a session approval carries a surface per pattern
2
+
3
+ Starting with the release that closes #810, a session-scoped approval records each of its patterns on the surface that pattern's own access was proven on, instead of recording every pattern on one shared surface.
4
+
5
+ This is a **breaking change** on one surface, with a second, milder effect during a version-skew window.
6
+
7
+ | Surface | Break | Who is affected |
8
+ | ----------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
9
+ | `ForwardedSessionApproval` (a field of `PromptPermissionDetails`) | `surface` and `patterns` removed; `grants` added | Any extension whose registered `Authorizer` reads `details.sessionApproval` |
10
+ | `ForwardedPermissionRequest.sessionApproval` (on-disk wire) | the old shape is rejected, not normalized | Anyone running **out-of-process** subagents across mixed versions |
11
+
12
+ ## What changed, and why it is not just a rename
13
+
14
+ A permission gate proves a direction for each path it sees: reading `/outside/a.ts` proves a read, redirecting into `/elsewhere/b.ts` proves a write, and an unclassifiable access proves neither.
15
+ The bash external-directory gate aggregates every uncovered path into **one** prompt, so it built one approval covering all of them.
16
+
17
+ Because that approval held a single surface, it could only be as narrow as the whole prompt agreed.
18
+ A command touching paths in two directions fell back to the direction-neutral family name, which grants both directions.
19
+ So approving
20
+
21
+ ```bash
22
+ cat /outside/a.ts > /elsewhere/b.ts
23
+ ```
24
+
25
+ for the session also granted writes under `/outside` and reads under `/elsewhere` — wider than the prompt named.
26
+
27
+ Each pattern now carries its own surface, so that approval grants a read under `/outside` and a write under `/elsewhere`, and nothing else.
28
+
29
+ Two paths in the **same** directory still grant both directions there.
30
+ The recorded pattern is the path's directory scope, so both paths produce the same pattern and the two grants land on the same directory in opposite directions — which is exactly what the prompt showed.
31
+
32
+ ## If you register an `Authorizer`
33
+
34
+ A chain link receives `PromptPermissionDetails`, whose optional `sessionApproval` field changed shape:
35
+
36
+ ```typescript
37
+ // Before
38
+ const surface = details.sessionApproval?.surface;
39
+ const first = details.sessionApproval?.patterns[0];
40
+
41
+ // After
42
+ const first = details.sessionApproval?.grants[0];
43
+ const surface = first?.surface;
44
+ const pattern = first?.pattern;
45
+ ```
46
+
47
+ `grants` is a non-empty array of `{ surface, pattern }`, in the order the gate produced them.
48
+ Read each entry's own `surface` — do not assume the first one describes the rest, which is the assumption this change exists to remove.
49
+
50
+ Most links never touch this field; it exists so a serving node can offer a whole-session grant scope, and links that only rule on the request are unaffected.
51
+
52
+ ## If you run out-of-process subagents
53
+
54
+ A subagent with no UI writes its ask to a request file that its parent session reads.
55
+ The suggestion travels in that file, and the reader accepts only the new shape — the old one is rejected rather than converted.
56
+
57
+ That matters only while a parent and a child are running **different** versions of this extension, which happens when an upgrade lands on disk while a parent session is already loaded.
58
+ An in-process child shares its parent's loaded extension and can never skew.
59
+
60
+ The effect is symmetric and bounded:
61
+
62
+ - The request is still accepted, and the ask still prompts.
63
+ Only the suggestion is dropped.
64
+ - With no suggestion, the prompt shows its base four options instead of asking whether the grant covers the whole serving session.
65
+ - Choosing "for this session" then records on the **requesting subagent** — the least-privilege default the two-step dialog already pre-selected.
66
+
67
+ So a skewed pair loses an affordance; it never produces a wider grant, and there is no upgrade ordering to observe.
68
+ Restart the serving session after upgrading to restore the scope step.
69
+
70
+ Carrying the old shape alongside the new was considered and declined.
71
+ The wire file turned out to be the smaller of the two breaks: the type is reachable from this package's published type declarations, so replacing its fields breaks a consumer at compile time regardless of what any file on disk contains — and a compatibility shape would have had to be maintained forever to avoid a break that had already happened.
72
+
73
+ ## Related decisions
74
+
75
+ - [ADR 0006 — Grant-scope selection on forwarded approvals](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0006-forwarded-grant-scope-selection.md), amended by this change.
76
+ - [ADR 0013 — The permission policy model](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0013-permission-policy-model.md), §3–§4 for the read/write axis and §9 for a session approval as a policy source.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "29.3.0",
3
+ "version": "30.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * One session-approval grant: a wildcard pattern approved on one surface.
3
+ *
4
+ * A gate proves a direction per accessed path (ADR 0013 §7), so an ask whose
5
+ * paths disagree records a surface per pattern rather than one for all of them
6
+ * (#810). This lives in its own module because both the {@link SessionApproval}
7
+ * value object and the forwarded wire type name it, and those two already
8
+ * import in one direction.
9
+ */
10
+ export interface ApprovalGrant {
11
+ readonly surface: string;
12
+ readonly pattern: string;
13
+ }
@@ -350,16 +350,11 @@ export class ForwardedRequestServer implements InboxProcessor {
350
350
  return decision;
351
351
  }
352
352
  if (request.sessionApproval) {
353
- this.recorder.recordSessionApproval(
354
- SessionApproval.multiple(
355
- request.sessionApproval.surface,
356
- request.sessionApproval.patterns,
357
- ),
358
- );
353
+ const { grants } = request.sessionApproval;
354
+ this.recorder.recordSessionApproval(SessionApproval.forGrants(grants));
359
355
  this.logger.review("forwarded_permission.session_recorded", {
360
356
  ...logDetails,
361
- surface: request.sessionApproval.surface,
362
- patterns: request.sessionApproval.patterns,
357
+ grants,
363
358
  });
364
359
  }
365
360
  return {
@@ -9,6 +9,7 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
 
12
+ import type { ApprovalGrant } from "#src/approval-grant";
12
13
  import { asDecisionSource } from "#src/authority/decision-source";
13
14
  import { isPermissionDecisionState } from "#src/authority/permission-dialog";
14
15
  import {
@@ -49,12 +50,34 @@ function asNullableDisplayString(value: unknown): string | null | undefined {
49
50
  return undefined;
50
51
  }
51
52
 
53
+ /** Narrow an unknown value to an `ApprovalGrant`, or `undefined`. */
54
+ function asApprovalGrant(value: unknown): ApprovalGrant | undefined {
55
+ if (typeof value !== "object" || value === null) {
56
+ return undefined;
57
+ }
58
+ const candidate = value as Partial<ApprovalGrant>;
59
+ if (
60
+ typeof candidate.surface !== "string" ||
61
+ candidate.surface.length === 0 ||
62
+ typeof candidate.pattern !== "string"
63
+ ) {
64
+ return undefined;
65
+ }
66
+ return { surface: candidate.surface, pattern: candidate.pattern };
67
+ }
68
+
52
69
  /**
53
70
  * Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
54
71
  *
55
72
  * Tolerant read: the child's session-approval suggestion is optional (absent
56
- * on an older child) and only accepted when well-formed — a non-empty surface
57
- * and an all-string patterns array.
73
+ * on an older child) and only accepted when well-formed — a non-empty `grants`
74
+ * array whose every entry names a non-empty surface and a pattern. The
75
+ * pre-#810 shape (`surface` plus `patterns`) is rejected rather than
76
+ * normalized, so a version-skewed request drops the suggestion and the serving
77
+ * dialog offers no whole-session scope.
78
+ *
79
+ * An empty `grants` array is rejected too: it would record nothing while still
80
+ * writing a `forwarded_permission.session_recorded` entry claiming it had.
58
81
  */
59
82
  function asForwardedSessionApproval(
60
83
  value: unknown,
@@ -63,15 +86,16 @@ function asForwardedSessionApproval(
63
86
  return undefined;
64
87
  }
65
88
  const candidate = value as Partial<ForwardedSessionApproval>;
66
- if (
67
- typeof candidate.surface !== "string" ||
68
- candidate.surface.length === 0 ||
69
- !Array.isArray(candidate.patterns) ||
70
- !candidate.patterns.every((pattern) => typeof pattern === "string")
71
- ) {
89
+ if (!Array.isArray(candidate.grants) || candidate.grants.length === 0) {
72
90
  return undefined;
73
91
  }
74
- return { surface: candidate.surface, patterns: [...candidate.patterns] };
92
+ const grants: ApprovalGrant[] = [];
93
+ for (const entry of candidate.grants) {
94
+ const grant = asApprovalGrant(entry);
95
+ if (!grant) return undefined;
96
+ grants.push(grant);
97
+ }
98
+ return { grants };
75
99
  }
76
100
 
77
101
  /**
@@ -72,13 +72,13 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
72
72
  function buildRequestOptions(
73
73
  details: PromptPermissionDetails,
74
74
  ): RequestPermissionOptions | undefined {
75
- const pattern = details.sessionApproval?.patterns[0];
76
- if (details.forwarding && details.sessionApproval && pattern) {
75
+ const grant = details.sessionApproval?.grants[0];
76
+ if (details.forwarding && grant) {
77
77
  return {
78
78
  sessionScope: buildForwardedScopeLabels(
79
79
  details.forwarding.requesterAgentName,
80
- details.sessionApproval.surface,
81
- pattern,
80
+ grant.surface,
81
+ grant.pattern,
82
82
  ),
83
83
  };
84
84
  }
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import type { ApprovalGrant } from "#src/approval-grant";
2
3
  import type { DecisionSource } from "#src/authority/decision-source";
3
4
  import type { PermissionUiPromptSource } from "#src/permission-events";
4
5
  import type { PromptPayload } from "#src/presentation/prompt-payload";
@@ -83,16 +84,21 @@ export interface ForwardedPromptDisplay {
83
84
 
84
85
  /**
85
86
  * The child's session-approval suggestion, relayed to the serving node so a
86
- * human who grants "the whole session" records the same pattern the child
87
- * would have recorded locally.
87
+ * human who grants "the whole session" records the same grants the child would
88
+ * have recorded locally.
88
89
  *
89
90
  * A plain data shape (not the `SessionApproval` value object) so it serializes
90
91
  * onto the forwarded request; the serving node rebuilds a `SessionApproval`
91
- * from it via `SessionApproval.multiple`.
92
+ * from it via `SessionApproval.forGrants`.
93
+ *
94
+ * Each grant carries its own surface (#810). The pre-#810 shape — one
95
+ * `surface` plus a `patterns` list — is rejected by the reader rather than
96
+ * normalized, so a version-skewed pair drops the suggestion and the serving
97
+ * dialog offers no whole-session scope; the requesting child still records its
98
+ * own grant, so the failure is narrow in both directions.
92
99
  */
93
100
  export interface ForwardedSessionApproval {
94
- surface: string;
95
- patterns: readonly string[];
101
+ grants: readonly ApprovalGrant[];
96
102
  }
97
103
 
98
104
  /**
@@ -18,11 +18,11 @@ import type { ToolCallContext } from "./types";
18
18
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
19
19
  *
20
20
  * Each path is resolved on the narrowest `external_directory`-family surface
21
- * its own attributed effect names. The session approval holds one surface for
22
- * all its patterns, so it narrows only when every uncovered path agrees; a
23
- * mixed-direction ask falls back to the bare family, which is exactly today's
24
- * width. Closing that last gap needs `(surface, pattern)` pairs on the
25
- * approval and its forwarded wire form (#810).
21
+ * its own attributed effect names, and the session approval records one grant
22
+ * per uncovered path at that same surface (#810) so an ask mixing a proven
23
+ * read with a proven write grants each path only its own direction, never both
24
+ * on both. Two paths sharing a directory derive the same glob and so grant
25
+ * both directions there, which is what the prompt showed.
26
26
  *
27
27
  * The shell command (native `bash` or an aliased shell tool) is read from the
28
28
  * injected `BashProgram`, which owns the source text it was parsed from, so
@@ -104,17 +104,15 @@ export function describeBashExternalDirectoryGate(
104
104
  surface,
105
105
  });
106
106
 
107
- const patterns = uncoveredEntries.map(({ path }) =>
108
- normalizer.approvalPatternFor(path),
109
- );
110
-
111
107
  return {
112
108
  surface,
113
109
  input: {},
114
110
  payload,
115
- sessionApproval: SessionApproval.multiple(
116
- approvalSurfaceFor(uncoveredEntries),
117
- patterns,
111
+ sessionApproval: SessionApproval.forGrants(
112
+ uncoveredEntries.map((entry) => ({
113
+ surface: entry.surface,
114
+ pattern: normalizer.approvalPatternFor(entry.path),
115
+ })),
118
116
  ),
119
117
  promptDetails: {
120
118
  source: "tool_call",
@@ -143,20 +141,3 @@ export function describeBashExternalDirectoryGate(
143
141
  preCheck,
144
142
  };
145
143
  }
146
-
147
- /**
148
- * The surface one session approval can carry for every uncovered path at once.
149
- *
150
- * A {@link SessionApproval} holds one surface for all its patterns, so it can
151
- * narrow only when the whole ask agrees on a direction. The bare family is the
152
- * fallback because it sugar-expands onto both members — exactly the width a
153
- * mixed-direction command is granted today, never wider.
154
- */
155
- function approvalSurfaceFor(
156
- uncoveredEntries: readonly { readonly surface: string }[],
157
- ): string {
158
- const surfaces = new Set(uncoveredEntries.map(({ surface }) => surface));
159
- return surfaces.size === 1
160
- ? [...surfaces][0]
161
- : ("external_directory" as const);
162
- }
@@ -209,7 +209,7 @@ export class GateRunner {
209
209
  };
210
210
  const gateResult = await applyPermissionGate({
211
211
  state: check.state,
212
- sessionApproval: descriptor.sessionApproval?.toGateApproval(),
212
+ canGrantForSession: descriptor.sessionApproval?.isRecordable ?? false,
213
213
  promptForApproval: async () => {
214
214
  const decision = await this.prompter.escalate({
215
215
  requestId,
@@ -230,7 +230,7 @@ export class GateRunner {
230
230
 
231
231
  // 4. Determine whether session approval was granted
232
232
  const hasSessionApproval =
233
- gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
233
+ gateResult.action === "allow" && gateResult.forSession === true;
234
234
 
235
235
  // 5. Emit decision event
236
236
  this.emitDecision(
@@ -13,7 +13,8 @@ export type PermissionGateResult =
13
13
  | {
14
14
  action: "allow";
15
15
  decidedBy: DecisionSource;
16
- sessionApproval?: { surface: string; pattern: string };
16
+ /** Set when the human granted the ask for the whole session. */
17
+ forSession?: true;
17
18
  }
18
19
  | { action: "block"; decidedBy: DecisionSource; reason: string };
19
20
 
@@ -30,11 +31,16 @@ export interface PermissionGateParams {
30
31
  promptForApproval: () => Promise<PermissionPromptDecision>;
31
32
 
32
33
  /**
33
- * Session approval suggestion to record when the user selects
34
- * "for this session". When present and the decision is `approved_for_session`,
35
- * the result carries the suggestion back to the caller for recording.
34
+ * Whether this ask has a session-approval suggestion to record when the user
35
+ * selects "for this session".
36
+ *
37
+ * A boolean rather than the suggestion itself: the gate decides only whether
38
+ * a whole-session grant happened, and the caller records the suggestion it
39
+ * already holds. Handing the gate the value would ask it to name a single
40
+ * representative `(surface, pattern)`, which a multi-pattern approval has no
41
+ * way to choose (#810).
36
42
  */
37
- sessionApproval?: { surface: string; pattern: string };
43
+ canGrantForSession: boolean;
38
44
 
39
45
  /** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
40
46
  writeLog: (event: string, extra: Record<string, unknown>) => void;
@@ -102,12 +108,11 @@ export async function applyPermissionGate(
102
108
  reason: messages.refusedReason(decision),
103
109
  };
104
110
  }
105
- if (decision.state === "approved_for_session" && params.sessionApproval) {
106
- return {
107
- action: "allow",
108
- decidedBy,
109
- sessionApproval: params.sessionApproval,
110
- };
111
+ if (
112
+ decision.state === "approved_for_session" &&
113
+ params.canGrantForSession
114
+ ) {
115
+ return { action: "allow", decidedBy, forSession: true };
111
116
  }
112
117
  return { action: "allow", decidedBy };
113
118
  }
@@ -1,54 +1,44 @@
1
+ import type { ApprovalGrant } from "#src/approval-grant";
1
2
  import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
2
3
 
3
4
  /**
4
- * Value object for a session-scoped approval: one surface, one-or-more patterns.
5
+ * Value object for a session-scoped approval: one or more
6
+ * {@link ApprovalGrant}s, each pairing a pattern with the surface it was
7
+ * proven on.
5
8
  *
6
9
  * Owned by gate descriptors and passed to the session store — the runner never
7
- * needs to know whether there is one pattern or many.
10
+ * needs to know how many grants an approval carries, and the store records
11
+ * each on the surface the grant itself names rather than one shared by all
12
+ * (#810).
8
13
  */
9
14
  export class SessionApproval {
10
- private constructor(
11
- readonly surface: string,
12
- readonly patterns: readonly string[],
13
- ) {}
15
+ private constructor(readonly grants: readonly ApprovalGrant[]) {}
14
16
 
15
17
  /** Create an approval for a single pattern (the common case). */
16
18
  static single(surface: string, pattern: string): SessionApproval {
17
- return new SessionApproval(surface, [pattern]);
19
+ return new SessionApproval([{ surface, pattern }]);
18
20
  }
19
21
 
20
22
  /**
21
- * Create an approval for multiple patterns (e.g. bash external-directory
22
- * gates that cover several uncovered paths in one prompt).
23
+ * Create an approval from grants that may name different surfaces (e.g. a
24
+ * bash external-directory ask whose uncovered paths proved different
25
+ * directions). Returns a defensive copy.
23
26
  */
24
- static multiple(
25
- surface: string,
26
- patterns: readonly string[],
27
- ): SessionApproval {
28
- return new SessionApproval(surface, [...patterns]);
27
+ static forGrants(grants: readonly ApprovalGrant[]): SessionApproval {
28
+ return new SessionApproval([...grants]);
29
29
  }
30
30
 
31
- /** Representative pattern for the interactive prompt the first, if any. */
32
- get representativePattern(): string | undefined {
33
- return this.patterns[0];
34
- }
35
-
36
- /**
37
- * Single-pattern shape `applyPermissionGate` echoes back to the caller.
38
- * Returns `undefined` when patterns is empty (degenerate case).
39
- */
40
- toGateApproval(): { surface: string; pattern: string } | undefined {
41
- const pattern = this.representativePattern;
42
- if (pattern === undefined) return undefined;
43
- return { surface: this.surface, pattern };
31
+ /** Whether this approval carries anything for the session store to record. */
32
+ get isRecordable(): boolean {
33
+ return this.grants.length > 0;
44
34
  }
45
35
 
46
36
  /**
47
37
  * Plain data shape for relaying this approval on a forwarded request, so the
48
- * serving node can record the same pattern(s) as a whole-session grant.
49
- * Returns a defensive copy of the patterns.
38
+ * serving node can record the same grants as a whole-session grant.
39
+ * Returns a defensive copy.
50
40
  */
51
41
  toForwardedData(): ForwardedSessionApproval {
52
- return { surface: this.surface, patterns: [...this.patterns] };
42
+ return { grants: [...this.grants] };
53
43
  }
54
44
  }
@@ -41,14 +41,16 @@ export class SessionRules implements SessionApprovalRecorder {
41
41
  }
42
42
 
43
43
  /**
44
- * Record all patterns from a `SessionApproval` value object.
44
+ * Record every grant from a `SessionApproval` value object.
45
45
  *
46
- * The loop lives here so callers never need to know whether an approval
47
- * carries one pattern or many — they just tell the store to record it.
46
+ * The loop lives here so callers never need to know how many grants an
47
+ * approval carries — they just tell the store to record it. Each grant is
48
+ * recorded on the surface it names, so an ask whose paths proved different
49
+ * directions grants each path only its own (#810).
48
50
  */
49
51
  recordSessionApproval(approval: SessionApproval): void {
50
- for (const pattern of approval.patterns) {
51
- this.approve(approval.surface, pattern);
52
+ for (const { surface, pattern } of approval.grants) {
53
+ this.approve(surface, pattern);
52
54
  }
53
55
  }
54
56