@sema-agent/core 7.25.1 → 7.26.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 (83) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/core/ask-origin.d.ts +84 -0
  3. package/dist/core/ask-origin.js +31 -3
  4. package/dist/core/auto-compaction.d.ts +5 -0
  5. package/dist/core/auto-compaction.js +7 -0
  6. package/dist/core/auto-mode-directive.d.ts +45 -0
  7. package/dist/core/auto-mode-directive.js +47 -0
  8. package/dist/core/checkpoint-store.d.ts +10 -1
  9. package/dist/core/engine-notice.d.ts +10 -0
  10. package/dist/core/gate-lanes.js +18 -10
  11. package/dist/core/governance-codes.d.ts +1 -1
  12. package/dist/core/governance-codes.js +3 -0
  13. package/dist/core/hooks.d.ts +3 -0
  14. package/dist/core/mcp-probe.d.ts +122 -0
  15. package/dist/core/mcp-probe.js +132 -0
  16. package/dist/core/memory-engine/engine.d.ts +6 -1
  17. package/dist/core/memory-engine/engine.js +30 -2
  18. package/dist/core/memory-engine/index.d.ts +1 -0
  19. package/dist/core/memory-engine/index.js +1 -0
  20. package/dist/core/memory-engine/shadowing.d.ts +58 -0
  21. package/dist/core/memory-engine/shadowing.js +40 -0
  22. package/dist/core/memory-engine/tools.d.ts +33 -1
  23. package/dist/core/memory-engine/tools.js +136 -31
  24. package/dist/core/memory-engine/types.d.ts +10 -0
  25. package/dist/core/memory.d.ts +60 -0
  26. package/dist/core/memory.js +47 -2
  27. package/dist/core/permission-rule-provider.js +6 -1
  28. package/dist/core/permission-rule-store.d.ts +87 -8
  29. package/dist/core/permission-rule-store.js +223 -76
  30. package/dist/core/permission-rule-sync.js +14 -5
  31. package/dist/core/reminder-mint.d.ts +41 -0
  32. package/dist/core/reminder-mint.js +55 -0
  33. package/dist/core/runner/compaction-restate.d.ts +9 -0
  34. package/dist/core/runner/compaction-restate.js +20 -0
  35. package/dist/core/runner/contracts.d.ts +18 -1
  36. package/dist/core/runner/denial-limit-arms.js +1 -1
  37. package/dist/core/runner/permission-rule-lanes.d.ts +5 -17
  38. package/dist/core/runner/permission-rule-lanes.js +5 -23
  39. package/dist/core/runner/prepare-ask-lane.js +2 -0
  40. package/dist/core/runner/prepare-context-lane.d.ts +4 -0
  41. package/dist/core/runner/prepare-context-lane.js +7 -2
  42. package/dist/core/runner/prepare-inherited-gate.d.ts +6 -2
  43. package/dist/core/runner/prepare-inherited-gate.js +6 -3
  44. package/dist/core/runner/prepare-memory.js +47 -6
  45. package/dist/core/runner/prepare-park-ask.js +1 -0
  46. package/dist/core/runner/prepare-policy-chain.js +8 -4
  47. package/dist/core/runner/prepare-suspend-saga.d.ts +2 -0
  48. package/dist/core/runner/prepare-suspend-saga.js +3 -1
  49. package/dist/core/runner/prepare-task.js +7 -4
  50. package/dist/core/runner/run-leg.js +9 -1
  51. package/dist/core/runner/run-recovery-lanes.js +2 -2
  52. package/dist/core/runner/run-terminal-adoption.js +2 -2
  53. package/dist/core/runner/run-turn-boundary.js +2 -2
  54. package/dist/core/runner/session-rule-policy.d.ts +1 -1
  55. package/dist/core/runner/session-rule-policy.js +6 -7
  56. package/dist/core/session-policy-store.d.ts +18 -0
  57. package/dist/core/session-policy-store.js +3 -0
  58. package/dist/core/store-contracts/permission-rule-write-contract.d.ts +20 -0
  59. package/dist/core/store-contracts/permission-rule-write-contract.js +90 -0
  60. package/dist/core/store-contracts/read-back-shape-contract.d.ts +56 -0
  61. package/dist/core/store-contracts/read-back-shape-contract.js +78 -0
  62. package/dist/core/task-result.d.ts +11 -0
  63. package/dist/core/task-spec.d.ts +31 -0
  64. package/dist/core/tool-catalog-entries.js +3 -3
  65. package/dist/core/tool-policy.d.ts +38 -0
  66. package/dist/core/tool-policy.js +5 -1
  67. package/dist/core/types.d.ts +1 -1
  68. package/dist/index.d.ts +11 -2
  69. package/dist/index.js +10 -1
  70. package/dist/stores/file/adoption/adopt.js +4 -10
  71. package/dist/stores/file/permission-rule-store.d.ts +5 -1
  72. package/dist/stores/file/permission-rule-store.js +52 -31
  73. package/dist/stores/file/session-policy-store.d.ts +32 -0
  74. package/dist/stores/file/session-policy-store.js +72 -50
  75. package/dist/tools/fs/shell-read-check.d.ts +58 -1
  76. package/dist/tools/fs/shell-read-check.js +47 -33
  77. package/dist/tools/fs/shell-read-establish.js +19 -8
  78. package/dist/tools/fs/shell-read-judge.d.ts +3 -0
  79. package/dist/tools/fs/shell-read-judge.js +3 -1
  80. package/package.json +1 -1
  81. package/test/export-surface.snapshot.json +79 -1
  82. package/wire-changes/core@7.26.0.json +695 -0
  83. package/wire-changes/core@7.26.1.json +605 -0
