@sema-agent/core 7.26.0 → 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 (29) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/core/permission-rule-provider.js +6 -1
  3. package/dist/core/permission-rule-store.d.ts +36 -0
  4. package/dist/core/permission-rule-store.js +119 -19
  5. package/dist/core/permission-rule-sync.js +14 -5
  6. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  7. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  8. package/dist/core/runner/session-rule-policy.d.ts +1 -1
  9. package/dist/core/runner/session-rule-policy.js +6 -7
  10. package/dist/core/session-policy-store.d.ts +18 -0
  11. package/dist/core/session-policy-store.js +3 -0
  12. package/dist/core/store-contracts/permission-rule-write-contract.js +16 -3
  13. package/dist/core/store-contracts/read-back-shape-contract.d.ts +56 -0
  14. package/dist/core/store-contracts/read-back-shape-contract.js +78 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +4 -0
  17. package/dist/stores/file/adoption/adopt.js +4 -10
  18. package/dist/stores/file/permission-rule-store.d.ts +5 -1
  19. package/dist/stores/file/permission-rule-store.js +5 -3
  20. package/dist/stores/file/session-policy-store.d.ts +32 -0
  21. package/dist/stores/file/session-policy-store.js +72 -50
  22. package/dist/tools/fs/shell-read-check.d.ts +58 -1
  23. package/dist/tools/fs/shell-read-check.js +47 -33
  24. package/dist/tools/fs/shell-read-establish.js +19 -8
  25. package/dist/tools/fs/shell-read-judge.d.ts +3 -0
  26. package/dist/tools/fs/shell-read-judge.js +3 -1
  27. package/package.json +1 -1
  28. package/test/export-surface.snapshot.json +51 -1
  29. package/wire-changes/core@7.26.1.json +605 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.26.1 — 2026-09-21
