@sema-agent/core 5.43.0 → 5.45.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 CHANGED
@@ -1,5 +1,104 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.45.0 — 2026-08-19
4
+
5
+ ### Added
6
+ - `RunnerDeps.memoryDelegationEvidence?: "static-face" | "attested-only"` (design/324, ruling-1
7
+ stopgap): the deployment's evidence standard for delegation-driven memory pollution.
8
+ `"attested-only"` retires ONLY the static capability-face mark (the "a child COULD reach
9
+ external content" over-approximation); the attestation-observed mark (`att === "external"`),
10
+ the chain's `incomplete` honesty, `recordExternal`, non-delegation classification and the
11
+ pre-call direct-class mark all stay. Key absent ≡ `"static-face"` ≡ byte-identical behavior.
12
+ A malformed value is refused loudly (`config.memory_delegation_evidence`, exact-spelling gate).
13
+ ACCEPTED COST (the deployment's to own): with `"attested-only"`, a BACKGROUND child's real
14
+ external contact does not mark this session — its content re-enters via TaskOutput /
15
+ task-notification / AgentTranscript, none of which carries an attestation.
16
+ - `memory.delegation_static_mark_waived` EngineNotice — mandatory companion: minted at most once
17
+ per prepared leg when the knob actually waives a static-face mark on a pollution-armed chain
18
+ (`detail: {subagentType?, sessionId?}`; reason carries the waived mark's own sentence, bounded).
19
+ - `TaskResult.effectiveReasoning?: ResolvedReasoning` (#327b): the leg's effective reasoning
20
+ resolution echoed on the result — same object the `reasoning.resolved` trace event carries
21
+ (two faces, one mint); rides the backstop/`resume.tool_unavailable` carrier like its
22
+ precedents. `ReasoningFormat` gains `"responses"` (dispatched by api family, single-source
23
+ `RESPONSES_APIS`).
24
+ - Cross-session recall pins (#260 verdict: engine clean): the consumer contract is pinned —
25
+ the model's final answer text exists BEFORE the terminal harvest commits, `TaskStream.result()`
26
+ is the commit boundary, and a consumer that prints the answer and kills the worker without
27
+ awaiting `result()` strands the entry id-less on disk (the incident's exact evidence shape).
28
+
29
+ ### Changed
30
+ - The reasoning resolver mirrors `thinkingLevelMap`'s null arm (5.45 rescan): a clamped tier the
31
+ model maps to `null` is reported tier-not-honored (`graded:false`, intent echo) — matching what
32
+ the wire appliers actually send (nothing) instead of claiming "honored exactly"; the r3/r4
33
+ mirror comment now names all three arms. `resolveEffort` reads a malformed non-array
34
+ `reasoningEffortLevels` as undeclared (member-level filtering: garbage/sparse/"off" entries can
35
+ no longer become clamp targets) — a catalog typo no longer fails the whole thinking-on leg with
36
+ a raw TypeError (5.44.0 regression window closed before release).
37
+ - The #260 pins tell the bounded-teardown truth: `result()` settles after the harvest LEG settled
38
+ (committed, or its 15s timeout REPORTED — never silent), the discriminative arm polls 50 ticks
39
+ (the abandon arm sat inside the old 1-tick slack), and the incident-scale argument is stated.
40
+ - `reasoning.resolved` now fires a LOUD DROP frame (`dropped: true`, `effective: "off"`) on
41
+ models whose `Model.reasoning` is falsy — a user-requested effort tier no longer evaporates
42
+ silently (#325). The public `resolveReasoning` reports the drop arm for such models; the
43
+ capable arm is byte-identical. The capability predicate is truthiness (matching every adapter),
44
+ and `supportsReasoningEffort === false` mirrors the ungraded intent-only arm.
45
+
46
+ ### Fixed
47
+ - The two 5.44.0 pollution notices are DISPOSITIONED in the governance registry and the
48
+ error-surface cap vocabulary gained `inlineUntrusted`/`pollutionSentence` (both bounded by
49
+ contract) — landed as gate-family debts caught by the full-suite leg.
50
+
51
+ ### Notes
52
+ - Commit `2f78edb7` (the delegation-evidence knob) carries the `TaskResult.effectiveReasoning`
53
+ declaration hunk of the parallel effort car (shared-checkout staging race; HEAD is consistent).
54
+ Do not cherry-pick that commit alone.
55
+
56
+ ## 5.44.0 — 2026-08-19
57
+
58
+ ### Added
59
+ - Memory pollution is LOUD (#324a): two EngineNotice codes — `memory.session_polluted` (once per
60
+ session, minted on the wx-atomic first transition; states the VERDICT: content written after the
61
+ mark is not admitted to the long-term library at harvest) and `memory.harvest_quarantined`
62
+ (at most once per harvest when a polluted session's harvest withheld ≥1 entry file;
63
+ `detail: {count, moved, escalated, reason?, sessionId?}` — deduplicated-path counts, `moved` =
64
+ files that actually left the model-visible plane, `escalated` = quarantine failures). Both ride
65
+ `deliverEngineNotice` (seat / absent-warn / malformed-seat fallback, unchanged). The notices
66
+ state facts, not promises: writeScope-null and persistence-incapable arms take the zero-adoption
67
+ return and are NOT described as quarantined.
68
+ - `MemoryEngine.markSessionPolluted` now returns `SessionPollutionMarkOutcome` (was `void`) —
69
+ additive; the wx-atomic marker distinguishes `"created" | "existed" | "unpersisted"`.
70
+ - Release-criteria gate hardening (5.44 rescan disposal, 8 findings): the EngineNotice MINT
71
+ judgment is checker-based (the `code` value inside an object literal whose non-nullable
72
+ contextual type carries `code`+`message` on EVERY union arm, in files the shipped build
73
+ compiles) — a literal type, the `err.code=` refusal dialect, a comment, an examples-only mint
74
+ and a bare `{code,message}` const no longer count; a public CLASS is a legal citation root
75
+ (instance + declared statics, minus private/write-only/language-supplied); a disclosed release
76
+ (CHANGELOG section present) with no criteria document reds the gate.
77
+ - design/278 recipe pins: the coordinator fresh-spawn recipe core arms (spawn-fresh imperative,
78
+ verbatim user quote, literal command, execute-only, relayed-consent line) are pinned six-ways —
79
+ the recipe the G6 classifier exception depends on can no longer drift silently. (The #278
80
+ product fix itself shipped in 5.38.0; the ledger row was stale and is now closed.)
81
+
82
+ ### Changed
83
+ - Grep target-spelling flip completion (#318): `jsGrepDetailed` answers — including the rescan
84
+ legs an rg-present host falls back to — carry the single explicit FILE target's own spelling
85
+ (family-aware dot-segment collapse, one family judgment for the whole anchor; `..` cannot climb
86
+ past a namespace root). The two engines no longer disagree on path shape for the same call.
87
+ - Grep structured rows completion (#313 remainder): the JS legs hand served rows too; the served-row
88
+ contract states its one measured exception.
89
+ - `memory_get` not-found no longer blindly suggests `memory_search`; when the missing slug was
90
+ written THIS session the text states the mount-snapshot fact (new writes become retrievable
91
+ next session) and, in a polluted session, states that the write will be withheld at harvest.
92
+ `memory_search` zero-hit output appends the pollution disclosure line in polluted sessions only
93
+ (clean sessions byte-identical). Tool contract revisions move: search 1→2, get 2→3.
94
+ - The 5.43.0 `-H` entry erratum (recorded in that section): the flip covers count mode too, and
95
+ the "without a deny judge" arm is not a configuration any public route produces.
96
+
97
+ ### Fixed
98
+ - `docs/release-criteria/5.43.0.json` four unexecutable/false arms repaired (deny-less arm no
99
+ public route can produce; the deny-note flip F5 declared and claimed by G3; the per-process
100
+ console latch construction made blackbox-executable; the rg-leg note wording quoted as minted).
101
+
3
102
  ## 5.43.0 — 2026-08-18
4
103
 
5
104
  ### Added
@@ -27,12 +126,15 @@
27
126
  - Grep's ripgrep legs always pass `-H` (#311): a single explicit file target keeps its path
28
127
  field, so a deny-guarded single-file grep keeps the rg fast path instead of abandoning rg for
29
128
  the JS scanner's explicit-file lane (an engine swap over that one file, losing rg's
30
- streaming/large-file handling). Observable flip, both arms: content-mode output for a single
31
- explicit file target now carries a path prefix in the TARGET's spelling as passed (the tool
32
- layer hands an absolute canonical path, so the prefix is ABSOLUTE); without a deny judge the
33
- line previously had no prefix, and WITH one it previously came from the JS lane with a
34
- root-relative prefix both spellings change. Multi-file and directory targets: byte-identical
35
- (measured). The two engines still disagree on path shape for the same call (jsGrep emits
129
+ streaming/large-file handling). Observable flip: content-mode AND count-mode output for a
130
+ single explicit file target now carries a path prefix in the TARGET's spelling as passed (the
131
+ tool layer hands an absolute canonical path, so the prefix is ABSOLUTE; count mode reads
132
+ `<path>:<n>`); `files_with_matches` did not move. **Erratum (5.44 rescan)**: this entry
133
+ originally scoped the flip to content mode and described a "without a deny judge" arm — no
134
+ public route produces that arm (every route to the tool compiles a read-deny judge), and the
135
+ previous release answered the call from the JS lane with a ROOT-RELATIVE prefix in every deny
136
+ configuration a caller can produce; count mode flipped the same way. Multi-file and directory
137
+ targets: byte-identical (measured). The two engines still disagree on path shape for the same call (jsGrep emits
36
138
  root-relative) — that alignment is tracked separately (#318), not silently changed here.
37
139
  - The deny-withholding note no longer fabricates on single-file scopes (5.43 rescan): `rg --files`
38
140
  lists an explicitly named file argument regardless of glob filters, so the existence probe
@@ -297,7 +297,9 @@ export declare function completedAgentCard(child: {
297
297
  * recorder aggregate at delivery. Present only when the parent chain armed the recorder; the
298
298
  * parent-side judgment (the content-origin wrap) reads it off this card — `clean` retires the
299
299
  * static face verdict for this call, `external` marks with the observed-event reason, `unknown`
300
- * (or absence) keeps the static floor. Deliberate whitelist admission (see the boundary note). */
300
+ * (or absence) keeps the static floor, whose mark action follows the parent run's deployment
301
+ * evidence standard (design/324, `RunnerDeps.memoryDelegationEvidence`). Deliberate whitelist
302
+ * admission (see the boundary note). */
301
303
  attestation?: import("../core/memory-engine/delegation-provenance.js").DelegationAttestation;
302
304
  }): Record<string, unknown>;
303
305
  /**
@@ -34,7 +34,20 @@ export type ReasoningFormat = "openai" | "openrouter" | "deepseek" | "together"
34
34
  /** Anthropic `output_config.effort` (CC 2.1.198 / Fable-5 class, design/119): the tier is a named
35
35
  * effort string, clamped to `compat.effortLevels` — the anthropic-side twin of the OpenAI
36
36
  * `reasoning_effort` path (thinking FORM is `thinkingMode`; effort carries the intensity). */
37
- | "effort";
37
+ | "effort"
38
+ /** OpenAI Responses API (`openai-responses` / `azure-openai-responses`, design/163): the tier rides
39
+ * the NESTED `reasoning: { effort }` request field (open-responses.ts `resolveWireEffort`), clamped
40
+ * by the same {@link resolveEffort} against `compat.reasoningEffortLevels` — NOT the completions
41
+ * top-level `reasoning_effort` the `"openai"` tag names. #325 review r3: before this member the
42
+ * resolver fell through to the completions default and labeled Responses resolutions `"openai"`. */
43
+ | "responses";
44
+ /**
45
+ * The Open Responses API family — every `Api` value `createOpenResponsesBrain` serves (design/163).
46
+ * The SINGLE family discriminant: request-params.ts (reserved-key set selection) and
47
+ * {@link resolveReasoning} (format dispatch, #325 review r4) both key off THIS set, so a new
48
+ * Responses api value added here reaches every family-scoped behavior instead of one of two twins.
49
+ */
50
+ export declare const RESPONSES_APIS: ReadonlySet<string>;
38
51
  /** How a requested intensity resolved against one endpoint's real capability. */
39
52
  export interface ReasoningResolution {
40
53
  /** The level the caller asked for. */
@@ -43,9 +56,10 @@ export interface ReasoningResolution {
43
56
  effective: ThinkingLevel;
44
57
  /**
45
58
  * True when the endpoint varies behavior by intensity (effort tiers / token budget). False = binary
46
- * enable-only (qwen / zai / qwen-chat-template, or an effort endpoint that declares
47
- * `supportsReasoningEffort:false`): thinking is on or off and the tier is NOT honored read this alongside
48
- * {@link effective}, which then echoes the request as intent only.
59
+ * enable-only (qwen / zai / qwen-chat-template, an effort endpoint that declares
60
+ * `supportsReasoningEffort:false`, or a tier `Model.thinkingLevelMap` maps to `null`the wire then
61
+ * carries NO effort value for it): thinking is on or off and the tier is NOT honored — read this
62
+ * alongside {@link effective}, which then echoes the request as intent only.
49
63
  */
50
64
  graded: boolean;
51
65
  /** True when {@link effective} differs from {@link requested} (the request couldn't be honored exactly). */
@@ -62,6 +76,23 @@ export interface ResolvedReasoning extends ReasoningResolution {
62
76
  format: ReasoningFormat;
63
77
  /** A coarse endpoint label for the trace (`model.api` — e.g. `openai-completions`, `anthropic-messages`). */
64
78
  endpoint: string;
79
+ /**
80
+ * Present (true) only when the model declares NO reasoning capability (`Model.reasoning` falsy): every
81
+ * brain early-returns on that flag (openai.ts applyThinking / anthropic.ts / open-responses.ts
82
+ * resolveWireEffort), so NO thinking
83
+ * parameter reaches the wire at all — the requested tier is DROPPED entirely, not clamped or
84
+ * downgraded-to-binary. `effective:"off"` here states the ENGINE side of that fact (nothing was
85
+ * requested), NOT a measured gateway state: on the binary enable-only formats (qwen / zai /
86
+ * qwen-chat-template) parameter ABSENCE is provider-default, and a default-on gateway may still
87
+ * reason at its own tier — the brains deliberately send no disable key for a model declared
88
+ * non-reasoning, so the engine cannot claim a measured off there; `format` tells a consumer which
89
+ * family applies (on every other format, absence is the closest the wire has to off — see
90
+ * applyThinking's own contract). `format`/`endpoint` report the family that WOULD have carried the
91
+ * tier (the why-shape a consumer diagnoses with, not a claim about the request). Absent on every
92
+ * reasoning-capable resolution, so existing consumers see byte-identical objects. Loud-bad-values
93
+ * rule: the one arm where the request evaporates must not be the one arm with no report.
94
+ */
95
+ dropped?: true;
65
96
  }
66
97
  /**
67
98
  * Resolve a requested intensity against a model END-TO-END, dispatching on its wire format the SAME way the
@@ -74,6 +105,17 @@ export interface ResolvedReasoning extends ReasoningResolution {
74
105
  * - Binary enable-only formats (qwen / zai / qwen-chat-template) → `graded:false` (tier not honored).
75
106
  * - An effort endpoint with `supportsReasoningEffort:false` → `graded:false` (enable key only, no effort tier).
76
107
  * - Otherwise effort-based → clamp DOWN to the endpoint's `reasoningEffortLevels` (default minimal|low|medium|high).
108
+ * - A CLAMPED tier that `model.thinkingLevelMap` maps to `null` (declared unsupported on this model) →
109
+ * `graded:false`: both effort-wire appliers key the map by the post-clamp tier and then carry NO effort
110
+ * value (thinking still enables via the format's own enable key where one exists), so the tier is not
111
+ * honored. Previously this resolver never read the map and reported such a request as exactly honored
112
+ * (`graded:true`, `clamped:false`) while the wire dropped the value — trace/result-face drift.
113
+ *
114
+ * - A model whose `reasoning` capability flag is FALSY drops the request ENTIRELY (neither brain emits any
115
+ * thinking parameter, whatever the format) → `effective:"off"`, `graded:false`, `clamped:true`,
116
+ * `dropped:true` — the loud-drop arm. Previously this resolver described the capability dispatch for such
117
+ * a model (a resolution the request never carried), and the runner's trace guard skipped the frame — the
118
+ * one arm where the request evaporates was the one arm with no disclosure.
77
119
  *
78
120
  * `off`/falsy never enables thinking, so it resolves trivially (no clamp, graded:true) — the caller decides
79
121
  * whether to emit at all.
@@ -82,12 +124,16 @@ export declare function resolveReasoning(requested: ThinkingLevel, model: {
82
124
  api?: string;
83
125
  reasoning?: boolean;
84
126
  compat?: unknown;
127
+ thinkingLevelMap?: Readonly<Partial<Record<ThinkingLevel, string | null>>>;
85
128
  }): ResolvedReasoning;
86
129
  /**
87
130
  * Resolve a requested intensity for an effort-based endpoint (`reasoning_effort` / `reasoning.effort`). Picks
88
131
  * the requested tier when supported; otherwise the highest supported tier ≤ requested (clamp DOWN, never
89
132
  * silently up); if none is ≤ requested (the endpoint's set is entirely higher — unusual), the lowest supported
90
133
  * tier. Never returns a value outside `allowed`, so the brain can't emit a tier the endpoint would 422.
134
+ * A malformed `allowed` reads as UNDECLARED → the conservative default set (non-array, empty, or no
135
+ * valid member); individually invalid MEMBERS (garbage strings, sparse holes, `"off"` — not a legal
136
+ * member of the 6-tier compat effort sets) are dropped, so they can never become a clamp target.
91
137
  */
92
138
  export declare function resolveEffort(requested: ThinkingLevel, allowed?: readonly ThinkingLevel[]): ReasoningResolution;
93
139
  /**
@@ -15,7 +15,18 @@ export function rankOf(level) {
15
15
  }
16
16
  export const DEFAULT_EFFORT_LEVELS = ["minimal", "low", "medium", "high"];
17
17
  const BINARY_FORMATS = new Set(["qwen", "zai", "qwen-chat-template"]);
18
+ export const RESPONSES_APIS = new Set(["openai-responses", "azure-openai-responses", "openai-chatgpt-responses"]);
18
19
  export function resolveReasoning(requested, model) {
20
+ const resolved = dispatchReasoning(requested, model);
21
+ if (requested !== "off" && !model.reasoning) {
22
+ return { requested, effective: "off", graded: false, clamped: true, format: resolved.format, endpoint: resolved.endpoint, dropped: true };
23
+ }
24
+ return resolved;
25
+ }
26
+ function effortTierUnmapped(requested, effective, model) {
27
+ return requested !== "off" && model.thinkingLevelMap?.[effective] === null;
28
+ }
29
+ function dispatchReasoning(requested, model) {
19
30
  const endpoint = model.api ?? "unknown";
20
31
  const compat = (model.compat ?? {});
21
32
  if (model.api === "anthropic-messages") {
@@ -24,6 +35,16 @@ export function resolveReasoning(requested, model) {
24
35
  }
25
36
  return { requested, effective: requested, graded: true, clamped: false, format: "budget", endpoint };
26
37
  }
38
+ if (model.api !== undefined && RESPONSES_APIS.has(model.api)) {
39
+ if (compat.supportsReasoningEffort === false) {
40
+ return { requested, effective: requested, graded: false, clamped: false, format: "responses", endpoint };
41
+ }
42
+ const responsesResolved = resolveEffort(requested, compat.reasoningEffortLevels ?? DEFAULT_EFFORT_LEVELS);
43
+ if (effortTierUnmapped(requested, responsesResolved.effective, model)) {
44
+ return { requested, effective: requested, graded: false, clamped: false, format: "responses", endpoint };
45
+ }
46
+ return { ...responsesResolved, format: "responses", endpoint };
47
+ }
27
48
  const format = compat.thinkingFormat ?? "openai";
28
49
  if (BINARY_FORMATS.has(format)) {
29
50
  return { ...resolveBinary(requested), format, endpoint };
@@ -32,15 +53,15 @@ export function resolveReasoning(requested, model) {
32
53
  if (!supportsEffort && format !== "openrouter") {
33
54
  return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
34
55
  }
35
- return {
36
- ...resolveEffort(requested, compat.reasoningEffortLevels ?? DEFAULT_EFFORT_LEVELS),
37
- format,
38
- endpoint,
39
- };
56
+ const resolved = resolveEffort(requested, compat.reasoningEffortLevels ?? DEFAULT_EFFORT_LEVELS);
57
+ if (effortTierUnmapped(requested, resolved.effective, model)) {
58
+ return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
59
+ }
60
+ return { ...resolved, format, endpoint };
40
61
  }
41
62
  export function resolveEffort(requested, allowed = DEFAULT_EFFORT_LEVELS) {
42
- if (allowed.length === 0)
43
- allowed = DEFAULT_EFFORT_LEVELS;
63
+ const declared = Array.isArray(allowed) ? allowed.filter((lvl) => isThinkingLevel(lvl) && lvl !== "off") : [];
64
+ allowed = declared.length > 0 ? declared : DEFAULT_EFFORT_LEVELS;
44
65
  if (requested === "off" || allowed.includes(requested)) {
45
66
  return { requested, effective: requested, graded: true, clamped: false };
46
67
  }
@@ -1,15 +1,3 @@
1
- /**
2
- * Request-parameter passthrough (design/40). A deployment can set official provider params it wants but
3
- * core doesn't model as first-class fields (OpenAI `frequency_penalty`/`presence_penalty`/`top_p`/`seed`/
4
- * `logit_bias`; Anthropic `top_k`/`top_p`/`stop_sequences`; …) via `Model.extraBody`, and core merges them
5
- * into the brain's request body. Anchored at the **Model** level (not per-turn) so the serialized body
6
- * stays byte-stable across turns → the prefix cache holds (design/9/31).
7
- *
8
- * Symmetric: each brain owns its structural + tuning keys (`reserved`) and ALWAYS wins — `extraBody` can
9
- * only supply the long tail, never clobber `messages`/`tools`/`temperature`/etc. Stripping is the safety
10
- * enforcement (the brains are standalone-usable); `prepareTask` separately warns on a collision (it has
11
- * `onError`, the brain doesn't — council design/40).
12
- */
13
1
  /** OpenAI request-body keys core owns; `extraBody` cannot override these. */
14
2
  export declare const OPENAI_RESERVED: ReadonlySet<string>;
15
3
  /** Anthropic request-body keys core owns; `extraBody` cannot override these. (Note the asymmetry vs
@@ -1,3 +1,4 @@
1
+ import { RESPONSES_APIS } from "./reasoning.js";
1
2
  export const OPENAI_RESERVED = new Set([
2
3
  "model",
3
4
  "messages",
@@ -38,7 +39,6 @@ export const RESPONSES_RESERVED = new Set([
38
39
  "previous_response_id",
39
40
  "conversation",
40
41
  ]);
41
- const RESPONSES_APIS = new Set(["openai-responses", "azure-openai-responses", "openai-chatgpt-responses"]);
42
42
  export function reservedFor(api) {
43
43
  if (RESPONSES_APIS.has(api))
44
44
  return RESPONSES_RESERVED;
@@ -14,6 +14,9 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
14
14
  "memory.partition_split",
15
15
  "memory.tail",
16
16
  "memory.pollution_mark_failed",
17
+ "memory.session_polluted",
18
+ "memory.harvest_quarantined",
19
+ "memory.delegation_static_mark_waived",
17
20
  "memory.challenge_sweep_failed",
18
21
  "memory.lineage_settle_failed",
19
22
  "memory.challenge_ledger_corrupt",
@@ -66,9 +66,12 @@ export interface AgentPoolTool {
66
66
  * A child that can reach a network or protocol tool returns content from outside the trust boundary
67
67
  * just as surely as calling that tool in this session would, and the parent's transcript is where it
68
68
  * lands. The judgment is STATIC — the child's face is known from its definition before the call — so
69
- * nothing here tracks what the child actually did (runtime provenance is the v2 ticket). That makes
70
- * it conservative in the safe direction: a child that COULD reach external content counts as having
71
- * done so.
69
+ * nothing here tracks what the child actually did (runtime provenance is design/180 half A). That
70
+ * makes it conservative in the safe direction: a child that COULD reach external content counts as
71
+ * having done so. Whether this verdict MARKS the judging session's memory when no attestation
72
+ * arrives is the deployment's evidence standard (design/324,
73
+ * `RunnerDeps.memoryDelegationEvidence`) — the classification itself is value-independent, and the
74
+ * wrap computes it on every standard (the waiver announcement keys on it).
72
75
  *
73
76
  * `pool` is the CHILD'S pool, not the parent's roster: a delegation tool carries its own tool set, and
74
77
  * the two are explicitly allowed to differ — a parent mounting nothing but the delegation tool can
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * design/180 half A — delegation RUNTIME provenance: the three-value attestation and its monotonic
3
3
  * aggregate. The static tool-face judgment ({@link import("./content-origin.js").delegationCallIsExternal})
4
- * stays the PERMANENT FLOOR: runtime evidence may only retire the static verdict when a COMPLETE
5
- * clean proof is in hand; any evidence gap falls back to the floor. "We could not prove it clean"
6
- * and "it is clean" are different answers — replacing the floor outright would fail OPEN on every
7
- * evidence gap (old child vintages, crashes, un-armed sessions).
4
+ * stays the FLOOR of the attestation reduction: runtime evidence may only retire the static verdict
5
+ * when a COMPLETE clean proof is in hand; any evidence gap falls back to the floor. "We could not
6
+ * prove it clean" and "it is clean" are different answers — replacing the floor outright would fail
7
+ * OPEN on every evidence gap (old child vintages, crashes, un-armed sessions). Whether the floor's
8
+ * verdict MARKS the judging session's memory is a separate, deployment-governed question
9
+ * (design/324, `RunnerDeps.memoryDelegationEvidence`): the reduction semantics here never change
10
+ * with that key — only the mark action at the judging run's wrap does.
8
11
  *
9
12
  * The aggregate is two MONOTONIC bits plus versioning:
10
13
  * - `sawExternal` — an actually-observed polluting-class invocation (own recorder, or a delivered
@@ -17,8 +20,9 @@
17
20
  * "clean" unprovable; it can never make an observed event disappear).
18
21
  */
19
22
  /** The three-value delegation attestation. Only `clean` changes behavior (the parent skips the
20
- * static mark); `unknown` is byte-equivalent to absence (static floor governs); `external` marks
21
- * unconditionally, with the observed-event reason. */
23
+ * static mark); `unknown` is byte-equivalent to absence (the static floor governs its mark
24
+ * action subject to the judging run's deployment evidence standard, design/324); `external` marks
25
+ * unconditionally on every evidence standard, with the observed-event reason. */
22
26
  export type DelegationAttestation = "clean" | "external" | "unknown";
23
27
  /** The monotonic per-run provenance aggregate (in-process this slice; the durable-checkpoint
24
28
  * persistence leg rides the background-face slice — absence after a resume reads `incomplete`,
@@ -58,5 +62,6 @@ export declare function reduceDelegationAttestation(agg: DelegationProvenanceAgg
58
62
  completed: boolean;
59
63
  }): DelegationAttestation;
60
64
  /** Read a delivered card's attestation field (the explicit-whitelist handback seat). Anything but
61
- * the three spellings — including absence — reads as undefined, i.e. the static floor governs. */
65
+ * the three spellings — including absence — reads as undefined, i.e. the static floor governs
66
+ * (whether that floor MARKS is the judging run's deployment evidence standard, design/324). */
62
67
  export declare function readCardAttestation(details: unknown): DelegationAttestation | undefined;
@@ -1,6 +1,7 @@
1
+ import type { EngineNotice } from "../types.js";
1
2
  import { type CommittedBinding, type EraseMemoryEntriesInput, type MemoryErasureAttestation, type TransferEvidence } from "./file-backend.js";
2
3
  import { type MemoryExportBundle, type MemoryImportReport } from "./export-bundle.js";
3
- import { type ChallengeAssignment, type ChallengeEvent, type ControlPlaneRebuildReceipt, type StrictControlPlaneLedger, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, type MemoryPartitionIncidentSink, type RetrievedAccountRow, type SessionPollutionRecord } from "./layout.js";
4
+ import { type ChallengeAssignment, type ChallengeEvent, type ControlPlaneRebuildReceipt, type StrictControlPlaneLedger, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, type MemoryPartitionIncidentSink, type RetrievedAccountRow, type SessionPollutionMarkOutcome, type SessionPollutionRecord } from "./layout.js";
4
5
  import type { HarvestReport, MemoryAnnouncement, MemoryBackend, MemorySessionHandle, ScanFinding } from "./types.js";
5
6
  /**
6
7
  * The CC `# Memory` instruction section — VERBATIM from the live capture
@@ -255,6 +256,91 @@ export interface EntryProvenanceAccount {
255
256
  reason?: string;
256
257
  };
257
258
  }
259
+ /**
260
+ * The pollution mark's loud half ({@link EngineNotice}). The engine's own `onIncident` seat carries the
261
+ * FAILURES of this machinery (`memory.pollution_mark_failed`, `memory.challenge_sweep_failed`); this is
262
+ * the other fact — the machinery worked, and its verdict silently changed what every memory write in
263
+ * this session will do. Before this code existed the transition had no face at all: the session went on
264
+ * writing memory files, the harvest quarantined every one of them, and nothing said so while a host
265
+ * could still act on it.
266
+ *
267
+ * SCOPE OF THE CLAIM (adversarial-review round 1, P1 — the first cut PROMISED quarantine on every mark).
268
+ * This notice states the VERDICT, not the mechanism's outcome: "no longer eligible", plus what a harvest
269
+ * does WHEN IT COLLECTS. That distinction is load-bearing, because several real paths reach a polluted
270
+ * harvest that quarantines nothing — a `writeScope`-null layering and a declared-unavailable session
271
+ * both take `harvestCore`'s zero-admission return (nothing is collected, so nothing is captured), and a
272
+ * mark landing after the pre-commit pollution read leaves already-committed rows to the CHALLENGE sweep
273
+ * rather than to containment. What a given harvest actually contained is the other code's job
274
+ * (`memory.harvest_quarantined`), and the message says so instead of guessing here. The baseline clause
275
+ * is likewise scoped to a collecting harvest: containment judges a changed file against the
276
+ * MATERIALIZE-time baseline, not against the mark's timestamp, so pre-mark writes are covered too.
277
+ *
278
+ * The challenge clause is likewise a POLICY statement with its failure channel named (adversarial-review
279
+ * round 3): the mark-side sweep is best-effort — an unreadable or unwritable lineage ledger makes
280
+ * `markSessionPolluted` announce `memory.challenge_sweep_failed` and return normally — so this notice
281
+ * must not be read as a receipt that the sweep landed.
282
+ *
283
+ * Minted by the runner's mark seat (the one call site of the mark face), so an engine-direct host keeps
284
+ * the incident seat and gains no new obligation.
285
+ *
286
+ * `reason` names a TOOL, and tool names come from the host/protocol roster: neutralized and bounded
287
+ * here — the same treatment the report warnings give it — before it rides either the message or the
288
+ * detail (a host renders both).
289
+ */
290
+ export declare function memorySessionPollutedNotice(input: {
291
+ reason: string;
292
+ sessionId?: string;
293
+ }): EngineNotice;
294
+ /**
295
+ * The polluted harvest's loud half ({@link EngineNotice}): what the containment actually did, minted
296
+ * once per harvest that withheld at least one ENTRY file. `count` is the withheld set (the
297
+ * `polluted`-coded rejections — entry files that diverged from the materialize baseline), `moved` the
298
+ * subset that also LEFT the model-visible plane, and `escalated` the subset carrying a
299
+ * `quarantineFailures` row.
300
+ *
301
+ * `escalated` is its own input rather than `count - moved` (adversarial-review round 3): the two are
302
+ * NOT the same set. A file whose delete fails but whose tombstone write succeeds counts as removed —
303
+ * it rides `movedToQuarantine` AND an escalation row saying it was tombstoned in place — so the
304
+ * arithmetic would report a clean containment while a known failure sat in the report. Every
305
+ * not-moved file does have an escalation row, so `escalated` is the superset and the honest clause.
306
+ *
307
+ * KNOWN GAP (adversarial-review round 1, P2 — registered, not closed here): the derived index is
308
+ * contained by a different path (`restorePollutedIndex` captures and restores `MEMORY.md`) that mints
309
+ * no rejection row, so a polluted session whose ONLY memory change was an index line produces no
310
+ * `count` and no notice — its containment is disclosed by the harvest report's warnings alone. Closing
311
+ * it needs a structured containment signal on `HarvestReport`, a public type this fix does not touch.
312
+ * The wording therefore says "entry file(s)" rather than implying the whole containment.
313
+ *
314
+ * `reason` is optional because the mark record is read separately from the report: a record that cannot
315
+ * be read must not turn a true "N files were withheld" into a lie about why.
316
+ */
317
+ /**
318
+ * The three numbers {@link memoryHarvestQuarantinedNotice} reports, read off ONE harvest report. Pure
319
+ * and exported so the derivation is provable on its own (the tombstone case — moved AND escalated — is
320
+ * not constructible through a real filesystem without root-dependent permission tricks).
321
+ *
322
+ * Every number is scoped to the WITHHELD paths: a dual-root harvest merges the read plane's report in,
323
+ * and that plane's ordinary write gate quarantines on its own grounds — counting its rows would credit
324
+ * this session's pollution with containment it never performed.
325
+ *
326
+ * FILES, NOT ROWS (adversarial-review round 4). One path can appear twice in the same report: when the
327
+ * pre-commit pollution recheck flips a clean harvest to containment, a file the ordinary secret/filename
328
+ * gate already quarantined is contained again, and both passes append. Counting rows would then publish
329
+ * `moved`/`escalated` greater than `count` — an impossible statement about a set of files — so every
330
+ * number here is a count of DISTINCT paths.
331
+ */
332
+ export declare function pollutionContainmentCounts(report: HarvestReport): {
333
+ count: number;
334
+ moved: number;
335
+ escalated: number;
336
+ };
337
+ export declare function memoryHarvestQuarantinedNotice(input: {
338
+ count: number;
339
+ moved: number;
340
+ escalated: number;
341
+ reason?: string;
342
+ sessionId?: string;
343
+ }): EngineNotice;
258
344
  export declare class MemoryEngine {
259
345
  private readonly backend;
260
346
  private readonly memoryDir;
@@ -301,8 +387,14 @@ export declare class MemoryEngine {
301
387
  * record, and the durable marker survives restarts (control plane — never model-writable
302
388
  * frontmatter). A failed durable write is disclosed via `onIncident` (`memory.pollution_mark_failed`)
303
389
  * while the in-memory mark keeps THIS process fail-closed.
390
+ *
391
+ * RETURNS the durable marker's {@link SessionPollutionMarkOutcome} — `"created"` ⇔ this call made
392
+ * the transition. That is the ONE atomic answer to "was this the clean → polluted crossing", and the
393
+ * runner's once-per-session disclosure is keyed on it: a pre-read cannot decide it (two concurrent
394
+ * markers of one session would both read clean and both announce). Callers that do not disclose
395
+ * ignore it exactly as before.
304
396
  */
305
- markSessionPolluted(sessionId: string, reason: string): void;
397
+ markSessionPolluted(sessionId: string, reason: string): SessionPollutionMarkOutcome;
306
398
  /** The session's pollution record (in-process mark first, then the durable marker) — undefined =
307
399
  * clean. Reads are side-effect-free. */
308
400
  sessionPollution(sessionId: string): SessionPollutionRecord | undefined;
@@ -48,6 +48,47 @@ export const DEFAULT_HARVEST_DEADLINE_MS = 5_000;
48
48
  export const DEFAULT_HARVEST_FILE_BUDGET = 2_000;
49
49
  export const MASS_DELETION_FUSE_RATIO = 0.5;
50
50
  let indexCaptureSeq = 0;
51
+ export function memorySessionPollutedNotice(input) {
52
+ const reason = inlineUntrusted(input.reason, 200);
53
+ return {
54
+ code: "memory.session_polluted",
55
+ message: `Memory for this session is marked polluted: ${reason}. Its memory writes are no longer eligible for the ` +
56
+ `long-term library — a harvest withholds what it collects (quarantined for host review, judged against the ` +
57
+ `session's opening baseline, so changes made before this point are withheld too), and contributions this ` +
58
+ `session already committed are challenged (a sweep that cannot run announces memory.challenge_sweep_failed). ` +
59
+ `What a given harvest actually contained is announced as memory.harvest_quarantined.`,
60
+ detail: { reason, ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}) },
61
+ };
62
+ }
63
+ export function pollutionContainmentCounts(report) {
64
+ const withheld = new Set(report.rejections.filter((r) => r.code === "polluted").map((r) => r.path));
65
+ const distinct = (paths) => new Set(paths.filter((p) => withheld.has(p))).size;
66
+ return {
67
+ count: withheld.size,
68
+ moved: distinct(report.movedToQuarantine),
69
+ escalated: distinct((report.quarantineFailures ?? []).map((q) => q.path)),
70
+ };
71
+ }
72
+ export function memoryHarvestQuarantinedNotice(input) {
73
+ const reason = input.reason !== undefined ? inlineUntrusted(input.reason, 200) : undefined;
74
+ return {
75
+ code: "memory.harvest_quarantined",
76
+ message: `Memory harvest committed nothing for this polluted session${reason !== undefined ? ` (${reason})` : ""}: ` +
77
+ `${input.count} memory entry file(s) written or changed in it were withheld, ${input.moved} of them moved into ` +
78
+ `the control-plane quarantine directory for host review` +
79
+ (input.escalated > 0
80
+ ? `; ${input.escalated} carr${input.escalated === 1 ? "ies" : "y"} a containment escalation (a file that did not leave the ` +
81
+ `model-visible plane, or one tombstoned in place rather than deleted) — the harvest report's quarantineFailures names them.`
82
+ : "."),
83
+ detail: {
84
+ count: input.count,
85
+ moved: input.moved,
86
+ escalated: input.escalated,
87
+ ...(reason !== undefined ? { reason } : {}),
88
+ ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
89
+ },
90
+ };
91
+ }
51
92
  export class MemoryEngine {
52
93
  backend;
53
94
  memoryDir;
@@ -115,14 +156,14 @@ export class MemoryEngine {
115
156
  markSessionPolluted(sessionId, reason) {
116
157
  if (!this.pollutedSessions.has(sessionId))
117
158
  this.pollutedSessions.set(sessionId, { at: this.now(), reason });
118
- let durable = false;
159
+ let outcome;
119
160
  try {
120
- durable = markSessionPolluted(this.controlDir, sessionId, reason, this.now);
161
+ outcome = markSessionPolluted(this.controlDir, sessionId, reason, this.now);
121
162
  }
122
163
  catch {
123
- durable = false;
164
+ outcome = "unpersisted";
124
165
  }
125
- if (!durable) {
166
+ if (outcome === "unpersisted") {
126
167
  const sink = this.onIncident;
127
168
  if (sink !== undefined) {
128
169
  try {
@@ -156,6 +197,7 @@ export class MemoryEngine {
156
197
  }
157
198
  }
158
199
  }
200
+ return outcome;
159
201
  }
160
202
  sessionPollution(sessionId) {
161
203
  const inProcess = this.pollutedSessions.get(sessionId);
@@ -288,11 +288,18 @@ export interface SessionPollutionRecord {
288
288
  }
289
289
  /**
290
290
  * Mark `sessionId` polluted (idempotent; the FIRST record wins — `wx` create refuses overwrite).
291
- * Returns true when a durable marker exists after the call (created now or already present);
292
- * false the marker could not be persisted the caller must keep its own in-memory mark and
293
- * disclose (the session stays polluted for this process either way).
291
+ *
292
+ * The outcome distinguishes the two ways a durable marker can exist after the call, because the
293
+ * `wx` create is the only ATOMIC answer to "who made this transition" available anywhere in the
294
+ * stack (#324a, adversarial-review round 5): a check-then-act pre-read lets two concurrent markers
295
+ * of one session both believe they were first, and a once-per-session disclosure keyed on that would
296
+ * announce twice. `"created"` ⇔ THIS call minted the marker; `"existed"` ⇔ it was already there (a
297
+ * repeat mark, a resumed session, or a racing peer that won the create); `"unpersisted"` ⇔ the marker
298
+ * could not be written at all — the caller must keep its own in-memory mark and disclose (the session
299
+ * stays polluted for this process either way).
294
300
  */
295
- export declare function markSessionPolluted(controlDir: string, sessionId: string, reason: string, now: () => number): boolean;
301
+ export type SessionPollutionMarkOutcome = "created" | "existed" | "unpersisted";
302
+ export declare function markSessionPolluted(controlDir: string, sessionId: string, reason: string, now: () => number): SessionPollutionMarkOutcome;
296
303
  /** Read the durable pollution record for `sessionId` (undefined = no marker).
297
304
  * A marker that EXISTS still reads as polluted whether or not its bytes can be read or parsed
298
305
  * (fail-closed: neither corruption nor an unreadable node may launder the state) — a synthesized
@@ -740,12 +740,12 @@ export function markSessionPolluted(controlDir, sessionId, reason, now) {
740
740
  ensureDirExists(dirname(path));
741
741
  const record = { at: now(), reason };
742
742
  writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
743
- return true;
743
+ return "created";
744
744
  }
745
745
  catch (err) {
746
746
  if (err instanceof Error && "code" in err && err.code === "EEXIST")
747
- return true;
748
- return false;
747
+ return "existed";
748
+ return "unpersisted";
749
749
  }
750
750
  }
751
751
  export function readSessionPollution(controlDir, sessionId) {