@@ -128,6 +128,42 @@ export type RuleWriteOutcome = PutResult | {
128
128
  refused: "session_ended";
129
129
  rev: number;
130
130
  };
131
+ /**
132
+ * What a caller may conclude from one {@link RuleWriteOutcome} — the arms told apart ONCE, here.
133
+ *
134
+ * `unreadable` is the arm the union cannot spell: an `apply` answer is DATA (a foreign backend, a
135
+ * deserialized reply, a twin compiled against another generation), so "it was not a conflict and not a
136
+ * refusal" is not a proof that it landed.
137
+ */
138
+ export type RuleWriteReading = {
139
+ kind: "landed";
140
+ rev: number;
141
+ sync?: RuleSyncLandingReport; /** The landing stands; its report does not. */
142
+ reportUnreadable?: string;
143
+ } | {
144
+ kind: "conflict";
145
+ rev: number;
146
+ } | {
147
+ kind: "refused";
148
+ refused: string;
149
+ }
150
+ /** The store ANSWERED, and nothing in the answer says what happened. Never a landing, never a
151
+ * confirmed nothing: the write may have committed and lost its acknowledgement, so the reading a
152
+ * caller renders from this arm is the indeterminate one and the reconciliation is `list()`. */
153
+ | {
154
+ kind: "unreadable";
155
+ why: string;
156
+ };
157
+ /**
158
+ * A revision is a COUNT the store advances, so this is the whole of what one may be. Zero is a revision:
159
+ * a session-scope add lands in a partition that has none of its own and the landing reports the durable
160
+ * revision unchanged, which is `0` for a durable bucket nothing has written yet.
161
+ *
162
+ * Exported because the acceptance kit judges the same thing about a backend's answer, and two copies of
163
+ * "what a revision is" is exactly the drift the contract-sentence discipline exists to stop.
164
+ */
165
+ export declare function isRuleRevision(rev: unknown): rev is number;
166
+ export declare function readRuleWriteOutcome(res: RuleWriteOutcome): RuleWriteReading;
131
167
  /** Authorization accompanying an add: the redemption that produced it. Carrying the record id makes the
132
168
  * add's logical operation identity checkable at the backend, not just at the caller. A v1 caller
133
169
  * carries `principal`; the local-owner path (design/182 §4.5) carries `owner` instead — never both
@@ -221,6 +257,19 @@ export interface RuleSyncJoinDelta {
221
257
  * calling layer, and closes identically for every caller.
222
258
  */
223
259
  export type RuleWriteDelta = RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
260
+ /**
261
+ * The refusal every backend owes a delta whose `kind` is outside {@link RuleWriteDelta} — minted here so
262
+ * the two bundled backends (and an out-of-repo twin) answer an unnamed operation with ONE sentence
263
+ * instead of each guessing.
264
+ *
265
+ * The parameter is `never` on purpose: the only way to reach it is from a `default` arm the type system
266
+ * has already proved unreachable, so the factory doubles as the compile-time fence (a fifth arm added to
267
+ * the union stops satisfying `never` and reds at every backend's switch, naming the missing word). The
268
+ * runtime half is not decoration — a delta arrives as DATA (a foreign caller, a replayed record, a
269
+ * deserialized join), and the alternative to throwing is folding an unnamed kind as whichever arm
270
+ * an if/else chain happens to end with: a write nobody defined, landed under a name nobody checked.
271
+ */
272
+ export declare function unknownRuleWriteDeltaError(delta: never): Error;
224
273
  /**
225
274
  * The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an
226
275
  * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
@@ -531,6 +580,22 @@ export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
531
580
  */
532
581
  export declare const REMOVAL_LIVENESS_WORDS: readonly ["yes", "no", "unknown"];
533
582
  export type RemovalLiveness = (typeof REMOVAL_LIVENESS_WORDS)[number];
