@sema-agent/core 5.22.0 → 5.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/agents/subagent.js +3 -2
  3. package/dist/core/governance-codes.js +3 -0
  4. package/dist/core/hooks.d.ts +62 -1
  5. package/dist/core/hooks.js +90 -12
  6. package/dist/core/memory-engine/engine.d.ts +28 -1
  7. package/dist/core/memory-engine/engine.js +62 -3
  8. package/dist/core/memory-engine/index.d.ts +1 -1
  9. package/dist/core/memory-engine/index.js +1 -1
  10. package/dist/core/memory-engine/layout.d.ts +69 -3
  11. package/dist/core/memory-engine/layout.js +75 -6
  12. package/dist/core/permission-rule-consent.js +2 -1
  13. package/dist/core/permission-rule-org.d.ts +36 -2
  14. package/dist/core/permission-rule-org.js +23 -0
  15. package/dist/core/permission-rule-store.js +2 -1
  16. package/dist/core/permission-rule-sync.d.ts +8 -0
  17. package/dist/core/permission-rule-sync.js +35 -6
  18. package/dist/core/runner/prepare-task.d.ts +10 -2
  19. package/dist/core/runner/prepare-task.js +111 -5
  20. package/dist/core/runner/runtask.js +19 -0
  21. package/dist/core/tool-policy.d.ts +37 -4
  22. package/dist/core/tool-policy.js +32 -4
  23. package/dist/core/tool-result-store.d.ts +9 -1
  24. package/dist/core/tool-result-store.js +2 -1
  25. package/dist/core/trace.d.ts +47 -0
  26. package/dist/core/types.d.ts +38 -0
  27. package/dist/core/wiring-manifest.d.ts +16 -1
  28. package/dist/core/wiring-manifest.js +7 -1
  29. package/dist/index.d.ts +5 -3
  30. package/dist/index.js +4 -2
  31. package/dist/orchestration/goal.d.ts +10 -0
  32. package/dist/orchestration/goal.js +6 -5
  33. package/dist/stores/file/adoption/adopt.d.ts +146 -0
  34. package/dist/stores/file/adoption/adopt.js +616 -0
  35. package/dist/stores/file/adoption/marker.d.ts +194 -0
  36. package/dist/stores/file/adoption/marker.js +198 -0
  37. package/dist/stores/file/background-agent-store.js +2 -0
  38. package/dist/stores/file/checkpoint-store.js +2 -0
  39. package/dist/stores/file/file-snapshot-store.js +2 -0
  40. package/dist/stores/file/index.d.ts +2 -0
  41. package/dist/stores/file/index.js +4 -0
  42. package/dist/stores/file/mailbox-store.js +2 -0
  43. package/dist/stores/file/memory-store.js +2 -0
  44. package/dist/stores/file/session-policy-store.js +2 -0
  45. package/dist/stores/file/session-store.js +2 -0
  46. package/dist/stores/file/task-list-store.js +2 -0
  47. package/dist/stores/file/tool-result-store.js +2 -0
  48. package/dist/stores/file/usage-window-store.js +2 -0
  49. package/dist/stores/file/workflow-journal-store.js +2 -0
  50. package/dist/stores/file/workflow-run-store.js +2 -0
  51. package/dist/tools/fs/bash-readonly-classifier.js +59 -10
  52. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.23.0 — 2026-08-10
