@sema-agent/core 7.24.3 → 7.25.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +101 -1
  2. package/dist/agents/peer-session-drain.d.ts +16 -1
  3. package/dist/agents/peer-session-drain.js +16 -1
  4. package/dist/agents/send-message-tool.d.ts +10 -2
  5. package/dist/agents/send-message-tool.js +18 -7
  6. package/dist/agents/subagent.js +13 -10
  7. package/dist/core/background-agent-store.d.ts +3 -3
  8. package/dist/core/checkpoint-store.d.ts +1 -0
  9. package/dist/core/delegation-frames.d.ts +5 -0
  10. package/dist/core/gate-fold.js +85 -0
  11. package/dist/core/gate-lanes.js +23 -1
  12. package/dist/core/gate-outcome.d.ts +8 -2
  13. package/dist/core/gate-outcome.js +3 -1
  14. package/dist/core/hooks.d.ts +29 -0
  15. package/dist/core/hooks.js +2 -0
  16. package/dist/core/mailbox-store.d.ts +5 -0
  17. package/dist/core/mailbox-store.js +1 -1
  18. package/dist/core/parent-spec-seat.d.ts +7 -1
  19. package/dist/core/parent-spec-seat.js +2 -0
  20. package/dist/core/permission-rule-consent.js +29 -47
  21. package/dist/core/permission-rule-provider.js +4 -3
  22. package/dist/core/permission-rule-store.d.ts +143 -7
  23. package/dist/core/permission-rule-store.js +165 -15
  24. package/dist/core/permission-rules.js +14 -10
  25. package/dist/core/rule-denial-sentence.d.ts +75 -0
  26. package/dist/core/rule-denial-sentence.js +16 -0
  27. package/dist/core/runner/contracts.d.ts +36 -2
  28. package/dist/core/runner/gate-exit.d.ts +14 -0
  29. package/dist/core/runner/gate-exit.js +4 -0
  30. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
  31. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  32. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  33. package/dist/core/runner/prepare-gate-stations.js +7 -4
  34. package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
  35. package/dist/core/runner/prepare-hands-readface.js +25 -10
  36. package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
  37. package/dist/core/runner/prepare-inherited-gate.js +11 -6
  38. package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
  39. package/dist/core/runner/prepare-policy-chain.js +42 -22
  40. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
  41. package/dist/core/runner/prepare-suspend-saga.js +4 -3
  42. package/dist/core/runner/prepare-task.js +10 -9
  43. package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
  44. package/dist/core/runner/prepare-wiring-manifest.js +8 -4
  45. package/dist/core/runner/resume-apply.js +2 -0
  46. package/dist/core/runner/run-harness-handlers.js +1 -0
  47. package/dist/core/runner/run-identity-wiring.d.ts +2 -2
  48. package/dist/core/runner/run-leg.js +28 -1
  49. package/dist/core/runner/run-notification-lane.d.ts +2 -2
  50. package/dist/core/runner/run-notification-lane.js +2 -1
  51. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  52. package/dist/core/runner/session-rule-policy.d.ts +73 -2
  53. package/dist/core/runner/session-rule-policy.js +121 -21
  54. package/dist/core/session-policy-store.d.ts +27 -3
  55. package/dist/core/session-policy-store.js +12 -4
  56. package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
  57. package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
  58. package/dist/core/store-contracts/peer-directory-contract.js +82 -0
  59. package/dist/core/task-event.d.ts +6 -0
  60. package/dist/core/task-notification.d.ts +79 -4
  61. package/dist/core/task-notification.js +90 -1
  62. package/dist/core/task-registry-agent.js +1 -2
  63. package/dist/core/task-registry-monitor.js +2 -1
  64. package/dist/core/task-registry-shared.d.ts +53 -5
  65. package/dist/core/task-registry-shared.js +13 -2
  66. package/dist/core/task-registry.js +8 -5
  67. package/dist/core/task-spec.d.ts +12 -5
  68. package/dist/core/tool-catalog-entries.js +1 -0
  69. package/dist/core/tool-spec.d.ts +1 -1
  70. package/dist/core/wiring-manifest.d.ts +2 -2
  71. package/dist/engine/loop/agent-loop.js +1 -1
  72. package/dist/engine/loop/types.d.ts +4 -0
  73. package/dist/index.d.ts +6 -3
  74. package/dist/index.js +4 -3
  75. package/dist/orchestration/workflow.js +12 -3
  76. package/dist/prompts/coordinator.d.ts +2 -1
  77. package/dist/stores/file/adoption/adopt.js +23 -1
  78. package/dist/stores/file/permission-rule-store.js +3 -1
  79. package/dist/stores/file/session-policy-store.d.ts +31 -0
  80. package/dist/stores/file/session-policy-store.js +87 -7
  81. package/dist/tools/fs/bash-program-position.d.ts +4 -26
  82. package/dist/tools/fs/bash-program-position.js +0 -11
  83. package/dist/tools/fs/bash-readonly-classifier.d.ts +20 -309
  84. package/dist/tools/fs/bash-readonly-classifier.js +28 -766
  85. package/dist/tools/fs/fs-bash.d.ts +43 -207
  86. package/dist/tools/fs/fs-bash.js +20 -341
  87. package/dist/tools/fs/index.d.ts +12 -13
  88. package/dist/tools/fs/index.js +3 -4
  89. package/dist/tools/fs/read-deny.d.ts +25 -0
  90. package/dist/tools/fs/read-deny.js +19 -0
  91. package/dist/tools/fs/safety.js +2 -6
  92. package/dist/tools/fs/shell-read-check.d.ts +99 -0
  93. package/dist/tools/fs/shell-read-check.js +229 -0
  94. package/dist/tools/fs/shell-read-establish.d.ts +70 -115
  95. package/dist/tools/fs/shell-read-establish.js +216 -102
  96. package/dist/tools/fs/shell-read-judge.d.ts +231 -0
  97. package/dist/tools/fs/shell-read-judge.js +170 -0
  98. package/dist/tools/fs/shell-read-plan.d.ts +3 -0
  99. package/dist/tools/fs/shell-read-plan.js +82 -19
  100. package/dist/tools/monitor.d.ts +5 -5
  101. package/dist/tools/monitor.js +3 -5
  102. package/dist/tools/task-list.d.ts +4 -0
  103. package/package.json +1 -1
  104. package/test/export-surface.snapshot.json +19 -3
  105. package/wire-changes/core@7.24.4.json +54 -0
  106. package/wire-changes/core@7.25.0.json +671 -0
