@sema-agent/core 5.22.0 → 5.24.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 (59) hide show
  1. package/CHANGELOG.md +169 -1
  2. package/dist/agents/subagent.js +3 -2
  3. package/dist/core/checkpoint-store.d.ts +38 -3
  4. package/dist/core/checkpoint-store.js +2 -1
  5. package/dist/core/governance-codes.js +3 -0
  6. package/dist/core/hooks.d.ts +69 -2
  7. package/dist/core/hooks.js +100 -15
  8. package/dist/core/memory-engine/engine.d.ts +28 -1
  9. package/dist/core/memory-engine/engine.js +62 -3
  10. package/dist/core/memory-engine/index.d.ts +1 -1
  11. package/dist/core/memory-engine/index.js +1 -1
  12. package/dist/core/memory-engine/layout.d.ts +69 -3
  13. package/dist/core/memory-engine/layout.js +75 -6
  14. package/dist/core/permission-rule-consent.js +2 -1
  15. package/dist/core/permission-rule-org.d.ts +36 -2
  16. package/dist/core/permission-rule-org.js +23 -0
  17. package/dist/core/permission-rule-store.d.ts +25 -14
  18. package/dist/core/permission-rule-store.js +7 -2
  19. package/dist/core/permission-rule-sync.d.ts +8 -0
  20. package/dist/core/permission-rule-sync.js +35 -6
  21. package/dist/core/runner/prepare-task.d.ts +10 -2
  22. package/dist/core/runner/prepare-task.js +120 -11
  23. package/dist/core/runner/runtask.js +46 -1
  24. package/dist/core/runner/session-file-state-replay.js +3 -0
  25. package/dist/core/tool-policy.d.ts +37 -4
  26. package/dist/core/tool-policy.js +49 -19
  27. package/dist/core/tool-result-store.d.ts +17 -1
  28. package/dist/core/tool-result-store.js +79 -4
  29. package/dist/core/trace.d.ts +47 -0
  30. package/dist/core/types.d.ts +45 -5
  31. package/dist/core/wiring-manifest.d.ts +16 -1
  32. package/dist/core/wiring-manifest.js +7 -1
  33. package/dist/index.d.ts +18 -10
  34. package/dist/index.js +5 -3
  35. package/dist/orchestration/goal.d.ts +10 -0
  36. package/dist/orchestration/goal.js +6 -5
  37. package/dist/stores/file/adoption/adopt.d.ts +146 -0
  38. package/dist/stores/file/adoption/adopt.js +611 -0
  39. package/dist/stores/file/adoption/marker.d.ts +202 -0
  40. package/dist/stores/file/adoption/marker.js +205 -0
  41. package/dist/stores/file/background-agent-store.js +2 -0
  42. package/dist/stores/file/checkpoint-store.js +2 -0
  43. package/dist/stores/file/file-snapshot-store.js +2 -0
  44. package/dist/stores/file/index.d.ts +2 -0
  45. package/dist/stores/file/index.js +4 -0
  46. package/dist/stores/file/mailbox-store.js +2 -0
  47. package/dist/stores/file/memory-store.js +2 -0
  48. package/dist/stores/file/session-policy-store.d.ts +11 -1
  49. package/dist/stores/file/session-policy-store.js +9 -2
  50. package/dist/stores/file/session-store.js +2 -0
  51. package/dist/stores/file/task-list-store.js +2 -0
  52. package/dist/stores/file/tool-result-store.js +2 -0
  53. package/dist/stores/file/usage-window-store.js +2 -0
  54. package/dist/stores/file/workflow-journal-store.js +2 -0
  55. package/dist/stores/file/workflow-run-store.js +2 -0
  56. package/dist/tools/fs/bash-readonly-classifier.js +59 -10
  57. package/dist/tools/fs/fs-bash.js +7 -4
  58. package/dist/tools/monitor.js +3 -3
  59. package/package.json +3 -2
