@sema-agent/core 6.0.0 → 7.0.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/agents/launch-receipt-contract.d.ts +34 -0
  3. package/dist/agents/launch-receipt-contract.js +5 -0
  4. package/dist/agents/subagent.d.ts +134 -2
  5. package/dist/agents/subagent.js +132 -31
  6. package/dist/core/file-history-store.js +24 -2
  7. package/dist/core/governance-codes.d.ts +11 -2
  8. package/dist/core/governance-codes.js +1 -0
  9. package/dist/core/permission-rule-consent.d.ts +42 -2
  10. package/dist/core/permission-rule-consent.js +93 -11
  11. package/dist/core/permission-rule-model.d.ts +51 -9
  12. package/dist/core/permission-rule-model.js +4 -2
  13. package/dist/core/permission-rule-session.d.ts +124 -0
  14. package/dist/core/permission-rule-session.js +121 -0
  15. package/dist/core/permission-rule-store.d.ts +65 -2
  16. package/dist/core/permission-rule-store.js +60 -6
  17. package/dist/core/permission-rule-sync.d.ts +9 -0
  18. package/dist/core/permission-rule-sync.js +37 -8
  19. package/dist/core/roles.js +1 -1
  20. package/dist/core/runner/prepare-task.js +35 -2
  21. package/dist/core/runner/runtask.js +2 -0
  22. package/dist/core/store-contracts/permission-rule-sync-contract.js +15 -1
  23. package/dist/core/task-notification.d.ts +20 -0
  24. package/dist/core/trace.d.ts +7 -2
  25. package/dist/core/types.d.ts +85 -1
  26. package/dist/core/wiring-manifest.d.ts +18 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/index.js +2 -1
  29. package/dist/orchestration/run-workflow-tool.js +2 -2
  30. package/dist/orchestration/workflow.js +18 -10
  31. package/dist/stores/file/permission-rule-store.d.ts +11 -0
  32. package/dist/stores/file/permission-rule-store.js +22 -9
  33. package/package.json +1 -1
  34. package/test/export-surface.snapshot.json +15 -1
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { escapeForDisclosure, hasUnrenderableCharacters, parseAllowRuleText, ruleAdmitsCommand, segmentCoverageOf, suggestRulesForCommand, translateImportedWildcardRule, } from "./permission-rule-model.js";
3
- import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
3
+ import { errText, isValidConsentScope, normalizePersistedRule, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
4
4
  export class InMemoryRuleApprovalRecordStore {
5
5
  rows = new Map();
6
6
  async get(id) {
@@ -121,6 +121,9 @@ function approvalRecordDamageOf(rec) {
121
121
  if (typeof cand !== "object" || cand === null || typeof cand.rule !== "string") {
122
122
  return `offer ${at} references candidate ${member}, which is not a candidate row`;
123
123
  }
124
+ if (!isValidConsentScope(cand.scope)) {
125
+ return `offer ${at} references candidate ${member}, whose scope is not a consent-face scope (global, project, or session)`;
126
+ }
124
127
  if (seen.has(member))
125
128
  return `batch offer ${at} references candidate ${member} twice`;
126
129
  seen.add(member);
@@ -196,11 +199,42 @@ export async function prepareCardApproval(opts) {
196
199
  }
197
200
  if (opts.toolName !== CARD_RULE_TOOL)
198
201
  return undefined;
199
- const scope = opts.scope ?? { kind: "global" };
202
+ const scope = (() => {
203
+ if (opts.scope !== undefined) {
204
+ if (!isValidConsentScope(opts.scope)) {
205
+ const e = new Error("prepareCardApproval got a scope that is not a consent-face scope (global, project with a non-empty root, or session with a non-empty sessionId) — refusing to guess where this consent should land");
206
+ e.code = "config.invalid_argument";
207
+ throw e;
208
+ }
209
+ return opts.scope;
210
+ }
211
+ if (opts.cwd !== undefined) {
212
+ if (typeof opts.cwd !== "string" || opts.cwd === "") {
213
+ const e = new Error("prepareCardApproval got a cwd that is not a usable path — it cannot anchor the derived project scope, and refusing beats minting a rule scoped to garbage");
214
+ e.code = "config.invalid_argument";
215
+ throw e;
216
+ }
217
+ return { kind: "project", root: opts.cwd };
218
+ }
219
+ const e = new Error("prepareCardApproval was given neither a scope nor a cwd — there is nowhere for this consent to land, and no direction of silence is honest (config.missing_scope): pass an explicit scope, or thread the adjudicated call's cwd");
220
+ e.code = "config.missing_scope";
221
+ throw e;
222
+ })();
200
223
  let coverage;
201
224
  try {
202
225
  const listed = await storeForOwner(opts.deps.provider, owner).list();
203
- coverage = segmentCoverageOf(opts.command, { persisted: listed.rules }, { tool: CARD_RULE_TOOL, cwd: opts.cwd });
226
+ let table = listed.rules;
227
+ const overlay = opts.deps.sessionRules;
228
+ if (overlay !== undefined && opts.sessionId !== undefined) {
229
+ const served = structuredClone(await overlay.read(opts.sessionId));
230
+ const sessionRows = served.filter((r) => {
231
+ const scope = r?.scope;
232
+ return scope?.kind === "session" && scope.sessionId === opts.sessionId && !("reject" in normalizePersistedRule(r));
233
+ });
234
+ if (sessionRows.length > 0)
235
+ table = [...sessionRows, ...listed.rules];
236
+ }
237
+ coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: CARD_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId });
204
238
  }
205
239
  catch {
206
240
  coverage = undefined;
@@ -497,6 +531,41 @@ async function applyRedemption(args) {
497
531
  if (writer === undefined)
498
532
  return { status: "refused", reason: "the resolved permission-rule store has no write face" };
499
533
  const origin = originOfRecordKind(args.kind);
534
+ if (args.candidate.scope.kind === "session") {
535
+ const overlay = args.deps.sessionRules;
536
+ if (overlay === undefined) {
537
+ return {
538
+ status: "refused",
539
+ reason: "this deployment wired no session-rule overlay (RuleConsentDeps.sessionRules) — a session-scope authorization has nowhere to land",
540
+ };
541
+ }
542
+ let outcome;
543
+ try {
544
+ outcome = await overlay.apply(args.candidate.scope.sessionId, {
545
+ rule: args.parsedRule.rule,
546
+ tool: args.parsedRule.tool,
547
+ match: args.parsedRule.match,
548
+ command: args.parsedRule.command,
549
+ add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
550
+ });
551
+ }
552
+ catch (err) {
553
+ return { status: "refused", reason: `the session-rule overlay refused the write: ${errText(err)}` };
554
+ }
555
+ if ("refused" in outcome) {
556
+ return {
557
+ status: "refused",
558
+ reason: "the session this authorization targets has ended — a session rule dies with its session, and a late or replayed redemption cannot revive it (re-trigger the command in a live session for a fresh card)",
559
+ };
560
+ }
561
+ let rev = 0;
562
+ try {
563
+ rev = (await store.list()).rev;
564
+ }
565
+ catch {
566
+ }
567
+ return { status: "redeemed", rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev, alreadyRedeemed: args.replay };
568
+ }
500
569
  for (let attempt = 0; attempt < REDEEM_MAX_ATTEMPTS; attempt++) {
501
570
  let rev;
502
571
  try {
@@ -717,14 +786,27 @@ export async function redeemRuleBatch(opts) {
717
786
  const candidate = rec.candidates[candidateIndex];
718
787
  const row = { candidateIndex, rule: candidate.rule, scope: candidate.scope };
719
788
  let alreadyThere = false;
720
- try {
721
- const snap = await store.list();
722
- rev = snap.rev;
723
- alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
789
+ if (candidate.scope.kind === "session") {
790
+ const sessionScope = candidate.scope;
791
+ try {
792
+ const overlayRows = opts.deps.sessionRules === undefined ? [] : await opts.deps.sessionRules.read(sessionScope.sessionId);
793
+ alreadyThere = overlayRows.some((r) => r.rule === candidate.rule && sameScope(r.scope, sessionScope));
794
+ }
795
+ catch (err) {
796
+ members.push({ ...row, status: "refused", reason: `could not read the session-rule overlay: ${errText(err)}` });
797
+ continue;
798
+ }
724
799
  }
725
- catch (err) {
726
- members.push({ ...row, status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` });
727
- continue;
800
+ else {
801
+ try {
802
+ const snap = await store.list();
803
+ rev = snap.rev;
804
+ alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
805
+ }
806
+ catch (err) {
807
+ members.push({ ...row, status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` });
808
+ continue;
809
+ }
728
810
  }
729
811
  const res = await redeemRuleTicket({
730
812
  ticket: mintRuleTicket(rec.id, candidateIndex),
@@ -735,7 +817,7 @@ export async function redeemRuleBatch(opts) {
735
817
  members.push({ ...row, status: "refused", reason: res.reason });
736
818
  continue;
737
819
  }
738
- rev = res.rev;
820
+ rev = candidate.scope.kind === "session" ? Math.max(rev, res.rev) : res.rev;
739
821
  members.push({ ...row, status: alreadyThere ? "deduped" : "persisted", alreadyRedeemed: res.alreadyRedeemed, dot: res.dot });
740
822
  }
741
823
  return { members, rev };
@@ -69,18 +69,43 @@ export type PersistedRuleTool = "Bash";
69
69
  /** v1 match forms. `"wildcard"` is reserved for v2 and is not a value this version ever produces. */
70
70
  export type PersistedRuleMatch = "exact" | "prefix";
71
71
  /**
72
- * Where a rule applies. A rule read out of a project-local settings layer applies ONLY inside that
73
- * project's canonical root — project A's approval is not project B's. User-layer, starter and
74
- * user-minted rules are global.
75
- *
76
- * `root` is expected to be already canonicalized (realpath) by whoever constructs the scope; the
77
- * matcher compares with word-boundary containment, never a bare `startsWith` (`/a` must not reach `/ab`).
72
+ * Where a rule applies design/382 §4.1, the THREE consent dimensions.
73
+ *
74
+ * · `global` — the personal-global dimension: this OWNER's rules, everywhere (the bucket is already
75
+ * per-owner, {@link import("./permission-rule-store.js").RuleOwner}). The member keeps its historical
76
+ * spelling on purpose: renaming it `personal-global` would be a no-benefit migration of every stored
77
+ * row; the "personal" half lives in documentation and card wording, not in the wire byte.
78
+ * · `project` — applies ONLY inside `root`. `root` is expected to be already canonicalized (realpath)
79
+ * by whoever constructs the scope; the matcher compares with word-boundary containment, never a bare
80
+ * `startsWith` (`/a` must not reach `/ab`).
81
+ * · `session` — applies ONLY to calls of the ONE session it names, and lives in that session's own
82
+ * state (the host's session-rule overlay), NEVER in the persisted store: every durable entrance —
83
+ * the write deltas, sync in both directions, at-rest bytes — refuses or drops a session row
84
+ * (design/382 §4.3, the durable two-member face). `sessionId` uniqueness is the HOST's duty (the
85
+ * `principal` posture): reusing an id is a declaration that it is the same session. A session row
86
+ * cannot be individually deleted — it dies with its session (design/382 §10, ruled; the durable
87
+ * removal entry refuses it rather than answering a false no-op).
88
+ *
89
+ * ORG is deliberately NOT a member (design/382 §4.1): an approval card must be structurally unable to
90
+ * spell an org-wide grant — org governance is a deny/ask snapshot published by an admin surface, with
91
+ * no allow bucket at all. Unspellable beats runtime-refused; the consent validators refuse the spelling
92
+ * anyway if a foreign writer plants it.
93
+ *
94
+ * TWO NAMED VALIDATION FACES read this union (design/382 §4.3): the CONSENT face (prepare candidates,
95
+ * approval records, confirm/redeem, batch outcomes) admits all three members
96
+ * ({@link import("./permission-rule-store.js").isValidConsentScope}); the DURABLE face (the persisted
97
+ * store's write deltas, rule-sync in both directions, at-rest decode) admits only
98
+ * `{global, project}` ({@link import("./permission-rule-store.js").isValidDurableScope}). One table
99
+ * "for both" is exactly what would let a session row leak into the store silently.
78
100
  */
79
101
  export type RuleScope = {
80
102
  kind: "global";
81
103
  } | {
82
104
  kind: "project";
83
105
  root: string;
106
+ } | {
107
+ kind: "session";
108
+ sessionId: string;
84
109
  };
85
110
  /** The immutable causal identity of one add: a replica identity plus a monotonic counter. */
86
111
  export interface RuleDot {
@@ -441,9 +466,16 @@ export declare function ruleLaneSegmentsOf(command: string): readonly string[] |
441
466
  * not contain `/ab`. Both sides are expected to be canonical already.
442
467
  */
443
468
  export declare function pathWithinRoot(path: string, root: string): boolean;
444
- /** Does a rule's scope cover a task running in `cwd`? A project rule needs a cwd to compare against;
445
- * without one it covers nothing (fail-closed). */
446
- export declare function scopeCoversCwd(scope: RuleScope, cwd: string | undefined): boolean;
469
+ /** Does a rule's scope cover a task running in `cwd` (and, for the session dimension, in the session
470
+ * named by `sessionId`)? A project rule needs a cwd to compare against; without one it covers nothing
471
+ * (fail-closed). A session rule (design/382 §4.3) covers a call iff the call's `sessionId` equals the
472
+ * scope's — and a call that carries NO session identity is covered by no session rule, the same
473
+ * fail-closed arm as project-without-cwd: an axis the caller did not thread is an axis the rule
474
+ * cannot speak on. `sessionId` is threaded from the ORIGINAL call context by the caller (the 375
475
+ * r3-② cwd posture), never inferred from the process. ONE function on purpose: this is the scope
476
+ * conjunct of the single eligibility predicate ({@link eligibleContext}), and a sister predicate
477
+ * would be the second fact source the 375 §5.1 discipline exists to forbid. */
478
+ export declare function scopeCoversCwd(scope: RuleScope, cwd: string | undefined, sessionId?: string): boolean;
447
479
  /** Is this rule live — i.e. does it still carry at least one add? Deleted adds are removed by the store
448
480
  * when tombstones are applied, so a rule with an empty `adds` is a rule that no longer exists. */
449
481
  export declare function isRuleLive(rule: PersistedAllowRule): boolean;
@@ -459,6 +491,12 @@ export declare function isRuleLive(rule: PersistedAllowRule): boolean;
459
491
  *
460
492
  * `scope` rides beside the rule rather than inside it for the PROPOSED half's sake: a
461
493
  * {@link ParsedAllowRule} carries no scope, so a proposal is judged against the scope it would LAND in.
494
+ *
495
+ * design/382 §4.3 — the call context carries a THIRD axis, `sessionId` (optional: an existing caller
496
+ * that threads only `{tool, cwd}` still compiles and still means what it meant — no session rule can
497
+ * cover its calls, the fail-closed direction). It is the session dimension's whole eligibility story:
498
+ * every consumer of this ONE predicate (the gate's conjunction arm, the coverage table, the consent
499
+ * prepare) inherits it without a second judge existing anywhere.
462
500
  */
463
501
  export declare function eligibleContext(rule: {
464
502
  tool: string;
@@ -466,6 +504,7 @@ export declare function eligibleContext(rule: {
466
504
  }, call: {
467
505
  tool: string;
468
506
  cwd: string | undefined;
507
+ sessionId?: string;
469
508
  }): boolean;
470
509
  /**
471
510
  * design/375 §5.1 — MAY this PERSISTED rule participate in admitting this call at all? The liveness
@@ -481,6 +520,7 @@ export declare function eligibleContext(rule: {
481
520
  export declare function eligiblePersisted(rule: PersistedAllowRule, call: {
482
521
  tool: string;
483
522
  cwd: string | undefined;
523
+ sessionId?: string;
484
524
  }): boolean;
485
525
  /**
486
526
  * Find the rules that admit this command for a task in `cwd`, or `undefined`.
@@ -515,6 +555,7 @@ export declare function findAdmittingRule(rules: readonly PersistedAllowRule[],
515
555
  tool: string;
516
556
  command: string;
517
557
  cwd: string | undefined;
558
+ sessionId?: string;
518
559
  }): readonly PersistedAllowRule[] | undefined;
519
560
  /**
520
561
  * design/375 §5.2 — the per-segment coverage table for `command`: which segments an eligible rule
@@ -555,6 +596,7 @@ export declare function segmentCoverageOf(command: string, rules: {
555
596
  }, call: {
556
597
  tool: string;
557
598
  cwd: string | undefined;
599
+ sessionId?: string;
558
600
  }): readonly SegmentCoverage[] | undefined;
559
601
  /** One row of a per-segment coverage table (see {@link segmentCoverageOf}): the FOLDED, trimmed
560
602
  * segment text (a display/correlation seat, never adjudication input) and whether an eligible rule
@@ -295,16 +295,18 @@ export function pathWithinRoot(path, root) {
295
295
  const base = root.endsWith("/") ? root : root + "/";
296
296
  return path.startsWith(base);
297
297
  }
298
- export function scopeCoversCwd(scope, cwd) {
298
+ export function scopeCoversCwd(scope, cwd, sessionId) {
299
299
  if (scope.kind === "global")
300
300
  return true;
301
+ if (scope.kind === "session")
302
+ return sessionId !== undefined && sessionId === scope.sessionId;
301
303
  return cwd !== undefined && pathWithinRoot(cwd, scope.root);
302
304
  }
303
305
  export function isRuleLive(rule) {
304
306
  return rule.adds.length > 0;
305
307
  }
306
308
  export function eligibleContext(rule, call) {
307
- return rule.tool === call.tool && scopeCoversCwd(rule.scope, call.cwd);
309
+ return rule.tool === call.tool && scopeCoversCwd(rule.scope, call.cwd, call.sessionId);
308
310
  }
309
311
  export function eligiblePersisted(rule, call) {
310
312
  return isRuleLive(rule) && eligibleContext(rule, call);
@@ -0,0 +1,124 @@
1
+ /**
2
+ * design/382 §4.3 — the SESSION-RULE OVERLAY: where a `{kind:"session"}` scoped allow rule lives.
3
+ *
4
+ * A session authorization's home is the session's own state — never the persisted store. The rows are
5
+ * ordinary {@link PersistedAllowRule}s (scope `session`), so every read-side consumer — the gate's
6
+ * conjunction arm, the coverage table, the consent prepare — takes them through the ONE eligibility
7
+ * predicate with zero second judges: the merge point splices overlay rows IN FRONT of the persisted
8
+ * `list()` (the §4.1 narrowest-first reporting order: session > project > global), and everything
9
+ * downstream is unchanged.
10
+ *
11
+ * ## The contract (design/382 §8-Q6, settled here)
12
+ *
13
+ * TWO verbs, and deliberately no third:
14
+ * · `read(sessionId)` — the live rows of ONE session, session-scoped, that session's only.
15
+ * · `apply(sessionId, add)` — land one redeemed authorization. Idempotent BY DOT (the redemption
16
+ * replay contract: `alreadyRedeemed` marks the dot as RECORDED, not the overlay as LANDED, so a
17
+ * replay must RE-APPLY, and this verb is what makes re-applying safe); refuses `session_ended`
18
+ * once the session's lifetime epoch is sealed — a late in-flight write after termination must not
19
+ * revive an authorization for a session that no longer exists.
20
+ *
21
+ * There is deliberately NO delete verb: a session row is not individually deletable — it dies with
22
+ * its session (design/382 §10, ruled). Termination is the HOST's act on its own implementation (see
23
+ * {@link InMemorySessionRuleOverlay.endSession}), not an engine verb.
24
+ *
25
+ * ## Lifecycle and durability
26
+ *
27
+ * The overlay lives WITH the session: a host that parks/checkpoints a session serializes its rows
28
+ * beside it and rehydrates them on resume (`snapshotSession`/`restoreSession` on the reference
29
+ * implementation) — that is how a session authorization survives a park without ever touching the
30
+ * persisted store, and why it still ends when the session ends. `sessionId` uniqueness is the host's
31
+ * duty (the `principal` posture): reusing an id declares "the same session".
32
+ *
33
+ * ## What this is NOT
34
+ *
35
+ * Not a store backend: no dots are minted here (the redemption leg mints the audit dot exactly as the
36
+ * store leg does), no tombstones exist here, nothing here syncs. Every durable entrance — write
37
+ * deltas, sync in both directions, at-rest bytes — refuses or drops a session row
38
+ * (`permission-rule-store.ts`, the durable two-member face); this module is the OTHER side of that
39
+ * wall, and the wall is the design.
40
+ */
41
+ import type { PersistedAllowRule, RuleAdd, PersistedRuleMatch, PersistedRuleTool } from "./permission-rule-model.js";
42
+ /** One redeemed authorization landing in a session's overlay: the rule's canonical shape plus the
43
+ * redemption's own add (dot + provenance) — the same fields a `redemption-add` delta carries, minus
44
+ * the scope (the verb's `sessionId` IS the scope, constructed here so a caller cannot land a row
45
+ * whose scope names a different session than the one it addressed). */
46
+ export interface SessionRuleOverlayAdd {
47
+ rule: string;
48
+ tool: PersistedRuleTool;
49
+ match: PersistedRuleMatch;
50
+ command: string;
51
+ add: RuleAdd;
52
+ }
53
+ /** What one overlay apply answered. `session_ended` is the epoch fence speaking: the write arrived at
54
+ * (or after) termination and landed nothing — the redemption reports the member `refused`. */
55
+ export type SessionRuleOverlayApplyResult = {
56
+ landed: true;
57
+ } | {
58
+ refused: "session_ended";
59
+ };
60
+ /**
61
+ * The host-provided seam the consent lane and the gate's rule lane consume
62
+ * (`RuleConsentDeps.sessionRules`, `RunnerDeps.sessionPermissionRules`). See the module doc for the
63
+ * contract; {@link InMemorySessionRuleOverlay} is the reference implementation and the semantics.
64
+ */
65
+ export interface SessionRuleOverlay {
66
+ /** The live session-scoped rows of ONE session. An ended (or never-written) session reads as `[]`. */
67
+ read(sessionId: string): Promise<readonly PersistedAllowRule[]>;
68
+ /** Land one redeemed authorization — idempotent by dot, refused once the session ended. */
69
+ apply(sessionId: string, add: SessionRuleOverlayAdd): Promise<SessionRuleOverlayApplyResult>;
70
+ }
71
+ /**
72
+ * The reference overlay — in-memory, per-process, the semantics every host implementation must keep:
73
+ *
74
+ * · APPLY is idempotent by dot identity (a replayed redemption re-submits its recorded dot and
75
+ * changes nothing — the store's `foldDelta` discipline, verbatim);
76
+ * · TERMINATION seals FIRST, clears SECOND ({@link endSession}): the sealed-set membership is the
77
+ * session's lifetime epoch, flipped atomically before any state is dropped, so an in-flight write
78
+ * that lost the race lands on the seal and answers `session_ended` — never on a half-cleared
79
+ * session, and never as a row that outlives its session (design/382 §4.3, the r2-⑥ fence);
80
+ * · a sealed session never reopens — `apply` and `restoreSession` both refuse it, so "session ended"
81
+ * is a one-way fact exactly like a quarantined dot's;
82
+ * · SNAPSHOT/RESTORE ({@link snapshotSession}/{@link restoreSession}) are the park/resume carriage:
83
+ * the host serializes a session's rows into its checkpoint and rehydrates them on resume. Restore
84
+ * is fail-closed on shape — every row must be a session row of THE session being restored (a
85
+ * checkpoint is host-owned bytes, and a global row smuggled through this door would ride the
86
+ * merge point straight into adjudication).
87
+ */
88
+ export declare class InMemorySessionRuleOverlay implements SessionRuleOverlay {
89
+ private readonly rows;
90
+ private readonly sealed;
91
+ /** How many times one session's row set has CHANGED. Reading a caller-owned value runs caller code,
92
+ * so a verb that reads its argument and then replaces a session's rows wholesale must be able to
93
+ * tell that something landed in between — otherwise it overwrites a grant that was already answered
94
+ * `landed:true`, and a consent nobody withdrew stops existing. Bumped by every landing, restore and
95
+ * termination; read across `restoreSession`'s snapshot. */
96
+ private readonly epochs;
97
+ private bumpEpoch;
98
+ read(sessionId: string): Promise<readonly PersistedAllowRule[]>;
99
+ apply(sessionId: string, add: SessionRuleOverlayAdd): Promise<SessionRuleOverlayApplyResult>;
100
+ /**
101
+ * The termination transfer: SEAL the lifetime epoch first (one atomic membership flip — after this
102
+ * line every in-flight `apply` holding this id answers `session_ended`), THEN drop the rows. The
103
+ * order is the whole fence: clear-then-seal would leave a window in which a late write re-grows a
104
+ * rule set for a session that is already gone. Idempotent; a never-seen id seals to the same place.
105
+ */
106
+ endSession(sessionId: string): void;
107
+ /** The park half of the checkpoint carriage: this session's rows, serializable as-is. */
108
+ snapshotSession(sessionId: string): Promise<readonly PersistedAllowRule[]>;
109
+ /**
110
+ * The resume half. Fail-closed on every axis: a sealed session never rehydrates (termination is
111
+ * one-way), and every row must be a well-formed SESSION row of exactly this session — anything else
112
+ * in the checkpoint bytes is refused whole, loudly, because the merge point trusts this overlay to
113
+ * serve only what its contract says it holds.
114
+ *
115
+ * The rows also pass the SAME `normalizePersistedRule` re-projection the sync face runs on store
116
+ * rows (rule text parses, canonical spelling, stored tool/match/command agree with the text): this
117
+ * door takes host-owned checkpoint bytes with no redemption validator in front of them, and a row
118
+ * the engine could not re-project would otherwise sit one merge away from adjudication. Rows this
119
+ * overlay landed itself always pass — {@link InMemorySessionRuleOverlay.apply} runs the same screen,
120
+ * so the two doors agree on what a session row is and a snapshot of this overlay always restores —
121
+ * which is what makes a refusal here foreign damage, refused whole.
122
+ */
123
+ restoreSession(sessionId: string, rows: readonly PersistedAllowRule[]): Promise<void>;
124
+ }
@@ -0,0 +1,121 @@
1
+ import { isValidConsentScope, normalizePersistedRule } from "./permission-rule-store.js";
2
+ function isValidOverlayAdd(add) {
3
+ const a = add;
4
+ if (typeof a?.dot?.actor !== "string" || a.dot.actor === "" || typeof a.dot.counter !== "number" || !Number.isFinite(a.dot.counter))
5
+ return false;
6
+ if (a.origin !== "user" && a.origin !== "imported-cc" && a.origin !== "starter")
7
+ return false;
8
+ return typeof a.createdAt === "string";
9
+ }
10
+ export class InMemorySessionRuleOverlay {
11
+ rows = new Map();
12
+ sealed = new Set();
13
+ epochs = new Map();
14
+ bumpEpoch(sessionId) {
15
+ this.epochs.set(sessionId, (this.epochs.get(sessionId) ?? 0) + 1);
16
+ }
17
+ async read(sessionId) {
18
+ return structuredClone(this.rows.get(sessionId) ?? []);
19
+ }
20
+ async apply(sessionId, add) {
21
+ if (this.sealed.has(sessionId))
22
+ return { refused: "session_ended" };
23
+ add = structuredClone(add);
24
+ if (this.sealed.has(sessionId))
25
+ return { refused: "session_ended" };
26
+ if (!isValidOverlayAdd(add?.add)) {
27
+ throw new Error("SessionRuleOverlay.apply was handed a malformed add (dot/origin/createdAt) — evidence this shape cannot vouch for must not install");
28
+ }
29
+ const scope = { kind: "session", sessionId };
30
+ if (!isValidConsentScope(scope)) {
31
+ throw new Error("SessionRuleOverlay.apply was addressed with an unusable session id — a session nobody can name is not a scope a grant can live under");
32
+ }
33
+ const projected = { rule: add.rule, tool: add.tool, match: add.match, command: add.command, scope, adds: [add.add] };
34
+ if ("reject" in normalizePersistedRule(projected)) {
35
+ throw new Error("SessionRuleOverlay.apply was handed a row whose text/metadata do not survive the canonical re-projection — a row the engine cannot re-project can never adjudicate, and would refuse its own restore");
36
+ }
37
+ const rows = this.rows.get(sessionId) ?? [];
38
+ if (!this.rows.has(sessionId))
39
+ this.rows.set(sessionId, rows);
40
+ const sameDotAs = (a) => a.dot.actor === add.add.dot.actor && a.dot.counter === add.add.dot.counter;
41
+ for (const [heldSession, heldRows] of this.rows) {
42
+ const holder = heldRows.find((r) => r.adds.some(sameDotAs));
43
+ if (holder !== undefined && (heldSession !== sessionId || holder.rule !== add.rule)) {
44
+ throw new Error(`SessionRuleOverlay.apply: dot ${add.add.dot.actor}#${add.add.dot.counter} already vouches for another grant — a dot identifies one add of one (rule, session), ever`);
45
+ }
46
+ }
47
+ const existing = rows.find((r) => r.rule === add.rule);
48
+ if (existing === undefined) {
49
+ rows.push({ rule: add.rule, tool: add.tool, match: add.match, command: add.command, scope, adds: [structuredClone(add.add)] });
50
+ this.bumpEpoch(sessionId);
51
+ return { landed: true };
52
+ }
53
+ if (!existing.adds.some((a) => a.dot.actor === add.add.dot.actor && a.dot.counter === add.add.dot.counter)) {
54
+ existing.adds.push(structuredClone(add.add));
55
+ this.bumpEpoch(sessionId);
56
+ }
57
+ return { landed: true };
58
+ }
59
+ endSession(sessionId) {
60
+ this.sealed.add(sessionId);
61
+ this.rows.delete(sessionId);
62
+ this.bumpEpoch(sessionId);
63
+ }
64
+ async snapshotSession(sessionId) {
65
+ return this.read(sessionId);
66
+ }
67
+ async restoreSession(sessionId, rows) {
68
+ if (this.sealed.has(sessionId)) {
69
+ throw new Error(`session "${sessionId}" has ended — a sealed session's authorizations cannot be restored (termination is one-way)`);
70
+ }
71
+ const epochBeforeRead = this.epochs.get(sessionId) ?? 0;
72
+ rows = structuredClone(rows);
73
+ if (this.sealed.has(sessionId)) {
74
+ throw new Error(`session "${sessionId}" has ended — a sealed session's authorizations cannot be restored (termination is one-way)`);
75
+ }
76
+ if ((this.epochs.get(sessionId) ?? 0) !== epochBeforeRead) {
77
+ throw new Error(`restoreSession("${sessionId}") raced a landing on the same session — refusing the whole restore (this door replaces the session's rows, and a grant that already answered landed must not be erased by a resume)`);
78
+ }
79
+ for (const r of rows) {
80
+ const scope = r?.scope;
81
+ if (scope?.kind !== "session" || scope.sessionId !== sessionId || !isValidConsentScope(scope)) {
82
+ throw new Error(`restoreSession("${sessionId}") was handed a row that is not a session row of that session — refusing the whole restore (a foreign-scope row through this door would reach adjudication)`);
83
+ }
84
+ if ("reject" in normalizePersistedRule(r)) {
85
+ throw new Error(`restoreSession("${sessionId}") was handed a row whose text/metadata do not survive the canonical re-projection — refusing the whole restore (a row the engine cannot re-project must not reach adjudication)`);
86
+ }
87
+ }
88
+ const ownDotRule = new Map();
89
+ for (const held of this.rows.get(sessionId) ?? []) {
90
+ for (const h of held.adds)
91
+ ownDotRule.set(`${h.dot.actor}#${h.dot.counter}`, held.rule);
92
+ }
93
+ const seenDots = new Map();
94
+ for (const r of rows) {
95
+ if (!Array.isArray(r.adds) || r.adds.length === 0 || !r.adds.every(isValidOverlayAdd)) {
96
+ throw new Error(`restoreSession("${sessionId}") was handed a row with malformed add evidence — refusing the whole restore (an add whose dot/origin cannot be read is not a consent anyone can audit)`);
97
+ }
98
+ for (const a of r.adds) {
99
+ const key = `${a.dot.actor}#${a.dot.counter}`;
100
+ const holder = seenDots.get(key);
101
+ if (holder !== undefined && holder !== r.rule) {
102
+ throw new Error(`restoreSession("${sessionId}"): dot ${key} vouches for two different rules — refusing the whole restore`);
103
+ }
104
+ seenDots.set(key, r.rule);
105
+ const ownHolder = ownDotRule.get(key);
106
+ if (ownHolder !== undefined && ownHolder !== r.rule) {
107
+ throw new Error(`restoreSession("${sessionId}"): dot ${key} is live in this session under a different rule — a restore may replay a grant, never reassign its dot; refusing the whole restore`);
108
+ }
109
+ for (const [heldSession, heldRows] of this.rows) {
110
+ if (heldSession === sessionId)
111
+ continue;
112
+ if (heldRows.some((held) => held.adds.some((h) => h.dot.actor === a.dot.actor && h.dot.counter === a.dot.counter))) {
113
+ throw new Error(`restoreSession("${sessionId}"): dot ${key} already vouches for another live session's grant — refusing the whole restore`);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ this.rows.set(sessionId, rows);
119
+ this.bumpEpoch(sessionId);
120
+ }
121
+ }
@@ -215,7 +215,8 @@ export interface WritablePermissionRuleStore extends PermissionRuleStore {
215
215
  }
216
216
  /** The writer of a store, or `undefined` when the backend is read-only from the engine's side. */
217
217
  export declare function writerOf(store: PermissionRuleStore): PermissionRuleWriter | undefined;
218
- /** Do two scopes name the same place? */
218
+ /** Do two scopes name the same place? Three members (design/382 §4.1): global, project-by-root,
219
+ * session-by-sessionId. */
219
220
  export declare function sameScope(a: RuleScope, b: RuleScope): boolean;
220
221
  /**
221
222
  * Apply tombstones to a raw persisted set: an add survives iff its dot appears in no tombstone for the
@@ -238,6 +239,33 @@ export declare function addDotsOf(rules: readonly PersistedAllowRule[]): RuleDot
238
239
  * Every backend calls this before appending a tombstone.
239
240
  */
240
241
  export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): void;
242
+ /**
243
+ * design/382 §4.3 — the DURABLE-scope gate of the two direct write arms, shared by every backend
244
+ * (the `assertDeleteDeltaCarriesNoAdd` doctrine: the check is executed, not merely intended, because a
245
+ * union does not constrain a JavaScript caller's payload).
246
+ *
247
+ * A `redemption-add` whose scope — or a `tighten-delete` whose tombstone's scope — is a session scope
248
+ * refuses LOUDLY with the typed code `unsupported.session_scope_store`: a session authorization's home
249
+ * is the session's own overlay, and a session TOMBSTONE is equally unspellable here — session rows are
250
+ * not individually deletable (they die with their session, design/382 §10), so a tombstone naming one
251
+ * is either a foreign writer's fabrication or a caller wiring fault, and both deserve the loud arm.
252
+ * The third arm (`sync-join`) is NOT gated here: its session rows are per-row DROPPED AND DISCLOSED by
253
+ * {@link screenRuleSyncState} (a peer's damage must not veto a whole round the way a local caller's
254
+ * bug must), and a session-scoped QUARANTINE instruction throws inside {@link applySyncJoin} itself.
255
+ *
256
+ * The session member is the LOUD, typed arm; the rest of the face is executed too. A scope that is
257
+ * neither member in well-formed shape — `{kind:"project", root:""}` above all, which `pathWithinRoot`
258
+ * turns into base `/` so the row crosses every project boundary — is a caller wiring fault, refused
259
+ * with the untyped loud arm the sibling {@link assertDeleteDeltaCarriesNoAdd} uses (no closed-set code
260
+ * names it: it is nobody's supported request). The sync arm already refuses the same shape inbound
261
+ * ({@link applySyncJoin} runs {@link isValidDurableScope} on every joined row), so this closes the
262
+ * matching direct-write door rather than inventing a new rule.
263
+ *
264
+ * Exported with the backend contract for the same OPEN-SET duty {@link isValidDurableScope} names:
265
+ * any lane — host-side import doors included — that writes rows into a persisted store carries the
266
+ * durable two-member face.
267
+ */
268
+ export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleDeleteDelta): void;
241
269
  /**
242
270
  * The state one sync round exchanges: raw adds (tombstoned dots NOT pre-filtered) plus tombstones.
243
271
  * The live view is always DERIVED (`applyTombstones`); the join below never deletes anything.
@@ -291,6 +319,27 @@ export interface RuleSyncLandingReport {
291
319
  * a dropped tombstone may have covered them — they re-arrive once the peer's state is clean). */
292
320
  withheldAdds: number;
293
321
  }
322
+ /**
323
+ * design/382 §4.3 — the CONSENT face's scope validity: the union's full three members, their shapes
324
+ * executed (a project scope with an empty root would admit every cwd, wider than global, through
325
+ * `pathWithinRoot`'s base construction; an empty sessionId would be a session nobody can name).
326
+ * Read by the consent protocol's entries (prepare's explicit-scope gate, the record integrity walk),
327
+ * and exported for the server's consent-projection twin. NEVER by a durable entrance — that face is
328
+ * {@link isValidDurableScope}, and keeping the two as separate named tables is the design's point:
329
+ * a session row leaking toward the store must meet an explicit refusal at every door, not a shared
330
+ * table someone widened for the other face's sake.
331
+ */
332
+ export declare function isValidConsentScope(scope: RuleScope): boolean;
333
+ /**
334
+ * design/382 §4.3 — the DURABLE face's scope validity: the two members a persisted row may carry,
335
+ * `{global, project}`. A session scope is a WELL-FORMED consent scope that is structurally not a
336
+ * durable one — session authorizations live in the session's own overlay and die with it; letting one
337
+ * into the store (or the sync wire, or the at-rest bytes) would give it exactly the afterlife the
338
+ * dimension is defined not to have. Exported for the server's rule-sync validator (same face, same
339
+ * two members) and for any host-side lane that writes rows into a persisted store — the OPEN-SET
340
+ * duty (design/382 §10): every such entrance, present or future, carries this face.
341
+ */
342
+ export declare function isValidDurableScope(scope: RuleScope): boolean;
294
343
  /** Is `dot` at or below the frontier? Absent actor ⇒ not below (a frontier never covers what it never saw). */
295
344
  export declare function dotAtOrBelowFrontier(dot: RuleDot, frontier: RuleSyncFrontier): boolean;
296
345
  /**
@@ -314,7 +363,21 @@ export declare function normalizePersistedRule(r: PersistedAllowRule): {
314
363
  } | {
315
364
  reject: Extract<RuleSyncDropReason, "invalid_rule_text" | "metadata_mismatch">;
316
365
  };
317
- /** The normalization half exposed for the DISCLOSURE layer: which records a join would refuse, and why. */
366
+ /** The normalization half exposed for the DISCLOSURE layer: which records a join would refuse, and why.
367
+ *
368
+ * design/382 §4.3 — this screen carries the SESSION member of the durable face on every JOIN leg
369
+ * (inbound state, local state, both sides of the pure join): a rule or tombstone row carrying a
370
+ * session scope is dropped and reported (`session_scope_not_durable`), never landed and never allowed
371
+ * to veto the round the way a malformed tombstone does. Dropping a session TOMBSTONE cannot widen:
372
+ * tombstones remove adds only under the SAME (rule, scope), and every session-scope add is dropped by
373
+ * this same screen — the #176 "its cover is not load-bearing" argument, one scope over.
374
+ *
375
+ * The face's OTHER member — a durable scope that is not well-formed, `{kind:"project", root:""}`
376
+ * above all — is executed on the INBOUND legs by {@link applySyncJoin} (its tombstone pre-loop and its
377
+ * rule arm both run {@link isValidDurableScope}) and at the direct write arms by
378
+ * {@link assertWriteDeltaScopeDurable}, not here. Stated so the boundary is readable: a LOCAL row of
379
+ * that shape — reachable only by editing the at-rest bytes, or from a store written before the write
380
+ * arm executed that axis — passes this screen and stays live. */
318
381
  export declare function screenRuleSyncState(state: RuleSyncState): {
319
382
  state: RuleSyncState;
320
383
  rejected: RuleSyncDrop[];