4
+
5
+ No BREAKING changes.
6
+
7
+ ### Added
8
+
9
+ - **design/183 adoption protocol, core file half.** Root adoption marker + six-phase
10
+ `adoptLocalDataRoot` state machine + `readAdoptionStatus` standing introspection + config account
11
+ (witness/ack) + quarantine listing; 23 new exports. The I6 boot gate is a new refusal surface: every
12
+ file-store constructor under a root whose `adoption.json` marker is IN FLIGHT throws
13
+ `AdoptionError("adoption_in_flight")` (corrupt marker: `adoption_marker_corrupt`, fail-closed).
14
+ Roots without a marker are untouched. The terminal report carries `notMigratedByDesign` — the
15
+ machine-readable closed set of assets ruled to stay local, sharded per migration form.
16
+ - **design/182 org layer wired into the decision chain** (org-governed deployments only): org deny
17
+ blocks on both the main gate and the durable-resume leg; an unavailable org snapshot past
18
+ `ORG_ADJUDICATION_TIMEOUT_MS` (15s, mandatory at both call sites) downgrades every terminal allow
19
+ to a human ask, fail-closed. Closed-set additions consumers should re-pin: `DecisionReason`
20
+ +`org_rule`/`org_unavailable`; `PermissionDeniedSource` +`org`; `TraceEvent`
21
+ +`rule_sync_resurrected`/`rule_sync_dropped`/`org_snapshot_unavailable`.
22
+ - `AskOutcome` terminal arms accept an optional `settledBy` (host self-report; `allow` × `"timeout"`
23
+ is loudly refused); `GoalResult.budgetCause` names which ceiling ended a budget run;
24
+ `NON_GOVERNANCE_MEMORY_CODES` +3 members (`memory.challenge_ledger_oversize`,
25
+ `memory.control_plane_rebuilt`, `memory.control_plane_not_corrupt`);
26
+ `MemoryEngine.rebuildCorruptControlPlaneLedger` (host-only, refuses a healthy ledger, quarantines
27
+ corrupt bytes) and `CHALLENGE_LEDGER_MAX_EVENTS` disclosure threshold.
28
+
29
+ ### Fixed / narrowed
30
+
31
+ - **#109**: separated option values (`head -c 5`, `cut -f 1`, `grep -A 3 pat`, …) are no longer
32
+ counted as file operands by the read-only shell classifier — fileless forms of these commands now
33
+ ask instead of auto-passing.
34
+ - **#110**: a checkpoint `put` that errors but whose row is durably present (read-back claims it)
35
+ suspends instead of running on; `put`+`get` both failing aborts the run fail-closed.
36
+ - **#114**: `settledBy:"timeout"` documented promise narrowed to the two core-managed wait windows.
37
+ - **#115**: the offload truncation notice states where the remainder IS instead of implying any
38
+ caller can fetch it.
39
+ - Consent-record ids and the in-memory CRDT actor default now take real entropy (`randomBytes`);
40
+ format is opaque, do not parse.
41
+
42
+ ### Tooling
43
+
44
+ - New `gate:error-surface` (four invariants over model-visible error minting: no `.stack`,
45
+ >400-char templates ledgered, uncapped-arm ratchet, verbatim-guidance dedup) wired into CI.
46
+ - The environment-knob surface (25 names) is under an exhaustive AST-driven liveness registry;
47
+ `docs/CONFIG-CATALOG-GUIDE.md` gained the environment-knobs chapter (two comment-only pseudo-knobs
48
+ called out: `MAX_TOOL_CONCURRENCY`, `APPROVAL_TTL_MS` — setting them has no effect).
49
+
3
50
  ## 5.22.0 — 2026-08-09
4
51
 
5
52
  ### BREAKING
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { withDelegationProvenance } from "../core/tool-policy.js";
4
+ import { isHighSurrogate, isLowSurrogate } from "../core/surrogate-safe-slice.js";
4
5
  import { newDelegationProvenanceAggregate, reduceDelegationAttestation } from "../core/memory-engine/delegation-provenance.js";
5
6
  import { resolveModel, resolveModelDisplayLabel } from "../core/roles.js";
6
7
  import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
@@ -65,8 +66,8 @@ function defuseGapMarkers(slice) {
65
66
  return slice.replace(/\[…/g, `${REVIEW_MARKER_SCAR}…`);
66
67
  }
67
68
  function surrogateSafeSlice(text, from, width, anchorEnd = false) {
68
- const isLow = (i) => text.charCodeAt(i) >= 0xdc00 && text.charCodeAt(i) <= 0xdfff;
69
- const isHigh = (i) => text.charCodeAt(i) >= 0xd800 && text.charCodeAt(i) <= 0xdbff;
69
+ const isLow = (i) => isLowSurrogate(text.charCodeAt(i));
70
+ const isHigh = (i) => isHighSurrogate(text.charCodeAt(i));
70
71
  let start = from;
71
72
  if (start > 0 && start < text.length && isLow(start) && isHigh(start - 1))
72
73
  start--;
@@ -17,6 +17,9 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
17
17
  "memory.challenge_sweep_failed",
18
18
  "memory.lineage_settle_failed",
19
19
  "memory.challenge_ledger_corrupt",
20
+ "memory.challenge_ledger_oversize",
21
+ "memory.control_plane_rebuilt",
22
+ "memory.control_plane_not_corrupt",
20
23
  ]);