@@ -11,7 +11,7 @@ import { catalogRuleFaceOf } from "./tool-registry.js";
11
11
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
12
12
  import { exitGate } from "./runner/gate-exit.js";
13
13
  import { traceHookCrash } from "./gate-crash-trace.js";
14
- import { createGateDeadline, awaitWriteProtectionJudgment } from "./gate-bounded-await.js";
14
+ import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
15
15
  import { thrownText } from "./thrown-value.js";
16
16
  function isPlainOwnRecord(x) {
17
17
  try {
@@ -691,6 +691,28 @@ export async function runGateLanes(pass) {
691
691
  pass.deniedBy = "write_protection";
692
692
  }
693
693
  }
694
+ if (editDenied === undefined && input.shellReadCheck !== undefined) {
695
+ const station = input.shellReadCheck;
696
+ const judged = station.judge(toolName, editArgs, input.trackedCwd?.(), callSignal ?? input.abortSignal, { seat: "rejudge", toolCallId });
697
+ if (judged !== null) {
698
+ let verdict;
699
+ try {
700
+ verdict = await withGateTimeout(judged, boundedAwaitDeadlineMs(), callSignal ?? input.abortSignal, "shellReadStation");
701
+ }
702
+ catch {
703
+ verdict = station.unanswered(toolName, "the read station could not answer");
704
+ }
705
+ if (verdict.disposition !== "vouch") {
706
+ editDenied = {
707
+ action: "deny",
708
+ message: verdict.sentence ?? `the approved edit for "${toolName}" reads where the read boundary does not vouch — denied fail-closed; re-submit the command as a fresh call and answer its card`,
709
+ };
710
+ pass.deniedBy = "read_boundary";
711
+ if (verdict.details !== undefined)
712
+ pass.denyDetails = verdict.details;
713
+ }
714
+ }
715
+ }
694
716
  pass.decision = editDenied ?? { ...pass.decision, updatedInput: editArgs };
695
717
  }
696
718
  else if (pass.decision.action === "allow" && pass.decision.updatedInput === undefined && resolved.presentedInput !== undefined) {
@@ -154,8 +154,11 @@ export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude
154
154
  * path that no approval covers.
155
155
  * - `ask_resolution` — the ask's own settlement is the refusal (a person said no, a window elapsed,
156
156
  * nobody could be asked…); the detail is on {@link GateOutcome.settlement}.
157
+ * - `read_boundary` — the shell read station refused: a deny-listed read (refused on every seat, with no
158
+ * ask), a read the read-only shell face may not make, or a rewritten / re-established command whose reads
159
+ * no longer are what a person approved.
157
160
  */
158
- export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
161
+ export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
159
162
  export type DeniedBy = (typeof DENIED_BY_VALUES)[number];
160
163
  /** Membership test for {@link DeniedBy}. */
161
164
  export declare function isDeniedBy(v: unknown): v is DeniedBy;
@@ -165,7 +168,8 @@ export declare function isDeniedBy(v: unknown): v is DeniedBy;
165
168
  * classifier, plan mode and compliance run before any ask exists, and `ask_resolution` IS the
166
169
  * settlement, not a veto of it. `persisted_rule` = a person's own standing DENY row (their settings'
167
170
  * deny list, imported) — the personal-store sibling of `org`, and like it re-judged on an edited