583
+ /**
584
+ * What a terminal write FAILURE may claim about the bytes — the failure half of the honesty
585
+ * {@link RemovalLiveness} keeps on the success half, spelled ONCE and read by every write entry here.
586
+ *
587
+ * `"no"` = the store ITSELF answered: a face that cannot write at all, a target the type face refuses,
588
+ * a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that lost
589
+ * every attempt. Each of those is the store saying nothing landed, so saying it is a fact.
590
+ * `"unknown"` = the apply THREW, which proves nothing either way (a backend can commit and then lose
591
+ * its acknowledgement), or the read-back that would have settled it could not be taken.
592
+ *
593
+ * The word exists because the caller's two branches — retry, or reconcile with `list()` — are not
594
+ * derivable from the failure PROSE. A consumer reading the strength out of the sentence is parsing a
595
+ * message written for a person, and every such reader has to be updated by hand when the sentence is.
596
+ */
597
+ export declare const RULE_WRITE_COMMITTED_WORDS: readonly ["no", "unknown"];
598
+ export type RuleWriteCommitted = (typeof RULE_WRITE_COMMITTED_WORDS)[number];
534
599
  /**
535
600
  * The result of a removal.
536
601
  *
@@ -548,21 +613,32 @@ export type RemoveResult =
548
613
  rev: number;
549
614
  stillLive: RemovalLiveness;
550
615
  }
551
- /** Nothing to remove — including a repeated delete. No empty tombstone is minted. */
616
+ /** Nothing to remove — including a repeated delete. No empty tombstone is minted. `stillLive` is the
617
+ * SAME read-back the `removed` arm reports, taken at the same point in the call ({@link
618
+ * readRuleLiveness}): "there was nothing to remove" is a statement about the snapshot this call read,
619
+ * and an approval landing after it leaves the rule standing. Both 200-shaped arms therefore carry the
620
+ * word, from one source. It is a LATER OBSERVATION, not an atomic absence: `rev` is the revision the
621
+ * decision was taken at, the word is what a second look saw, and the store may change again right
622
+ * after — `yes` beside a `no-op` is the honest pair, not a contradiction. */
552
623
  | {
553
624
  status: "no-op";
554
625
  rev: number;
626
+ stillLive: RemovalLiveness;
555
627
  }
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
628
+ /** Terminal failure, in one of two strengths — word for word the dual of {@link AddResult}'s, and
629
+ * `committed` is the word for it ({@link RuleWriteCommitted}), so the strength is READ, not parsed out
630
+ * of the sentence. When the STORE answered every attempt (the compare-and-set simply lost), nothing
631
+ * landed: `committed: "no"`, and the read-back confirms it in the text. When the apply THREW, a missing
559
632
  * tombstone cannot tell a write that never landed from one whose evidence a paired collection erased
560
633
  * (`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. */
634
+ * `committed: "unknown"`, reported INDETERMINATE, and the caller reconciles with `list()` rather than
635
+ * retrying blind. When the read-back itself failed, the word is the one the apply earned — a throw
636
+ * leaves it `unknown`, an answered compare-and-set stays `no`, because an unreadable store cannot
637
+ * un-answer what the store already said. */
563
638
  | {
564
639
  status: "failed";
565
640
  error: string;
641
+ committed: RuleWriteCommitted;
566
642
  };
567
643
  /**
568
644
  * Remove a persisted rule.
@@ -602,7 +678,7 @@ export type RuleAddLanding = {
602
678
  | {
603
679
  landed: false;
604
680
  reason: string;
605
- committed: "no" | "unknown";
681
+ committed: RuleWriteCommitted;
606
682
  };
607
683
  /**
608
684
  * Land ONE add — the terminal write both doors take (the consent protocol's redemption and the
@@ -652,10 +728,13 @@ export type AddResult =
652
728
  * survives the tombstone a concurrent removal laid on it — but a sync collection can erase even
653
729
  * that, so a dot it cannot find is reported INDETERMINATE rather than as a confirmed nothing, and
654
730
  * 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). */
731
+ * can reinstate a restriction somebody had just removed). `committed` ({@link RuleWriteCommitted}) is
732
+ * which of the two this is, as a WORD — the landing's own answer, passed through rather than re-derived,
733
+ * so this entry and the consent protocol's door cannot disagree about a write they both took. */
656
734
  | {
657
735
  status: "failed";
658
736
  error: string;
737
+ committed: RuleWriteCommitted;
659
738
  };