4
+
5
+ ### One store, one answer about one file; one lineage judgment at every station (#1003 + #1000; narrowing; +0 exports; @server)
6
+
7
+ - **#1003 — the enumeration face judged less than the per-key read.** `FileSessionPolicyStore.getRules` validated a row's SHAPE (a `rev` of at least 1, a whole `gen`, string-array rule fields) and answered ABSENT with a disclosure when it failed; `listBySession` checked "a JSON object carrying our `__sid`" and cast. So one store answered two different things about one file: a row carrying `toolDeny: "rm"` or `rev: 0` was absent to the keyed read and served VERBATIM through the enumeration face that the cross-backend policy export and a deployment's session listing read.
8
+ - **What changes.** The shape judgment is one function now, and both rules-serving faces run it, in the same order, with the same disclosure words. **Narrowing:** a malformed row is absent from `listBySession` and disclosed once through `onCorruptRead`, where it used to be returned. The tolerance is unchanged — one bad row does not void an enumeration. The enumeration attributes before it judges shape, so another session's malformed row is that session's fact rather than noise repeated in every other session's listing.
9
+ - **Who is hurt.** A deployment that read `listBySession` expecting whatever is on disk (a characterization test, a backup differ) sees the row vanish and one extra disclosure. That is the point: nothing could ENFORCE that row, so nothing should have exported it either. The erase sweep and the ownership rebind are unaffected — they read a row's attribution, never its rules, so a malformed-rules row is still erasable and still travels with its key.
10
+ - **#1000 — an absent `gen` meant three things.** `StoredSessionRules.gen` names the lineage a row's `rev` counts within, and the type says an absent one is generation 0. Three stations read it otherwise: the file store's stamp took a READ row carrying no `gen` for "no lineage" and opened a new generation over it; the live reader skipped the comparison unless BOTH rows carried the field; the inherited-gate merge read either side's absence as "same lineage, go ahead". A row of generation 1 could therefore pass for a continuation of a row that spelled its generation nowhere.
11
+ - **What changes.** `continuesLineage(a, b)` is that judgment, in one place, absent = 0; all three stations read it, including the per-call handoff claim, which compared revs without asking at all. **Narrowing:** a live row of another generation no longer replaces the one a leg enforces or the one a checkpoint recorded. The file store's stamp continues a row it could READ and keeps the new generation for the case it was written for — bytes that exist and cannot be read as a row. That half is load-bearing for the other: without it, an upgraded deployment's first tightening over a pre-lineage row would open a generation the leg enforcing that very row would then refuse to follow.
12
+ - **Who is hurt (@server).** A durable twin that does not persist `gen` while core's checkpoint recorded one has its live ancestor rows refused at the merge — fail-closed, the checkpoint's row stands. The repair is to carry the column; both bundled backends do. Absence still carries one other fact, and it is a different question: a backend that stamps no lineage at ALL offers its reader no recycle evidence, so the live reader keeps the older, weaker guard there (it watched the row vanish). What that guard cannot do at the MERGE, which has no observation channel, is recorded as KL-1003a.
13
+ - **Three more from the adversarial round, same class.** (1) **An erase now ends the lineage of EVERY row it removes**, including a torn row belonging to another session. The sweep deletes every unparseable row it meets — that ruling stands — but it used to end the lineage only when the mark named the session being erased, so a foreign key lost its row and kept its generation: the key's next write found no file, continued a generation a leg still enforced, and was taken for that row at a higher rev (measured: the leg answered `allow`, now `deny`). One removal, one lineage transition; the "whose session is this" special case is gone. (2) **The no-lineage fallback reads the LIVE row.** Whether a backend stamps lineages at all is a fact about what it writes NOW; reading the ENFORCED row's absence the same way refused a legitimate generation-0 tightening forever after one transient gap — precisely the row the new file-store stamp produces on an upgraded deployment. A genuinely lineage-less recreation after an observed gap is still refused (pinned as the control). (3) **`listBySession` no longer casts a principal it cannot spell**: a `__principal` that is neither a string nor null is disclosed and skipped instead of shipped through a `string | undefined` seat. That is the enumeration's own attribution question — the keyed read never asks it, because its key comes from the caller.
14
+ - Wire table: `docs/wire-consumption/unreleased-1003.md`. Export surface: `npm run handoff:diff v7.26.0 HEAD` = +0 / −0.
15
+ ### A rule-write outcome is READ, and every revision it carries is judged (#1007; behaviour NARROWING; +0 exports; @server @cli @client-core)
16
+ - **What changed.** `apply`'s answer is DATA — a foreign backend, a deserialized reply, a twin compiled against another generation — and both write entries used to discriminate it by asking whether it carried `conflict` or `refused`, calling everything else a landing. `{}` satisfies that test, so a write nobody could confirm was reported as committed at revision `undefined` through a field the type says is a `number`; `null` threw the `in` operator out of an entry whose declared result union has no throwing arm. One reader (`readRuleWriteOutcome`, module-internal) now tells the arms apart once, for all three call sites (`addPersistedRule`, `removePersistedRule`, the sync round).
17
+ - **What a revision is.** A non-negative safe integer. **Zero is a landing**: a session-scope add lands in a partition that has no revision of its own and the landing reports the durable revision unchanged, which is `0` for a bucket nothing has written yet. A string, a negative, a fraction, `NaN`, `null` or an absent `rev` is an answer nothing can read.
18
+ - **Who is hurt.** A host whose rule backend answered a landing without a readable revision was being told `added` / `removed`; it is now told `failed` with `committed: "unknown"` and reconciles with `list()`. That is the narrowing: a false success becomes an honest indeterminate. **Compensation**: the add entry's indeterminate arm takes an evidence read-back, so a backend that really landed the write still reports `added` — only a backend that landed nothing, or cannot be read, reaches the failure. **Consumer matrix**: in-tree backends (file, in-memory, the session partition) are unaffected — each already answered a readable revision, pinned by the positive controls.
19
+ - **Tags are judged by VALUE, not presence.** `{refused: null}` is not a refusal and `{conflict: false}` is not a decline; an answer carrying both tags is an answer about neither. Reading a tag by presence turned a malformed answer into a DEFINITE `committed: "no"`, which is the same over-claim one arm over.
20
+ - **The landing report rides the same judgement.** A `sync` report that is not an object, or whose `droppedInbound` / `quarantined` are not lists of drop records, no longer reaches the sync round's walk (which threw a type error at a caller holding a committed write). The revision still proves the landing, so the landing stands and the lost report is DISCLOSED in the round's warnings.
21
+ - **The session partition's answer is judged before translation.** `SessionRuleApplyResult` has two named arms (`{landed: true}` / `{refused: "session_ended"}`); the routing writer read it by absence of a refusal, so a host partition answering `{}` produced a well-formed landing at the caller's own revision. Exactly one arm now; anything else is a write face that failed, which the entries report as indeterminate.
22
+ - **A readable revision is not evidence of a row.** The judgement above is about the ANSWER; a backend that reports a landing at a perfectly readable revision and writes nothing answers just as well as one that wrote. Both add doors (the record-less entry and the consent protocol's redemption) now WITNESS a reported landing before reporting it — the same read-back the indeterminate arm already took, asking the evidence face for THIS call's own dot, which is minted once and so identifies this call's work and nobody else's. Not seen ⇒ `committed: "unknown"`, never `"no"` (an eventually-consistent backend may converge later); the read-back itself failing ⇒ `unknown` as well. A backend that really wrote still lands, including when it under-reports its revision.
23
+ - **Two smaller over-claims, same ticket.** The bundled file backend's read-back failure said "the change was not committed" about bytes it had not observed — it now says only that the store did not read back as the version just written, and that whether the change reached disk is unobservable from there. And a write-face accessor that THREW was reported as "nothing was written": the backend's own code had run on the seat whose purpose is to touch bytes, so that arm is now `unknown` (an ABSENT face keeps `no` — nothing ran).
24
+ - **Backend authors**: `permissionRuleWriteContract` now asserts that every arm carrying a revision carries a readable one, and that a decline is tagged `conflict: true`. A backend answering `{}`, or tagging a decline with anything else, fails the kit — it was being certified as usable by an engine that reads its answers as unreadable.
25
+
26
+ ### `store.read_back_shape_judged` — one read-back rule across every store family (#1009; additive; +6 exports; @server @test)
27
+ - **The sentence.** `@contract store.read_back_shape_judged` (`src/core/store-contracts/read-back-shape-contract.ts`, quoted for integrators in `docs/INTEGRATION-CORE.md` §5.0): every read a store answers — a single get and an enumeration alike — judges the SHAPE of what it read; a body that is not the shape the store writes reaches the caller as a refusal or as an absence carrying a disclosure that names the loss, never as an empty set, an empty object or a default. Which read-back failures a store TOLERATES is registered per store in §5.0, never inherited from another store's.
28
+ - **The suite.** `storeReadBackShapeContract` (exported, vitest-free like its siblings) plants four corrupt-body forms — `not-an-object` / `missing-required` / `wrong-type` / `torn-json` (`CORRUPT_BODY_FORMS`) — and asks one mechanical question per form: does the corrupt read answer something OTHER than the empty read, and does it keep the planted bytes out of its answer. Clause 1 is the control: the empty and seeded reads must SUCCEED, answer differently, and the empty answer must be REPRODUCIBLE — which is what stops a face that throws at everything, a face that answers everything alike, and a face carrying a per-read id or timestamp from passing for free.
29
+ - **Its ceiling is written down**: clause 2 proves the corrupt case is distinguishable, not that the distinguishing answer is correct. A backend with no bytes a foreign writer could damage declares that ONCE, at the subject, with a reason — never per attempt, where a planter that had simply gone blind could exempt a backend that still had bodies.
30
+ - **The census** (`test/backlog1009-store-read-back-shape.test.ts`) carries a row for every kit in `src/core/store-contracts/`: bound, pending a named ticket, unbound with what is known, or not a store. A kit with no row reds — "nobody wrote one" and "there is nothing to write" may not look the same. Bound today: the durable permission-rule bucket's `list` / `readRaw` / `quarantined`, the unified `effective`, and `SessionPolicyStore.getRules`.
31
+ - **One store took the fix the sentence asks for**: the durable rule bucket's `quarantined()` answered "the quarantine is empty" about a bucket it could not read — exactly the rows a person is being sent to look at. It now refuses the way `list()` does; an ABSENT bucket is still an empty quarantine.
32
+ - **@server**: this is the sentence S-550 asked for. The SQL twin mounts the same suite against its own read faces; `docs/INTEGRATION-CORE.md` §5.0 is the clause-by-clause table.
33
+ ### The shell read station has a consumer face on the package root (#1010; additive; +20 exports; @cli)
34
+ - **Before.** Everything the read boundary knows lived inside `src/tools/fs/`, so a downstream fence over the 24-grid decision table had to deep-import `dist/tools/fs/*.js` — a path the package's `exports` map does not promise and a directory re-arrangement breaks. A prior statement that this face was already on the root was wrong: the package root had zero of these names.
35
+ - **After.** Twenty names on the root, and nothing else: the vocabulary (`SHELL_READ_FINDINGS`, `SHELL_READ_SEATS`, `SHELL_READ_FINDING_RANK`, `SHELL_READ_DECISION` — the closed sets already registered in `docs/CLOSED-SETS.md`), thirteen shape types (`ShellReadVerdict`, `ShellReadBoundary`, `ShellReadPlan`, `EstablishedRead`, `ReadEstablishment`, …), and ONE call: `judgeShellRead({ command, env, boundary, seat })` → `{ plan, establishment, verdict, offer? }`.
36
+ - **The one call is not a second judgment.** The station's pipeline is now written once and has two entries: the gate input's judge leg, and this one. They differ only in bookkeeping — the gate remembers an admission, the consumer face brings its own establisher — so the verdict a consumer reads is the verdict the gate applies, `offer` included (the verdict's grant candidate through the gate's own `normalizeReadRootCandidate`). Pinned against the real lane (`runToolGate` + the station over a `NodeExecutionEnv` and a real directory) on ten command shapes × three seats.
37
+ - **The steps between stay private** (`planShellReads`, the establisher, `judgeEstablishedPlan`, `foldShellReadFindings`, `decideShellRead`, `readRootGrantCandidateOf`, `judgeShellReadLexically`): they are the station's working, not its answer, and they are free to move. A fence that needs one should name the grid and why the one call is not enough.
38
+ - **Which `ExecutionEnv` an offline judgment may use** — `docs/INTEGRATION-CORE.md` §9. `NodeExecutionEnv` over a temporary directory is the supported face: the same adapter production runs on. `StubExecutionEnv` reads LEXICALLY (measured: a symlink inside the roots pointing out reads `none`/`vouch` under it where the real env reads `outside_roots`/`mandated`; a pattern it cannot expand reads `unestablishable` where the real env names the targets), and `judgeShellReadLexically` is a third reading again — on a pattern that expands onto such a symlink it vouches. Core ships no in-memory filesystem fixture: a third derivation of `realpath`/glob semantics is a third place for them to be wrong.
39
+ - **What the one call does NOT carry: a deadline.** Bounding an environment-facing wait and substituting a fail-closed verdict is the gate FOLD's policy (`withGateTimeout` + the station's `unanswered` arm), not the station's — the station has never held one on either entry. So a stalling filesystem adapter, or a `boundary.alsoInside` promise that never settles, stalls this call where a run would refuse; `signal` reaches the environment's own `canonicalPath` / `listDir` and is advisory, not a deadline (measured: an already-aborted signal still answers). A consumer wanting the gate's posture races its own deadline and reads the row the fold reads, `SHELL_READ_DECISION.unestablishable[seat]`. Recorded as `KL-1010a`; a second deadline inside the library would be a second place deciding it, and would turn a slow-but-correct filesystem's true answer into a fail-closed one with nobody having asked.
40
+ - **Who is hurt / compensation / consumers.** Nobody: 0 exports removed, 0 changed kind, no gate decision moves, no wire key changes. Deep `dist/tools/fs/*.js` imports keep working. @cli L-467 moves its fence to the root face at its own pace. Wire table: `docs/wire-consumption/unreleased-1010.md`. Export surface: `npm run handoff:diff v7.26.0 HEAD` = +20 / −0.
41
+
42
+ ### Merge-tree adversarial round: two fixes (R1 #1000 × erase; R2 #1014 the traversal grant)
43
+ - **R1.** The erase sweep ends a torn row's lineage for EVERY torn row it removes, and the row always goes (a crash artifact must not outlive a delete that reported success): a mark that names no session is bumped as found; a torn row with NO mark leaves a lost-lineage tombstone (`{__hwm: 0, __gen: 0, __lineage: "lost"}`) in the mark's place — without evidence a minted generation could repeat one a leg still enforces (round 2: an erased-once key's enforced row is already generation 1) — and a torn row whose mark is corrupt leaves that mark as it is. A write to a key carrying the tombstone, or an unreadable row and no mark, is refused (`SessionPolicyError("corrupt")`) rather than guessed; deleting the mark is the operator's restart, as the mark's own contract says. Before the fix a session-less mark was skipped (the recreation continued the enforced generation) and a missing one was minted.
44
+ - **R2 (#1014).** A traversal's root-grant offer names what the environment said the target IS, not what its provenance was: `EstablishedRead.node` (`"directory" | "file"`, set only by the walk — the root by its own listing, a member by whether the walk listed it) and `readRootGrantCandidateOf` grants a file through its containing directory. Before the fix `grep -R needle /abs/file.txt` and a recursive read descending a link onto an outside file offered the FILE as a directory grant (`{ dir: …/report.txt }`); a link onto a directory is still granted as that directory (control). The same derivation serves the gate and `judgeShellRead`, so both faces change together.
45
+
46
+
3
47
  ## 7.26.0 — 2026-09-21
4
48
 
5
49
  ### Six root exports the neighbours were hand-copying (#995; additive; +6 exports; @server @cli @client-core)
@@ -121,7 +121,12 @@ function routingWriter(durable, session) {
121
121
  throw e;
122
122
  }
123
123
  const outcome = await session.apply(scope.sessionId, { behavior: delta.behavior, rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, add: delta.add });
124
- return "refused" in outcome ? { refused: "session_ended", rev: opts.expectedRev } : { rev: opts.expectedRev };
124
+ const answered = outcome !== null && typeof outcome === "object" ? outcome : {};
125
+ const landed = answered.landed === true;
126
+ const sealed = answered.refused === "session_ended";
127
+ if (landed !== sealed)
128
+ return landed ? { rev: opts.expectedRev } : { refused: "session_ended", rev: opts.expectedRev };
129
+ throw new Error(`the session rule partition answered the ${delta.kind} with ${landed ? "both of its arms at once" : "neither of its two arms"} (landed / refused) — nothing here can say whether the authorization landed; reconcile by reading the session's rules`);
125
130
  },
126
131
  };
