@sema-agent/core 5.45.0 → 5.46.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 (69) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/agents/subagent.js +1 -1
  3. package/dist/core/checkpoint-store.d.ts +12 -0
  4. package/dist/core/hooks.d.ts +9 -2
  5. package/dist/core/hooks.js +6 -5
  6. package/dist/core/memory-engine/content-origin.d.ts +3 -1
  7. package/dist/core/memory-engine/engine.d.ts +50 -3
  8. package/dist/core/memory-engine/engine.js +194 -32
  9. package/dist/core/memory-engine/export-bundle.d.ts +10 -1
  10. package/dist/core/memory-engine/export-bundle.js +21 -0
  11. package/dist/core/memory-engine/file-backend.d.ts +33 -4
  12. package/dist/core/memory-engine/file-backend.js +165 -39
  13. package/dist/core/memory-engine/frontmatter.d.ts +42 -1
  14. package/dist/core/memory-engine/frontmatter.js +141 -1
  15. package/dist/core/memory-engine/header-hints.d.ts +17 -0
  16. package/dist/core/memory-engine/header-hints.js +6 -0
  17. package/dist/core/memory-engine/index.d.ts +4 -3
  18. package/dist/core/memory-engine/index.js +3 -2
  19. package/dist/core/memory-engine/layout.d.ts +25 -2
  20. package/dist/core/memory-engine/layout.js +25 -12
  21. package/dist/core/memory-engine/memory-backend-contract.js +65 -0
  22. package/dist/core/memory-engine/sync-client.d.ts +1 -1
  23. package/dist/core/memory-engine/sync-client.js +33 -1
  24. package/dist/core/memory-engine/tools.d.ts +7 -0
  25. package/dist/core/memory-engine/tools.js +3 -0
  26. package/dist/core/memory-engine/types.d.ts +75 -1
  27. package/dist/core/memory-engine/types.js +1 -1
  28. package/dist/core/reminder-mint.d.ts +70 -0
  29. package/dist/core/reminder-mint.js +25 -0
  30. package/dist/core/runner/git-status-frame.d.ts +3 -14
  31. package/dist/core/runner/git-status-frame.js +39 -14
  32. package/dist/core/runner/prepare-config-doors.d.ts +4 -0
  33. package/dist/core/runner/prepare-config-doors.js +15 -0
  34. package/dist/core/runner/prepare-hands-readface.d.ts +5 -11
  35. package/dist/core/runner/prepare-hands-readface.js +26 -0
  36. package/dist/core/runner/prepare-memory.d.ts +11 -0
  37. package/dist/core/runner/prepare-memory.js +12 -10
  38. package/dist/core/runner/prepare-task.d.ts +22 -1
  39. package/dist/core/runner/prepare-task.js +48 -13
  40. package/dist/core/runner/runtask.js +62 -55
  41. package/dist/core/side-query.d.ts +11 -1
  42. package/dist/core/side-query.js +3 -0
  43. package/dist/core/types.d.ts +47 -7
  44. package/dist/engine/harness/types.d.ts +46 -1
  45. package/dist/engine/harness/types.js +11 -0
  46. package/dist/engine/session/import-validate.js +6 -1
  47. package/dist/engine/session/session.d.ts +20 -0
  48. package/dist/engine/session/session.js +26 -1
  49. package/dist/index.d.ts +2 -1
  50. package/dist/index.js +2 -1
  51. package/dist/orchestration/run-workflow-tool.d.ts +16 -0
  52. package/dist/orchestration/run-workflow-tool.js +23 -3
  53. package/dist/orchestration/workflow-governance.d.ts +8 -1
  54. package/dist/prompt-assembly/epoch.js +2 -0
  55. package/dist/prompt-assembly/types.d.ts +6 -0
  56. package/dist/prompts/default.d.ts +13 -1
  57. package/dist/prompts/default.js +5 -1
  58. package/dist/tools/fs/fs-bash.d.ts +4 -0
  59. package/dist/tools/fs/fs-bash.js +1 -1
  60. package/dist/tools/fs/fs-read.d.ts +1 -1
  61. package/dist/tools/fs/fs-read.js +8 -7
  62. package/dist/tools/fs/fs-shared.d.ts +10 -4
  63. package/dist/tools/fs/fs-shared.js +6 -3
  64. package/dist/tools/fs/gh-rate-limit.d.ts +4 -1
  65. package/dist/tools/fs/gh-rate-limit.js +3 -2
  66. package/dist/tools/fs/index.d.ts +10 -2
  67. package/dist/tools/fs/index.js +2 -1
  68. package/package.json +1 -1
  69. package/test/export-surface.snapshot.json +12 -1
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { MEMORY_ORIGIN_CAUSES } from "./types.js";
2
3
  const FM_FENCE = "---";
3
4
  const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$/;