660
739
  /**
661
740
  * Add a persisted rule in ONE step, with no approval record.
@@ -5,6 +5,75 @@ import { thrownText } from "./thrown-value.js";
5
5
  export function sameRuleOwner(a, b) {
6
6
  return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
7
7
  }
8
+ export function isRuleRevision(rev) {
9
+ return typeof rev === "number" && Number.isSafeInteger(rev) && rev >= 0;
10
+ }
11
+ function outcomeWord(value) {
12
+ if (value === undefined)
13
+ return "no revision at all";
14
+ if (value === null)
15
+ return "a null revision";
16
+ if (typeof value === "number")
17
+ return `the number ${String(value)}, which is not a revision`;
18
+ return `a ${typeof value} where a revision belongs`;
19
+ }
20
+ function isReadableDrop(drop) {
21
+ if (drop === null || typeof drop !== "object")
22
+ return false;
23
+ const dot = drop.dot;
24
+ if (dot === null || typeof dot !== "object")
25
+ return false;
26
+ return typeof dot.actor === "string" && typeof dot.counter === "number" && typeof drop.reason === "string";
27
+ }
28
+ function readSyncReport(sync) {
29
+ if (sync === undefined)
30
+ return {};
31
+ if (sync === null || typeof sync !== "object")
32
+ return { unreadable: `the landing carried a ${sync === null ? "null" : typeof sync} where its sync report belongs` };
33
+ for (const member of ["droppedInbound", "quarantined"]) {
34
+ const value = sync[member];
35
+ if (value === undefined)
36
+ continue;
37
+ if (!Array.isArray(value))
38
+ return { unreadable: `the landing's sync report carried a ${typeof value} as \`${member}\`, which a reader cannot walk` };
39
+ const bad = value.findIndex((row) => !isReadableDrop(row));
40
+ if (bad !== -1)
41
+ return { unreadable: `the landing's sync report carried a \`${member}\` entry (#${String(bad)}) that is not a drop record, so what was dropped cannot be read` };
42
+ }
43
+ return { report: sync };
44
+ }
45
+ export function readRuleWriteOutcome(res) {
46
+ if (res === null || typeof res !== "object") {
47
+ return { kind: "unreadable", why: `the store answered ${res === null ? "null" : `a ${typeof res}`} where a write outcome belongs` };
48
+ }
49
+ const tagged = res;
50
+ const refusal = tagged.refused;
51
+ const conflict = tagged.conflict;
52
+ if (refusal !== undefined && conflict !== undefined) {
53
+ return { kind: "unreadable", why: "the store answered a refusal and a decline at once, and an answer that is two arms is not an answer about either" };
54
+ }
55
+ if (refusal !== undefined) {
56
+ return typeof refusal === "string" && refusal !== ""
57
+ ? { kind: "refused", refused: refusal }
58
+ : { kind: "unreadable", why: `the store tagged its answer as a refusal and named it with a ${refusal === null ? "null" : typeof refusal}, so which refusal it is cannot be read` };
59
+ }
60
+ if (conflict !== undefined) {
61
+ if (conflict !== true)
62
+ return { kind: "unreadable", why: `the store tagged its answer as a decline with \`${String(conflict)}\`, which does not say a compare-and-set lost` };
63
+ return isRuleRevision(tagged.rev)
64
+ ? { kind: "conflict", rev: tagged.rev }
65
+ : { kind: "unreadable", why: `the store declined the write with ${outcomeWord(tagged.rev)}, so the revision to re-anchor the next attempt to is unknown` };
66
+ }
67
+ if (!isRuleRevision(tagged.rev)) {
68
+ return { kind: "unreadable", why: `the store answered neither a conflict nor a refusal, and carried ${outcomeWord(tagged.rev)} — a landing reports the revision it committed at, so this answer cannot say whether the write landed` };
69
+ }
70
+ const { report, unreadable } = readSyncReport(tagged.sync);
71
+ return { kind: "landed", rev: tagged.rev, ...(report !== undefined ? { sync: report } : {}), ...(unreadable !== undefined ? { reportUnreadable: unreadable } : {}) };
72
+ }
73
+ export function unknownRuleWriteDeltaError(delta) {
74
+ const kind = delta?.kind;
75
+ return new Error(`the permission-rule store cannot apply a write delta of kind ${escapeForDisclosure(typeof kind === "string" ? kind : String(kind))} — the write face folds exactly four named arms (redemption-add, tighten-add, tighten-delete, sync-join), and an unnamed one is refused rather than read as any of them`);
76
+ }
8
77
  export const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter";
9
78
  export function writerOf(store) {
10
79
  const w = store[PERMISSION_RULE_WRITER];
@@ -394,6 +463,50 @@ export async function ruleStoreChecksum(payload) {
394
463
  return `sha256:${createHash("sha256").update(canonicalize(payload), "utf8").digest("hex")}`;
395
464
  }
396
465
  export const REMOVAL_LIVENESS_WORDS = ["yes", "no", "unknown"];
466
+ export const RULE_WRITE_COMMITTED_WORDS = ["no", "unknown"];
467
+ function resolveRuleWriter(store) {
468
+ let w;
469
+ try {
470
+ w = writerOf(store);
471
+ }
472
+ catch (err) {
473
+ return { unavailable: `the permission-rule store's write face could not be acquired (${errText(err)})`, committed: "unknown" };
474
+ }
475
+ return w === undefined ? { unavailable: "the resolved permission-rule store has no write face", committed: "no" } : { writer: w };
476
+ }
477
+ function ruleIdentityStands(rules, identity) {
478
+ return rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r));
479
+ }
480
+ async function witnessRuleAdd(store, writer, delta) {
481
+ const identity = { behavior: delta.behavior, rule: delta.rule, scope: delta.scope };
482
+ const dot = delta.add.dot;
483
+ try {
484
+ if (delta.scope.kind === "session") {
485
+ const view = await store.effective({ sessionId: delta.scope.sessionId });
486
+ if (view.unreadable !== undefined)
487
+ return { unreadable: view.unreadable };
488
+ return { seen: view.rules.some((r) => sameRuleIdentity(r, identity) && r.adds.some((a) => sameDot(a.dot, dot))), rev: view.rev };
489
+ }
490
+ const raw = await writer.readRaw();
491
+ const inRows = raw.rules.some((r) => sameRuleIdentity(r, identity) && r.adds.some((a) => sameDot(a.dot, dot)));
492
+ const inQuarantine = (raw.quarantined ?? []).some((q) => sameDot(q.add.dot, dot));
493
+ return { seen: inRows || inQuarantine, rev: raw.rev };
494
+ }
495
+ catch (err) {
496
+ return { unreadable: errText(err) };
497
+ }
498
+ }
499
+ async function readRuleLiveness(store, identity) {
500
+ try {
501
+ const after = await store.effective();
502
+ if (after.unreadable !== undefined)
503
+ throw new Error(after.unreadable);
504
+ return ruleIdentityStands(after.rules, identity) ? "yes" : "no";
505
+ }
506
+ catch {
507
+ return "unknown";
508
+ }
509
+ }
397
510
  const REMOVE_MAX_ATTEMPTS = 8;
398
511
  export async function removePersistedRule(opts) {
399
512
  const behavior = opts.behavior;
@@ -401,7 +514,7 @@ export async function removePersistedRule(opts) {
401
514
  const scope = structuredClone(opts.scope);
402
515
  const target = opts.principal;
403
516
  if (!isRuleBehavior(behavior)) {
404
- return { status: "failed", error: `removePersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry` };
517
+ return { status: "failed", error: `removePersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry`, committed: "no" };
405
518
  }
406
519
  const identity = { behavior, rule, scope };
407
520
  const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
@@ -410,80 +523,92 @@ export async function removePersistedRule(opts) {
410
523
  return {
411
524
  status: "failed",
412
525
  error: "unsupported.session_scope_store: a session-scope rule cannot be removed through the persisted-store entry — session authorizations live in the session's own overlay and end with the session (design/382 §4.3)",
526
+ committed: "no",
413
527
  };
414
528
  }
415
529
  if (owner.kind === "local-owner" && provider.forLocalOwner === undefined) {
416
- return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it" };
530
+ return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it", committed: "no" };
417
531
  }
418
532
  const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
419
- const writer = writerOf(store);
420
- if (writer === undefined) {
421
- return { status: "failed", error: "the resolved permission-rule store has no write face — rules cannot be removed through it" };
533
+ const face = resolveRuleWriter(store);
534
+ if ("unavailable" in face) {
535
+ return { status: "failed", error: `${face.unavailable} — rules cannot be removed through it`, committed: face.committed };
422
536
  }
537
+ const writer = face.writer;
423
538
  let snapshot;
424
539
  try {
425
540
  snapshot = await store.effective();
426
541
  }
427
542
  catch (err) {
428
- return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}` };
543
+ return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}`, committed: "no" };
429
544
  }
430
545
  if (snapshot.unreadable !== undefined)
431
- return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}` };
546
+ return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}`, committed: "no" };
432
547
  const live = snapshot.rules.filter((r) => sameRuleIdentity(r, identity));
433
548
  const removedDots = live.flatMap((r) => r.adds.map((a) => a.dot));
434
549
  if (removedDots.length === 0)
435
- return { status: "no-op", rev: snapshot.rev };
436
- const tombstone = { behavior, rule, scope, removedDots, deletedBy: await writer.nextDot() };
550
+ return { status: "no-op", rev: snapshot.rev, stillLive: await readRuleLiveness(store, identity) };
551
+ let deletedBy;
552
+ try {
553
+ deletedBy = await writer.nextDot();
554
+ }
555
+ catch (err) {
556
+ return { status: "failed", error: `could not mint the removal's delete dot: ${errText(err)}`, committed: "no" };
557
+ }
558
+ const tombstone = { behavior, rule, scope, removedDots, deletedBy };
437
559
  let expectedRev = snapshot.rev;
438
560
  let lastError = "";
439
- let applyThrew = false;
561
+ let settlement = "no";
440
562
  for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
441
563
  try {
442
- const res = await writer.apply({ kind: "tighten-delete", tombstone }, { expectedRev });
443
- if ("refused" in res) {
444
- return { status: "failed", error: `the store refused the tombstone (${res.refused}) — nothing was removed` };
564
+ const reading = readRuleWriteOutcome(await writer.apply({ kind: "tighten-delete", tombstone }, { expectedRev }));
565
+ if (reading.kind === "refused") {
566
+ return { status: "failed", error: `the store refused the tombstone (${reading.refused}) — nothing was removed`, committed: "no" };
445
567
  }
446
- if (!("conflict" in res)) {
447
- let stillLive;
448
- try {
449
- const after = await store.effective();
450
- if (after.unreadable !== undefined)
451
- throw new Error(after.unreadable);
452
- stillLive = after.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)) ? "yes" : "no";
453
- }
454
- catch {
455
- stillLive = "unknown";
456
- }
457
- return { status: "removed", rev: res.rev, stillLive };
568
+ if (reading.kind === "unreadable") {
569
+ settlement = "unknown";
570
+ lastError = `the permission-rule store answered the write unreadably: ${reading.why}`;
571
+ break;
458
572
  }
459
- expectedRev = res.rev;
573
+ if (reading.kind === "landed") {
574
+ return { status: "removed", rev: reading.rev, stillLive: await readRuleLiveness(store, identity) };
575
+ }
576
+ expectedRev = reading.rev;
460
577
  }
461
578
  catch (err) {
462
- applyThrew = true;
579
+ settlement = "unknown";
463
580
  lastError = errText(err);
464
581
  break;
465
582
  }
466
583
  }
467
- const reason = applyThrew
584
+ const reason = settlement === "unknown"
468
585
  ? lastError !== ""
469
586
  ? lastError
470
587
  : "the permission-rule store threw while applying the tombstone, with no message"
471
588
  : `optimistic-concurrency retries exhausted after ${REMOVE_MAX_ATTEMPTS} attempts`;
589
+ const committed = settlement;
472
590
  try {
473
591
  const after = await writer.readRaw();
474
592
  const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
475
593
  if (landed) {
476
- return { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones).some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)) ? "yes" : "no" };
594
+ return { status: "removed", rev: after.rev, stillLive: ruleIdentityStands(applyTombstones(after.rules, after.tombstones), identity) ? "yes" : "no" };
477
595
  }
478
596
  return {
479
597
  status: "failed",
480
- error: applyThrew
598
+ error: settlement === "unknown"
481
599
  ? `${reason}; the store retains no tombstone for this removal, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()`
482
600
  : `${reason} (read-back confirms no tombstone was written)`,
601
+ committed,
483
602
  };
484
603
  }
485
604
  catch (err) {
486
- return { status: "failed", error: `${reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()` };
605
+ return {
606
+ status: "failed",
607
+ error: settlement === "unknown"
608
+ ? `${reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()`
609
+ : `${reason}; the read-back also failed (${errText(err)}), but every attempt was answered by the store, so nothing was written`,
610
+ committed,
611
+ };
487
612
  }
488
613
  }
489
614
  const RULE_ADD_MAX_ATTEMPTS = 8;
@@ -519,10 +644,21 @@ export async function landRuleAdd(args) {
519
644
  catch (err) {
520
645
  return { landed: false, reason: `the permission-rule store refused the write: ${errText(err)}`, committed: "unknown" };
521
646
  }
522
- if ("refused" in res)
647
+ const reading = readRuleWriteOutcome(res);
648
+ if (reading.kind === "refused")
523
649
  return { landed: false, reason: SESSION_ENDED_REFUSAL, committed: "no" };
524
- if (!("conflict" in res))
525
- return { landed: true, rev: res.rev };
650
+ if (reading.kind === "unreadable")
651
+ return { landed: false, reason: `the permission-rule store answered the write unreadably: ${reading.why}`, committed: "unknown" };
652
+ if (reading.kind === "landed") {
653
+ const witness = await witnessRuleAdd(args.store, args.writer, args.delta);
654
+ if ("unreadable" in witness) {
655
+ return { landed: false, reason: `the permission-rule store reported a landing at revision ${String(reading.rev)}, and the read-back that would have witnessed it could not be taken (${witness.unreadable})`, committed: "unknown" };
656
+ }
657
+ if (!witness.seen) {
658
+ return { landed: false, reason: `the permission-rule store reported a landing at revision ${String(reading.rev)}, and a read-back of this add's own dot finds no trace of it — a revision says the ANSWER was well formed, not that a row exists, so nothing here can say the write landed`, committed: "unknown" };
659
+ }
660
+ return { landed: true, rev: reading.rev };
661
+ }
526
662
  }
527
663
  return { landed: false, reason: `optimistic-concurrency retries exhausted after ${RULE_ADD_MAX_ATTEMPTS} attempts`, committed: "no" };
528
664
  }
@@ -532,41 +668,43 @@ export async function addPersistedRule(opts) {
532
668
  const scope = structuredClone(opts.scope);
533
669
  const target = opts.principal;
534
670
  if (!isRuleBehavior(behavior)) {
535
- return { status: "failed", error: `addPersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry` };
671
+ return { status: "failed", error: `addPersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry`, committed: "no" };
536
672
  }
537
673
  if (behavior === "allow") {
538
674
  return {
539
675
  status: "failed",
540
676
  error: "addPersistedRule: an allow cannot be written through this entry — a standing approval is a person's yes and takes a consent record (prepare → confirm → redeem); this entry writes the tightening direction (deny/ask) only",
677
+ committed: "no",
541
678
  };
542
679
  }
543
680
  if (!isValidConsentScope(scope)) {
544
- return { status: "failed", error: `addPersistedRule: ${escapeForDisclosure(JSON.stringify(scope))} is not a valid rule scope — refusing to write a row nothing can read back` };
681
+ return { status: "failed", error: `addPersistedRule: ${escapeForDisclosure(JSON.stringify(scope))} is not a valid rule scope — refusing to write a row nothing can read back`, committed: "no" };
545
682
  }
546
683
  const parsed = parseRuleText(ruleText, behavior);
547
684
  if ("reject" in parsed)
548
- return { status: "failed", error: `addPersistedRule: ${parsed.reject.code}: ${parsed.reject.message}` };
685
+ return { status: "failed", error: `addPersistedRule: ${parsed.reject.code}: ${parsed.reject.message}`, committed: "no" };
549
686
  const identity = { behavior, rule: parsed.rule.rule, scope };
550
687
  const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
551
688
  const provider = opts.provider;
552
689
  if (owner.kind === "local-owner" && provider.forLocalOwner === undefined) {
553
- return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be added through it" };
690
+ return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be added through it", committed: "no" };
554
691
  }
555
692
  const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
556
- const writer = writerOf(store);
557
- if (writer === undefined) {
558
- return { status: "failed", error: "the resolved permission-rule store has no write face — rules cannot be added through it" };
693
+ const face = resolveRuleWriter(store);
694
+ if ("unavailable" in face) {
695
+ return { status: "failed", error: `${face.unavailable} — rules cannot be added through it`, committed: face.committed };
559
696
  }
697
+ const writer = face.writer;
560
698
  const query = scope.kind === "session" ? { sessionId: scope.sessionId } : undefined;
561
699
  let snapshot;
562
700
  try {
563
701
  snapshot = await store.effective(query);
564
702
  }
565
703
  catch (err) {
566
- return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}` };
704
+ return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}`, committed: "no" };
567
705
  }
568
706
  if (snapshot.unreadable !== undefined)
569
- return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}` };
707
+ return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}`, committed: "no" };
570
708
  if (snapshot.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)))
571
709
  return { status: "no-op", rev: snapshot.rev };
572
710
  let dot;
@@ -574,7 +712,7 @@ export async function addPersistedRule(opts) {
574
712
  dot = await writer.nextDot();
575
713
  }
576
714
  catch (err) {
577
- return { status: "failed", error: `could not mint the add's dot: ${errText(err)}` };
715
+ return { status: "failed", error: `could not mint the add's dot: ${errText(err)}`, committed: "no" };
578
716
  }
579
717
  const landing = await landRuleAdd({
580
718
  store,
@@ -593,7 +731,7 @@ export async function addPersistedRule(opts) {
593
731
  if (landing.landed)
594
732
  return { status: "added", rev: landing.rev, dot };
595
733
  if (landing.committed === "no")
596
- return { status: "failed", error: landing.reason };
734
+ return { status: "failed", error: landing.reason, committed: landing.committed };
597
735
  try {
598
736
  let rev;
599
737
  let rows;
@@ -613,10 +751,10 @@ export async function addPersistedRule(opts) {
613
751
  quarantinedRows.some((q) => sameDot(q.add.dot, dot));
614
752
  return stood
615
753
  ? { status: "added", rev, dot }
616
- : { status: "failed", error: `${landing.reason}; the store retains no trace of this add, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()` };
754
+ : { status: "failed", error: `${landing.reason}; the store retains no trace of this add, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()`, committed: landing.committed };
617
755
  }
618
756
  catch (err) {
619
- return { status: "failed", error: `${landing.reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()` };
757
+ return { status: "failed", error: `${landing.reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()`, committed: landing.committed };
620
758
  }
621
759
  }
622
760
  export function errText(err) {
@@ -666,35 +804,44 @@ export class InMemoryDurableRulePartition {
666
804
  delta = structuredClone(delta);
667
805
  if (opts.expectedRev !== this.rev)
668
806
  return { conflict: true, rev: this.rev };
669
- if (delta.kind === "redemption-add") {
670
- assertWriteDeltaScopeDurable(delta);
671
- assertRedemptionNotQuarantined(this.quarantinedRows, delta);
672
- this.rules = foldDelta(this.rules, delta);
673
- }
674
- else if (delta.kind === "tighten-add") {
675
- assertWriteDeltaScopeDurable(delta);
676
- this.rules = foldDelta(this.rules, delta);
677
- }
678
- else if (delta.kind === "tighten-delete") {
679
- assertWriteDeltaScopeDurable(delta);
680
- assertDeleteDeltaCarriesNoAdd(delta);
681
- this.tombstones = [...this.tombstones, delta.tombstone];
682
- }
683
- else {
684
- const { next, report } = applySyncJoin({
685
- actor: this.actor,
686
- counter: this.counter,
687
- rules: this.rules,
688
- tombstones: this.tombstones,
689
- quarantined: this.quarantinedRows,
690
- ...(this.observedVector !== undefined ? { observedVector: this.observedVector } : {}),
691
- }, delta, this.now());
692
- this.rules = next.rules;
693
- this.tombstones = next.tombstones;
694
- this.quarantinedRows = next.quarantined;
695
- this.observedVector = next.observedVector;
696
- this.rev += 1;
697
- return { rev: this.rev, sync: report };
807
+ switch (delta.kind) {
808
+ case "redemption-add": {
809
+ assertWriteDeltaScopeDurable(delta);
810
+ assertRedemptionNotQuarantined(this.quarantinedRows, delta);
811
+ this.rules = foldDelta(this.rules, delta);
812
+ break;
813
+ }
814
+ case "tighten-add": {
815
+ assertWriteDeltaScopeDurable(delta);
816
+ this.rules = foldDelta(this.rules, delta);
817
+ break;
818
+ }
819
+ case "tighten-delete": {
820
+ assertWriteDeltaScopeDurable(delta);
821
+ assertDeleteDeltaCarriesNoAdd(delta);
822
+ this.tombstones = [...this.tombstones, delta.tombstone];
823
+ break;
824
+ }
825
+ case "sync-join": {
826
+ const { next, report } = applySyncJoin({
827
+ actor: this.actor,
828
+ counter: this.counter,
829
+ rules: this.rules,
830
+ tombstones: this.tombstones,
831
+ quarantined: this.quarantinedRows,
832
+ ...(this.observedVector !== undefined ? { observedVector: this.observedVector } : {}),
833
+ }, delta, this.now());
834
+ this.rules = next.rules;
835
+ this.tombstones = next.tombstones;
836
+ this.quarantinedRows = next.quarantined;
837
+ this.observedVector = next.observedVector;
838
+ this.rev += 1;
839
+ return { rev: this.rev, sync: report };
840
+ }
841
+ default: {
842
+ const unhandled = delta;
843
+ throw unknownRuleWriteDeltaError(unhandled);
844
+ }
698
845
  }
699
846
  this.rev += 1;
700
847
  return { rev: this.rev };
@@ -1,5 +1,5 @@
1
1
  import { escapeForDisclosure, isRuleBehavior, parseRuleText } from "./permission-rule-model.js";
2
- import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
2
+ import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, readRuleWriteOutcome, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
3
3
  import { emitTrace } from "./trace.js";
4
4
  export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
5
5
  export const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
@@ -174,11 +174,12 @@ export async function syncPermissionRules(opts) {
174
174
  warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
175
175
  });
176
176
  }
177
- if ("conflict" in res) {
177
+ const reading = readRuleWriteOutcome(res);
178
+ if (reading.kind === "conflict") {
178
179
  current = await writer.readRaw();
179
180
  continue;
180
181
  }
181
- if ("refused" in res) {
182
+ if (reading.kind === "refused" || reading.kind === "unreadable") {
182
183
  return disclose({
183
184
  ok: false,
184
185
  pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
@@ -186,7 +187,12 @@ export async function syncPermissionRules(opts) {
186
187
  resurrected: [],
187
188
  dropped,
188
189
  rev: current.rev,
189
- warnings: [...warnings, `the store refused the sync landing (${res.refused}) — nothing landed, the local state is unchanged`],
190
+ warnings: [
191
+ ...warnings,
192
+ reading.kind === "refused"
193
+ ? `the store refused the sync landing (${reading.refused}) — nothing landed, the local state is unchanged`
194
+ : `the store answered the sync landing unreadably: ${reading.why} — this round is not reported as landed; re-read before trusting the local state`,
195
+ ],
190
196
  });
191
197
  }
192
198
  const landedRaw = await writer.readRaw();
@@ -211,7 +217,10 @@ export async function syncPermissionRules(opts) {
211
217
  const tombKeyOf = (t) => JSON.stringify([t.behavior, t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
212
218
  const preTombs = new Set(current.tombstones.map(tombKeyOf));
213
219
  const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
214
- const landedReport = res.sync;
220
+ const landedReport = reading.sync;
221
+ if (reading.reportUnreadable !== undefined) {
222
+ warnings.push(`${reading.reportUnreadable} — the join landed, but what it dropped or quarantined cannot be reported from this round`);
223
+ }
215
224
  for (const d of landedReport?.droppedInbound ?? []) {
216
225
  if (!dropped.some((x) => x.dot.actor === d.dot.actor && x.dot.counter === d.dot.counter && x.reason === d.reason))
217
226
  dropped.push(d);