168
- * command. */
171
+ * command. `read_boundary` vetoes too: the rewrite re-judge and the pre-spawn re-establishment run after
172
+ * the person's yes, on bytes or targets the person never saw. */
169
173
  export declare const DENIED_BY_MAY_VETO: {
170
174
  readonly policy: true;
171
175
  readonly hook: true;
@@ -176,6 +180,7 @@ export declare const DENIED_BY_MAY_VETO: {
176
180
  readonly compliance: false;
177
181
  readonly write_protection: true;
178
182
  readonly ask_resolution: false;
183
+ readonly read_boundary: true;
179
184
  };
180
185
  /** Compile-time fence: `never` while every deny layer has a veto row. */
181
186
  export type VetoTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_VETO>>;
@@ -209,6 +214,7 @@ export declare const DENIED_BY_MAY_CARRY_CAUSE: {
209
214
  readonly compliance: false;
210
215
  readonly write_protection: false;
211
216
  readonly ask_resolution: false;
217
+ readonly read_boundary: false;
212
218
  };
213
219
  /** Compile-time fence: `never` while every deny layer has a cause row. */
214
220
  export type CauseTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_CARRY_CAUSE>>;
@@ -39,7 +39,7 @@ export const SETTLEMENT_IS_REFUSAL = {
39
39
  blanket_allow_refused: true,
40
40
  task_aborted: true,
41
41
  };
42
- export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
42
+ export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
43
43
  const DENIED_BY_SET = new Set(DENIED_BY_VALUES);
44
44
  export function isDeniedBy(v) {
45
45
  return DENIED_BY_SET.has(v);
@@ -54,6 +54,7 @@ export const DENIED_BY_MAY_VETO = {
54
54
  compliance: false,
55
55
  write_protection: true,
56
56
  ask_resolution: false,
57
+ read_boundary: true,
57
58
  };
58
59
  export const CLASSIFIER_DENY_CAUSES = ["unavailable", "parse_error"];
59
60
  const CLASSIFIER_DENY_CAUSE_SET = new Set(CLASSIFIER_DENY_CAUSES);
@@ -70,6 +71,7 @@ export const DENIED_BY_MAY_CARRY_CAUSE = {
70
71
  compliance: false,
71
72
  write_protection: false,
72
73
  ask_resolution: false,
74
+ read_boundary: false,
73
75
  };
74
76
  export function screenGateOutcome(g) {
75
77
  const defects = [];
@@ -732,6 +732,16 @@ export interface HookInvocationIdentity {
732
732
  * UNTRUSTED display text (model-chosen) — already single-line-bounded at mint (same
733
733
  * `inlineUntrusted` treatment as the `task_progress` name lane); never an identity key. */
734
734
  readonly agentName?: string;
735
+ /** The EXPLICIT `Agent({name})` spawn identity, when the spawn carried one — sema's teammate key
736
+ * (validated at spawn: `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`, never `main`). The same string the
737
+ * child's roster row, its uplink `from` label and its `task_progress.spawnName` carry, so a hook
738
+ * seat binds "which teammate" by string equality. ABSENT = an unnamed delegation (not a
739
+ * teammate); {@link agentName} above stays the DISPLAY label and is never an identity key. */
740
+ readonly spawnName?: string;
741
+ /** The RESOLVED subagent type the leg was spawned as (`"general-purpose"` / `"fork"` / a roster
742
+ * definition's name — the fleet row's `agentType` word, never the description). Absent on a
743
+ * top-level run and on a leg whose spawner threaded no type (a workflow-lane agent). */
744
+ readonly agentType?: string;
735
745
  /** The spawning Agent tool call's own id, when this leg runs under one — the delegation-chain
736
746
  * position anchor ({@link import("./types.js").TaskEvent}'s `parentToolCallId` twin). Absent on
737
747
  * a top-level run and on a directly-started workflow child (no launching tool call — no id is
@@ -919,6 +929,11 @@ export interface ToolGateResult {
919
929
  gate?: GateOutcome;
920
930
  /** Rewritten args to execute with (re-validated by the loop); omitted when nothing rewrote. */
921
931
  updatedInput?: unknown;
932
+ /** A blocking refusal's structured details, when the refusing station minted any (the shell read station's
933
+ * `read_path_denied` / `readonly_out_of_root` cards): the loop attaches them to the error result it mints
934
+ * from `reason`, so the refusal reaches `tool_end.structured` / `errorCode` exactly as the tool's own
935
+ * refusal of the same finding did. Absent on every other block. */
936
+ details?: unknown;
922
937
  /**
923
938
  * design/45: the gate routed a policy `ask` to a **durable suspension** (no synchronous approver).
924
939
  * `suspendAsk` already persisted the checkpoint and aborted the run; the caller records this so the
@@ -1362,6 +1377,20 @@ export interface ToolGateInput {
1362
1377
  * point there), a promise for a covered write. Absent ⇒ no table (`[]`) and the decision path is byte-identical.
1363
1378
  */
1364
1379
  writeProtectionCheck?: import("./write-protect.js").WriteProtectionCheck;
1380
+ /**
1381
+ * The shell READ STATION — ENGINE-BUILT by the hands phase (`createShellReadStation`,
1382
+ * `src/tools/fs/shell-read-check.ts`) for the shell-running tools of this leg, never a deployment callback.
1383
+ * Judges the FINAL args of a shell call: ONE parse of the command into its read candidates, each
1384
+ * established against the execution environment (symlinks resolved, patterns expanded, traversal roots
1385
+ * enumerated, within a bound) and judged by the leg's read deny seat and its containment roots. A
1386
+ * POLICY-class member of the fold: a deny-listed read is REFUSED (`deniedBy: "read_boundary"`, composing
1387
+ * under `deny > ask > allow`), an out-of-root or unestablishable read tightens a surviving allow to a
1388
+ * MANDATED ask (or marks an existing ask mandated), a read the station declined to judge tightens to an
1389
+ * ordinary ask on the classify seat and a mandated one on the off seat, and a vouched call is untouched.
1390
+ * `null` SYNCHRONOUSLY for a call it does not cover (no suspension point there), a promise for a shell
1391
+ * call. Absent ⇒ no station (no shell mounted) and the decision path is byte-identical.
1392
+ */
1393
+ shellReadCheck?: import("../tools/fs/shell-read-check.js").ShellReadCheck;
1365
1394
  /**
1366
1395
  * design/143 批2 (A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
1367
1396
  * small-model policy CLASSIFIER before any human/durable resolution:
@@ -167,6 +167,8 @@ export function mintHookInvocationIdentity(facts) {
167
167
  isDelegatedChild: facts.isDelegatedChild,
168
168
  ...(facts.insideFork === true ? { insideFork: true } : {}),
169
169
  ...(facts.agentName !== undefined ? { agentName: inlineUntrusted(facts.agentName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
170
+ ...(facts.spawnName !== undefined ? { spawnName: inlineUntrusted(facts.spawnName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
171
+ ...(facts.agentType !== undefined ? { agentType: inlineUntrusted(facts.agentType.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
170
172
  ...(facts.parentToolCallId !== undefined ? { parentToolCallId: facts.parentToolCallId } : {}),
171
173
  }));
172
174
  }
@@ -25,6 +25,11 @@ export interface MailboxMessage {
25
25
  export interface MailboxPeerMeta {
26
26
  /** The sending session's id (reply routing). */
27
27
  fromSession?: string;
28
+ /** The delegation-tree ROOT the sender belongs to, stamped by the park seat of a session that
29
+ * received the frame through its own child uplink — never by the sender. A recipient whose
30
+ * session IS this root reads the record as a teammate's words (same tree = same admission as the
31
+ * live uplink: the admission gate runs, the cross-session parity hold does not). */
32
+ fromRoot?: string;
28
33
  /** The sender's permission-mode class at send time — the drain judges parity against the RECEIVER. */
29
34
  fromMode?: MailboxPeerFromMode;
30
35
  /** The admission gate's sender key (rate/dedup axis). */
@@ -4,7 +4,7 @@ export const MAILBOX_PEER_RECORD_KINDS = ["peer_message", "idle_notice", "delive
4
4
  export const MAILBOX_PEER_NOTICE_STATES = ["held", "denied", "expired", "delivered", "refused", "dropped", "idle", "exited", "unavailable"];
5
5
  export const MAILBOX_INVALID_PEER_META_CODE = "mailbox.invalid_peer_meta";
6
6
  export const MAILBOX_CROSS_PROCESS_UNSAFE_CODE = "mailbox.cross_process_unsafe";
7
- const PEER_META_STRING_KEYS = ["fromSession", "senderKey", "fromScope", "gateReceiptId"];
7
+ const PEER_META_STRING_KEYS = ["fromSession", "fromRoot", "senderKey", "fromScope", "gateReceiptId"];
8
8
  export function readMailboxPeerMeta(raw) {
9
9
  if (raw === undefined)
10
10
  return undefined;
@@ -9,10 +9,11 @@
9
9
  * The checkpoint seat (`./checkpoint-seat.ts`) is the same shape for one field; this seat carries the rest.
10
10
  */
11
11
  import type { OnQuestion } from "./ask-question.js";
12
+ import type { Hooks } from "./hooks.js";
12
13
  import type { ToolExecuteContext } from "./tool-spec.js";
13
14
  import type { TaskSpec } from "./types.js";
14
15
  /** The fields of a spec the seat carries — one `Pick`, shared by the mint's input and the apply's target. */
15
- export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "clientContext" | "onQuestion" | "additionalDirectories" | "additionalReadDirectories" | "envFacts" | "memoryPersistenceCapable" | "memory">;
16
+ export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "clientContext" | "onQuestion" | "hooks" | "additionalDirectories" | "additionalReadDirectories" | "envFacts" | "memoryPersistenceCapable" | "memory">;
16
17
  /**
17
18
  * The seat a run hands its children. Every member is the parent's own value, frozen at mint (arrays copied and
18
19
  * frozen; the facts object copied). Members present iff the parent carries them — an absent member is "nothing to
@@ -22,6 +23,10 @@ export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "cli
22
23
  * · `clientContext` — the user's TZ/email facts (the child's environment block localizes the same way).
23
24
  * · `onQuestion` — the parent's FROZEN question face (`spec.onQuestion ?? deps.onQuestion`, the value the parent's
24
25
  * own AskUserQuestion resolves at): the child's question tool MOUNTS on it, so without it the child has no tool.
26
+ * · `hooks` — the parent's SPEC-level hook seams (`spec.hooks`, the request-level lifecycle hooks a host mounts from
27
+ * the user's settings). A deployment's `RunnerDeps.hooks` already serve every leg; the spec-level seams did not,
28
+ * so a `stop` seat a user wired ran for the leader and never for its teammates (CC runs `SubagentStop` from the
29
+ * same settings). Same law as `onQuestion`: a child's own value wins, the seat fills an unset one.
25
30
  * · `additionalDirectories` / `additionalReadDirectories` — the fs-fence widening (deployment property).
26
31
  * · `envFacts` — the sandbox facts (profile / egress / scratchpad).
27
32
  * · `memoryPersistenceCapable` — the deployment's DECLARED persistence statement: `false` is a REMOVAL (floor),
@@ -34,6 +39,7 @@ export interface ParentSpecSeat {
34
39
  readonly oneShot?: true;
35
40
  readonly clientContext?: TaskSpec["clientContext"];
36
41
  readonly onQuestion?: OnQuestion;
42
+ readonly hooks?: Hooks;
37
43
  readonly additionalDirectories?: readonly string[];
38
44
  readonly additionalReadDirectories?: readonly string[];
39
45
  readonly envFacts?: TaskSpec["envFacts"];
@@ -4,6 +4,7 @@ export function parentSpecSeatOf(spec, frozenOnQuestion) {
4
4
  ...(spec.oneShot === true ? { oneShot: true } : {}),
5
5
  ...(spec.clientContext !== undefined ? { clientContext: spec.clientContext } : {}),
6
6
  ...(frozenOnQuestion !== undefined ? { onQuestion: frozenOnQuestion } : {}),
7
+ ...(spec.hooks !== undefined ? { hooks: spec.hooks } : {}),
7
8
  ...(spec.additionalDirectories !== undefined ? { additionalDirectories: Object.freeze([...spec.additionalDirectories]) } : {}),
8
9
  ...(spec.additionalReadDirectories !== undefined ? { additionalReadDirectories: Object.freeze([...spec.additionalReadDirectories]) } : {}),
9
10
  ...(spec.envFacts !== undefined ? { envFacts: Object.freeze({ ...spec.envFacts }) } : {}),
@@ -20,6 +21,7 @@ export function applyParentSpecSeat(child, seat) {
20
21
  ...fill("principal", seat.principal),
21
22
  ...fill("clientContext", seat.clientContext),
22
23
  ...fill("onQuestion", seat.onQuestion),
24
+ ...fill("hooks", seat.hooks),
23
25
  ...fill("additionalDirectories", (seat.additionalDirectories !== undefined ? [...seat.additionalDirectories] : undefined)),
24
26
  ...fill("additionalReadDirectories", (seat.additionalReadDirectories !== undefined ? [...seat.additionalReadDirectories] : undefined)),
25
27
  ...fill("envFacts", (seat.envFacts !== undefined ? { ...seat.envFacts } : undefined)),
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { escapeForDisclosure, hasUnrenderableCharacters, parseRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRulesForCommand, RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, isRuleBehavior, } from "./permission-rule-model.js";
3
3
  import { effectiveOrThrow } from "./permission-rule-provider.js";
4
- import { errText, isValidConsentScope, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
4
+ import { errText, isValidConsentScope, landRuleAdd, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
5
5
  export class InMemoryRuleApprovalRecordStore {
6
6
  rows = new Map();
7
7
  async get(id) {
@@ -600,7 +600,6 @@ export async function redeemRuleTicket(opts) {
600
600
  }
601
601
  return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined, ...(siblingsOf !== undefined ? { siblingsOf } : {}) });
602
602
  }
603
- const REDEEM_MAX_ATTEMPTS = 8;
604
603
  async function tighteningSiblingsStand(rec, offer, index, store, durableSnapshot) {
605
604
  for (const member of offerMembersOf(offer)) {
606
605
  if (member === index)
@@ -630,52 +629,35 @@ async function applyRedemption(args) {
630
629
  if (writer === undefined)
631
630
  return { status: "refused", reason: "the resolved permission-rule store has no write face" };
632
631
  const origin = originOfRecordKind(args.kind);
633
- for (let attempt = 0; attempt < REDEEM_MAX_ATTEMPTS; attempt++) {
634
- let rev;
635
- try {
636
- const snapshot = await effectiveOrThrow(store);
637
- rev = snapshot.rev;
638
- if (args.siblingsOf !== undefined && !(await tighteningSiblingsStand(args.siblingsOf.rec, args.siblingsOf.offer, args.siblingsOf.index, store, snapshot))) {
639
- return {
640
- status: "refused",
641
- reason: "withheld: this allow belongs to a batch whose deny/ask members do not all stand in the store — an allow must not stand wider than the list that was confirmed; redeem the batch (redeemRuleBatch) so the tightening members land first, or draw a fresh card",
642
- };
632
+ const siblings = args.siblingsOf;
633
+ const landing = await landRuleAdd({
634
+ store,
635
+ writer,
636
+ delta: {
637
+ kind: "redemption-add",
638
+ behavior: args.parsedRule.behavior,
639
+ rule: args.parsedRule.rule,
640
+ scope: args.candidate.scope,
641
+ tool: args.parsedRule.tool,
642
+ match: args.parsedRule.match,
643
+ command: args.parsedRule.command,
644
+ add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
645
+ redemption: {
646
+ recordId: args.recordId,
647
+ ...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }),
648
+ },
649
+ },
650
+ ...(siblings !== undefined
651
+ ? {
652
+ preflight: async (snapshot) => (await tighteningSiblingsStand(siblings.rec, siblings.offer, siblings.index, store, snapshot))
653
+ ? undefined
654
+ : "withheld: this allow belongs to a batch whose deny/ask members do not all stand in the store — an allow must not stand wider than the list that was confirmed; redeem the batch (redeemRuleBatch) so the tightening members land first, or draw a fresh card",
643
655
  }
644
- }
645
- catch (err) {
646
- return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
647
- }
648
- let res;
649
- try {
650
- res = await writer.apply({
651
- kind: "redemption-add",
652
- behavior: args.parsedRule.behavior,
653
- rule: args.parsedRule.rule,
654
- scope: args.candidate.scope,
655
- tool: args.parsedRule.tool,
656
- match: args.parsedRule.match,
657
- command: args.parsedRule.command,
658
- add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
659
- redemption: {
660
- recordId: args.recordId,
661
- ...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }),
662
- },
663
- }, { expectedRev: rev });
664
- }
665
- catch (err) {
666
- return { status: "refused", reason: `the permission-rule store refused the write: ${errText(err)}` };
667
- }
668
- if ("refused" in res) {
669
- return {
670
- status: "refused",
671
- 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)",
672
- };
673
- }
674
- if (!("conflict" in res)) {
675
- return { status: "redeemed", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: res.rev, alreadyRedeemed: args.replay };
676
- }
677
- }
678
- return { status: "refused", reason: `optimistic-concurrency retries exhausted after ${REDEEM_MAX_ATTEMPTS} attempts` };
656
+ : {}),
657
+ });
658
+ if (!landing.landed)
659
+ return { status: "refused", reason: landing.reason };
660
+ return { status: "redeemed", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: landing.rev, alreadyRedeemed: args.replay };
679
661
  }
680
662
  function originOfRecordKind(kind) {
681
663
  return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user";
@@ -111,11 +111,12 @@ function routingWriter(durable, session) {
111
111
  nextDot: () => durable.nextDot(),
112
112
  readRaw: () => durable.readRaw(),
113
113
  apply: async (delta, opts) => {
114
- const scope = delta.kind === "redemption-add" ? structuredClone(delta.scope) : undefined;
115
- if (delta.kind !== "redemption-add" || scope?.kind !== "session")
114
+ const isAdd = delta.kind === "redemption-add" || delta.kind === "tighten-add";
115
+ const scope = isAdd ? structuredClone(delta.scope) : undefined;
116
+ if (!isAdd || scope?.kind !== "session")
116
117
  return durable.apply(delta, opts);
117
118
  if (session === undefined) {
118
- const e = new Error("a session-scope rule has nowhere to land — this deployment configured no session rule partition (createPermissionRuleStoreProvider({ session })); refusing the redemption-add");
119
+ const e = new Error(`a session-scope rule has nowhere to land — this deployment configured no session rule partition (createPermissionRuleStoreProvider({ session })); refusing the ${delta.kind}`);
119
120
  e.code = "unsupported.session_scope_store";
120
121
  throw e;
121
122
  }
@@ -150,6 +150,30 @@ export interface RuleAddDelta {
150
150
  add: RuleAdd;
151
151
  redemption: RedemptionAuthorization;
152
152
  }
153
+ /**
154
+ * The TIGHTENING add — the true dual of {@link RuleDeleteDelta}, and the reason it is a separate arm
155
+ * rather than a `redemption-add` with the authorization left out: a delta named for a redemption that
156
+ * carries no redemption is a name that lies. This arm carries NO authorization block at all, exactly as
157
+ * `tighten-delete` does, because it needs none: a host may narrow on a person's behalf, it may not
158
+ * widen — so `behavior` excludes `allow` IN THE TYPE and the widening direction cannot reach this door.
159
+ * Minting a standing approval still takes a person's consent record, which is what `redemption-add` is.
160
+ *
161
+ * The QUARANTINE fence ({@link assertRedemptionNotQuarantined}) applies to `redemption-add` only, and
162
+ * not because it was forgotten here: the fence exists to stop a REPLAYED approval record from reviving
163
+ * an add its dot was quarantined for. A tightening add has no record and is never replayed — its dot is
164
+ * minted once, at the call — so there is no replay for the fence to catch.
165
+ */
166
+ export interface RuleTightenAddDelta {
167
+ kind: "tighten-add";
168
+ /** The tightening members only. `allow` is not in the type: see this interface's doc. */
169
+ behavior: Exclude<RuleBehavior, "allow">;
170
+ rule: string;
171
+ scope: RuleScope;
172
+ tool: PersistedRule["tool"];
173
+ match: PersistedRule["match"];
174
+ command: string;
175
+ add: RuleAdd;
176
+ }
153
177
  /** The delete half: appending one tombstone, and nothing else. */
154
178
  export interface RuleDeleteDelta {
155
179
  kind: "tighten-delete";
@@ -196,7 +220,7 @@ export interface RuleSyncJoinDelta {
196
220
  * again AT THE BACKEND: the fifth door of design/179 §4's validator list closes here, not at the
197
221
  * calling layer, and closes identically for every caller.
198
222
  */
199
- export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
223
+ export type RuleWriteDelta = RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
200
224
  /**
201
225
  * The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an
202
226
  * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
@@ -269,7 +293,7 @@ export declare function sameRuleIdentity(a: {
269
293
  export declare function applyTombstones(rules: readonly PersistedRule[], tombstones: readonly RuleTombstone[]): PersistedRule[];
270
294
  /** Fold one delta into a raw rule set, returning the new set. Pure — the backends share it so the file and
271
295
  * in-memory forms cannot drift in what a delta MEANS. */
272
- export declare function foldDelta(rules: readonly PersistedRule[], delta: RuleAddDelta): PersistedRule[];
296
+ export declare function foldDelta(rules: readonly PersistedRule[], delta: RuleAddDelta | RuleTightenAddDelta): PersistedRule[];
273
297
  /** Every add dot present in a rule set. */
274
298
  export declare function addDotsOf(rules: readonly PersistedRule[]): RuleDot[];
275
299
  /**
@@ -307,7 +331,7 @@ export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): v
307
331
  * any lane — host-side import doors included — that writes rows into a persisted store carries the
308
332
  * durable two-member face.
309
333
  */
310
- export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleDeleteDelta): void;
334
+ export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta): void;
311
335
  /**
312
336
  * The state one sync round exchanges: raw adds (tombstoned dots NOT pre-filtered) plus tombstones.
313
337
  * The live view is always DERIVED (`applyTombstones`); the join below never deletes anything.
@@ -497,6 +521,16 @@ export declare function applySyncJoin(cur: RuleSyncJoinBase, delta: RuleSyncJoin
497
521
  export declare function assertRedemptionNotQuarantined(quarantined: readonly QuarantinedRuleAdd[], delta: RuleAddDelta): void;
498
522
  /** Integrity fingerprint over a store's own facts. Corruption detection, not tamper-proofing. */
499
523
  export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
524
+ /**
525
+ * What a removal may say about the rule identity it just tombstoned. THREE words, not a boolean, for the
526
+ * same reason {@link RuleAddLanding} has `committed: "no" | "unknown"`: the answer comes from a read-back,
527
+ * and a read-back can fail. `yes` and `no` are things the store said; `unknown` is the honest word for a
528
+ * look that did not happen — reporting `no` there would render "we could not see it" as "it is not there",
529
+ * which is the one sentence this domain refuses to say anywhere else ("we cannot see the rules" is never
530
+ * "there are none").
531
+ */
532
+ export declare const REMOVAL_LIVENESS_WORDS: readonly ["yes", "no", "unknown"];
533
+ export type RemovalLiveness = (typeof REMOVAL_LIVENESS_WORDS)[number];
500
534
  /**
501
535
  * The result of a removal.
502
536
  *
@@ -506,19 +540,26 @@ export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
506
540
  export type RemoveResult =
507
541
  /** The tombstone landed and none of the observed dots survive. `stillLive` reports whether an approval
508
542
  * that landed DURING this call keeps the rule alive anyway (add-wins) — a real outcome with a name,
509
- * not an anomaly. */
543
+ * not an anomaly — or {@link RemovalLiveness} `unknown` when the read-back that answers it could not be
544
+ * taken. A consumer reading `unknown` as "removed" has re-introduced the very over-claim the third word
545
+ * exists to prevent: the tombstone landed, the rule's liveness is UNOBSERVED, reconcile with `list()`. */
510
546
  {
511
547
  status: "removed";
512
548
  rev: number;
513
- stillLive: boolean;
549
+ stillLive: RemovalLiveness;
514
550
  }
515
551
  /** Nothing to remove — including a repeated delete. No empty tombstone is minted. */
516
552
  | {
517
553
  status: "no-op";
518
554
  rev: number;
519
555
  }
520
- /** Terminal failure, confirmed by read-back to have written NOTHING. When the read-back itself failed,
521
- * `error` says so and the caller reconciles with `list()`. */
556
+ /** Terminal failure, in one of two strengths the `error` text distinguishes — word for word the dual of
557
+ * {@link AddResult}'s. When the STORE answered every attempt (the compare-and-set simply lost), the
558
+ * read-back confirms nothing was written and the text says so plainly. When the apply THREW, a missing
559
+ * tombstone cannot tell a write that never landed from one whose evidence a paired collection erased
560
+ * (`collectBelowFrontier` recycles a tombstone together with the adds it covered), so that outcome is
561
+ * reported INDETERMINATE and the caller reconciles with `list()` rather than retrying blind. When the
562
+ * read-back itself failed, `error` says so and the caller reconciles with `list()` too. */
522
563
  | {
523
564
  status: "failed";
524
565
  error: string;
@@ -544,6 +585,101 @@ export declare function removePersistedRule(opts: {
544
585
  principal: string | RuleOwner;
545
586
  provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider;
546
587
  }): Promise<RemoveResult>;
588
+ /**
589
+ * What the ONE add-write path answered. `landed` carries the revision the add committed at; every other
590
+ * terminal state is a single refusal string, which each door renders in its own vocabulary (the consent
591
+ * protocol says `refused`, the record-less entry says `failed`) — one mechanism, two spellings, never
592
+ * two mechanisms.
593
+ */
594
+ export type RuleAddLanding = {
595
+ landed: true;
596
+ rev: number;
597
+ }
598
+ /** `committed` is what the caller may CLAIM afterwards. `"no"` means the store itself answered — a
599
+ * read that failed before any write, a refused preflight, an epoch seal, or a compare-and-set that
600
+ * lost every attempt: each of those is the store saying nothing landed. `"unknown"` means the apply
601
+ * THREW, which proves nothing either way: a backend can commit and then lose its acknowledgement. */
602
+ | {
603
+ landed: false;
604
+ reason: string;
605
+ committed: "no" | "unknown";
606
+ };
607
+ /**
608
+ * Land ONE add — the terminal write both doors take (the consent protocol's redemption and the
609
+ * record-less tightening entry below). Optimistic: every attempt re-reads the store, anchors the
610
+ * compare-and-set to that revision, and retries a conflict without rebuilding the add (the dot is the
611
+ * add's identity and is minted once, by the caller, so a retry can never mint a second).
612
+ *
613
+ * The scope routing is the writer's ({@link writerOf}): a session-scope add lands in the session
614
+ * partition, everything else in the durable one — one path, so the two doors cannot disagree about
615
+ * where a rule goes.
616
+ */
617
+ export declare function landRuleAdd(args: {
618
+ store: import("./permission-rule-provider.js").PermissionRuleStore;
619
+ writer: PermissionRuleWriter;
620
+ /** Either add arm, whole. The kinds are dispatched EXHAUSTIVELY below, so a third add arm cannot be
621
+ * added to the union without this function being told what it means. */
622
+ delta: RuleAddDelta | RuleTightenAddDelta;
623
+ /** Judged on EVERY attempt's snapshot — the very revision the write below is anchored to, so a fact
624
+ * that changed in between conflicts the write and is re-judged. A returned string refuses. */
625
+ preflight?: (snapshot: import("./permission-rule-provider.js").EffectivePermissionRules) => Promise<string | undefined>;
626
+ }): Promise<RuleAddLanding>;
627
+ /**
628
+ * The result of an add — the dual of {@link RemoveResult}, word for word where the two mean the same
629
+ * thing. `conflict` is not a member for the same reason: the retry loop is inside.
630
+ */
631
+ export type AddResult =
632
+ /** The add landed; `dot` is its audit identity (the same value a removal's tombstone will name).
633
+ * Deliberately WITHOUT a `stillLive` twin of {@link RemoveResult}: a removal's twin reports the
634
+ * semilattice's own add-wins resolution, a structural outcome of the write; a fresh dot has no such
635
+ * resolution to report, and a removal that lands afterwards is an ordinary later event `list()`
636
+ * answers. */
637
+ {
638
+ status: "added";
639
+ rev: number;
640
+ dot: RuleDot;
641
+ }
642
+ /** An equivalent rule is already live in this bucket — the instruction is already in effect, and no
643
+ * second dot is minted. Repeating the call is a no-op, exactly as repeating a delete is. */
644
+ | {
645
+ status: "no-op";
646
+ rev: number;
647
+ }
648
+ /** Terminal failure, in one of two strengths the `error` text distinguishes. When the STORE answered
649
+ * (a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that
650
+ * lost every attempt) nothing landed and the text says so plainly. When the apply THREW, the write
651
+ * may have committed and lost its acknowledgement: the read-back asks the raw face, where an add
652
+ * survives the tombstone a concurrent removal laid on it — but a sync collection can erase even
653
+ * that, so a dot it cannot find is reported INDETERMINATE rather than as a confirmed nothing, and
654
+ * the caller reconciles with `list()` instead of retrying blind (a blind retry mints a fresh dot and
655
+ * can reinstate a restriction somebody had just removed). */
656
+ | {
657
+ status: "failed";
658
+ error: string;
659
+ };
660
+ /**
661
+ * Add a persisted rule in ONE step, with no approval record.
662
+ *
663
+ * The dual of {@link removePersistedRule}, and bounded by the same asymmetry: a host may narrow on a
664
+ * person's behalf, it may not widen. So this entry writes the TIGHTENING direction only — `deny` and
665
+ * `ask` — and `allow` is not spellable here at all (minting a standing approval still takes a person's
666
+ * consent record: prepare → confirm → redeem). A JS caller that spells it anyway meets a loud refusal.
667
+ *
668
+ * The provenance is MINTED HERE as `user`: this entry takes no origin argument, so a host cannot claim
669
+ * a rule came from an import or a starter set. Before it existed, the only way to land a rule was to
670
+ * borrow the approval-record legs, which stamped the borrowed record's kind onto a write nobody had
671
+ * approved — a second, unverified story about where a rule came from.
672
+ */
673
+ export declare function addPersistedRule(opts: {
674
+ /** The identity's behavior member. `allow` is deliberately not in the type — see the entry's doc. */
675
+ behavior: Exclude<RuleBehavior, "allow">;
676
+ rule: string;
677
+ scope: RuleScope;
678
+ /** Whose bucket — the same two spellings {@link removePersistedRule} takes: a bare string is the
679
+ * principal shorthand, the structural {@link RuleOwner} adds the local-owner bucket. */
680
+ principal: string | RuleOwner;
681
+ provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider;
682
+ }): Promise<AddResult>;
547
683
  export declare function errText(err: unknown): string;
548
684
  /** A durable partition that always reports zero rules — what an unauthenticated principal resolves to. */
549
685
  export declare const EMPTY_DURABLE_RULE_PARTITION: DurableRulePartition;