21
24
  export function governanceRetryClass(code) {
22
25
  if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
@@ -161,8 +161,13 @@ export interface Hooks {
161
161
  * - `"shellGate"` — same tighten-deny, but the tool's irreversibility tier was installed by the COARSE
162
162
  * `TaskSpec.shellGate` doctrine (design/80 D-E), not an explicit per-tool mark.
163
163
  * - `"planMode"` — the design/108 plan-mode write-deny short-circuit (a read-only fidelity gate).
164
+ * - `"org"` (design/182 §7) — the ORG layer: an organization deny rule, or the fail-closed tighten an
165
+ * org-governed deployment applies while it cannot adjudicate against a snapshot (that tighten's ask
166
+ * then resolving to deny). Its own word rather than `"policy"` because the authority is the ORG's
167
+ * published snapshot, not this deployment's `ToolPolicy` — an operator reading "policy" on a call
168
+ * their own policy allowed would be looking in the wrong place.
164
169
  */
165
- export type PermissionDeniedSource = "policy" | "hook" | "safety" | "shellGate" | "planMode" | "classifier";
170
+ export type PermissionDeniedSource = "policy" | "hook" | "safety" | "shellGate" | "planMode" | "classifier" | "org";
166
171
  /** The payload a {@link Hooks.permissionDenied} callback observes (CC-exact fields + `source`). */
167
172
  export interface PermissionDeniedPayload {
168
173
  toolName: string;
@@ -520,6 +525,26 @@ export interface ParkAttemptFailed {
520
525
  /** Why the park could not be minted — a sentence, already worded for a reader. */
521
526
  parkFailed: string;
522
527
  }
528
+ /**
529
+ * design/182 §7 — what the ORG layer answers for ONE call (see `ToolGateInput.orgRules`).
530
+ *
531
+ * The two arms are distinguished by `status`, NEVER by the absence of a verdict: "the org published no
532
+ * rule for this call" and "this deployment cannot see the org's rules" are opposite facts, and reading
533
+ * an empty answer as the first one is exactly the governance fail-open the availability contract exists
534
+ * to prevent.
535
+ */
536
+ export type OrgGateVerdict = {
537
+ status: "available";
538
+ /** The org rule that speaks for this call, if any. Deny outranks ask; there is no allow bucket. */
539
+ verdict?: {
540
+ behavior: "deny" | "ask";
541
+ rule: string;
542
+ };
543
+ } | {
544
+ status: "unavailable";
545
+ /** The overlay's own disclosure lines (engine/deployment-authored), for the operator channel. */
546
+ disclosures: readonly string[];
547
+ };
523
548
  /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort signal. */
524
549
  export interface ToolGateInput {
525
550
  /** RB-473 batch 2b — bounded disclosure for a HOST callback that throws inside this gate call's
@@ -713,6 +738,42 @@ export interface ToolGateInput {
713
738
  rule: string;
714
739
  }) => void;
715
740
  };
741
+ /**
742
+ * design/182 §7 — the ORG layer. Present ONLY when the deployment DECLARED org governance (the
743
+ * overlay constructor is the boot gate: a governed declaration with no snapshot provider refuses to
744
+ * boot). Absent ⇒ the whole step does not exist and the decision path is byte-identical.
745
+ *
746
+ * The org layer is the outermost TIGHTENING authority, so it runs after every fold and every engine
747
+ * tighten and before the ask-resolution chain — "org deny > org ask (non-dismissable) > personal
748
+ * allow rule > bare ask". It never loosens: there is structurally no org allow bucket.
749
+ *
750
+ * The `unavailable` arm is the load-bearing one. An org-governed deployment that cannot reach a
751
+ * snapshot fails the WHOLE decision boundary closed — every terminal allow (a deployment-policy
752
+ * allow and the no-policy default allow included) becomes an ask carrying
753
+ * `decisionReason: "org_unavailable"` AND `requiresRealApproval: true`, and both ask→allow seams (the
754
+ * personal-rule lane and the auto-mode classifier) are disarmed. Tightening only the two seams was
755
+ * refuted during design: a call that is ALREADY allow never passes through either seam, so it would
756
+ * have sailed past the org deny that could not be read. The `requiresRealApproval` bit closes the
757
+ * third loosening path — a blanket `onAsk: "allow"` would otherwise clear the synthetic ask.
758
+ */
759
+ orgRules?: {
760
+ /** Adjudicate this call against the current org overlay. MUST NOT throw — a throw is read as
761
+ * `unavailable` (fail-closed), never as "no org rule spoke". */
762
+ adjudicate: (req: ToolCallRequest) => Promise<OrgGateVerdict>;
763
+ /** True iff the reserved question-tool NAME on this leg resolves to the ENGINE's own content-ask
764
+ * tool. It is the exemption's real predicate: the name alone is not, because a caller may mount
765
+ * its own tool under that name when the first-party one is not mounted, and such a tool has
766
+ * arbitrary side effects (codex review, confirmed MED). Absent ⇒ `false` ⇒ the call is governed
767
+ * like any other, which is the fail-closed default. */
768
+ contentAskToolMounted?: boolean;
769
+ /** Disclosure sink for the unavailable arm (trace `permission.org_snapshot_unavailable`). Never
770
+ * affects the outcome. */
771
+ onUnavailable?: (info: {
772
+ toolName: string;
773
+ toolCallId: string;
774
+ message: string;
775
+ }) => void;
776
+ };
716
777
  /**
717
778
  * F-012 L2 — the SANDBOX ADMISSION leg. Present ONLY when the runner armed the mode: the execution
718
779
  * env DECLARES isolation (`RemoteExecutionEnv.capabilities.isolation === true` — a Runner-resolved
@@ -3,6 +3,7 @@ import { brandPolicyAskClass } from "./ask-class.js";
3
3
  import { inlineUntrusted } from "./untrusted-text.js";
4
4
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
5
5
  import { createSafeNotifier } from "./safe-notify.js";
6
+ import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
6
7
  export function cloneObserverInput(input) {
7
8
  try {
8
9
  return structuredClone(input);
@@ -246,12 +247,73 @@ export async function runToolGate(input) {
246
247
  denySource = input.shellGated === true ? "shellGate" : "safety";
247
248
  }
248
249
  }
250
+ let orgRealApprovalRequired = false;
251
+ let orgTightenCount = 0;
252
+ const applyOrgLayer = async (current, args) => {
253
+ if (input.orgRules === undefined)
254
+ return current;
255
+ if (toolName === ASK_USER_QUESTION_TOOL_NAME && input.orgRules.contentAskToolMounted === true)
256
+ return current;
257
+ let decided = current;
258
+ const answer = await settleOrgVerdictWithin(input.orgRules
259
+ .adjudicate({ toolName, args, toolCallId })
260
+ .catch((err) => ({
261
+ status: "unavailable",
262
+ disclosures: [`the org adjudication face threw: ${err instanceof Error ? err.message : String(err)}`],
263
+ })), {
264
+ status: "unavailable",
265
+ disclosures: [`the org adjudication face did not answer within ${ORG_ADJUDICATION_TIMEOUT_MS}ms (or the task ended first)`],
266
+ }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
267
+ if (answer.status === "unavailable") {
268
+ orgRealApprovalRequired = true;
269
+ await notifier.notifyAsync(() => input.orgRules?.onUnavailable?.({ toolName, toolCallId, message: answer.disclosures.join("; ") }), "toolGate.orgSnapshotUnavailable");
270
+ if (decided.action === "allow") {
271
+ decided = {
272
+ action: "ask",
273
+ message: `tool "${toolName}" needs approval: this deployment is org-governed and cannot currently adjudicate ` +
274
+ `against an organization policy snapshot — every allow tightens to a real approval until it can`,
275
+ decisionReason: ORG_UNAVAILABLE_DECISION_REASON,
276
+ requiresRealApproval: true,
277
+ };
278
+ orgTightenCount += 1;
279
+ denySource = "org";
280
+ }
281
+ else if (decided.action === "ask" && decided.requiresRealApproval !== true) {
282
+ decided = { ...decided, requiresRealApproval: true };
283
+ orgTightenCount += 1;
284
+ }
285
+ return decided;
286
+ }
287
+ if (answer.verdict === undefined)
288
+ return decided;
289
+ const { behavior, rule } = answer.verdict;
290
+ if (behavior === "deny") {
291
+ orgTightenCount += 1;
292
+ denySource = "org";
293
+ return { action: "deny", message: `an organization policy rule (${rule}) denies this call`, decisionReason: ORG_RULE_DECISION_REASON };
294
+ }
295
+ if (decided.action === "deny")
296
+ return decided;
297
+ orgTightenCount += 1;
298
+ denySource = "org";
299
+ orgRealApprovalRequired = true;
300
+ return decided.action === "ask"
301
+ ? { ...decided, requiresRealApproval: true }
302
+ : {
303
+ action: "ask",
304
+ message: `an organization policy rule (${rule}) requires approval for this call`,
305
+ decisionReason: ORG_RULE_DECISION_REASON,
306
+ requiresRealApproval: true,
307
+ };
308
+ };
309
+ decision = await applyOrgLayer(decision, policyRewrite !== undefined ? policyRewrite : currentInput);
249
310
  if (decision.action === "ask") {
250
311
  if (policyRewrite !== undefined) {
251
312
  currentInput = policyRewrite;
252
313
  req.args = policyRewrite;
253
314
  }
254
315
  if (input.persistedRules &&
316
+ !orgRealApprovalRequired &&
255
317
  decision.action === "ask" &&
256
318
  decision.requiresRealApproval !== true &&
257
319
  decision.decisionReason !== "hook" &&
@@ -269,6 +331,7 @@ export async function runToolGate(input) {
269
331
  }
270
332
  }
271
333
  if (input.autoMode &&
334
+ !orgRealApprovalRequired &&
272
335
  decision.action === "ask" &&
273
336
  req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
274
337
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
@@ -418,23 +481,34 @@ export async function runToolGate(input) {
418
481
  editArgs = hr.updatedInput;
419
482
  }
420
483
  }
421
- if (!adjudicate)
484
+ if (!adjudicate && input.orgRules === undefined)
422
485
  break;
423
- let recheck;
424
- try {
425
- recheck = await adjudicate({ toolName, args: editArgs, toolCallId });
426
- }
427
- catch (err) {
428
- recheck = {
429
- action: "deny",
430
- message: `policy re-check of the approved edit for "${toolName}" errored: ${err instanceof Error ? err.message : String(err)}`,
431
- };
486
+ let recheck = { action: "allow" };
487
+ if (adjudicate) {
488
+ try {
489
+ recheck = await adjudicate({ toolName, args: editArgs, toolCallId });
490
+ }
491
+ catch (err) {
492
+ recheck = {
493
+ action: "deny",
494
+ message: `policy re-check of the approved edit for "${toolName}" errored: ${err instanceof Error ? err.message : String(err)}`,
495
+ };
496
+ }
432
497
  }
433
498
  if (recheck.action === "deny") {
434
499
  editDenied = recheck;
435
500
  denySource = "policy";
436
501
  break;
437
502
  }
503
+ if (recheck.updatedInput !== undefined)
504
+ editArgs = recheck.updatedInput;
505
+ const orgTightensBefore = orgTightenCount;
506
+ recheck = await applyOrgLayer(recheck, editArgs);
507
+ const orgRaisedThisRound = orgTightenCount > orgTightensBefore;
508
+ if (recheck.action === "deny") {
509
+ editDenied = recheck;
510
+ break;
511
+ }
438
512
  if (recheck.action === "allow") {
439
513
  if (recheck.updatedInput !== undefined)
440
514
  editArgs = recheck.updatedInput;
@@ -445,11 +519,15 @@ export async function runToolGate(input) {
445
519
  const rr = await resolveAsk(recheck, { toolName, args: editArgs, toolCallId });
446
520
  if (rr.action !== "allow") {
447
521
  editDenied = rr;
448
- denySource = "policy";
522
+ if (!orgRaisedThisRound)
523
+ denySource = "policy";
449
524
  break;
450
525
  }
451
- if (rr.updatedInput === undefined)
526
+ if (rr.updatedInput === undefined) {
527
+ if (rr.presentedInput !== undefined)
528
+ editArgs = rr.presentedInput;
452
529
  break;
530
+ }
453
531
  editArgs = rr.updatedInput;
454
532
  }
455
533
  decision = editDenied ?? { ...decision, updatedInput: editArgs };
@@ -1,4 +1,4 @@
1
- import { type ChallengeAssignment, type ChallengeEvent, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, type MemoryPartitionIncidentSink, type RetrievedAccountRow, type SessionPollutionRecord } from "./layout.js";
1
+ import { type ChallengeAssignment, type ChallengeEvent, type ControlPlaneRebuildReceipt, type StrictControlPlaneLedger, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, type MemoryPartitionIncidentSink, type RetrievedAccountRow, type SessionPollutionRecord } from "./layout.js";
2
2
  import type { HarvestReport, MemoryAnnouncement, MemoryBackend, MemorySessionHandle, ScanFinding } from "./types.js";
3
3
  /**
4
4
  * The CC `# Memory` instruction section — VERBATIM from the live capture
@@ -220,6 +220,33 @@ export declare class MemoryEngine {
220
220
  * generation only — the announcement is a model-visible face, so no name/description/free reason
221
221
  * rides it; content stays on the host audit face). Failures are disclosed, never thrown past. */
222
222
  private challengeAndAnnounce;
223
+ /** design/180 §10 — one disclosure per PROCESS per engine, not one per append: the size is a
224
+ * standing property of the account, and repeating it on every challenge is the spam that gets
225
+ * advisories muted (the same argument the durable-park disclosures are built on). */
226
+ private challengeLedgerSizeDisclosed;
227
+ /**
228
+ * design/180 §10 — the challenge ledger's size bound is a DISCLOSURE threshold, never a cap: the
229
+ * ledger cannot be rotated (the exclusion predicate is derived from the events, so dropping the
230
+ * oldest rows would un-withhold their entries), so crossing {@link CHALLENGE_LEDGER_MAX_EVENTS}
231
+ * changes nothing about what is withheld — it tells the deployment that an account on the
232
+ * per-tool-call read path has grown to where it costs the session latency.
233
+ */
234
+ private discloseChallengeLedgerSize;
235
+ /**
236
+ * design/180 §10 — the RECOVERY channel for a corrupt fail-closed ledger (lineage / challenges).
237
+ *
238
+ * The fail-closed family refuses every read on corruption so a silent "start fresh" cannot launder
239
+ * taint; permanent refusal is the point, and it left a deployment with genuinely corrupt bytes no way
240
+ * back except deleting engine files by hand. This is that way back, and it is explicit in every
241
+ * dimension: only a host call reaches it (nothing in the engine self-heals), a HEALTHY ledger is
242
+ * refused (`memory.control_plane_not_corrupt`), the corrupt bytes are quarantined beside the file
243
+ * rather than deleted, and the rebuild ANNOUNCES itself — resetting the challenge ledger un-withholds
244
+ * whatever it was withholding, which the model's next session must not learn about silently.
245
+ *
246
+ * `reason` is required and recorded in the announcement's shape (not its text — the announcement is a
247
+ * model-visible face and stays opaque); it is the operator's own note on the host error face.
248
+ */
249
+ rebuildCorruptControlPlaneLedger(ledger: StrictControlPlaneLedger, reason: string): ControlPlaneRebuildReceipt;
223
250
  /**
224
251
  * Host API (advisory, control-plane trust level — the same plane that can mark pollution):
225
252
  * challenge entries by id. `requestId` is REQUIRED (the generation ledger's idempotency identity;
@@ -6,7 +6,7 @@ import { inlineUntrusted } from "../untrusted-text.js";
6
6
  import { formatMemoryAge } from "../memory-recall.js";
7
7
  import { computeEntryRev, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
8
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./file-backend.js";
9
- import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, markSessionPolluted, readSessionPollution, recordRetrievedAccount, writeFileNoFollow, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, resolveChallengeEvent, stageLineagePending, } from "./layout.js";
9
+ import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, markSessionPolluted, readSessionPollution, recordRetrievedAccount, writeFileNoFollow, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, resolveChallengeEvent, stageLineagePending, } from "./layout.js";
10
10
  import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
11
11
  export const MEMORY_INSTRUCTION_TEMPLATE = `# Memory
12
12
 
@@ -173,7 +173,9 @@ export class MemoryEngine {
173
173
  challengeAndAnnounce(events) {
174
174
  if (events.length === 0)
175
175
  return [];
176
- const assigned = appendChallengeEvents(this.controlDir, events, this.now);
176
+ const { assignments: assigned, eventCount } = appendChallengeEvents(this.controlDir, events, this.now);
177
+ if (eventCount !== undefined)
178
+ this.discloseChallengeLedgerSize(eventCount);
177
179
  const fresh = assigned.filter((a) => !a.replayed);
178
180
  if (fresh.length > 0) {
179
181
  try {
@@ -189,6 +191,60 @@ export class MemoryEngine {
189
191
  }
190
192
  return assigned;
191
193
  }
194
+ challengeLedgerSizeDisclosed = false;
195
+ discloseChallengeLedgerSize(eventCount) {
196
+ if (this.challengeLedgerSizeDisclosed || eventCount <= CHALLENGE_LEDGER_MAX_EVENTS)
197
+ return;
198
+ this.challengeLedgerSizeDisclosed = true;
199
+ const sink = this.onIncident;
200
+ if (sink === undefined)
201
+ return;
202
+ try {
203
+ const err = new Error(`memory challenge ledger holds ${eventCount} events, past the calibrated bound of ${CHALLENGE_LEDGER_MAX_EVENTS}: ` +
204
+ `the whole file is rewritten on every challenge and parsed on every model-visible read (index rebuild, memory tools), ` +
205
+ `so this account now costs the session latency. It is NOT rotated — dropping events would un-withhold their entries — ` +
206
+ `so the deployment resolves the settled generations or archives the account deliberately.`);
207
+ err.code = "memory.challenge_ledger_oversize";
208
+ sink(err);
209
+ }
210
+ catch {
211
+ }
212
+ }
213
+ rebuildCorruptControlPlaneLedger(ledger, reason) {
214
+ if (typeof reason !== "string" || reason.trim() === "") {
215
+ const e = new Error("rebuildCorruptControlPlaneLedger: a non-empty reason is required (this call drops taint evidence — it is recorded, never implicit)");
216
+ e.code = "config.memory_rebuild_reason";
217
+ throw e;
218
+ }
219
+ if (!isStrictControlPlaneLedgerCorrupt(this.controlDir, ledger)) {
220
+ const e = new Error(`${ledger === "challenges" ? "memory challenge ledger" : "memory lineage ledger"} reads cleanly — refusing to rebuild it. This call exists to recover a ledger whose corruption already refuses every read; resetting a healthy one would drop taint evidence that is still doing its job.`);
221
+ e.code = "memory.control_plane_not_corrupt";
222
+ throw e;
223
+ }
224
+ enqueueMemoryAnnouncement(this.controlDir, {
225
+ kind: "gate",
226
+ at: this.now(),
227
+ items: [
228
+ ledger === "challenges"
229
+ ? `memory control plane: the challenge ledger was found CORRUPT and a deployment-initiated reset to empty was requested — entries that were withheld by an unresolved challenge may no longer be withheld. Treat facts recalled from memory as unverified until confirmed with the user.`
230
+ : `memory control plane: the harvest lineage ledger was found CORRUPT and a deployment-initiated reset to empty was requested — which session contributed which entry may no longer be recorded for anything written before this point.`,
231
+ ],
232
+ });
233
+ const receipt = rebuildStrictControlPlaneLedger(this.controlDir, ledger, this.now);
234
+ const sink = this.onIncident;
235
+ if (sink !== undefined) {
236
+ try {
237
+ const err = new Error(`memory control plane: ${ledger} ledger rebuilt after corruption (reason: ${reason}). ` +
238
+ `The corrupt bytes are quarantined at ${receipt.quarantinedTo.join(", ") || "(no file existed)"} — ` +
239
+ `they are the only remaining record of what this account held.`);
240
+ err.code = "memory.control_plane_rebuilt";
241
+ sink(err);
242
+ }
243
+ catch {
244
+ }
245
+ }
246
+ return receipt;
247
+ }
192
248
  challengeEntries(ids, reason, requestId) {
193
249
  if (typeof requestId !== "string" || requestId === "") {
194
250
  const e = new Error("challengeEntries: requestId is required (idempotency identity — retries must reuse it; the engine does not mint one)");
@@ -207,7 +263,10 @@ export class MemoryEngine {
207
263
  return this.challengeAndAnnounce(contributions.map((c) => ({ eventId: `${requestId}:${c.entryId}`, entryId: c.entryId, reason, challengedRev: c.lastRev })));
208
264
  }
209
265
  resolveChallenge(entryId, generation, reason, requestId) {
210
- return resolveChallengeEvent(this.controlDir, entryId, generation, reason, this.now, requestId);
266
+ const { resolved, eventCount } = resolveChallengeEvent(this.controlDir, entryId, generation, reason, this.now, requestId);
267
+ if (eventCount !== undefined)
268
+ this.discloseChallengeLedgerSize(eventCount);
269
+ return resolved;
211
270
  }
212
271
  adjudicatePendingLineage(txnId, action) {
213
272
  const promoted = adjudicateLineagePending(this.controlDir, txnId, action, this.now);
@@ -2,7 +2,7 @@ export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION
2
2
  export { MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, type MemorySearchDetails, type MemorySearchHit, type MemoryGetDetails, } from "./tools.js";
3
3
  export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE } from "./scan.js";
4
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH, type ScannedEntryFile } from "./file-backend.js";
5
- export { ControlPlaneCorruptError, deriveControlPlaneDir, deriveRepoControlPlaneDir, deriveRepoKey, deriveRepoMemoryDir, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, claimRootScope, rootScopeOf, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, LINEAGE_FILE, CHALLENGES_FILE, CHALLENGED_HISTORY_FILE, type ChallengeAssignment, type ChallengeEvent, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, } from "./layout.js";
5
+ export { ControlPlaneCorruptError, deriveControlPlaneDir, deriveRepoControlPlaneDir, deriveRepoKey, deriveRepoMemoryDir, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, claimRootScope, rootScopeOf, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, LINEAGE_FILE, CHALLENGES_FILE, CHALLENGED_HISTORY_FILE, CHALLENGE_LEDGER_MAX_EVENTS, rebuildStrictControlPlaneLedger, type ControlPlaneRebuildReceipt, type StrictControlPlaneLedger, type ChallengeAppendResult, type ChallengeAssignment, type ChallengeEvent, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, } from "./layout.js";
6
6
  export { readV2HeaderHints, type V2HeaderHints } from "./header-hints.js";
7
7
  export { parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile, type ParsedEntryFile } from "./frontmatter.js";
8
8
  export type { MemoryBackend, MemoryEntry, MemoryEntryFrontmatter, MemoryEntryHeader, ScoredMemoryEntry, NotePatch, PatchReport, MaterializedFile, MemorySessionHandle, HarvestReport, HarvestRejection, HarvestRejectionCode, MemoryAnnouncement, ScanFinding, } from "./types.js";
@@ -2,7 +2,7 @@ export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION
2
2
  export { MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, } from "./tools.js";
3
3
  export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE } from "./scan.js";
4
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH } from "./file-backend.js";
5
- export { ControlPlaneCorruptError, deriveControlPlaneDir, deriveRepoControlPlaneDir, deriveRepoKey, deriveRepoMemoryDir, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, claimRootScope, rootScopeOf, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, LINEAGE_FILE, CHALLENGES_FILE, CHALLENGED_HISTORY_FILE, } from "./layout.js";
5
+ export { ControlPlaneCorruptError, deriveControlPlaneDir, deriveRepoControlPlaneDir, deriveRepoKey, deriveRepoMemoryDir, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, claimRootScope, rootScopeOf, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, LINEAGE_FILE, CHALLENGES_FILE, CHALLENGED_HISTORY_FILE, CHALLENGE_LEDGER_MAX_EVENTS, rebuildStrictControlPlaneLedger, } from "./layout.js";
6
6
  export { readV2HeaderHints } from "./header-hints.js";
7
7
  export { parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile } from "./frontmatter.js";
8
8
  export { memoryBackendContract, assertMemoryBackendSearchEquivalence, } from "./memory-backend-contract.js";
@@ -402,6 +402,33 @@ export interface ChallengeAssignment {
402
402
  at: number;
403
403
  replayed: boolean;
404
404
  }
405
+ /**
406
+ * design/180 §10 — the challenge ledger's SIZE CALIBRATION.
407
+ *
408
+ * The ledger is append-only and cannot be rotated: the exclusion predicate is DERIVED from the events
409
+ * ("a challenge generation with no matching resolve"), so dropping the oldest rows would silently
410
+ * un-withhold their entries — taint laundering, the exact failure the fail-closed family exists to
411
+ * prevent. The bound is therefore a DISCLOSURE threshold, not a cap: crossing it changes nothing about
412
+ * what the ledger holds, it tells the deployment the account needs attention.
413
+ *
414
+ * Calibrated against what actually degrades, which is not disk: every append REWRITES the whole file
415
+ * under the strict lock, and every model-visible read path ({@link challengedEntryIds}, called on each
416
+ * index rebuild and each memory tool call) PARSES it whole. At ~200 bytes per event, 10k events is a
417
+ * ~2 MB parse on a read path that runs per tool call — the point where "an audit account nobody looks
418
+ * at" becomes a latency the session feels. A deployment reaching it should resolve the settled
419
+ * generations (they stay in the file, and that is the point) or archive the account deliberately.
420
+ */
421
+ export declare const CHALLENGE_LEDGER_MAX_EVENTS = 10000;
422
+ /** What {@link appendChallengeEvents} returns: the per-request assignments plus the ledger's size
423
+ * AFTER the append — the caller owns disclosure (this module has no sink), and reading the size here
424
+ * costs nothing because the record is already in hand under the lock. */
425
+ export interface ChallengeAppendResult {
426
+ assignments: ChallengeAssignment[];
427
+ /** Total events in the ledger after this append, or `undefined` when nothing was appended — the
428
+ * empty-request fast path takes no lock and reads nothing, so it has no size to report and must not
429
+ * report a made-up `0` (a caller comparing that against the bound would read "tiny", not "unknown"). */
430
+ eventCount: number | undefined;
431
+ }
405
432
  /** Append S-grade challenge events (idempotent per eventId, generation allocation inside the strict
406
433
  * lock). Every NEW eventId opens a NEW generation for its entry — a resolve of an earlier
407
434
  * generation never immunizes the entry against later pollution (design/180 R-8). */
@@ -410,11 +437,20 @@ export declare function appendChallengeEvents(controlDir: string, events: Readon
410
437
  entryId: string;
411
438
  reason: string;
412
439
  challengedRev?: string;
413
- }>, now: () => number): ChallengeAssignment[];
440
+ }>, now: () => number): ChallengeAppendResult;
414
441
  /** The trusted resolve: closes EXACTLY the named generation (a later challenge's generation stays
415
442
  * open — resolves never blanket-clear). Idempotent: resolving an already-resolved generation is a
416
- * no-op. Returns false ⇔ no such open generation existed. */
417
- export declare function resolveChallengeEvent(controlDir: string, entryId: string, generation: number, reason: string, now: () => number, requestId?: string): boolean;
443
+ * no-op. `resolved` is false ⇔ no such open generation existed.
444
+ *
445
+ * `eventCount` rides along for the same reason it does on {@link appendChallengeEvents}: a resolve
446
+ * APPENDS an event too, so it crosses the size bound exactly as a challenge does (independent
447
+ * adversarial review, verified — hanging the disclosure off the challenge writer alone leaves a ledger
448
+ * that crossed the bound on a resolve silent until the next challenge, which may never come).
449
+ * `undefined` when nothing was appended. */
450
+ export declare function resolveChallengeEvent(controlDir: string, entryId: string, generation: number, reason: string, now: () => number, requestId?: string): {
451
+ resolved: boolean;
452
+ eventCount: number | undefined;
453
+ };
418
454
  /** The exclusion predicate's input: entry id → its LATEST unresolved challenge generation. An entry
419
455
  * appears iff at least one challenge generation has no matching resolve. Throws on corruption —
420
456
  * consumers refuse service (integrity unknown ≠ clean). */
@@ -424,6 +460,36 @@ export declare function challengedEntryIds(controlDir: string): Map<string, {
424
460
  }>;
425
461
  /** Full event read (host audit / tests). Throws on corruption. */
426
462
  export declare function readChallengeEvents(controlDir: string): ChallengeEvent[];
463
+ /** Which fail-closed control-plane ledger a rebuild targets. */
464
+ export type StrictControlPlaneLedger = "lineage" | "challenges";
465
+ /** What one rebuild did — the deployment's record of a taint-bearing account it chose to reset. */
466
+ export interface ControlPlaneRebuildReceipt {
467
+ ledger: StrictControlPlaneLedger;
468
+ /** The corrupt file (and its journal, when one existed) moved aside — never deleted. */
469
+ quarantinedTo: string[];
470
+ at: number;
471
+ }
472
+ /**
473
+ * design/180 §10 — rebuild a CORRUPT fail-closed ledger, explicitly.
474
+ *
475
+ * The fail-closed family refuses service on corruption precisely because a silent "start fresh" would
476
+ * LAUNDER taint (a challenged entry re-enters the prompt; a polluted session's contributions become
477
+ * untraceable). That refusal is permanent by design — which left a deployment whose disk really did
478
+ * corrupt with no way back other than deleting engine files by hand. This is that way back, and its
479
+ * whole shape is "never automatic":
480
+ * · the caller names the ledger — nothing in the engine calls this on its own;
481
+ * · a HEALTHY ledger is REFUSED (`memory.control_plane_not_corrupt`) — a rebuild is only ever the
482
+ * answer to a corruption that is already blocking every read, never a way to clear an account;
483
+ * · the corrupt bytes are QUARANTINED beside the file, never deleted: what the ledger held is
484
+ * exactly the evidence someone will want after a laundering decision;
485
+ * · the caller (the engine's host API) announces the rebuild — a reset of this account changes what
486
+ * the model may see, so it is not a silent repair.
487
+ */
488
+ /** Lock-less probe: does this fail-closed ledger currently REFUSE reads? The rebuild's own check runs
489
+ * again under the lock (this one is for a caller that must decide something — announce, refuse — before
490
+ * taking the lock). `false` for a ledger that reads cleanly, including an absent one (genuinely empty). */
491
+ export declare function isStrictControlPlaneLedgerCorrupt(controlDir: string, ledger: StrictControlPlaneLedger): boolean;
492
+ export declare function rebuildStrictControlPlaneLedger(controlDir: string, ledger: StrictControlPlaneLedger, now: () => number): ControlPlaneRebuildReceipt;
427
493
  /** One challenged-history row: how often a retrieved-then-corrected event hit this entry. */
428
494
  export interface ChallengedHistoryRow {
429
495
  count: number;