127
132
  }
@@ -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
@@ -5,6 +5,71 @@ 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
+ }
8
73
  export function unknownRuleWriteDeltaError(delta) {
9
74
  const kind = delta?.kind;
10
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`);
@@ -405,13 +470,32 @@ function resolveRuleWriter(store) {
405
470
  w = writerOf(store);
406
471
  }
407
472
  catch (err) {
408
- return { unavailable: `the permission-rule store's write face could not be acquired (${errText(err)})` };
473
+ return { unavailable: `the permission-rule store's write face could not be acquired (${errText(err)})`, committed: "unknown" };
409
474
  }
410
- return w === undefined ? { unavailable: "the resolved permission-rule store has no write face" } : { writer: w };
475
+ return w === undefined ? { unavailable: "the resolved permission-rule store has no write face", committed: "no" } : { writer: w };
411
476
  }
412
477
  function ruleIdentityStands(rules, identity) {
413
478
  return rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r));
414
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
+ }
415
499
  async function readRuleLiveness(store, identity) {
416
500
  try {
417
501
  const after = await store.effective();
@@ -448,7 +532,7 @@ export async function removePersistedRule(opts) {
448
532
  const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
449
533
  const face = resolveRuleWriter(store);
450
534
  if ("unavailable" in face) {
451
- return { status: "failed", error: `${face.unavailable} — rules cannot be removed through it`, committed: "no" };
535
+ return { status: "failed", error: `${face.unavailable} — rules cannot be removed through it`, committed: face.committed };
452
536
  }
453
537
  const writer = face.writer;
454
538
  let snapshot;
@@ -474,30 +558,35 @@ export async function removePersistedRule(opts) {
474
558
  const tombstone = { behavior, rule, scope, removedDots, deletedBy };
475
559
  let expectedRev = snapshot.rev;
476
560
  let lastError = "";
477
- let applyThrew = false;
561
+ let settlement = "no";
478
562
  for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
479
563
  try {
480
- const res = await writer.apply({ kind: "tighten-delete", tombstone }, { expectedRev });
481
- if ("refused" in res) {
482
- return { status: "failed", error: `the store refused the tombstone (${res.refused}) — nothing was removed`, committed: "no" };
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" };
567
+ }
568
+ if (reading.kind === "unreadable") {
569
+ settlement = "unknown";
570
+ lastError = `the permission-rule store answered the write unreadably: ${reading.why}`;
571
+ break;
483
572
  }
484
- if (!("conflict" in res)) {
485
- return { status: "removed", rev: res.rev, stillLive: await readRuleLiveness(store, identity) };
573
+ if (reading.kind === "landed") {
574
+ return { status: "removed", rev: reading.rev, stillLive: await readRuleLiveness(store, identity) };
486
575
  }
487
- expectedRev = res.rev;
576
+ expectedRev = reading.rev;
488
577
  }
489
578
  catch (err) {
490
- applyThrew = true;
579
+ settlement = "unknown";
491
580
  lastError = errText(err);
492
581
  break;
493
582
  }
494
583
  }
495
- const reason = applyThrew
584
+ const reason = settlement === "unknown"
496
585
  ? lastError !== ""
497
586
  ? lastError
498
587
  : "the permission-rule store threw while applying the tombstone, with no message"
499
588
  : `optimistic-concurrency retries exhausted after ${REMOVE_MAX_ATTEMPTS} attempts`;
500
- const committed = applyThrew ? "unknown" : "no";
589
+ const committed = settlement;
501
590
  try {
502
591
  const after = await writer.readRaw();
503
592
  const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
@@ -506,7 +595,7 @@ export async function removePersistedRule(opts) {
506
595
  }
507
596
  return {
508
597
  status: "failed",
509
- error: applyThrew
598
+ error: settlement === "unknown"
510
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()`
511
600
  : `${reason} (read-back confirms no tombstone was written)`,
512
601
  committed,
@@ -515,7 +604,7 @@ export async function removePersistedRule(opts) {
515
604
  catch (err) {
516
605
  return {
517
606
  status: "failed",
518
- error: applyThrew
607
+ error: settlement === "unknown"
519
608
  ? `${reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()`
520
609
  : `${reason}; the read-back also failed (${errText(err)}), but every attempt was answered by the store, so nothing was written`,
521
610
  committed,
@@ -555,10 +644,21 @@ export async function landRuleAdd(args) {
555
644
  catch (err) {
556
645
  return { landed: false, reason: `the permission-rule store refused the write: ${errText(err)}`, committed: "unknown" };
557
646
  }
558
- if ("refused" in res)
647
+ const reading = readRuleWriteOutcome(res);
648
+ if (reading.kind === "refused")
559
649
  return { landed: false, reason: SESSION_ENDED_REFUSAL, committed: "no" };
560
- if (!("conflict" in res))
561
- 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
+ }
562
662
  }
563
663
  return { landed: false, reason: `optimistic-concurrency retries exhausted after ${RULE_ADD_MAX_ATTEMPTS} attempts`, committed: "no" };
564
664
  }
@@ -592,7 +692,7 @@ export async function addPersistedRule(opts) {
592
692
  const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
593
693
  const face = resolveRuleWriter(store);
594
694
  if ("unavailable" in face) {
595
- return { status: "failed", error: `${face.unavailable} — rules cannot be added through it`, committed: "no" };
695
+ return { status: "failed", error: `${face.unavailable} — rules cannot be added through it`, committed: face.committed };
596
696
  }
597
697
  const writer = face.writer;
598
698
  const query = scope.kind === "session" ? { sessionId: scope.sessionId } : undefined;
@@ -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);
@@ -41,7 +41,7 @@ import { type HookEnvCapabilities, type Hooks } from "../hooks.js";
41
41
  import type { LockedPreflight } from "../locked-config.js";
42
42
  import type { OwnOrgAdmissionVerdict } from "../memory-admission.js";
43
43
  import type { DelegationContentSafety } from "../memory-engine/delegation-provenance.js";
44
- import type { SessionPermissionRules } from "../session-policy-store.js";
44
+ import { type SessionPermissionRules } from "../session-policy-store.js";
45
45
  import type { SessionStore } from "../session.js";
46
46
  import { type OnAsk } from "../tool-policy.js";
47
47
  import type { ApproverPosture, RunnerDeps, RuntimeCaps, ShellGateDoctrine, TaskEvent, TaskSpec, ToolExecuteContext } from "../types.js";
@@ -2,6 +2,7 @@ import { forkGovernanceDenial, resolveDelegationEntryCaps } from "../../agents/s
2
2
  import { isLiveQuestionFace } from "../ask-question.js";
3
3
  import { parentSpecSeatOf } from "../parent-spec-seat.js";
4
4
  import { createPreToolUseConstraintPolicy } from "../hooks.js";
5
+ import { continuesLineage } from "../session-policy-store.js";
5
6
  import { StubExecutionEnv } from "../stub-env.js";
6
7
  import { askApproverIdentity, durableMandateSourceOf } from "../tool-policy.js";
7
8
  import { attachRebuiltDenialTrackers, createDenialLimitStop } from "./denial-limit-arms.js";
@@ -21,7 +22,7 @@ export function prepareInheritedGate(input) {
21
22
  return [...seedRules];
22
23
  const merged = seedRules.map((s) => {
23
24
  const live = liveRules.find((l) => l.sessionId === s.sessionId && l.principal === s.principal);
24
- const sameLineage = live === undefined || live.gen === undefined || s.gen === undefined || live.gen === s.gen;
25
+ const sameLineage = live === undefined || continuesLineage(live, s);
25
26
  return live !== undefined && sameLineage && live.rev >= s.rev ? live : s;
26
27
  });
27
28
  const extras = liveRules.filter((l) => !seedRules.some((s) => s.sessionId === l.sessionId && s.principal === l.principal));
@@ -1,7 +1,7 @@
1
1
  import type { ExecutionEnv } from "../../internal/harness-types.js";
2
2
  import type { ToolEffect } from "../types.js";
3
3
  import { type NamedToolPolicy } from "../tool-policy.js";
4
- import type { SessionPermissionRules, SessionPolicyStore, StoredSessionRules } from "../session-policy-store.js";
4
+ import { type SessionPermissionRules, type SessionPolicyStore, type StoredSessionRules } from "../session-policy-store.js";
5
5
  /** Tool names whose write target is the `file_path`/`path` arg ({@link fileArgPath}).
6
6
  *
7
7
  * REF-B3 — the ONE definition of that narrower set. Production consumer today:
@@ -1,5 +1,6 @@
1
1
  import { canonicalizeTarget, isWinFormPath, pathFamilyOf, writeTargetPath } from "../../tools/fs/safety.js";
2
2
  import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
3
+ import { continuesLineage } from "../session-policy-store.js";
3
4
  import { thrownText } from "../thrown-value.js";
4
5
  import { ruleDenialSentence } from "../rule-denial-sentence.js";
5
6
  import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, gateWaitFailureOf, withGateTimeout } from "../gate-bounded-await.js";
@@ -61,13 +62,11 @@ export function createLiveSessionRules(store, floor, hooks) {
61
62
  return enforced;
62
63
  }
63
64
  if (enforced.rev > 0) {
64
- if (live.gen !== undefined && enforced.gen !== undefined) {
65
- if (live.gen !== enforced.gen) {
66
- announce(`session ${JSON.stringify(floor.sessionId)} has a rules row of a new lineage (generation ${live.gen}, rev ${live.rev}), which does not continue the row this leg enforces (generation ${enforced.gen})`);
67
- return enforced;
68
- }
65
+ if (!continuesLineage(live, enforced)) {
66
+ announce(`session ${JSON.stringify(floor.sessionId)} has a rules row of a new lineage (generation ${live.gen}, rev ${live.rev}), which does not continue the row this leg enforces (generation ${enforced.gen})`);
67
+ return enforced;
69
68
  }
70
- else if (vanished) {
69
+ if (vanished && live.gen === undefined) {
71
70
  announce(`session ${JSON.stringify(floor.sessionId)} has a new rules row at rev ${live.rev} after the row this leg enforced was deleted, and its backend carries no lineage to continue it by`);
72
71
  return enforced;
73
72
  }
@@ -89,7 +88,7 @@ export function createLiveSessionRules(store, floor, hooks) {
89
88
  if (handedRead !== undefined) {
90
89
  handed.delete(toolCallId);
91
90
  const claimed = await handedRead;
92
- if (claimed.rev >= enforced.rev)
91
+ if (continuesLineage(claimed, enforced) && claimed.rev >= enforced.rev)
93
92
  return claimed;
94
93
  }
95
94
  const p = read(signal);
@@ -131,6 +131,24 @@ export declare function normalizeRules(rules: SessionPermissionRules): SessionPe
131
131
  * allowDirs: removing confinement, or adding a dir not within some prior dir, loosens (coarse raw containment).
132
132
  */
133
133
  export declare function loosenReasons(prior: SessionPermissionRules, next: SessionPermissionRules): string[];
134
+ /**
135
+ * Does row `a` belong to the SAME lineage as row `b` — the one question every reader asks before it compares
136
+ * two rows' `rev`, asked in ONE place so a `gen` that is ABSENT cannot mean three different things at three
137
+ * stations. It means what {@link StoredSessionRules.gen} says it means: generation ZERO. A row that spells
138
+ * `gen: 0` and a row that omits it are therefore the same row's generation, and a row of generation 1 does not
139
+ * continue either of them — which is the case the readings used to disagree on, each treating absence as
140
+ * "unknown, so do not judge" and letting a recreated row pass for a continuation of the one it enforced.
141
+ *
142
+ * ABSENCE STILL CARRIES ONE OTHER FACT, and it is a different question: a backend that stamps no lineage at
143
+ * ALL gives its reader no recycle evidence, so a reader that has one of its own (it watched the row vanish)
144
+ * keeps using it. That test asks whether the FIELD is there, never what it compares to, and it is the caller's
145
+ * to make — this predicate answers only "same lineage or not".
146
+ */
147
+ export declare function continuesLineage(a: {
148
+ gen?: number;
149
+ }, b: {
150
+ gen?: number;
151
+ }): boolean;
134
152
  /** strip any caller-passed `rev` from a rules object so the store is the sole authority on it. */
135
153
  export declare function stripRev(rules: SessionPermissionRules): SessionPermissionRules;
136
154
  /**
@@ -69,6 +69,9 @@ export function loosenReasons(prior, next) {
69
69
  }
70
70
  return reasons;
71
71
  }
72
+ export function continuesLineage(a, b) {
73
+ return (a.gen ?? 0) === (b.gen ?? 0);
74
+ }
72
75
  export function stripRev(rules) {
73
76
  const { ...rest } = rules;
74
77
  delete rest.rev;
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert";
2
- import { PERMISSION_RULE_WRITER } from "../permission-rule-store.js";
2
+ import { PERMISSION_RULE_WRITER, isRuleRevision } from "../permission-rule-store.js";
3
3
  import { beginContract } from "./contract-harness.js";
4
4
  const GLOBAL = { kind: "global" };
5
5
  function add(actor, counter) {
@@ -18,6 +18,15 @@ async function rawShape(part) {
18
18
  const tombs = raw.tombstones.map((t) => `${t.behavior}|${t.rule}|${t.deletedBy.actor}#${t.deletedBy.counter}`).sort();
19
19
  return JSON.stringify({ rev: raw.rev, dots, tombs });
20
20
  }
21
+ function assertDecline(outcome, what) {
22
+ const answer = (outcome ?? {});
23
+ assert.equal(answer.conflict, true, `${what} must answer \`conflict: true\` — a decline tagged with anything else is an answer the engine reads as unreadable, so a kit accepting it certifies a backend the engine cannot use`);
24
+ assert.equal(answer.refused, undefined, `${what} must not wear the refusal tag as well — an answer that is two arms is an answer about neither`);
25
+ }
26
+ function assertRevision(outcome, what) {
27
+ const rev = outcome?.rev;
28
+ assert.ok(isRuleRevision(rev), `${what} must carry a revision — a non-negative safe integer — and this answer carried ${rev === undefined ? "none at all" : `a ${typeof rev}`}`);
29
+ }
21
30
  export async function permissionRuleWriteContract(hooks) {
22
31
  const { run, settle } = beginContract(hooks.runAssertion);
23
32
  run("the named arms land, a landing ADVANCES the revision, and a spent revision is ANSWERED (positive control)", async () => {
@@ -27,19 +36,23 @@ export async function permissionRuleWriteContract(hooks) {
27
36
  const held = (await part.list()).rev;
28
37
  const first = await writer.apply({ kind: "tighten-add", ...IDENTITY, add: a1 }, { expectedRev: held });
29
38
  assert.ok(!("conflict" in first) && !("refused" in first), "a well-formed tighten-add must land");
39
+ assertRevision(first, "the tighten-add landing");
30
40
  const afterAdd = await part.list();
31
41
  assert.deepEqual(liveDots(afterAdd.rules), new Set([`deny|${IDENTITY.rule}|kit#1`]), "the added dot must be visible through list()");
32
42
  assert.ok(afterAdd.rev > held, "a landed write must ADVANCE the revision — a constant one is not a fence, it is a number");
33
43
  assert.equal((await part[PERMISSION_RULE_WRITER].readRaw()).rev, afterAdd.rev, "every face must report ONE revision");
34
44
  const rawBefore = await rawShape(part);
35
45
  const stale = await writer.apply({ kind: "tighten-add", ...IDENTITY, add: add("kit", 2) }, { expectedRev: held });
36
- assert.ok("conflict" in stale, "a spent expectedRev must answer `conflict`, not throw and not land");
46
+ assertDecline(stale, "a spent expectedRev");
47
+ assertRevision(stale, "the declining conflict");
37
48
  const staleDelete = await writer.apply({ kind: "tighten-delete", tombstone: tombstoneFor([a1], { actor: "kit", counter: 8 }) }, { expectedRev: held });
38
- assert.ok("conflict" in staleDelete, "a spent expectedRev must answer `conflict` on the delete arm too");
49
+ assertDecline(staleDelete, "a spent expectedRev on the delete arm");
50
+ assertRevision(staleDelete, "the declining conflict on the delete arm");
39
51
  assert.equal(await rawShape(part), rawBefore, "a declined write must move nothing — rows, tombstones or revision");
40
52
  const beforeDelete = (await part.list()).rev;
41
53
  const removed = await writer.apply({ kind: "tighten-delete", tombstone: tombstoneFor([a1], { actor: "kit", counter: 9 }) }, { expectedRev: beforeDelete });
42
54
  assert.ok(!("conflict" in removed) && !("refused" in removed), "a well-formed tighten-delete must land");
55
+ assertRevision(removed, "the tighten-delete landing");
43
56
  assert.deepEqual(liveDots((await part.list()).rules), new Set(), "the tombstoned dot must leave the live view");
44
57
  assert.ok((await part.list()).rev > beforeDelete, "a landed delete must advance the revision too");
45
58
  const raw = await part[PERMISSION_RULE_WRITER].readRaw();
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The CROSS-STORE read-back rule (`@contract store.read_back_shape_judged`, quoted for integrators in
3
+ * `docs/INTEGRATION-CORE.md` §5.0) and the acceptance suite that pins it. Kit form as the sibling kits
4
+ * (see `permission-rule-write-contract.ts`): no test framework, no skip hook.
5
+ */
6
+ import { type ContractAssertionRunner } from "./contract-harness.js";
7
+ /**
8
+ * @contract store.read_back_shape_judged — every read a store answers, a single get and an enumeration
9
+ * alike, judges the SHAPE of what it read before answering: a body that is not the shape this store
10
+ * writes is CORRUPT, and a corrupt body is answered in the store's own corrupt vocabulary — a loud
11
+ * refusal, or an absence that carries a disclosure naming the loss — never as an empty set, an empty
12
+ * object or a default value, because an answer a caller cannot tell apart from "there is nothing here"
13
+ * turns a recoverable incident into a silent one. Which read-back failures a store TOLERATES (an
14
+ * enumeration that skips one unreadable row and serves the rest, a torn trailing write read as absent)
15
+ * is a per-store fact registered beside that store's own contract, never assumed from another store's.
16
+ */
17
+ export declare const CORRUPT_BODY_FORMS: readonly ["not-an-object", "missing-required", "wrong-type", "torn-json"];
18
+ /** One way a persisted body can fail to be the shape its store writes. */
19
+ export type CorruptBodyForm = (typeof CORRUPT_BODY_FORMS)[number];
20
+ export interface ReadBackShapeSubject {
21
+ /** Declared ONCE per subject, and only by a backend that keeps no bytes a foreign writer could damage
22
+ * (a process-local map holding objects it minted itself). Never per attempt: a planter that cannot
23
+ * find bytes it knows exist must FAIL rather than exempt its backend. */
24
+ readonly unplantable?: string;
25
+ /** Plant ONE corrupt body of `form`, with `sentinel` inside the planted bytes wherever the form allows
26
+ * (that is how clause 3 detects a body handed straight back). No declining return: a backend with no
27
+ * bodies declares that at the subject, and a planter that cannot find bytes it knows exist THROWS. */
28
+ plant: (form: CorruptBodyForm, sentinel: string) => Promise<void>;
29
+ /** Perform the read under test. THROWING IS AN ANSWER — the loud refusal — and is not a suite failure. */
30
+ read: () => Promise<unknown>;
31
+ /** Write ONE well-formed row — clause 1's subject. */
32
+ seed: () => Promise<void>;
33
+ close?: () => void;
34
+ }
35
+ export interface StoreReadBackShapeHooks {
36
+ /** The face under test, named as a reader would name it (`"FileCheckpointStore.get"`). Failure text only. */
37
+ face: string;
38
+ /** Open a FRESH, EMPTY store and return its subject. Called once per observation; teardown is `close`. */
39
+ open: () => Promise<ReadBackShapeSubject>;
40
+ runAssertion?: ContractAssertionRunner;
41
+ }
42
+ /**
43
+ * Run the read-back shape suite over ONE read face. Throws (assert) on the first violated clause.
44
+ *
45
+ * Clauses: (1) a seeded row and an empty store both ANSWER, answer differently, and the empty answer is
46
+ * REPRODUCIBLE — a face that throws at everything, answers everything alike, or carries incidental data
47
+ * (a read id, a timestamp) proves nothing by differing under corruption;
48
+ * (2) each of the four {@link CORRUPT_BODY_FORMS}, planted, is answered differently from an empty store;
49
+ * (3) and the answer never contains the planted bytes.
50
+ *
51
+ * CEILING: clause 2 proves the corrupt case is DISTINGUISHABLE, not that the distinguishing answer is
52
+ * right — a face answering one invalid row where a good read answers rows passes. Whether the store then
53
+ * named the loss correctly is judged by its own section in §5.0, by a person; the four forms are a
54
+ * fixture set, not the space of corrupt bodies.
55
+ */
56
+ export declare function storeReadBackShapeContract(hooks: StoreReadBackShapeHooks): Promise<void>;