@@ -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;
@@ -1,4 +1,4 @@
1
- import { closeSync, constants as fsConstants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
1
+ import { closeSync, constants as fsConstants, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
2
  const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW } = fsConstants;
3
3
  import { homedir } from "node:os";
4
4
  import { createHash } from "node:crypto";
@@ -984,9 +984,10 @@ function coerceChallenges(raw) {
984
984
  }
985
985
  return raw;
986
986
  }
987
+ export const CHALLENGE_LEDGER_MAX_EVENTS = 10_000;
987
988
  export function appendChallengeEvents(controlDir, events, now) {
988
989
  if (events.length === 0)
989
- return [];
990
+ return { assignments: [], eventCount: undefined };
990
991
  return lockedStrictUpdate(controlDir, CHALLENGES_FILE, "memory challenge ledger", coerceChallenges, (rec) => {
991
992
  const byEventId = new Map(rec.events.map((e) => [e.eventId, e]));
992
993
  const maxGen = new Map();
@@ -1020,17 +1021,17 @@ export function appendChallengeEvents(controlDir, events, now) {
1020
1021
  out.push({ entryId: ev.entryId, eventId: ev.eventId, generation, at: ev.at, replayed: false });
1021
1022
  changed = true;
1022
1023
  }
1023
- return { ...(changed ? { next: rec } : {}), result: out };
1024
+ return { ...(changed ? { next: rec } : {}), result: { assignments: out, eventCount: rec.events.length } };
1024
1025
  });
1025
1026
  }