4
5
  export function isValidEntryId(value) {
@@ -28,8 +29,12 @@ export function parseEntryFile(text) {
28
29
  const provRaw = [];
29
30
  const provUnknown = [];
30
31
  const prov = {};
32
+ let inOrigin = false;
33
+ const originRaw = [];
34
+ const originUnknown = [];
35
+ const orig = {};
31
36
  for (let i = 1; i < end; i++) {
32
- const line = lines[i];
37
+ const line = lines[i] ?? "";
33
38
  const trimmed = line.trim();
34
39
  if (trimmed === "")
35
40
  continue;
@@ -37,6 +42,7 @@ export function parseEntryFile(text) {
37
42
  if (!indented) {
38
43
  inMetadata = false;
39
44
  inProvenance = false;
45
+ inOrigin = false;
40
46
  }
41
47
  if (inMetadata) {
42
48
  const m = /^\s+type:\s*(.+?)\s*$/.exec(line);
@@ -72,11 +78,35 @@ export function parseEntryFile(text) {
72
78
  inMetadata = true;
73
79
  continue;
74
80
  }
81
+ if (inOrigin) {
82
+ originRaw.push(line);
83
+ const m = /^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/.exec(line);
84
+ if (m) {
85
+ const [, k = "", v = ""] = m;
86
+ if (k === "taint")
87
+ orig.taint = v;
88
+ else if (k === "cause")
89
+ orig.cause = v;
90
+ else if (k === "at")
91
+ orig.at = /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : Number.NaN;
92
+ else
93
+ originUnknown.push(line);
94
+ }
95
+ else {
96
+ originUnknown.push(line);
97
+ }
98
+ continue;
99
+ }
75
100
  if (/^provenance:\s*$/.test(trimmed)) {
76
101
  inProvenance = true;
77
102
  provRaw.push(line);
78
103
  continue;
79
104
  }
105
+ if (/^origin:\s*$/.test(trimmed) && originRaw.length === 0) {
106
+ inOrigin = true;
107
+ originRaw.push(line);
108
+ continue;
109
+ }
80
110
  const kv = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*?)\s*$/.exec(trimmed);
81
111
  if (!kv || indented) {
82
112
  extra.push(line);
@@ -126,6 +156,15 @@ export function parseEntryFile(text) {
126
156
  extra.push(...provRaw);
127
157
  }
128
158
  }
159
+ if (originRaw.length > 0) {
160
+ if (orig.taint === "external" && typeof orig.at === "number" && Number.isFinite(orig.at) && (orig.cause === undefined || MEMORY_ORIGIN_CAUSES.includes(orig.cause))) {
161
+ fm.origin = { taint: "external", ...(orig.cause !== undefined ? { cause: orig.cause } : {}), at: orig.at };
162
+ extra.push(...originUnknown);
163
+ }
164
+ else {
165
+ extra.push(...originRaw);
166
+ }
167
+ }
129
168
  if (extra.length > 0)
130
169
  fm.extra = extra;
131
170
  let body = lines.slice(end + 1).join("\n");
@@ -149,6 +188,9 @@ export function serializeEntryFile(entry) {
149
188
  }
150
189
  if (fm.trust !== undefined)
151
190
  lines.push(`trust: ${fm.trust}`);
191
+ if (fm.origin !== undefined) {
192
+ lines.push("origin:", ` taint: ${fm.origin.taint}`, ...(fm.origin.cause !== undefined ? [` cause: ${fm.origin.cause}`] : []), ` at: ${fm.origin.at}`);
193
+ }
152
194
  if (fm.extra)
153
195
  lines.push(...fm.extra);
154
196
  lines.push(FM_FENCE, "");
@@ -168,9 +210,107 @@ export function computeEntryRev(entry) {
168
210
  ...(fm.provenance !== undefined || fm.trust !== undefined
169
211
  ? [fm.trust ?? null, fm.provenance !== undefined ? [fm.provenance.kind, fm.provenance.path, fm.provenance.contentHash, fm.provenance.ingestedAt] : null]
170
212
  : []),
213
+ ...(fm.origin !== undefined ? [["origin", fm.origin.taint, fm.origin.cause ?? null, fm.origin.at]] : []),
171
214
  ]);
172
215
  return createHash("sha256").update(canonical, "utf8").digest("hex").slice(0, 16);
173
216
  }
217
+ const ORIGIN_FORM_LINE_RE = /^\s*origin\s*:/;
218
+ export function hasOriginFormExtra(fm) {
219
+ return fm.extra !== undefined && fm.extra.some((line) => ORIGIN_FORM_LINE_RE.test(line));
220
+ }
221
+ function walkExtraOrigin(extra) {
222
+ if (extra === undefined)
223
+ return { carriers: 0, first: undefined };
224
+ let carriers = 0;
225
+ let inBlock = false;
226
+ let blockIndex = 0;
227
+ let taint;
228
+ let cause;
229
+ let at;
230
+ for (const line of extra) {
231
+ const trimmed = line.trim();
232
+ if (/^origin\s*:\s*$/.test(trimmed)) {
233
+ carriers += 1;
234
+ inBlock = true;
235
+ blockIndex += 1;
236
+ continue;
237
+ }
238
+ if (ORIGIN_FORM_LINE_RE.test(line) && !inBlock) {
239
+ carriers += 1;
240
+ continue;
241
+ }
242
+ if (!/^\s/.test(line))
243
+ inBlock = false;
244
+ if (!inBlock || blockIndex !== 1)
245
+ continue;
246
+ const m = /^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/.exec(line);
247
+ if (m === null)
248
+ continue;
249
+ const [, k = "", v = ""] = m;
250
+ if (k === "taint" && taint === undefined)
251
+ taint = v;
252
+ else if (k === "cause" && cause === undefined)
253
+ cause = v;
254
+ else if (k === "at" && at === undefined)
255
+ at = /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : Number.NaN;
256
+ }
257
+ if (carriers === 0)
258
+ return { carriers: 0, first: undefined };
259
+ if (taint === "external" && typeof at === "number" && Number.isFinite(at) && (cause === undefined || MEMORY_ORIGIN_CAUSES.includes(cause))) {
260
+ return { carriers, first: { taint: "external", ...(cause !== undefined ? { cause: cause } : {}), at } };
261
+ }
262
+ return { carriers, first: { taint: "external", at: 0 } };
263
+ }
264
+ export function committedOriginOf(fm) {
265
+ if (fm.origin !== undefined)
266
+ return { taint: fm.origin.taint, ...(fm.origin.cause !== undefined ? { cause: fm.origin.cause } : {}), at: fm.origin.at };
267
+ if (!hasOriginFormExtra(fm))
268
+ return undefined;
269
+ return walkExtraOrigin(fm.extra).first ?? { taint: "external", at: 0 };
270
+ }
271
+ export function originEquals(a, b) {
272
+ if (a === undefined || b === undefined)
273
+ return a === b;
274
+ return a.taint === b.taint && a.cause === b.cause && a.at === b.at;
275
+ }
276
+ export function ambiguousOriginRepresentation(fm) {
277
+ const { carriers, first } = walkExtraOrigin(fm.extra);
278
+ const total = carriers + (fm.origin !== undefined ? 1 : 0);
279
+ if (total <= 1)
280
+ return false;
281
+ if (total === 2 && fm.origin !== undefined && carriers === 1)
282
+ return !originEquals(fm.origin, first);
283
+ return true;
284
+ }
285
+ export function stripModelWrittenOrigin(fm) {
286
+ let stripped = false;
287
+ if (fm.origin !== undefined) {
288
+ delete fm.origin;
289
+ stripped = true;
290
+ }
291
+ if (fm.extra !== undefined && fm.extra.some((line) => ORIGIN_FORM_LINE_RE.test(line))) {
292
+ const kept = [];
293
+ let inBlock = false;
294
+ for (const line of fm.extra) {
295
+ if (ORIGIN_FORM_LINE_RE.test(line)) {
296
+ inBlock = /^\s*origin\s*:\s*$/.test(line);
297
+ stripped = true;
298
+ continue;
299
+ }
300
+ if (inBlock && /^\s/.test(line)) {
301
+ stripped = true;
302
+ continue;
303
+ }
304
+ inBlock = false;
305
+ kept.push(line);
306
+ }
307
+ if (kept.length > 0)
308
+ fm.extra = kept;
309
+ else
310
+ delete fm.extra;
311
+ }
312
+ return stripped;
313
+ }
174
314
  export function entryFromFile(text, id, slug, scope) {
175
315
  const parsed = parseEntryFile(text);
176
316
  const entry = { id, slug, frontmatter: parsed.frontmatter, body: parsed.body, rev: "", scope };
@@ -25,6 +25,23 @@ export interface V2HeaderHints {
25
25
  /** `applies-when: <free text>` — non-empty free text, trimmed. */
26
26
  appliesWhen?: string;
27
27
  }
28
+ /**
29
+ * design/336 §4.1 — the MECHANICAL instruction-form predicate (deliberately zero semantic judgment:
30
+ * no model, no content heuristics — SOTA R4's LLM-judge ban). An entry is instruction-form ⇔
31
+ * - `metadata.type === "feedback"` (the declared behavior-correction type), OR
32
+ * - its v2 header hints declare an injection/trigger privilege: `pinned` / `triggers` /
33
+ * `applies-when` (the three hints whose whole purpose is automatic or conditional injection;
34
+ * `last-confirmed` is bookkeeping and does not count).
35
+ * Escape analysis: an entry that declares none of these rides the ordinary-entry row — where the
36
+ * origin-marker floor governs — so NOT declaring buys no privilege; declaring buys injection
37
+ * privilege AND walks through the exposure hard gate with it. ONE exported predicate on purpose:
38
+ * the write side (harvest hard gate) and any read-side consumer must judge through the same
39
+ * function, never through re-implementations that can drift.
40
+ */
41
+ export declare function isInstructionEntry(fm: {
42
+ type?: string;
43
+ extra?: readonly string[];
44
+ }): boolean;
28
45
  /** Read the v2 header hints out of an entry's preserved-verbatim `extra` lines. First well-formed
29
46
  * occurrence of each key wins (duplicate lines are model bookkeeping noise, not a merge input). */
30
47
  export declare function readV2HeaderHints(extra: readonly string[] | undefined): V2HeaderHints;
@@ -1,5 +1,11 @@
1
1
  const LINE_RE = /^\s*([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/;
2
2
  const ISO_DATE_PREFIX_RE = /^\d{4}-\d{2}-\d{2}(?:$|[T\s])/;
3
+ export function isInstructionEntry(fm) {
4
+ if (fm.type === "feedback")
5
+ return true;
6
+ const hints = readV2HeaderHints(fm.extra);
7
+ return hints.pinned !== undefined || hints.triggers !== undefined || hints.appliesWhen !== undefined;
8
+ }
3
9
  export function readV2HeaderHints(extra) {
4
10
  const out = {};
5
11
  if (extra === undefined)
@@ -4,9 +4,10 @@ export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_S
4
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
5
  export { computeBundleSectionHashes, computeMemoryBundleHash, memoryBundleInvalid, type MemoryExportBundle, type MemoryImportReport, type MemoryBundleImportPlan, type BundleChallengeRow, type BundleLineageRow, type BundlePollutedSession, } from "./export-bundle.js";
6
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";
7
- export { readV2HeaderHints, type V2HeaderHints } from "./header-hints.js";
8
- export { parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile, type ParsedEntryFile } from "./frontmatter.js";
9
- export type { MemoryBackend, MemoryEntry, MemoryEntryFrontmatter, MemoryEntryHeader, ScoredMemoryEntry, NotePatch, PatchReport, MaterializedFile, MemorySessionHandle, HarvestReport, HarvestRejection, HarvestRejectionCode, MemoryAnnouncement, ScanFinding, } from "./types.js";
7
+ export { readV2HeaderHints, isInstructionEntry, type V2HeaderHints } from "./header-hints.js";
8
+ export { parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile, committedOriginOf, originEquals, type ParsedEntryFile } from "./frontmatter.js";
9
+ export { MEMORY_ORIGIN_CAUSES } from "./types.js";
10
+ export type { MemoryBackend, MemoryEntry, MemoryEntryFrontmatter, MemoryEntryOrigin, MemoryOriginCause, MemoryEntryHeader, ScoredMemoryEntry, NotePatch, PatchReport, MaterializedFile, MemorySessionHandle, HarvestReport, HarvestRejection, HarvestRejectionCode, MemoryAnnouncement, ScanFinding, } from "./types.js";
10
11
  export { memoryBackendContract, assertMemoryBackendSearchEquivalence, type MemoryBackendContractHooks, } from "./memory-backend-contract.js";
11
12
  export { SCOPE_SEGMENT_MAX_ENCODED, PROJECT_MARKER_PATH, encodeScopeSegment, decodeScopeSegment, parseScopeKey, formatUserScope, formatOrgScope, formatProjScope, formatUserProjScope, isPersonalScope, assertScopeContractPlacement, formatProjectMarker, parseProjectMarker, resolveProjectId, PROJECT_ID_REGEX, type ParsedScopeKey, } from "./scope-contract.js";
12
13
  export { migrateScope, type MigrateScopeReport } from "./migrate.js";
@@ -4,8 +4,9 @@ export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_S
4
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH, erasureSelectHash, } from "./file-backend.js";
5
5
  export { computeBundleSectionHashes, computeMemoryBundleHash, memoryBundleInvalid, } from "./export-bundle.js";
6
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";
7
- export { readV2HeaderHints } from "./header-hints.js";
8
- export { parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile } from "./frontmatter.js";
7
+ export { readV2HeaderHints, isInstructionEntry } from "./header-hints.js";
8
+ export { parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile, committedOriginOf, originEquals } from "./frontmatter.js";
9
+ export { MEMORY_ORIGIN_CAUSES } from "./types.js";
9
10
  export { memoryBackendContract, assertMemoryBackendSearchEquivalence, } from "./memory-backend-contract.js";
10
11
  export { SCOPE_SEGMENT_MAX_ENCODED, PROJECT_MARKER_PATH, encodeScopeSegment, decodeScopeSegment, parseScopeKey, formatUserScope, formatOrgScope, formatProjScope, formatUserProjScope, isPersonalScope, assertScopeContractPlacement, formatProjectMarker, parseProjectMarker, resolveProjectId, PROJECT_ID_REGEX, } from "./scope-contract.js";
11
12
  export { migrateScope } from "./migrate.js";
@@ -1,4 +1,4 @@
1
- import type { MemoryAnnouncement } from "./types.js";
1
+ import { type MemoryAnnouncement } from "./types.js";
2
2
  /** Cursor sidecar for the design/84 Seam B pair on the FileBackend (`{ [scope]: cursor }`). B3: control plane. */
3
3
  export declare const CURSORS_FILE = "cursors.json";
4
4
  /** The control-plane subdir name under a key dir — ONE spelling for every derivation and for the
@@ -285,6 +285,11 @@ export declare const SESSION_POLLUTION_DIR = "session-pollution";
285
285
  export interface SessionPollutionRecord {
286
286
  at: number;
287
287
  reason: string;
288
+ /** design/336 §2.2 — the structured mechanical cause of the exposure (the value an origin marker
289
+ * minted off this record carries). ADDITIVE: records written before the field existed read as
290
+ * honestly cause-less (the mint then falls back to `"observed"`); old readers narrow-take the
291
+ * two fields they know. */
292
+ cause?: import("./types.js").MemoryOriginCause;
288
293
  }
289
294
  /**
290
295
  * Mark `sessionId` polluted (idempotent; the FIRST record wins — `wx` create refuses overwrite).
@@ -299,7 +304,7 @@ export interface SessionPollutionRecord {
299
304
  * stays polluted for this process either way).
300
305
  */
301
306
  export type SessionPollutionMarkOutcome = "created" | "existed" | "unpersisted";
302
- export declare function markSessionPolluted(controlDir: string, sessionId: string, reason: string, now: () => number): SessionPollutionMarkOutcome;
307
+ export declare function markSessionPolluted(controlDir: string, sessionId: string, reason: string, now: () => number, cause?: import("./types.js").MemoryOriginCause): SessionPollutionMarkOutcome;
303
308
  /** Read the durable pollution record for `sessionId` (undefined = no marker).
304
309
  * A marker that EXISTS still reads as polluted whether or not its bytes can be read or parsed
305
310
  * (fail-closed: neither corruption nor an unreadable node may launder the state) — a synthesized
@@ -360,6 +365,11 @@ export declare const CHALLENGED_HISTORY_FILE = "usage-challenged-history.json";
360
365
  export interface LineageContribution {
361
366
  lastRev: string;
362
367
  lastAt: number;
368
+ /** design/336 — carried from the staged row at promotion: the contribution's content (at
369
+ * `lastRev`) carries its external-origin marker, so the pollution retroaction sweeps owe it no
370
+ * challenge (the account travels with the entry). Absence = unmarked content — the sweeps'
371
+ * domain (older vintages read as unmarked, the conservative side). */
372
+ marked?: true;
363
373
  }
364
374
  /** One staged (pre-commit) row: an entry this transaction WOULD commit. `kind: "latch-only"` marks
365
375
  * a bundle-import SYNTHETIC latch row (design/178 v2-c §1): it withholds the id on the model-visible
@@ -370,6 +380,12 @@ export interface LineagePendingRow {
370
380
  entryId: string;
371
381
  rev: string;
372
382
  kind?: "latch-only";
383
+ /** design/336 — set ⇔ the staged content CARRIES its external-origin marker (`frontmatter.origin`
384
+ * in the very rev this row names): the exposure account travels WITH the entry, so promotion
385
+ * settlement owes it no pollution challenge (challenging it would re-quarantine a tag-admission
386
+ * at the read face). Absence = the row's content is unmarked (older vintages included) — the
387
+ * conservative side: an unmarked row of a marked session is challenged. */
388
+ marked?: true;
373
389
  }
374
390
  /** One staged transaction: rows land BEFORE `applyPatches`; the commit credential lands after it
375
391
  * succeeds; promotion consumes both. A pending txn without a credential is the crash window —
@@ -406,6 +422,10 @@ export interface LineagePromotion {
406
422
  entryId: string;
407
423
  sessionId: string;
408
424
  rev: string;
425
+ /** design/336 — carried through from {@link LineagePendingRow.marked}: the promoted content
426
+ * already carries its external-origin marker, so promotion settlement skips the pollution
427
+ * challenge for it. */
428
+ marked?: true;
409
429
  }
410
430
  /** Promote a CREDENTIALED pending transaction: rows named by the credential's applied set join the
411
431
  * committed contribution set (deduped by (entryId, sessionId) — a repeat pair refreshes
@@ -473,6 +493,8 @@ export declare function lineageLatchedIds(controlDir: string): Set<string>;
473
493
  export declare function lineageContributionsOfSession(controlDir: string, sessionId: string): Array<{
474
494
  entryId: string;
475
495
  lastRev: string;
496
+ lastAt: number;
497
+ marked?: true;
476
498
  }>;
477
499
  /** design/178 v2-a §1.3 — the BY-ENTRY lineage account, from ONE parse of the ledger (the
478
500
  * by-session read's transpose; the committed set is keyed by entryId, so the index is O(1) after
@@ -538,6 +560,7 @@ export declare function importLineageCommitted(controlDir: string, rows: Readonl
538
560
  sessionId: string;
539
561
  lastRev: string;
540
562
  lastAt: number;
563
+ marked?: true;
541
564
  }>): LineageImportDivergence[];
542
565
  /** Full-ledger read (tests / host observability). Throws on corruption. */
543
566
  export declare function readLineageRecord(controlDir: string): {
@@ -3,6 +3,7 @@ const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW, O_EXCL } = fsConstants;
3
3
  import { homedir } from "node:os";
4
4
  import { createHash } from "node:crypto";
5
5
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
6
+ import { MEMORY_ORIGIN_CAUSES } from "./types.js";
6
7
  const SCOPES_FILE = "scopes.json";
7
8
  export const CURSORS_FILE = "cursors.json";
8
9
  export const CONTROL_PLANE_DIR = ".engine";
@@ -734,11 +735,11 @@ export const SESSION_POLLUTION_DIR = "session-pollution";
734
735
  function pollutionPath(controlDir, sessionId) {
735
736
  return join(controlDir, SESSION_POLLUTION_DIR, `${encodeURIComponent(sessionId)}.json`);
736
737
  }
737
- export function markSessionPolluted(controlDir, sessionId, reason, now) {
738
+ export function markSessionPolluted(controlDir, sessionId, reason, now, cause) {
738
739
  const path = pollutionPath(controlDir, sessionId);
739
740
  try {
740
741
  ensureDirExists(dirname(path));
741
- const record = { at: now(), reason };
742
+ const record = { at: now(), reason, ...(cause !== undefined ? { cause } : {}) };
742
743
  writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
743
744
  return "created";
744
745
  }
@@ -764,8 +765,10 @@ export function readSessionPollution(controlDir, sessionId) {
764
765
  if (parsed !== null && typeof parsed === "object") {
765
766
  const at = parsed.at;
766
767
  const reason = parsed.reason;
768
+ const cause = parsed.cause;
769
+ const knownCause = typeof cause === "string" && MEMORY_ORIGIN_CAUSES.includes(cause) ? cause : undefined;
767
770
  if (typeof at === "number" && typeof reason === "string")
768
- return { at, reason };
771
+ return { at, reason, ...(knownCause !== undefined ? { cause: knownCause } : {}) };
769
772
  }
770
773
  }
771
774
  catch {
@@ -804,7 +807,7 @@ export function importSessionPollution(controlDir, sessionId, record) {
804
807
  const path = pollutionPath(controlDir, sessionId);
805
808
  ensureDirExists(dirname(path));
806
809
  try {
807
- writeFileSync(path, `${JSON.stringify({ at: record.at, reason: record.reason }, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
810
+ writeFileSync(path, `${JSON.stringify({ at: record.at, reason: record.reason, ...(record.cause !== undefined ? { cause: record.cause } : {}) }, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
808
811
  return "written";
809
812
  }
810
813
  catch (err) {
@@ -976,6 +979,8 @@ function coerceLineage(raw) {
976
979
  if (!row || typeof row !== "object" || typeof row.lastRev !== "string" || typeof row.lastAt !== "number") {
977
980
  throw badShape(`committed[${JSON.stringify(entryId)}][${JSON.stringify(sid)}]`);
978
981
  }
982
+ if (row.marked !== undefined && row.marked !== true)
983
+ throw badShape(`committed[${JSON.stringify(entryId)}][${JSON.stringify(sid)}] marked`);
979
984
  }
980
985
  }
981
986
  for (const [txnId, t] of Object.entries(r.pending)) {
@@ -991,6 +996,8 @@ function coerceLineage(raw) {
991
996
  throw badShape(`pending[${JSON.stringify(txnId)}] row`);
992
997
  if (p.kind !== undefined && p.kind !== "latch-only")
993
998
  throw badShape(`pending[${JSON.stringify(txnId)}] row kind`);
999
+ if (p.marked !== undefined && p.marked !== true)
1000
+ throw badShape(`pending[${JSON.stringify(txnId)}] row marked`);
994
1001
  }
995
1002
  if (txn.credential !== undefined) {
996
1003
  const c = txn.credential;
@@ -1049,8 +1056,8 @@ export function promoteLineagePending(controlDir, txnId, now) {
1049
1056
  if (!applied.has(row.entryId))
1050
1057
  continue;
1051
1058
  const sessions = (rec.committed[row.entryId] ??= Object.create(null));
1052
- sessions[txn.sessionId] = { lastRev: row.rev, lastAt: at };
1053
- promoted.push({ entryId: row.entryId, sessionId: txn.sessionId, rev: row.rev });
1059
+ sessions[txn.sessionId] = { lastRev: row.rev, lastAt: at, ...(row.marked === true ? { marked: true } : {}) };
1060
+ promoted.push({ entryId: row.entryId, sessionId: txn.sessionId, rev: row.rev, ...(row.marked === true ? { marked: true } : {}) });
1054
1061
  }
1055
1062
  delete rec.pending[txnId];
1056
1063
  return { next: rec, result: promoted };
@@ -1075,8 +1082,8 @@ export function adjudicateLineagePending(controlDir, txnId, action, now) {
1075
1082
  if (applied !== undefined && !applied.has(row.entryId))
1076
1083
  continue;
1077
1084
  const sessions = (rec.committed[row.entryId] ??= Object.create(null));
1078
- sessions[txn.sessionId] = { lastRev: row.rev, lastAt: at };
1079
- promoted.push({ entryId: row.entryId, sessionId: txn.sessionId, rev: row.rev });
1085
+ sessions[txn.sessionId] = { lastRev: row.rev, lastAt: at, ...(row.marked === true ? { marked: true } : {}) };
1086
+ promoted.push({ entryId: row.entryId, sessionId: txn.sessionId, rev: row.rev, ...(row.marked === true ? { marked: true } : {}) });
1080
1087
  }
1081
1088
  delete rec.pending[txnId];
1082
1089
  return { next: rec, result: promoted };
@@ -1099,8 +1106,8 @@ export function reconcileLineage(controlDir, now) {
1099
1106
  continue;
1100
1107
  at ??= now();
1101
1108
  const sessions = (rec.committed[row.entryId] ??= Object.create(null));
1102
- sessions[txn.sessionId] = { lastRev: row.rev, lastAt: at };
1103
- promoted.push({ entryId: row.entryId, sessionId: txn.sessionId, rev: row.rev });
1109
+ sessions[txn.sessionId] = { lastRev: row.rev, lastAt: at, ...(row.marked === true ? { marked: true } : {}) };
1110
+ promoted.push({ entryId: row.entryId, sessionId: txn.sessionId, rev: row.rev, ...(row.marked === true ? { marked: true } : {}) });
1104
1111
  }
1105
1112
  delete rec.pending[txnId];
1106
1113
  changed = true;
@@ -1182,7 +1189,7 @@ export function lineageContributionsOfSession(controlDir, sessionId) {
1182
1189
  for (const [entryId, sessions] of Object.entries(rec.committed)) {
1183
1190
  const c = Object.prototype.hasOwnProperty.call(sessions, sessionId) ? sessions[sessionId] : undefined;
1184
1191
  if (c !== undefined)
1185
- out.push({ entryId, lastRev: c.lastRev });
1192
+ out.push({ entryId, lastRev: c.lastRev, lastAt: c.lastAt, ...(c.marked === true ? { marked: true } : {}) });
1186
1193
  }
1187
1194
  return out;
1188
1195
  }
@@ -1262,12 +1269,18 @@ export function importLineageCommitted(controlDir, rows) {
1262
1269
  const sessions = (rec.committed[row.entryId] ??= Object.create(null));
1263
1270
  const standing = Object.prototype.hasOwnProperty.call(sessions, row.sessionId) ? sessions[row.sessionId] : undefined;
1264
1271
  if (standing === undefined || row.lastAt > standing.lastAt) {
1265
- sessions[row.sessionId] = { lastRev: row.lastRev, lastAt: row.lastAt };
1272
+ sessions[row.sessionId] = { lastRev: row.lastRev, lastAt: row.lastAt, ...(row.marked === true ? { marked: true } : {}) };
1266
1273
  changed = true;
1267
1274
  continue;
1268
1275
  }
1269
1276
  if (row.lastAt === standing.lastAt && row.lastRev !== standing.lastRev) {
1270
1277
  divergence.push({ entryId: row.entryId, sessionId: row.sessionId, kept: "destination" });
1278
+ continue;
1279
+ }
1280
+ if (row.lastAt === standing.lastAt && row.lastRev === standing.lastRev && row.marked === true && standing.marked !== true) {
1281
+ sessions[row.sessionId] = { ...standing, marked: true };
1282
+ changed = true;
1283
+ continue;
1271
1284
  }
1272
1285
  }
1273
1286
  return { ...(changed ? { next: rec } : {}), result: divergence };
@@ -243,6 +243,71 @@ export async function memoryBackendContract(hooks) {
243
243
  assert.strictEqual(after.frontmatter.trust, "untrusted");
244
244
  assert.strictEqual(after.rev, next.rev, "provenance participates in the rev (computeEntryRev closure)");
245
245
  });
246
+ defer("design/336: origin marker round-trips; strip/rewrite refused across update, re-add, guard add and same-batch delete+re-add; committed tombstone is the legal exit", async () => {
247
+ const b = await hooks.make();
248
+ const origin = { taint: "external", cause: "observed", at: 1700000000500 };
249
+ const base = {
250
+ id: "id-origin-001",
251
+ scope: "s1",
252
+ slug: "marked-note",
253
+ frontmatter: { name: "marked-note", description: "written by an exposed session", origin },
254
+ body: "marked body v1",
255
+ rev: "",
256
+ };
257
+ base.rev = computeEntryRev(base);
258
+ const repAdd = await b.applyPatches([{ op: "add", id: base.id, entry: base }]);
259
+ assert.deepStrictEqual(repAdd.conflicts, []);
260
+ const stored = (await b.getByIds([base.id]))[0];
261
+ assert.deepStrictEqual(stored.frontmatter.origin, origin, "origin must survive storage (round-trip)");
262
+ assert.strictEqual(stored.rev, base.rev, "origin participates in the rev (computeEntryRev closure)");
263
+ const mkStripped = () => {
264
+ const e = { ...base, frontmatter: { name: "marked-note", description: "written by an exposed session" }, rev: "" };
265
+ e.rev = computeEntryRev(e);
266
+ return e;
267
+ };
268
+ const rep1 = await b.applyPatches([{ op: "update", id: base.id, entry: mkStripped(), baseRev: base.rev }]);
269
+ assert.strictEqual(rep1.applied.length, 0, "an origin-stripping update must not apply");
270
+ assert.match(rep1.conflicts[0]?.reason ?? "", /malformed patch refused/);
271
+ const rewritten = { ...base, frontmatter: { ...base.frontmatter, origin: { taint: "external", cause: "static", at: 1 } }, rev: "" };
272
+ rewritten.rev = computeEntryRev(rewritten);
273
+ const rep2 = await b.applyPatches([{ op: "update", id: base.id, entry: rewritten, baseRev: base.rev }]);
274
+ assert.strictEqual(rep2.applied.length, 0, "an origin-rewriting update must not apply");
275
+ assert.match(rep2.conflicts[0]?.reason ?? "", /malformed patch refused/);
276
+ const rep3 = await b.applyPatches([{ op: "add", id: base.id, entry: mkStripped() }]);
277
+ assert.strictEqual(rep3.applied.length, 0, "an origin-stripping re-add must not apply");
278
+ assert.match(rep3.conflicts[0]?.reason ?? "", /malformed patch refused/);
279
+ const rep4 = await b.applyPatches([{ op: "add", id: base.id, entry: mkStripped(), guard: "absent" }]);
280
+ assert.strictEqual(rep4.applied.length, 0, "an origin-stripping guard add must not apply");
281
+ assert.match(rep4.conflicts[0]?.reason ?? "", /malformed patch refused/);
282
+ const rep5 = await b.applyPatches([
283
+ { op: "delete", id: base.id, baseRev: base.rev },
284
+ { op: "add", id: base.id, entry: mkStripped() },
285
+ ]);
286
+ const readd = rep5.conflicts.find((c) => c.op === "add");
287
+ assert.ok(readd !== undefined, "the same-batch unmarked re-add must be refused");
288
+ assert.match(readd.reason, /malformed patch refused/);
289
+ const survivors = await b.getByIds([base.id]);
290
+ if (survivors.length > 0) {
291
+ assert.deepStrictEqual(survivors[0].frontmatter.origin, origin, "a surviving entry keeps its marker verbatim");
292
+ }
293
+ if (survivors.length > 0) {
294
+ const repDel = await b.applyPatches([{ op: "delete", id: base.id }]);
295
+ assert.deepStrictEqual(repDel.conflicts.filter((c) => c.op === "delete"), [], "the tombstone itself applies");
296
+ }
297
+ const fresh = mkStripped();
298
+ const rep6 = await b.applyPatches([{ op: "add", id: base.id, entry: fresh }]);
299
+ assert.deepStrictEqual(rep6.conflicts, [], "after a committed tombstone, the id starts an unmarked life");
300
+ assert.strictEqual((await b.getByIds([base.id]))[0]?.frontmatter.origin, undefined);
301
+ const b2 = await hooks.make();
302
+ const m0 = { ...base, id: "id-origin-002", rev: "" };
303
+ m0.rev = computeEntryRev(m0);
304
+ await b2.applyPatches([{ op: "add", id: m0.id, entry: m0 }]);
305
+ const m1 = { ...m0, body: "marked body v2", rev: "" };
306
+ m1.rev = computeEntryRev(m1);
307
+ const rep7 = await b2.applyPatches([{ op: "update", id: m0.id, entry: m1, baseRev: m0.rev }]);
308
+ assert.deepStrictEqual(rep7.conflicts, []);
309
+ assert.deepStrictEqual((await b2.getByIds([m0.id]))[0]?.frontmatter.origin, origin, "the conforming update carries the marker forward verbatim");
310
+ });
246
311
  defer("projection authority: getByIds carries the OWNING scope; other scopes never list the entry", async () => {
247
312
  const b = await hooks.make();
248
313
  const e = entry("id-auth-0001", "s1", "authored", "authored body", { name: "Authored" });
@@ -1,5 +1,5 @@
1
1
  import { type MemorySyncCursor } from "./sync.js";
2
- import type { MemoryBackend, MemoryEntry, PatchReport } from "./types.js";
2
+ import { type MemoryBackend, type MemoryEntry, type PatchReport } from "./types.js";
3
3
  /** The injected HTTP seam — core never bundles a fetch. The deployment maps `path` (e.g.
4
4
  * `/v1/memory/sync/user%3Aalice`) onto its server base URL, attaches auth, and returns the
5
5
  * DECODED JSON body of a 2xx response; any non-2xx / network failure should THROW (the round then
@@ -1,8 +1,9 @@
1
- import { computeEntryRev, serializeEntryFile } from "./frontmatter.js";
1
+ import { ambiguousOriginRepresentation, computeEntryRev, serializeEntryFile } from "./frontmatter.js";
2
2
  import { screenInboundEntries } from "./data-plane.js";
3
3
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
4
4
  import { MAX_MEMORY_BYTES } from "../memory.js";
5
5
  import { reconcileMemoryEntries } from "./sync.js";
6
+ import { MEMORY_ORIGIN_CAUSES } from "./types.js";
6
7
  function memorySyncPath(scope) {
7
8
  return `/v1/memory/sync/${encodeURIComponent(scope)}`;
8
9
  }
@@ -79,6 +80,28 @@ function parseMemorySyncResponse(raw, scope, peer) {
79
80
  !Number.isFinite(provWire["ingestedAt"]))) {
80
81
  fail('entry.frontmatter.provenance must be { kind: "repo_file", path, contentHash, ingestedAt } when present');
81
82
  }
83
+ const originWire = fm["origin"];
84
+ if (originWire !== undefined &&
85
+ (!isRecord(originWire) ||
86
+ originWire["taint"] !== "external" ||
87
+ typeof originWire["at"] !== "number" ||
88
+ !Number.isFinite(originWire["at"]) ||
89
+ (originWire["cause"] !== undefined && !(typeof originWire["cause"] === "string" && MEMORY_ORIGIN_CAUSES.includes(originWire["cause"]))))) {
90
+ fail('entry.frontmatter.origin must be { taint: "external", cause?, at } with a known cause when present');
91
+ }
92
+ const extraRaw = fm["extra"];
93
+ if (Array.isArray(extraRaw) && extraRaw.every((s) => typeof s === "string")) {
94
+ const pickedOrigin = originWire !== undefined
95
+ ? {
96
+ taint: "external",
97
+ ...(originWire["cause"] !== undefined ? { cause: originWire["cause"] } : {}),
98
+ at: originWire["at"],
99
+ }
100
+ : undefined;
101
+ if (ambiguousOriginRepresentation({ ...(pickedOrigin !== undefined ? { origin: pickedOrigin } : {}), extra: fm["extra"] })) {
102
+ fail("entry.frontmatter carries conflicting or duplicated origin representations (ambiguous marker representation refused)");
103
+ }
104
+ }
82
105
  serverEntries.push({
83
106
  id: e["id"],
84
107
  slug: e["slug"],
@@ -101,6 +124,15 @@ function parseMemorySyncResponse(raw, scope, peer) {
101
124
  }
102
125
  : {}),
103
126
  ...(fm["trust"] !== undefined ? { trust: "untrusted" } : {}),
127
+ ...(originWire !== undefined
128
+ ? {
129
+ origin: {
130
+ taint: "external",
131
+ ...(originWire["cause"] !== undefined ? { cause: originWire["cause"] } : {}),
132
+ at: originWire["at"],
133
+ },
134
+ }
135
+ : {}),
104
136
  ...(fm["extra"] !== undefined ? { extra: [...fm["extra"]] } : {}),
105
137
  },
106
138
  });
@@ -78,6 +78,13 @@ export interface MemoryEngineToolsOptions {
78
78
  sessionPollution?: () => {
79
79
  reason: string;
80
80
  } | undefined;
81
+ /**
82
+ * design/336 §13-3 — the provenance mode the write engine runs under, so the pollution sentence
83
+ * states what the mark actually DOES: under `"carry"` writes commit with an origin marker (the
84
+ * pre-336 "not admitted" sentence would be false); absent ≡ `"off"` keeps that sentence
85
+ * byte-identical. Pure display input — nothing else branches on it here.
86
+ */
87
+ provenance?: "off" | "carry";
81
88
  }
82
89
  export interface MemorySearchHit {
83
90
  id: string;
@@ -101,6 +101,9 @@ export function createMemoryEngineTools(opts) {
101
101
  }
102
102
  if (reason === undefined)
103
103
  return "";
104
+ if (opts.provenance === "carry") {
105
+ return ` Note: this session read external content, so memory entries it writes are saved with an external-origin marker; instruction-style entries (type: feedback, or pinned/triggers/applies-when lines) are withheld for host review instead.`;
106
+ }
104
107
  return ` This session is marked polluted (${inlineUntrusted(reason, 200)}): the memory it writes is not admitted to the library at harvest, so writing this down now would not make it retrievable later either.`;
105
108
  };
106
109
  const noMatch = (query) => {