@sema-agent/core 5.38.0 → 5.40.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.
- package/CHANGELOG.md +182 -10
- package/dist/agents/send-message-tool.d.ts +8 -0
- package/dist/agents/send-message-tool.js +8 -0
- package/dist/agents/teacher.js +9 -3
- package/dist/agents/verify.js +9 -3
- package/dist/core/checkpoint-store.d.ts +12 -0
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/hooks.d.ts +23 -0
- package/dist/core/hooks.js +53 -4
- package/dist/core/mailbox-store.d.ts +39 -0
- package/dist/core/mailbox-store.js +9 -0
- package/dist/core/memory-engine/engine.d.ts +27 -0
- package/dist/core/memory-engine/engine.js +103 -1
- package/dist/core/memory-engine/export-bundle.d.ts +192 -0
- package/dist/core/memory-engine/export-bundle.js +306 -0
- package/dist/core/memory-engine/file-backend.d.ts +178 -1
- package/dist/core/memory-engine/file-backend.js +637 -6
- package/dist/core/memory-engine/index.d.ts +2 -1
- package/dist/core/memory-engine/index.js +1 -0
- package/dist/core/memory-engine/layout.d.ts +89 -1
- package/dist/core/memory-engine/layout.js +131 -1
- package/dist/core/memory-engine/memory-backend-contract.d.ts +1 -1
- package/dist/core/memory-engine/memory-backend-contract.js +52 -0
- package/dist/core/memory-engine/tools.js +8 -1
- package/dist/core/permission-rule-consent.d.ts +27 -4
- package/dist/core/permission-rule-consent.js +41 -4
- package/dist/core/permission-rule-model.d.ts +7 -1
- package/dist/core/runner/prepare-task.js +24 -3
- package/dist/core/runner/runtask.js +5 -0
- package/dist/core/runner/synthetic-tools.js +3 -1
- package/dist/core/runner/tool-disclosure.js +2 -1
- package/dist/core/sensitive-path-policy.js +3 -3
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +29 -1
- package/dist/core/store-contracts/mailbox-store-contract.js +78 -0
- package/dist/core/types.d.ts +21 -0
- package/dist/core/write-protect.d.ts +73 -0
- package/dist/core/write-protect.js +195 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/orchestration/governance-baseline-validity.d.ts +44 -0
- package/dist/orchestration/governance-baseline-validity.js +55 -0
- package/dist/orchestration/run-workflow-tool.js +33 -8
- package/dist/orchestration/workflow-script-runner.js +9 -4
- package/dist/tools/fs/read-deny.d.ts +15 -5
- package/dist/tools/fs/read-deny.js +33 -12
- package/dist/tools/fs/safety.d.ts +5 -2
- package/dist/tools/fs/safety.js +5 -3
- package/dist/tools/fs/search.d.ts +33 -0
- package/dist/tools/fs/search.js +72 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +21 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, type MemoryEngineOptions, type MemoryInjection, type EntryProvenanceAccount, } from "./engine.js";
|
|
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
|
-
export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH, type ScannedEntryFile, type TransferEvidence, type CommittedBinding, type CommittedEntrySnapshot, type CommittedScopeSnapshots, type EntryCustodyReport, erasureSelectHash, type EraseMemoryEntriesInput, type ErasureSelect, type ErasedBinding, type MemoryErasureAttestation, } from "./file-backend.js";
|
|
4
|
+
export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH, type ScannedEntryFile, type TransferEvidence, type CommittedBinding, type CommittedEntrySnapshot, type CommittedScopeSnapshots, type EntryCustodyReport, erasureSelectHash, type EraseMemoryEntriesInput, type ErasureSelect, type ErasedBinding, type MemoryErasureAttestation, type MemoryExportSnapshot, } from "./file-backend.js";
|
|
5
|
+
export { computeBundleSectionHashes, computeMemoryBundleHash, memoryBundleInvalid, type MemoryExportBundle, type MemoryImportReport, type MemoryBundleImportPlan, type BundleChallengeRow, type BundleLineageRow, type BundlePollutedSession, } from "./export-bundle.js";
|
|
5
6
|
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
7
|
export { readV2HeaderHints, type V2HeaderHints } from "./header-hints.js";
|
|
7
8
|
export { parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile, type ParsedEntryFile } from "./frontmatter.js";
|
|
@@ -2,6 +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, erasureSelectHash, } from "./file-backend.js";
|
|
5
|
+
export { computeBundleSectionHashes, computeMemoryBundleHash, memoryBundleInvalid, } from "./export-bundle.js";
|
|
5
6
|
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
7
|
export { readV2HeaderHints } from "./header-hints.js";
|
|
7
8
|
export { parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile } from "./frontmatter.js";
|
|
@@ -284,6 +284,25 @@ export declare function markSessionPolluted(controlDir: string, sessionId: strin
|
|
|
284
284
|
* record stands in. Only ABSENCE is clean, so a control plane that cannot be stat'ed at all reads
|
|
285
285
|
* clean for every session rather than disabling memory deployment-wide. */
|
|
286
286
|
export declare function readSessionPollution(controlDir: string, sessionId: string): SessionPollutionRecord | undefined;
|
|
287
|
+
/**
|
|
288
|
+
* design/178 v2-c §3-3 — enumerate EVERY durable pollution marker (the export snapshot's pollution
|
|
289
|
+
* face; `readSessionPollution` stays the per-session read). Same fail-closed record posture as the
|
|
290
|
+
* per-session read: a marker file that exists but cannot be read or parsed answers a synthesized
|
|
291
|
+
* record, never a silent omission (corruption must not launder the state out of an export). An
|
|
292
|
+
* absent marker directory is the genuine empty state (ENOENT-only); any OTHER enumeration failure
|
|
293
|
+
* THROWS — a listing that silently dropped markers would export a package that launders pollution.
|
|
294
|
+
*/
|
|
295
|
+
export declare function listSessionPollution(controlDir: string): Record<string, SessionPollutionRecord>;
|
|
296
|
+
/**
|
|
297
|
+
* design/178 v2-c §5.3 — the STRICT durable pollution import. Deliberately NOT
|
|
298
|
+
* `markSessionPolluted`: that leg is best-effort-with-disclosure and mints its own `at`, so a
|
|
299
|
+
* bundle's source record (the governance fact being carried) could neither land verbatim nor prove
|
|
300
|
+
* durability. This leg writes the SOURCE record verbatim (`wx`); on EEXIST it reads the standing
|
|
301
|
+
* record back and COMPARES — equal = converged no-op, different = the destination's first-mark-wins
|
|
302
|
+
* record stands and the divergence is REPORTED (`"divergent"`), never silently absorbed. Any other
|
|
303
|
+
* write failure THROWS (the import aborts with its latch standing — §1-2⑤).
|
|
304
|
+
*/
|
|
305
|
+
export declare function importSessionPollution(controlDir: string, sessionId: string, record: SessionPollutionRecord): "written" | "already" | "divergent";
|
|
287
306
|
/** The retrieved-account sidecar (control plane): entry id → { count, lastAt }. */
|
|
288
307
|
export declare const USAGE_RETRIEVED_FILE = "usage-retrieved.json";
|
|
289
308
|
/** Bound on tracked ids — over the cap, the entries with the OLDEST `lastAt` are evicted first
|
|
@@ -320,10 +339,15 @@ export interface LineageContribution {
|
|
|
320
339
|
lastRev: string;
|
|
321
340
|
lastAt: number;
|
|
322
341
|
}
|
|
323
|
-
/** One staged (pre-commit) row: an entry this transaction WOULD commit.
|
|
342
|
+
/** One staged (pre-commit) row: an entry this transaction WOULD commit. `kind: "latch-only"` marks
|
|
343
|
+
* a bundle-import SYNTHETIC latch row (design/178 v2-c §1): it withholds the id on the model-visible
|
|
344
|
+
* read faces exactly like a real staged row, but it is NOT a contribution — the erasure lane's
|
|
345
|
+
* capture-and-clear strikes it without crediting its txn's sessionId, and the host adjudication
|
|
346
|
+
* refuses to touch its transaction (the import protocol's receipt-gated release is the only exit). */
|
|
324
347
|
export interface LineagePendingRow {
|
|
325
348
|
entryId: string;
|
|
326
349
|
rev: string;
|
|
350
|
+
kind?: "latch-only";
|
|
327
351
|
}
|
|
328
352
|
/** One staged transaction: rows land BEFORE `applyPatches`; the commit credential lands after it
|
|
329
353
|
* succeeds; promotion consumes both. A pending txn without a credential is the crash window —
|
|
@@ -338,6 +362,12 @@ export interface LineagePendingTxn {
|
|
|
338
362
|
appliedIds: string[];
|
|
339
363
|
at: number;
|
|
340
364
|
};
|
|
365
|
+
/** design/178 v2-c §1 — present ⇔ this txn is a bundle-import SYNTHETIC LATCH: it never promotes
|
|
366
|
+
* (no credential is ever written), the generic adjudication REFUSES both arms on it, and the only
|
|
367
|
+
* release channel is {@link releaseImportLatch} (durable completion-receipt precondition). An
|
|
368
|
+
* older binary reads it as an ordinary uncredentialed pending txn and reports it undecidable —
|
|
369
|
+
* the accepted mixed-version posture (the drain precondition of the ledger family applies). */
|
|
370
|
+
kind?: "latch-only";
|
|
341
371
|
}
|
|
342
372
|
/** Stage this transaction's would-commit rows BEFORE `applyPatches` (write-ahead). Throws on any
|
|
343
373
|
* write/lock/corruption failure — the caller refuses the whole harvest (fail-closed). */
|
|
@@ -373,8 +403,39 @@ export declare function reconcileLineage(controlDir: string, now: () => number):
|
|
|
373
403
|
txnId: string;
|
|
374
404
|
sessionId: string;
|
|
375
405
|
entryIds: string[];
|
|
406
|
+
kind?: "latch-only";
|
|
376
407
|
}>;
|
|
377
408
|
};
|
|
409
|
+
/** design/178 v2-c §1 — the import COMPLETION-RECEIPT directory (control plane): one `wx`-minted
|
|
410
|
+
* JSON per bundle hash, written only after the import's governance legs all landed. Its presence
|
|
411
|
+
* is {@link releaseImportLatch}'s hard precondition and the idempotent re-import's short-circuit
|
|
412
|
+
* credential. */
|
|
413
|
+
export declare const IMPORT_RECEIPTS_DIR = "import-receipts";
|
|
414
|
+
/** The lineage txn id of a bundle import's synthetic latch (ONE latch per bundle — a re-import of
|
|
415
|
+
* the same bundle re-stages the same txn id, which is what makes the latch idempotent). */
|
|
416
|
+
export declare function importLatchTxnId(bundleHash: string): string;
|
|
417
|
+
/**
|
|
418
|
+
* design/178 v2-c §1-1 — stage the bundle import's SYNTHETIC LATCH: one pending lineage txn
|
|
419
|
+
* (txnId = {@link importLatchTxnId}) whose rows name every bundle entry id, every row and the txn
|
|
420
|
+
* itself carrying the structural `kind: "latch-only"` seat. From this write until
|
|
421
|
+
* {@link releaseImportLatch}, those ids are withheld on every model-visible read face (the same
|
|
422
|
+
* dirty-latch consumption real staged rows get) — a crash anywhere inside the import leaves the
|
|
423
|
+
* withhold standing (durable pending row), and re-staging the same bundle is an idempotent
|
|
424
|
+
* overwrite with identical content. Never credentialed, never promoted.
|
|
425
|
+
*/
|
|
426
|
+
export declare function stageImportLatch(controlDir: string, bundleHash: string, rows: ReadonlyArray<{
|
|
427
|
+
entryId: string;
|
|
428
|
+
rev: string;
|
|
429
|
+
}>, now: () => number): string;
|
|
430
|
+
/**
|
|
431
|
+
* design/178 v2-c §1-2⑥ — the ONE latch release channel. Hard precondition: the durable completion
|
|
432
|
+
* receipt for the bundle is IN PLACE (`import-receipts/<bundleHash>.json`, minted after the
|
|
433
|
+
* governance legs all landed) — absent receipt ⇒ loud refusal, the latch stands, and convergence is
|
|
434
|
+
* re-running the same bundle import. Receipt-presence is ENOENT-only-absent (a probe failure never
|
|
435
|
+
* reads as "no receipt" — fail-closed). Idempotent: an already-released (absent) txn is a no-op;
|
|
436
|
+
* a NON-latch txn under the id is refused (this is never a generic discard).
|
|
437
|
+
*/
|
|
438
|
+
export declare function releaseImportLatch(controlDir: string, txnId: string): void;
|
|
378
439
|
/** The DIRTY-LATCH set: every entry id named by ANY pending row (credentialed or not). While an id
|
|
379
440
|
* is latched, its model-visible read faces (index mechanical rows / memory_search / memory_get)
|
|
380
441
|
* refuse — "the account is not settled, the content does not go on the table". Throws on a
|
|
@@ -429,6 +490,33 @@ export declare function clearLineageForEntries(controlDir: string, entryIds: rea
|
|
|
429
490
|
* carries the chain's pre-captured set (honest downgrade, never a fabricated rebuild).
|
|
430
491
|
*/
|
|
431
492
|
export declare function captureAndClearLineageForEntries(controlDir: string, entryIds: readonly string[]): Map<string, string[]>;
|
|
493
|
+
/** One (entryId, sessionId) pair whose imported value LOST to the destination's standing row —
|
|
494
|
+
* design/178 v2-c §5.1's disclosure seat (kept is always "destination": the in-account fact is
|
|
495
|
+
* never overwritten by an equal-time divergent import, only reported). */
|
|
496
|
+
export interface LineageImportDivergence {
|
|
497
|
+
entryId: string;
|
|
498
|
+
sessionId: string;
|
|
499
|
+
kept: "destination";
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* design/178 v2-c §5.1 — the lineage FIDELITY importer: write bundle lineage rows into the
|
|
503
|
+
* committed set VERBATIM (`lastRev`/`lastAt` carried byte-for-byte — the promote leg mints its own
|
|
504
|
+
* `now` and is therefore unusable for carriage). Deterministic same-key merge, idempotent under
|
|
505
|
+
* re-import of the same bundle:
|
|
506
|
+
* - destination has no (entryId, sessionId) row ⇒ the source row lands verbatim;
|
|
507
|
+
* - both present, source `lastAt` is GREATER ⇒ source wins ("last contribution" is a time-axis
|
|
508
|
+
* fact); destination greater ⇒ destination stands (no divergence — the merge is deterministic);
|
|
509
|
+
* - equal `lastAt`, equal `lastRev` ⇒ no-op; equal `lastAt`, DIFFERENT `lastRev` ⇒ the
|
|
510
|
+
* destination row stands and the pair is reported (first-mark-wins side: an in-account fact is
|
|
511
|
+
* never rewritten by an外来 package; the disagreement must not be silent).
|
|
512
|
+
* Throws on a corrupt ledger (fail-closed family).
|
|
513
|
+
*/
|
|
514
|
+
export declare function importLineageCommitted(controlDir: string, rows: ReadonlyArray<{
|
|
515
|
+
entryId: string;
|
|
516
|
+
sessionId: string;
|
|
517
|
+
lastRev: string;
|
|
518
|
+
lastAt: number;
|
|
519
|
+
}>): LineageImportDivergence[];
|
|
432
520
|
/** Full-ledger read (tests / host observability). Throws on corruption. */
|
|
433
521
|
export declare function readLineageRecord(controlDir: string): {
|
|
434
522
|
committed: Record<string, Record<string, LineageContribution>>;
|
|
@@ -766,6 +766,48 @@ export function readSessionPollution(controlDir, sessionId) {
|
|
|
766
766
|
}
|
|
767
767
|
return { at: 0, reason: "pollution marker present but unreadable (kept fail-closed)" };
|
|
768
768
|
}
|
|
769
|
+
export function listSessionPollution(controlDir) {
|
|
770
|
+
const dir = join(controlDir, SESSION_POLLUTION_DIR);
|
|
771
|
+
let names;
|
|
772
|
+
try {
|
|
773
|
+
names = readdirSync(dir);
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
if (err.code === "ENOENT")
|
|
777
|
+
return {};
|
|
778
|
+
throw new ControlPlaneCorruptError(`session pollution markers could not be enumerated (${err.code ?? "io error"}) at ${dir} — an enumeration failure is not an empty set (fail-closed)`, { cause: err });
|
|
779
|
+
}
|
|
780
|
+
const out = Object.create(null);
|
|
781
|
+
for (const name of names.sort()) {
|
|
782
|
+
if (!name.endsWith(".json"))
|
|
783
|
+
continue;
|
|
784
|
+
let sessionId;
|
|
785
|
+
try {
|
|
786
|
+
sessionId = decodeURIComponent(name.slice(0, -".json".length));
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const rec = readSessionPollution(controlDir, sessionId);
|
|
792
|
+
if (rec !== undefined)
|
|
793
|
+
out[sessionId] = rec;
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
export function importSessionPollution(controlDir, sessionId, record) {
|
|
798
|
+
const path = pollutionPath(controlDir, sessionId);
|
|
799
|
+
ensureDirExists(dirname(path));
|
|
800
|
+
try {
|
|
801
|
+
writeFileSync(path, `${JSON.stringify({ at: record.at, reason: record.reason }, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
802
|
+
return "written";
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
if (!(err instanceof Error && "code" in err && err.code === "EEXIST"))
|
|
806
|
+
throw err;
|
|
807
|
+
}
|
|
808
|
+
const standing = readSessionPollution(controlDir, sessionId);
|
|
809
|
+
return standing !== undefined && standing.at === record.at && standing.reason === record.reason ? "already" : "divergent";
|
|
810
|
+
}
|
|
769
811
|
export const USAGE_RETRIEVED_FILE = "usage-retrieved.json";
|
|
770
812
|
export const USAGE_RETRIEVED_MAX_IDS = 4096;
|
|
771
813
|
function coerceRetrievedAccount(raw) {
|
|
@@ -932,10 +974,14 @@ function coerceLineage(raw) {
|
|
|
932
974
|
if (!txn || typeof txn !== "object" || typeof txn.sessionId !== "string" || typeof txn.at !== "number" || !Array.isArray(txn.rows)) {
|
|
933
975
|
throw badShape(`pending[${JSON.stringify(txnId)}]`);
|
|
934
976
|
}
|
|
977
|
+
if (txn.kind !== undefined && txn.kind !== "latch-only")
|
|
978
|
+
throw badShape(`pending[${JSON.stringify(txnId)}] kind`);
|
|
935
979
|
for (const row of txn.rows) {
|
|
936
980
|
const p = row;
|
|
937
981
|
if (!p || typeof p !== "object" || typeof p.entryId !== "string" || typeof p.rev !== "string")
|
|
938
982
|
throw badShape(`pending[${JSON.stringify(txnId)}] row`);
|
|
983
|
+
if (p.kind !== undefined && p.kind !== "latch-only")
|
|
984
|
+
throw badShape(`pending[${JSON.stringify(txnId)}] row kind`);
|
|
939
985
|
}
|
|
940
986
|
if (txn.credential !== undefined) {
|
|
941
987
|
const c = txn.credential;
|
|
@@ -1006,6 +1052,9 @@ export function adjudicateLineagePending(controlDir, txnId, action, now) {
|
|
|
1006
1052
|
const txn = rec.pending[txnId];
|
|
1007
1053
|
if (txn === undefined)
|
|
1008
1054
|
return { result: [] };
|
|
1055
|
+
if (isLatchOnlyTxn(txn)) {
|
|
1056
|
+
throw new ControlPlaneCorruptError(`memory lineage ledger: pending transaction ${txnId} is a bundle-import latch — it cannot be ${action === "promote" ? "promoted (its rows are not contributions)" : "discarded (its withhold guards not-yet-governed imported entries)"}; re-run the same bundle import to converge it (releaseImportLatch is receipt-gated)`);
|
|
1057
|
+
}
|
|
1009
1058
|
if (action === "discard") {
|
|
1010
1059
|
delete rec.pending[txnId];
|
|
1011
1060
|
return { next: rec, result: [] };
|
|
@@ -1032,7 +1081,7 @@ export function reconcileLineage(controlDir, now) {
|
|
|
1032
1081
|
let changed = false;
|
|
1033
1082
|
for (const [txnId, txn] of Object.entries(rec.pending)) {
|
|
1034
1083
|
if (txn.credential === undefined) {
|
|
1035
|
-
undecidable.push({ txnId, sessionId: txn.sessionId, entryIds: txn.rows.map((r) => r.entryId) });
|
|
1084
|
+
undecidable.push({ txnId, sessionId: txn.sessionId, entryIds: txn.rows.map((r) => r.entryId), ...(isLatchOnlyTxn(txn) ? { kind: "latch-only" } : {}) });
|
|
1036
1085
|
continue;
|
|
1037
1086
|
}
|
|
1038
1087
|
const applied = new Set(txn.credential.appliedIds);
|
|
@@ -1050,6 +1099,66 @@ export function reconcileLineage(controlDir, now) {
|
|
|
1050
1099
|
return { ...(changed ? { next: rec } : {}), result: { promoted, undecidable } };
|
|
1051
1100
|
});
|
|
1052
1101
|
}
|
|
1102
|
+
function isLatchOnlyTxn(txn) {
|
|
1103
|
+
if (txn.kind === "latch-only")
|
|
1104
|
+
return true;
|
|
1105
|
+
return txn.rows.some((row) => row.kind === "latch-only");
|
|
1106
|
+
}
|
|
1107
|
+
export const IMPORT_RECEIPTS_DIR = "import-receipts";
|
|
1108
|
+
export function importLatchTxnId(bundleHash) {
|
|
1109
|
+
return `import:${bundleHash}`;
|
|
1110
|
+
}
|
|
1111
|
+
function importReceiptPath(controlDir, bundleHash) {
|
|
1112
|
+
return join(controlDir, IMPORT_RECEIPTS_DIR, `${bundleHash}.json`);
|
|
1113
|
+
}
|
|
1114
|
+
export function stageImportLatch(controlDir, bundleHash, rows, now) {
|
|
1115
|
+
const txnId = importLatchTxnId(bundleHash);
|
|
1116
|
+
lockedStrictUpdate(controlDir, LINEAGE_FILE, "memory lineage ledger", coerceLineage, (rec) => {
|
|
1117
|
+
const existing = rec.pending[txnId];
|
|
1118
|
+
if (existing !== undefined && !isLatchOnlyTxn(existing)) {
|
|
1119
|
+
throw new ControlPlaneCorruptError(`memory lineage ledger: pending transaction ${txnId} exists and is NOT an import latch — refusing to overwrite a real staged transaction`);
|
|
1120
|
+
}
|
|
1121
|
+
rec.pending[txnId] = {
|
|
1122
|
+
sessionId: txnId,
|
|
1123
|
+
at: now(),
|
|
1124
|
+
kind: "latch-only",
|
|
1125
|
+
rows: rows.map((r) => ({ entryId: r.entryId, rev: r.rev, kind: "latch-only" })),
|
|
1126
|
+
};
|
|
1127
|
+
return { next: rec, result: undefined };
|
|
1128
|
+
});
|
|
1129
|
+
return txnId;
|
|
1130
|
+
}
|
|
1131
|
+
export function releaseImportLatch(controlDir, txnId) {
|
|
1132
|
+
const prefix = "import:";
|
|
1133
|
+
if (!txnId.startsWith(prefix) || !/^[0-9a-f]{64}$/.test(txnId.slice(prefix.length))) {
|
|
1134
|
+
throw new ControlPlaneCorruptError(`releaseImportLatch: ${JSON.stringify(txnId)} is not an import-latch transaction id (import:<64-hex bundleHash>)`);
|
|
1135
|
+
}
|
|
1136
|
+
const receipt = importReceiptPath(controlDir, txnId.slice(prefix.length));
|
|
1137
|
+
let receiptPresent;
|
|
1138
|
+
try {
|
|
1139
|
+
readFileSync(receipt);
|
|
1140
|
+
receiptPresent = true;
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
if (err.code !== "ENOENT") {
|
|
1144
|
+
throw new ControlPlaneCorruptError(`releaseImportLatch: the completion receipt at ${receipt} could not be read (${err.code ?? "io error"}) — a probe failure is not absence (fail-closed), and absence would refuse the release`, { cause: err });
|
|
1145
|
+
}
|
|
1146
|
+
receiptPresent = false;
|
|
1147
|
+
}
|
|
1148
|
+
if (!receiptPresent) {
|
|
1149
|
+
throw new ControlPlaneCorruptError(`releaseImportLatch: no completion receipt at ${receipt} — the import's governance legs are not proven landed, the latch stays; re-run the same bundle import to converge`);
|
|
1150
|
+
}
|
|
1151
|
+
lockedStrictUpdate(controlDir, LINEAGE_FILE, "memory lineage ledger", coerceLineage, (rec) => {
|
|
1152
|
+
const txn = rec.pending[txnId];
|
|
1153
|
+
if (txn === undefined)
|
|
1154
|
+
return { result: undefined };
|
|
1155
|
+
if (!isLatchOnlyTxn(txn)) {
|
|
1156
|
+
throw new ControlPlaneCorruptError(`releaseImportLatch: pending transaction ${txnId} is NOT an import latch — refusing (this channel never discards a real staged transaction)`);
|
|
1157
|
+
}
|
|
1158
|
+
delete rec.pending[txnId];
|
|
1159
|
+
return { next: rec, result: undefined };
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1053
1162
|
export function lineageLatchedIds(controlDir) {
|
|
1054
1163
|
const rec = coerceLineage(readStrictSidecar(controlDir, LINEAGE_FILE, "memory lineage ledger"));
|
|
1055
1164
|
const out = new Set();
|
|
@@ -1134,6 +1243,27 @@ export function captureAndClearLineageForEntries(controlDir, entryIds) {
|
|
|
1134
1243
|
return { ...(changed ? { next: rec } : {}), result };
|
|
1135
1244
|
});
|
|
1136
1245
|
}
|
|
1246
|
+
export function importLineageCommitted(controlDir, rows) {
|
|
1247
|
+
if (rows.length === 0)
|
|
1248
|
+
return [];
|
|
1249
|
+
return lockedStrictUpdate(controlDir, LINEAGE_FILE, "memory lineage ledger", coerceLineage, (rec) => {
|
|
1250
|
+
const divergence = [];
|
|
1251
|
+
let changed = false;
|
|
1252
|
+
for (const row of rows) {
|
|
1253
|
+
const sessions = (rec.committed[row.entryId] ??= Object.create(null));
|
|
1254
|
+
const standing = Object.prototype.hasOwnProperty.call(sessions, row.sessionId) ? sessions[row.sessionId] : undefined;
|
|
1255
|
+
if (standing === undefined || row.lastAt > standing.lastAt) {
|
|
1256
|
+
sessions[row.sessionId] = { lastRev: row.lastRev, lastAt: row.lastAt };
|
|
1257
|
+
changed = true;
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
if (row.lastAt === standing.lastAt && row.lastRev !== standing.lastRev) {
|
|
1261
|
+
divergence.push({ entryId: row.entryId, sessionId: row.sessionId, kept: "destination" });
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return { ...(changed ? { next: rec } : {}), result: divergence };
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1137
1267
|
export function readLineageRecord(controlDir) {
|
|
1138
1268
|
const rec = coerceLineage(readStrictSidecar(controlDir, LINEAGE_FILE, "memory lineage ledger"));
|
|
1139
1269
|
return { committed: rec.committed, pending: rec.pending };
|
|
@@ -22,7 +22,7 @@ export interface MemoryBackendContractHooks {
|
|
|
22
22
|
* assembly answers it as capability-absent.
|
|
23
23
|
*/
|
|
24
24
|
onOptionalMember?: (report: {
|
|
25
|
-
member: "committedSnapshotOf" | "committedSnapshotsOfScopes" | "custodyOf" | "eraseWithEvidence";
|
|
25
|
+
member: "committedSnapshotOf" | "committedSnapshotsOfScopes" | "custodyOf" | "eraseWithEvidence" | "exportSnapshotOf" | "governanceExport" | "custodyImport" | "importBundleCommit";
|
|
26
26
|
status: "verified" | "absent";
|
|
27
27
|
}) => void;
|
|
28
28
|
}
|
|
@@ -384,6 +384,58 @@ export async function memoryBackendContract(hooks) {
|
|
|
384
384
|
await assert.rejects(async () => erase.call(b, { requestId: "req-contract-1", select: { ids: ["id-other-7777"] } }), (err) => err.code === "memory.erasure_selector_mismatch", "a reused requestId with a different selector refuses loudly");
|
|
385
385
|
hooks.onOptionalMember?.({ member: "eraseWithEvidence", status: "verified" });
|
|
386
386
|
});
|
|
387
|
+
defer("design/178 v2-c optional export/import faces: export composite / governance slice / custody carriage (absent ⇒ reported)", async () => {
|
|
388
|
+
const b = await hooks.make();
|
|
389
|
+
const seats = [
|
|
390
|
+
["exportSnapshotOf", b.exportSnapshotOf],
|
|
391
|
+
["governanceExport", b.governanceExport],
|
|
392
|
+
["custodyImport", b.custodyImport],
|
|
393
|
+
["importBundleCommit", b.importBundleCommit],
|
|
394
|
+
];
|
|
395
|
+
for (const [member, seat] of seats) {
|
|
396
|
+
assert.ok(seat === undefined || typeof seat === "function", `${member} must be undefined (absent) or a function — a non-function occupant is a defect, not absence`);
|
|
397
|
+
}
|
|
398
|
+
const e = entry("id-export-0001", "s1", "exported", "exported body", { name: "Exported" });
|
|
399
|
+
await b.applyPatches([{ op: "add", id: e.id, entry: e }]);
|
|
400
|
+
const exportSnapshotOf = typeof seats[0]?.[1] === "function" ? seats[0][1] : undefined;
|
|
401
|
+
if (exportSnapshotOf === undefined) {
|
|
402
|
+
hooks.onOptionalMember?.({ member: "exportSnapshotOf", status: "absent" });
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const snap = await exportSnapshotOf.call(b, ["s1"]);
|
|
406
|
+
assert.ok(typeof snap.storeId === "string" && snap.storeId.length > 0, "the export snapshot names the store identity");
|
|
407
|
+
assert.strictEqual(typeof snap.fullStore, "boolean");
|
|
408
|
+
assert.deepStrictEqual(snap.entries.map((r) => r.id), [e.id], "the composite answers the committed entries of the requested scopes");
|
|
409
|
+
assert.strictEqual(snap.entries[0]?.rev, e.rev, "entries carry the committed rev");
|
|
410
|
+
assert.ok(Array.isArray(snap.custody) && Array.isArray(snap.unbound) && Array.isArray(snap.pendingLatch));
|
|
411
|
+
hooks.onOptionalMember?.({ member: "exportSnapshotOf", status: "verified" });
|
|
412
|
+
}
|
|
413
|
+
const governanceExport = typeof seats[1]?.[1] === "function" ? seats[1][1] : undefined;
|
|
414
|
+
if (governanceExport === undefined) {
|
|
415
|
+
hooks.onOptionalMember?.({ member: "governanceExport", status: "absent" });
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const slice = await governanceExport.call(b, ["s1"]);
|
|
419
|
+
assert.strictEqual(slice.complete, true, "a quiet store's governance slice answers complete");
|
|
420
|
+
assert.ok(Array.isArray(slice.custody) && Array.isArray(slice.unbound));
|
|
421
|
+
hooks.onOptionalMember?.({ member: "governanceExport", status: "verified" });
|
|
422
|
+
}
|
|
423
|
+
const custodyImport = typeof seats[2]?.[1] === "function" ? seats[2][1] : undefined;
|
|
424
|
+
if (custodyImport === undefined) {
|
|
425
|
+
hooks.onOptionalMember?.({ member: "custodyImport", status: "absent" });
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
const opts = { bundleHash: "c".repeat(64), sourceStoreId: "contract-src-store" };
|
|
429
|
+
const outcome = await custodyImport.call(b, [{ ev: "kit-del-1", channel: "delete", id: "id-ghost-kit-1", rev: "deadbeefdeadbeef", at: 1 }], opts);
|
|
430
|
+
assert.strictEqual(outcome.appended, 1, "a well-formed delete row for an absent id is carried (anti-resurrection evidence needs no living entry)");
|
|
431
|
+
assert.deepStrictEqual(outcome.withheld, []);
|
|
432
|
+
const unknown = await custodyImport.call(b, [{ ev: "kit-unk-1", channel: "kit-unknown-channel", payload: 1, at: 1 }], opts);
|
|
433
|
+
assert.strictEqual(unknown.appended, 0, "an unknown channel is never appended verbatim");
|
|
434
|
+
assert.deepStrictEqual(unknown.withheld, [{ srcEv: "kit-unk-1", channel: "kit-unknown-channel" }], "an unknown channel is withheld AND reported — never a whole-package refusal");
|
|
435
|
+
hooks.onOptionalMember?.({ member: "custodyImport", status: "verified" });
|
|
436
|
+
}
|
|
437
|
+
hooks.onOptionalMember?.({ member: "importBundleCommit", status: typeof seats[3]?.[1] === "function" ? "verified" : "absent" });
|
|
438
|
+
});
|
|
387
439
|
if (!hooks.makeSibling) {
|
|
388
440
|
const reason = hooks.skipCrossInstanceCas?.trim();
|
|
389
441
|
if (reason) {
|
|
@@ -163,7 +163,14 @@ export function createMemoryEngineTools(opts) {
|
|
|
163
163
|
throw err;
|
|
164
164
|
return refusedSearch("error", GENERIC_FAILURE, "failed");
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
let terminalExclusions;
|
|
167
|
+
try {
|
|
168
|
+
terminalExclusions = planes.map((plane) => plane.challengeExclusions?.());
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return refusedSearch("challenge_ledger_unavailable", "Memory search is unavailable: the challenge ledger for a mounted memory plane cannot be read (fail-closed). Report this to the operator.", "failed");
|
|
172
|
+
}
|
|
173
|
+
const live = top.filter((h) => bodyById.has(h.id) && terminalExclusions[h.planeIndex]?.has(h.id) !== true);
|
|
167
174
|
if (live.length === 0) {
|
|
168
175
|
const details = { outcome: "ok", hits: [] };
|
|
169
176
|
return { content: `No memory entries matched ${JSON.stringify(inlineUntrusted(query, 120))}. If the user expected you to know this, say that you checked memory and found nothing.`, details };
|
|
@@ -285,9 +285,9 @@ export interface CcImportLayer {
|
|
|
285
285
|
/**
|
|
286
286
|
* What the import WOULD do, shown before anyone confirms.
|
|
287
287
|
*
|
|
288
|
-
* `uncovered` is a
|
|
288
|
+
* `uncovered` is a fixed-key record, not a list: everything this version does not import is named in the
|
|
289
289
|
* TYPE, so an empty list, a missing member or a duplicate is not expressible. A preview that reported only
|
|
290
|
-
*
|
|
290
|
+
* what it read would be claiming completeness it does not have.
|
|
291
291
|
*/
|
|
292
292
|
export interface ImportPreview {
|
|
293
293
|
candidates: RuleCandidate[];
|
|
@@ -303,6 +303,26 @@ export interface ImportPreview {
|
|
|
303
303
|
uncovered: {
|
|
304
304
|
flagSettings: "not-imported-v1";
|
|
305
305
|
policySettings: "not-imported-v1";
|
|
306
|
+
/**
|
|
307
|
+
* The deny/ask buckets of the layers actually read: how many entries are SITTING THERE, across all
|
|
308
|
+
* layers, that this import deliberately leaves in place (they are the tightening direction and have
|
|
309
|
+
* their own channel — importing them through a loosening lane would be the wrong door).
|
|
310
|
+
*
|
|
311
|
+
* Always present, zero when the buckets are absent or empty: "this settings file has 14 deny entries
|
|
312
|
+
* we did not touch" and "there was nothing there" are different facts, and a key that appears only in
|
|
313
|
+
* the first case makes them indistinguishable for a reader who sees one preview. The count is of
|
|
314
|
+
* ENTRIES IN PLACE, not of a decision — nothing here is skipped-and-lost; `skipped` stays the list of
|
|
315
|
+
* allow-bucket entries that did not become candidates.
|
|
316
|
+
*
|
|
317
|
+
* A bucket that is PRESENT but not an array cannot be counted; it contributes 0 and is disclosed in
|
|
318
|
+
* `skipped` under the layer's path, so a zero is never the report for something unreadable. Same for
|
|
319
|
+
* a layer whose whole `permissions` member is not an object — every bucket in it reads as absent,
|
|
320
|
+
* which is exactly the shape that would otherwise report two clean zeros.
|
|
321
|
+
*/
|
|
322
|
+
denyAskBuckets: {
|
|
323
|
+
deny: number;
|
|
324
|
+
ask: number;
|
|
325
|
+
};
|
|
306
326
|
};
|
|
307
327
|
}
|
|
308
328
|
/** What the import ACTUALLY did — a different moment and a different contract from the preview, because
|
|
@@ -320,8 +340,11 @@ export interface ImportResult {
|
|
|
320
340
|
* Read the allow buckets of the user-editable settings layers and produce a preview plus a PENDING
|
|
321
341
|
* approval record. Nothing is stored until someone confirms that record and the batch is redeemed.
|
|
322
342
|
*
|
|
323
|
-
* Only the allow bucket is
|
|
324
|
-
* channel; importing them through a loosening lane would be the wrong door.
|
|
343
|
+
* Only the allow bucket is IMPORTED. The deny/ask buckets are the tightening direction and have their own
|
|
344
|
+
* channel; importing them through a loosening lane would be the wrong door. They are still COUNTED and
|
|
345
|
+
* reported (`preview.uncovered.denyAskBuckets`): by-design-not-imported and unnoticed look identical to
|
|
346
|
+
* the person confirming the batch, and only one of them is true here — the same reason the non-Bash allow
|
|
347
|
+
* entries are disclosed rather than dropped silently.
|
|
325
348
|
*/
|
|
326
349
|
export declare function prepareCcImport(opts: {
|
|
327
350
|
layers: CcImportLayer[];
|
|
@@ -337,12 +337,13 @@ async function applyRedemption(args) {
|
|
|
337
337
|
function originOfRecordKind(kind) {
|
|
338
338
|
return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user";
|
|
339
339
|
}
|
|
340
|
-
const
|
|
340
|
+
const IMPORT_UNCOVERED_LAYERS = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" };
|
|
341
341
|
export async function prepareCcImport(opts) {
|
|
342
342
|
const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCcImport");
|
|
343
343
|
const candidates = [];
|
|
344
344
|
const skipped = [];
|
|
345
345
|
const layers = [];
|
|
346
|
+
const denyAskBuckets = { deny: 0, ask: 0 };
|
|
346
347
|
for (const layer of opts.layers) {
|
|
347
348
|
let raw;
|
|
348
349
|
try {
|
|
@@ -356,14 +357,50 @@ export async function prepareCcImport(opts) {
|
|
|
356
357
|
continue;
|
|
357
358
|
}
|
|
358
359
|
layers.push({ path: layer.path, layer: layer.layer, found: true });
|
|
359
|
-
let
|
|
360
|
+
let settingsRoot;
|
|
360
361
|
try {
|
|
361
|
-
|
|
362
|
+
settingsRoot = JSON.parse(raw);
|
|
362
363
|
}
|
|
363
364
|
catch (err) {
|
|
364
365
|
skipped.push({ rule: layer.path, reason: `settings file is not valid JSON (${errText(err)})` });
|
|
365
366
|
continue;
|
|
366
367
|
}
|
|
368
|
+
let container;
|
|
369
|
+
if (settingsRoot === null)
|
|
370
|
+
container = undefined;
|
|
371
|
+
else if (typeof settingsRoot === "object" && !Array.isArray(settingsRoot))
|
|
372
|
+
container = settingsRoot.permissions;
|
|
373
|
+
else {
|
|
374
|
+
skipped.push({
|
|
375
|
+
rule: layer.path,
|
|
376
|
+
reason: `settings file is not a JSON object — no bucket in this layer could be read, so it is reported as unread rather than as empty (it stays in the settings file either way)`,
|
|
377
|
+
});
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
let permissions;
|
|
381
|
+
if (container === undefined || container === null)
|
|
382
|
+
permissions = undefined;
|
|
383
|
+
else if (typeof container === "object" && !Array.isArray(container))
|
|
384
|
+
permissions = container;
|
|
385
|
+
else {
|
|
386
|
+
skipped.push({
|
|
387
|
+
rule: layer.path,
|
|
388
|
+
reason: `settings "permissions" is not an object — no bucket in this layer could be read, so it is reported as unread rather than as empty (it stays in the settings file either way)`,
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
for (const bucket of ["deny", "ask"]) {
|
|
393
|
+
const entries = permissions?.[bucket];
|
|
394
|
+
if (Array.isArray(entries))
|
|
395
|
+
denyAskBuckets[bucket] += entries.length;
|
|
396
|
+
else if (entries !== undefined && entries !== null) {
|
|
397
|
+
skipped.push({
|
|
398
|
+
rule: layer.path,
|
|
399
|
+
reason: `settings ${bucket} bucket is not an array — its entries could not be counted for the not-imported report (it stays in the settings file either way)`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const allow = permissions?.allow;
|
|
367
404
|
if (!Array.isArray(allow))
|
|
368
405
|
continue;
|
|
369
406
|
const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
|
|
@@ -401,7 +438,7 @@ export async function prepareCcImport(opts) {
|
|
|
401
438
|
createdAt: nowIso(opts.deps),
|
|
402
439
|
};
|
|
403
440
|
await opts.deps.approvals.create(record);
|
|
404
|
-
return { preview: { candidates, skipped, layers, uncovered:
|
|
441
|
+
return { preview: { candidates, skipped, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS, denyAskBuckets } }, approvalId: record.id };
|
|
405
442
|
}
|
|
406
443
|
export const STARTER_RULES = [
|
|
407
444
|
"Bash(ls)",
|
|
@@ -29,7 +29,13 @@
|
|
|
29
29
|
* here) and no leading-env-assignment stripping (`FOO=1 git status` simply does not match
|
|
30
30
|
* `Bash(git status:*)`) — the last two are strict-side divergences from upstream, registered as such.
|
|
31
31
|
*/
|
|
32
|
-
/** The one tool the v1 rule lane speaks for. The field exists on the rule so v2 can widen without a shape change.
|
|
32
|
+
/** The one tool the v1 rule lane speaks for. The field exists on the rule so v2 can widen without a shape change.
|
|
33
|
+
*
|
|
34
|
+
* design/276 §3.3 (doctrine, for whoever widens this set): a future `SendMessage(…)`-form rule MAY
|
|
35
|
+
* clear the peer-referral ask — that ask is classifier hesitation by construction (#144: allow rules
|
|
36
|
+
* silence the classifier's questions, never a mandated one), so a recorded human yes is exactly what
|
|
37
|
+
* clears it. The org/hook/matchedAskRule immunities stay: those conjuncts live in the gate's
|
|
38
|
+
* persisted-rule lane and do not loosen with this set. */
|
|
33
39
|
export type PersistedRuleTool = "Bash";
|
|
34
40
|
/** v1 match forms. `"wildcard"` is reserved for v2 and is not a value this version ever produces. */
|
|
35
41
|
export type PersistedRuleMatch = "exact" | "prefix";
|