1026
1027
  export function resolveChallengeEvent(controlDir, entryId, generation, reason, now, requestId) {
1027
1028
  return lockedStrictUpdate(controlDir, CHALLENGES_FILE, "memory challenge ledger", coerceChallenges, (rec) => {
1028
1029
  const opened = rec.events.some((e) => e.kind === "challenge" && e.entryId === entryId && e.generation === generation);
1029
1030
  if (!opened)
1030
- return { result: false };
1031
+ return { result: { resolved: false, eventCount: undefined } };
1031
1032
  const alreadyResolved = rec.events.some((e) => e.kind === "resolve" && e.entryId === entryId && e.generation === generation);
1032
1033
  if (alreadyResolved)
1033
- return { result: true };
1034
+ return { result: { resolved: true, eventCount: undefined } };
1034
1035
  rec.events.push({
1035
1036
  eventId: requestId !== undefined && requestId !== "" ? `${requestId}:${entryId}` : `resolve:${entryId}:${generation}`,
1036
1037
  entryId,
@@ -1039,7 +1040,7 @@ export function resolveChallengeEvent(controlDir, entryId, generation, reason, n
1039
1040
  at: now(),
1040
1041
  reason,
1041
1042
  });
1042
- return { next: rec, result: true };
1043
+ return { next: rec, result: { resolved: true, eventCount: rec.events.length } };
1043
1044
  });
1044
1045
  }
1045
1046
  export function challengedEntryIds(controlDir) {
@@ -1061,6 +1062,74 @@ export function challengedEntryIds(controlDir) {
1061
1062
  export function readChallengeEvents(controlDir) {
1062
1063
  return coerceChallenges(readStrictSidecar(controlDir, CHALLENGES_FILE, "memory challenge ledger")).events;
1063
1064
  }
1065
+ export function isStrictControlPlaneLedgerCorrupt(controlDir, ledger) {
1066
+ const fileName = ledger === "lineage" ? LINEAGE_FILE : CHALLENGES_FILE;
1067
+ const what = ledger === "lineage" ? "memory lineage ledger" : "memory challenge ledger";
1068
+ const coerce = ledger === "lineage" ? coerceLineage : coerceChallenges;
1069
+ try {
1070
+ coerce(readStrictSidecar(controlDir, fileName, what));
1071
+ return false;
1072
+ }
1073
+ catch (err) {
1074
+ if (err instanceof ControlPlaneCorruptError)
1075
+ return true;
1076
+ throw err;
1077
+ }
1078
+ }
1079
+ export function rebuildStrictControlPlaneLedger(controlDir, ledger, now) {
1080
+ const fileName = ledger === "lineage" ? LINEAGE_FILE : CHALLENGES_FILE;
1081
+ const what = ledger === "lineage" ? "memory lineage ledger" : "memory challenge ledger";
1082
+ const coerce = ledger === "lineage" ? coerceLineage : coerceChallenges;
1083
+ const empty = ledger === "lineage" ? { version: 1, committed: {}, pending: {} } : { version: 1, events: [] };
1084
+ ensureDirExists(controlDir);
1085
+ const file = join(controlDir, fileName);
1086
+ const journal = `${file}.journal`;
1087
+ const lock = `${file}.lock`;
1088
+ const token = acquireSidecarLock(lock, { onDeadline: "throw" });
1089
+ try {
1090
+ let healthy = false;
1091
+ try {
1092
+ coerce(readStrictSidecar(controlDir, fileName, what));
1093
+ healthy = true;
1094
+ }
1095
+ catch (err) {
1096
+ if (!(err instanceof ControlPlaneCorruptError))
1097
+ throw err;
1098
+ }
1099
+ if (healthy) {
1100
+ const e = new Error(`${what} 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.`);
1101
+ e.code = "memory.control_plane_not_corrupt";
1102
+ throw e;
1103
+ }
1104
+ const at = now();
1105
+ const quarantinedTo = [];
1106
+ assertSidecarLockOwnership(lock, token, what);
1107
+ for (const path of [file, journal]) {
1108
+ if (!existsSync(path))
1109
+ continue;
1110
+ let dest = `${path}.corrupt-${at}`;
1111
+ for (let n = 2;; n++) {
1112
+ try {
1113
+ copyFileSync(path, dest, fsConstants.COPYFILE_EXCL);
1114
+ break;
1115
+ }
1116
+ catch (err) {
1117
+ if (err.code !== "EEXIST")
1118
+ throw err;
1119
+ dest = `${path}.corrupt-${at}-${n}`;
1120
+ }
1121
+ }
1122
+ quarantinedTo.push(dest);
1123
+ }
1124
+ assertSidecarLockOwnership(lock, token, what);
1125
+ atomicWriteFileSync(file, `${JSON.stringify(empty, null, 2)}\n`);
1126
+ rmSync(journal, { force: true });
1127
+ return { ledger, quarantinedTo, at };
1128
+ }
1129
+ finally {
1130
+ releaseSidecarLock(lock, token);
1131
+ }
1132
+ }
1064
1133
  function coerceChallengedHistory(raw) {
1065
1134
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
1066
1135
  return {};
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { parseAllowRuleText, suggestRulesForCommand } from "./permission-rule-model.js";
2
3
  import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
3
4
  export class InMemoryRuleApprovalRecordStore {
@@ -28,7 +29,7 @@ function nowIso(deps) {
28
29
  function mintId(deps, prefix) {
29
30
  if (deps.newId !== undefined)
30
31
  return deps.newId();
31
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
32
+ return `${prefix}_${Date.now().toString(36)}_${randomBytes(9).toString("base64url")}`;
32
33
  }
33
34
  function requirePrincipal(principal, entry) {
34
35
  if (typeof principal !== "string" || principal === "") {
@@ -80,8 +80,42 @@ export interface OrgRuleStatePersistence {
80
80
  /** Duck-typed discovery of the persistence face on a store backend, `writerOf` style. */
81
81
  export declare function orgRuleStatePersistenceOf(store: PermissionRuleStore): OrgRuleStatePersistence | undefined;
82
82
  /** The `decisionReason` the consuming gate stamps on the synthetic asks it mints while org adjudication
83
- * is unavailable. Exported so the gate integration and its tests share one spelling. */
84
- export declare const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
83
+ * is unavailable. Exported so the gate integration and its tests share one spelling — and TYPED as the
84
+ * member of {@link import("./tool-policy.js").DecisionReason} it must equal, so the constant and the
85
+ * vocabulary cannot drift into two spellings of one word. */
86
+ export declare const ORG_UNAVAILABLE_DECISION_REASON: Extract<import("./tool-policy.js").DecisionReason, "org_unavailable">;
87
+ /**
88
+ * How long a single org adjudication may take before the consuming gate stops waiting and reads the
89
+ * answer as UNAVAILABLE (its fail-closed word).
90
+ *
91
+ * A bound is MANDATORY rather than optional because of where this call sits: on the hot path of every
92
+ * governed tool call, and — on the durable resume leg — after the checkpoint has been consumed and the
93
+ * start frame emitted. A provider that never settles would otherwise wedge the call past any task
94
+ * deadline (tasks carry no walltime by default), leaving the frame unpaired and the approval spent with
95
+ * nothing retryable. A hang is the one outcome worse than either verdict.
96
+ *
97
+ * The value matches the auto-mode classifier's per-round-trip cap — the other model/network call the
98
+ * permission path makes — because the reasoning is the same: a governance lookup that takes longer than
99
+ * this is indistinguishable from an outage, and an outage is exactly what the unavailable arm is for.
100
+ * The task's own abort signal still applies in parallel; whichever fires first ends the wait.
101
+ */
102
+ export declare const ORG_ADJUDICATION_TIMEOUT_MS = 15000;
103
+ /**
104
+ * Await `p`, but settle with `fallback` if the deadline elapses or the signal fires first.
105
+ *
106
+ * Shared by both org consumption sites so one bound cannot drift into two. Deliberately NOT `.unref()`
107
+ * on the timer: this is a foreground rescue timer, and the caller is awaiting it inside the control
108
+ * flow — an unref'd timer stops firing in exactly the situation it exists for (an otherwise idle loop).
109
+ * A rejecting `p` also lands on the fallback: the callers' fail-closed word is the same either way, and
110
+ * an escaping rejection here would turn a dependency failure into a task crash.
111
+ */
112
+ export declare function settleOrgVerdictWithin<T>(p: Promise<T>, fallback: T, opts: {
113
+ signal?: AbortSignal;
114
+ timeoutMs: number;
115
+ }): Promise<T>;
116
+ /** The `decisionReason` of a decision an ORG RULE produced (a deny, or a non-dismissable ask). Same
117
+ * single-spelling contract as {@link ORG_UNAVAILABLE_DECISION_REASON}. */
118
+ export declare const ORG_RULE_DECISION_REASON: Extract<import("./tool-policy.js").DecisionReason, "org_rule">;
85
119
  /** How far into the future a snapshot's `fetchedAtMs` may sit before it is refused — ordinary NTP-level
86
120
  * clock skew passes; a far-future timestamp (which would satisfy the staleness bound INDEFINITELY,
87
121
  * turning fail-closed into evergreen freshness) does not. */
@@ -7,6 +7,29 @@ export function orgRuleStatePersistenceOf(store) {
7
7
  : undefined;
8
8
  }
9
9
  export const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
10
+ export const ORG_ADJUDICATION_TIMEOUT_MS = 15_000;
11
+ export function settleOrgVerdictWithin(p, fallback, opts) {
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ const finish = (v) => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ clearTimeout(timer);
19
+ opts.signal?.removeEventListener("abort", onAbort);
20
+ resolve(v);
21
+ };
22
+ const onAbort = () => finish(fallback);
23
+ const timer = setTimeout(() => finish(fallback), opts.timeoutMs);
24
+ if (opts.signal?.aborted === true) {
25
+ finish(fallback);
26
+ return;
27
+ }
28
+ opts.signal?.addEventListener("abort", onAbort);
29
+ p.then(finish, () => finish(fallback));
30
+ });
31
+ }
32
+ export const ORG_RULE_DECISION_REASON = "org_rule";
10
33
  export const ORG_FETCHED_AT_SKEW_ALLOWANCE_MS = 5 * 60_000;
11
34
  export function createOrgRuleOverlay(cfg) {
12
35
  if (cfg.governed !== true) {
@@ -1,11 +1,14 @@
1
1
  /**
2
- * design/179 §8 — the persisted allow-rule store seam, its core-private write face, and the removal entry.
2
+ * design/179 §8 — the persisted allow-rule store seam, its backend write face, and the removal entry.
3
3
  *
4
4
  * ## Two faces, deliberately unequal
5
5
  *
6
- * A deployment sees a READ face: `list()`, scoped to one verified principal by a factory. There is no
7
- * exported write API at all the only way a rule enters the store through the engine is the core-private
8
- * redemption path (`permission-rule-consent.ts`), which requires an approved durable approval record.
6
+ * A deployment sees a READ face: `list()`, scoped to one verified principal by a factory. The write face
7
+ * is a BACKEND CONTRACT (ruled 2026-08-10: exported so an out-of-repo store twin builds against the same
8
+ * definitions instead of mirroring them), not a host write API the only way a rule enters the store
9
+ * through the ENGINE is the redemption path (`permission-rule-consent.ts`), which requires an approved
10
+ * durable approval record; that invariant lives in the engine's wiring and is pinned by the
11
+ * writer-caller registry test, not in type visibility.
9
12
  * `expectedRev` is concurrency control, not authorization, so "hold a put API and skip the ticket" is not
10
13
  * a shape that exists here rather than a rule someone must remember.
11
14
  *
@@ -156,15 +159,19 @@ export interface RuleSyncJoinDelta {
156
159
  * What a write may say. Authorization discriminates on the DELTA SHAPE, not on a full snapshot: only the
157
160
  * add arm can introduce a dot, and the delete arm carries a tombstone and no adds. A backend additionally
158
161
  * REFUSES at runtime any delete that would introduce a new add dot — structure and runtime check together,
159
- * so "pick the delete arm and smuggle an add" is neither expressible nor accepted. The `sync-join` arm is
160
- * constructible only by core (the writer is never exported), and every inbound record inside it passes the
161
- * single validator again AT THE BACKEND the fifth door of design/179 §4's validator list closes here,
162
- * not at the calling layer.
162
+ * so "pick the delete arm and smuggle an add" is neither expressible nor accepted. The `sync-join` arm's
163
+ * safety does not rest on hiding the type (ruled 2026-08-10: the backend contract IS exported for
164
+ * out-of-repo store twins) it rests on every inbound record inside it passing the single validator
165
+ * again AT THE BACKEND: the fifth door of design/179 §4's validator list closes here, not at the
166
+ * calling layer, and closes identically for every caller.
163
167
  */
164
168
  export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
165
169
  /**
166
- * The core-private write face. Deliberately absent from the package's public exports: a host cannot hold
167
- * one, so no API-level path to the store bypasses the consent protocol.
170
+ * The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an
171
+ * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
172
+ * types. The consent boundary is unchanged by the export: the ENGINE reaches a writer only through the
173
+ * consent protocol's redemption (and the sync client's join) — a property of the engine's wiring —
174
+ * and a deployment always owned its own storage bytes, so type visibility grants nothing new.
168
175
  */
169
176
  export interface PermissionRuleWriter {
170
177
  /** Mint the next dot for this replica. Counters need only be unique and monotonic, so a dot minted for
@@ -198,11 +205,11 @@ export interface RawRuleSyncState {
198
205
  quarantined?: QuarantinedRuleAdd[];
199
206
  }
200
207
  /**
201
- * The internal handle a writable backend hangs its write face on. Not exported from the package index
202
- * that omission IS the boundary described in the module doc.
208
+ * The handle a writable backend hangs its write face on (exported with the backend contract, ruled 2026-08-10;
209
+ * the consent boundary lives in the engine's wiring, not in this key's visibility).
203
210
  */
204
211
  export declare const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter";
205
- /** A store that also carries the core-private write face. */
212
+ /** A store that also carries the backend write face. */
206
213
  export interface WritablePermissionRuleStore extends PermissionRuleStore {
207
214
  readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter;
208
215
  }
@@ -419,7 +426,11 @@ export type RemoveResult =
419
426
  export declare function removePersistedRule(opts: {
420
427
  rule: string;
421
428
  scope: RuleScope;
422
- principal: string;
429
+ /** Whose bucket. A bare string stays the principal shorthand (unchanged callers); the structural
430
+ * {@link RuleOwner} form adds the local-owner bucket (downstream request, 2026-08-10 — design/182 §4.5's
431
+ * `forLocalOwner()` face existed, but removal could not name it, so a local-owner rule was
432
+ * unrevokable through this entry). Same observed-remove/add-wins/stillLive semantics either way. */
433
+ principal: string | RuleOwner;
423
434
  provider: PermissionRuleStoreProvider;
424
435
  }): Promise<RemoveResult>;
425
436
  export declare function errText(err: unknown): string;
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { canonicalize } from "./canonical-json.js";
2
3
  import { isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
3
4
  export function sameRuleOwner(a, b) {
@@ -326,7 +327,11 @@ export async function ruleStoreChecksum(payload) {
326
327
  }
327
328
  const REMOVE_MAX_ATTEMPTS = 8;
328
329
  export async function removePersistedRule(opts) {
329
- const store = opts.provider.forPrincipal(opts.principal);
330
+ const owner = typeof opts.principal === "string" ? { kind: "principal", principal: opts.principal } : opts.principal;
331
+ if (owner.kind === "local-owner" && opts.provider.forLocalOwner === undefined) {
332
+ return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it" };
333
+ }
334
+ const store = owner.kind === "local-owner" ? opts.provider.forLocalOwner() : opts.provider.forPrincipal(owner.principal);
330
335
  const writer = writerOf(store);
331
336
  if (writer === undefined) {
332
337
  return { status: "failed", error: "the resolved permission-rule store has no write face — rules cannot be removed through it" };
@@ -396,7 +401,7 @@ export class InMemoryPermissionRuleStore {
396
401
  observedVector;
397
402
  rev = 0;
398
403
  counter = 0;
399
- constructor(actor = `mem-${Math.random().toString(36).slice(2, 10)}`, now = Date.now) {
404
+ constructor(actor = `mem-${randomBytes(6).toString("base64url")}`, now = Date.now) {
400
405
  this.actor = actor;
401
406
  this.now = now;
402
407
  }
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import type { PersistedAllowRule, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
29
29
  import type { PermissionRuleStoreProvider, RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
30
+ import { type TracerHook } from "./trace.js";
30
31
  /** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
31
32
  * retries; a non-2xx / network failure should THROW (the round then aborts with zero local effects —
32
33
  * the transport runs before any local write). */
@@ -103,6 +104,13 @@ export declare function syncPermissionRules(opts: {
103
104
  transport: PermissionRuleSyncTransport;
104
105
  /** Injected clock — observation timestamps only, never adjudication input. */
105
106
  now?: () => number;
107
+ /** design/182 §4.6 — optional trace sink for the round's DISCLOSURES: one
108
+ * `permission.rule_sync_resurrected` per removed→live transition and one
109
+ * `permission.rule_sync_dropped` per refused/quarantined row. The same facts are already on the
110
+ * returned result; this is the operator channel for a deployment that drives sync outside a task
111
+ * (where there is no `RunnerDeps.tracer` in scope) and does not read the result itself. Absent ⇒ no
112
+ * events, and the round is byte-identical. */
113
+ tracer?: TracerHook;
106
114
  }): Promise<PermissionRuleSyncResult>;
107
115
  interface ParsedRuleSyncResponse {
108
116
  principal: string;