@sema-agent/core 5.63.0 → 5.65.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 (56) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/agents/cascade.d.ts +5 -1
  3. package/dist/agents/cascade.js +6 -1
  4. package/dist/agents/subagent.js +1 -0
  5. package/dist/agents/verify.d.ts +5 -1
  6. package/dist/agents/verify.js +5 -2
  7. package/dist/core/checkpoint-store.d.ts +100 -5
  8. package/dist/core/fs-write-gate-policy.d.ts +21 -0
  9. package/dist/core/fs-write-gate-policy.js +14 -3
  10. package/dist/core/hooks.d.ts +9 -1
  11. package/dist/core/hooks.js +1 -1
  12. package/dist/core/memory-engine/dual-root.js +3 -0
  13. package/dist/core/memory-engine/engine.d.ts +1 -0
  14. package/dist/core/memory-engine/engine.js +9 -5
  15. package/dist/core/memory-engine/types.d.ts +16 -0
  16. package/dist/core/remote-env.d.ts +34 -2
  17. package/dist/core/runner/prepare-hands-readface.d.ts +9 -0
  18. package/dist/core/runner/prepare-hands-readface.js +4 -1
  19. package/dist/core/runner/prepare-memory.js +1 -1
  20. package/dist/core/runner/prepare-task.d.ts +31 -0
  21. package/dist/core/runner/prepare-task.js +53 -18
  22. package/dist/core/runner/prepare-workspace-restore.js +13 -0
  23. package/dist/core/runner/runtask.d.ts +18 -0
  24. package/dist/core/runner/runtask.js +173 -20
  25. package/dist/core/stub-env.d.ts +4 -0
  26. package/dist/core/stub-env.js +1 -0
  27. package/dist/core/task-registry-shared.js +20 -2
  28. package/dist/core/types.d.ts +104 -11
  29. package/dist/core/usage-window-store.d.ts +44 -12
  30. package/dist/core/usage-window-store.js +11 -3
  31. package/dist/core/workflow-run-store-contract.js +30 -0
  32. package/dist/core/workflow-run-store.d.ts +39 -1
  33. package/dist/core/workflow-run-store.js +2 -0
  34. package/dist/engine/harness/agent-harness.d.ts +8 -3
  35. package/dist/engine/harness/agent-harness.js +9 -4
  36. package/dist/engine/harness/types.d.ts +107 -3
  37. package/dist/engine/loop/agent-loop.js +39 -15
  38. package/dist/engine/loop/types.d.ts +43 -22
  39. package/dist/index.d.ts +2 -2
  40. package/dist/internal/harness-types.d.ts +1 -1
  41. package/dist/orchestration/run-workflow-tool.d.ts +6 -0
  42. package/dist/orchestration/run-workflow-tool.js +1 -0
  43. package/dist/orchestration/workflow-types.d.ts +35 -0
  44. package/dist/orchestration/workflow-types.js +2 -2
  45. package/dist/orchestration/workflow.d.ts +6 -0
  46. package/dist/orchestration/workflow.js +21 -1
  47. package/dist/prompts/default.d.ts +8 -0
  48. package/dist/prompts/default.js +3 -0
  49. package/dist/tools/fs/bash-readonly-classifier.d.ts +44 -1
  50. package/dist/tools/fs/bash-readonly-classifier.js +132 -5
  51. package/dist/tools/fs/fs-bash.js +9 -2
  52. package/dist/tools/fs/fs-write.js +19 -8
  53. package/dist/tools/fs/index.d.ts +1 -0
  54. package/dist/tools/fs/index.js +1 -0
  55. package/package.json +1 -1
  56. package/test/export-surface.snapshot.json +9 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.65.0 — 2026-08-28
4
+
5
+ ### Added
6
+ - design/380 batch 2 — the device-lane durable half:
7
+ - `CheckpointState.placementRootSessionId` (additive): stamped at every suspend mint, restored into the internals binding on resume, chain-stable across arbitrary park depth. A resume whose EXPLICIT `internals.placementRoot` contradicts the recorded root refuses pre-CAS with the NEW code `resume.placement_mismatch` (closed-set addition to `CheckpointError`), positioned in the identity block above the usage-window rung. A supplied `rootSessionId` is delegation identity, never a placement claim — the engine's own parked-agent revive lands at the recorded root. Legacy rows keep the historic fold byte-identical.
8
+ - `RunnerDeps.resumePreflight` + `resumePreflightTimeoutMs`, NEW public exports `ResumePreflightInfo` / `ResumePreflightVerdict`: a deployment callback screening every redeem attempt as the last pre-CAS rung, reading facts off the persisted row only. Non-answers (throw/timeout/unreadable verdict — including a terminal verdict without a readable message) fail closed as the NEW code `resume.preflight_rejected` on the retry-later arm; only an explicit terminal disposition settles the row, honoring the fence-winner duties (same-process paused env destroyed exactly once; cross-Runner losers retire their reap records). The deadline knob validates its full admissible range loudly and folds to its own 10s default.
9
+ - `ExecutionEnv.externalContentTarget` (adapter-declared) + `CheckpointState.externalContentTarget` (monotonic bit): the run's env-MOUNTED tool band (fs/bash, Monitor, worktree, LSP — the roster is minted at the mount sites) classifies as external for memory governance, ORs into the `execIsExternalContent` channel, persists across park/resume, and derives one neutral environment-facts prompt line. Orchestration tools never enter the roster. Undeclared deployments are byte-identical.
10
+ - `WorkflowRun.timeoutInterruption` + `WorkflowRunSummary.timeoutInterruption` (#485 core half): a run abandoned by `totalTimeoutMs` carries `{timeoutMs, agentsCompleted, agentsFailed, agentsInFlight}` stamped once at the failure terminal (before the abandonment fold — the seat describes the world the deadline found); record face, list projection (cross-backend contract case), and the poll `note` read the same seat. The status enum is unchanged.
11
+ - `HarvestReport.reportId` (#479): engine-minted uuidv7 per-occurrence identity on all five report-derived memory notices as flat `detail.reportId`; the dedup key is `(code, detail.reportId)`.
12
+ - The workflow poll receipt disclosed a terminal budget overshoot (#478): one additive sentence off the summary seat; seatless runs byte-identical.
13
+
14
+ ### Changed
15
+ - Workflow budget narration labels its unit honestly (#487, case A): four sema-authored faces now say "total tokens"; the CC-verbatim `WorkflowBudgetExceededError` message is deliberately frozen (anchor-inherited label, pinned byte-exact).
16
+ - The read-boundary positive-control pins converted to a differential invariant (test-only).
17
+
18
+ ### Fixed
19
+ - Merged-code rescan batch (A-081, 7 confirmed pre-ship): the placement rung no longer bricks parked durable agents revived under an explicit spawner placement root (P1); the O9a roster is minted at the mount sites instead of a static table, closing the Monitor/worktree/LSP under-marking hole AND the TaskOutput false-positive (P1); the terminal preflight expire honors the design/51 fence-winner duties (paused env reclaimed, P2); the preflight deadline knob's silent double-validation band closed (P2); empty-string placement anchors read uniformly as absence; a terminal verdict without a readable message never terminalizes a row; rung order fixed so a deterministic dead claim is never masked by a retryable code.
20
+
21
+ ### BREAKING/adaptation notes
22
+ - `CheckpointError` closed set +2 (`resume.placement_mismatch`, `resume.preflight_rejected`); `detail.retryAfterMs` now rides two codes.
23
+ - `SerializedCheckpointState` gains two REQUIRED keys (`placementRootSessionId`, `externalContentTarget`) — out-of-tree builders/`satisfies` sites gain compile errors (the mapped type's documented non-homomorphic contract).
24
+ - Export surface +2 (`ResumePreflightInfo`, `ResumePreflightVerdict`).
25
+ - A durable resume whose explicit placement anchor contradicts the recorded root now refuses loudly (named narrowing; re-pin old-behavior probes).
26
+
27
+ ## 5.64.0 — 2026-08-27
28
+
29
+ ### BREAKING
30
+ - **design/380 batch 1 — device-lane placement identity**: `ExecutionEnvFactoryContext` grows a
31
+ REQUIRED `placementRootSessionId` (the run TREE's fixed point, minted once as
32
+ `placementRoot ?? rootSessionId ?? sessionId`; an empty resolved value is refused loudly as
33
+ `config.placement_root_invalid` before the factory is ever called). Deployment code that
34
+ CONSTRUCTS its own factory context gains a compile-time break — factory *implementors* are purely
35
+ additive. The fixed point threads through cascade rungs, verification legs and every spawn family
36
+ (subagent, workflow), so a descendant's fresh session id can no longer read as a new placement;
37
+ durable-resume carriage is the next batch's seat (`O1③`) and its current fallback is pinned in the
38
+ tree so landing it must flip the pin consciously.
39
+
40
+ ### Added
41
+ - `WorkspaceHandle.deviceId` — explicit, minter-stated identity of the target a workspace lives on
42
+ (rides the checkpoint's all-string handle whitelist unchanged).
43
+ - `outcome_unknown` joins `ExecutionErrorCode`, `FileErrorCode` and `RemoteExecutionErrorCode`, and
44
+ `target_unavailable` joins `ExecutionErrorCode`: an op COMMITTED to a remote target whose outcome
45
+ is unknowable must never be auto-retried (it is deliberately NOT in
46
+ `RETRYABLE_REMOTE_ERROR_CODES`), while a never-started commit refusal is retry-safe by contract.
47
+ `precondition_failed` joins `FileErrorCode` for the guarded write below. **Downstream exhaustive
48
+ switches over these unions will red — re-pin per the new members.**
49
+ - `FileSystem.writeFileGuarded?` (+ `WriteExpectation` / `WriteReceipt`, both type-only exports):
50
+ optional atomic verify-and-write with a receipt of the real on-disk object, carrying
51
+ `writeFileExclusive`'s degradation law verbatim (a backend without the primitive leaves it
52
+ `undefined` rather than emulating it). New deployment knob `requireGuardedWrite` (default off,
53
+ byte-identical): armed, a covered write on an env lacking the capability is a typed refusal
54
+ instead of a silent fallback.
55
+ - `UsageWindow.maxTokens` becomes optional (#480): a window may govern by `maxCostUsd` alone. A
56
+ window declaring NEITHER ceiling refuses loudly, the exhaustion arithmetic reads declared axes
57
+ only, and a `maxCostUsd` whose micro-USD conversion overflows is refused at the door instead of
58
+ being admitted and never binding (**named narrowing**: a dual window declaring such a ceiling
59
+ previously ran with its money arm silently dead).
60
+
61
+ ### Fixed
62
+ - **#477 — the `before_agent_start` replacement prompt is run-scoped**: the between-turn rebuild
63
+ now carries it, so a run's prompt is byte-stable across its three legal change points (run start /
64
+ compaction-boundary epoch adoption / clear) instead of silently reverting to the base prompt from
65
+ turn 2 (which also broke prefix-cache stability). Deployments with no handler — or whose handler
66
+ returns no `systemPrompt` — are byte-identical.
67
+ - **#482 / #486 — read-only command judgment, both directions**: the allowlist grows to CC's safe
68
+ command set (with per-verb availability guards), a classify-only superset admits `find`/`sed`/`cd`
69
+ behind CC's own guard grammars, and compound judgment now THREADS the base a `cd` actually moves —
70
+ every segment's containment *and* deny resolve against the base bash will have, and a poll-loop
71
+ body containing a `cd` is judged over its worst-case iterated shift. Two escapes are closed (an
72
+ iterated `cd ..` loop climbing out of the read roots; a `cd` eating the first segment of a
73
+ multi-segment deny pattern). **This is a judgment fix, not a narrowing**: legitimately-configured
74
+ full-read deployments keep zero-ask on the same commands, pinned by positive controls carrying the
75
+ same weight as the escape pins.
76
+ - **#473** the resume verb snapshots its config once at the door (a mutated bag can no longer make
77
+ the identity gate and the resumed leg disagree); **#474** the prompt-too-long recovery lane adopts
78
+ the rebuilt prompt and honours the turn-scoped abort; **#470/#475** a workflow run that overran
79
+ its token ceiling discloses it on the list projection as well as the full record; **#468**
80
+ `SubagentSteerHandle.steer` gains the `inputId` pass-through its workflow twin already had.
81
+
82
+
3
83
  ## 5.63.0 — 2026-08-27
4
84
 
5
85
  ### BREAKING
@@ -199,6 +199,10 @@ export declare function runCascade(runner: Runner, spec: TaskSpec, config: Casca
199
199
  * what teardown does to the workspace is the factory's business.
200
200
  *
201
201
  * Absent ⇒ every rung is dispatched exactly as before (the call site passes `undefined`, which is
202
- * what an omitted optional argument already was).
202
+ * what an omitted optional argument already was) — with ONE design/380 O1② placement delta either
203
+ * way: when neither `placementRoot` nor `rootSessionId` is present, rungs after the first are
204
+ * dispatched with a fresh shallow copy carrying `placementRoot` = the first rung's resolved
205
+ * sessionId (the ladder's placement fixed point); every other field still rides by the same
206
+ * reference, and an internals object that already carries a fixed point is forwarded verbatim.
203
207
  */
204
208
  internals?: RunInternals): Promise<CascadeRunResult>;
@@ -57,6 +57,7 @@ export async function runCascade(runner, spec, config, internals) {
57
57
  });
58
58
  let lastResult;
59
59
  let passedRung = -1;
60
+ let ladderPlacementRoot;
60
61
  const { sessionId: _drop, ...specBase } = spec;
61
62
  for (let i = 0; i < maxRungs; i++) {
62
63
  if (deadlineAt !== undefined && Date.now() >= deadlineAt)
@@ -81,14 +82,18 @@ export async function runCascade(runner, spec, config, internals) {
81
82
  ? AbortSignal.any([specBase.signal, rungAbort.signal])
82
83
  : rungAbort.signal;
83
84
  let result;
85
+ const rungInternals = ladderPlacementRoot === undefined || internals?.placementRoot !== undefined || internals?.rootSessionId !== undefined
86
+ ? internals
87
+ : { ...internals, placementRoot: ladderPlacementRoot };
84
88
  try {
85
- result = await runner.runTask({ ...specBase, model: rung.model, ...(rung.overrides ?? {}), ...(rungSignal ? { signal: rungSignal } : {}) }, internals);
89
+ result = await runner.runTask({ ...specBase, model: rung.model, ...(rung.overrides ?? {}), ...(rungSignal ? { signal: rungSignal } : {}) }, rungInternals);
86
90
  }
87
91
  finally {
88
92
  if (deadlineTimer !== undefined)
89
93
  clearTimeout(deadlineTimer);
90
94
  }
91
95
  lastResult = result;
96
+ ladderPlacementRoot ??= internals?.placementRoot ?? internals?.rootSessionId ?? result.sessionId;
92
97
  const s = result.stats;
93
98
  const rungCostKnown = s.costMicroUsd !== undefined && (s.nested === undefined || s.nested.costMicroUsd !== undefined);
94
99
  const rungCost = rungCostKnown ? (s.costMicroUsd ?? 0) + (s.nested?.costMicroUsd ?? 0) : undefined;
@@ -1967,6 +1967,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1967
1967
  ...(ctx.taskId !== undefined ? { parentTaskId: ctx.taskId } : {}),
1968
1968
  ...(ctx.sessionId !== undefined ? { parentSessionId: ctx.sessionId } : {}),
1969
1969
  ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}),
1970
+ ...(ctx.placementRoot !== undefined ? { placementRoot: ctx.placementRoot } : {}),
1970
1971
  }),
1971
1972
  ...(ctx.centerArtifactDigest !== undefined ? { parentCenterArtifactDigest: ctx.centerArtifactDigest } : {}),
1972
1973
  ...(ctx.centerSourceRevision !== undefined ? { parentCenterSourceRevision: ctx.centerSourceRevision } : {}),
@@ -287,7 +287,11 @@ export declare function verifyCompleted(runner: Runner, result: TaskResult, spec
287
287
  * refusal does and does not undo.
288
288
  *
289
289
  * Absent ⇒ every leg is dispatched exactly as before (each call site passes `undefined`, which is
290
- * what an omitted optional argument already was).
290
+ * what an omitted optional argument already was) — with ONE design/380 O1② placement delta either
291
+ * way: when neither `placementRoot` nor `rootSessionId` is present, the verifier and fix legs are
292
+ * dispatched with a fresh shallow copy carrying `placementRoot` = the entry implementation leg's
293
+ * sessionId (the gate's placement fixed point); every other field still rides by the same
294
+ * reference, and an internals object that already carries a fixed point is forwarded verbatim.
291
295
  */
292
296
  internals?: RunInternals): Promise<VerificationResult>;
293
297
  /**
@@ -129,6 +129,9 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
129
129
  };
130
130
  const evidenceMode = config.evidence != null && config.evidence.trim() !== "";
131
131
  const verifierPrompt = config.verifierPrompt ?? (evidenceMode ? STATIC_VERIFICATION_PROMPT : VERIFICATION_PROMPT);
132
+ const legInternals = () => internals?.placementRoot !== undefined || internals?.rootSessionId !== undefined
133
+ ? internals
134
+ : { ...internals, placementRoot: result.sessionId };
132
135
  const runVerifier = async (impl, round) => {
133
136
  refuseUnhonorableInternals(internals, "before_leg");
134
137
  const roundEvidence = round === 1 ? config.evidence : undefined;
@@ -162,7 +165,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
162
165
  ...(specBase.clientContext !== undefined ? { clientContext: { ...specBase.clientContext } } : {}),
163
166
  ...(specBase.promptProfile !== undefined ? { promptProfile: specBase.promptProfile } : {}),
164
167
  signal: specBase.signal,
165
- }, internals);
168
+ }, legInternals());
166
169
  try {
167
170
  const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
168
171
  return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.status) ? { paused: v } : {}) };
@@ -242,7 +245,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
242
245
  objective: fixObjective(outcome.findings),
243
246
  ...(foldedRootsFace ? { readFace: "roots" } : {}),
244
247
  ...(foldedReadDeny.length > 0 ? { readDenyPatterns: [...foldedReadDeny] } : {}),
245
- }, internals);
248
+ }, legInternals());
246
249
  spend += (current.stats.costMicroUsd ?? 0) + (current.stats.nested?.costMicroUsd ?? 0);
247
250
  implAccount.add(current.stats);
248
251
  if (isDurablePause(current.status)) {
@@ -349,7 +349,11 @@ export interface PendingSteerEntry {
349
349
  seq: number;
350
350
  /** Caller-supplied correlation/idempotency id (a service passes the message id it already minted);
351
351
  * a uuidv7 is minted when absent. Re-appending the SAME `inputId` is a no-op, which is what keeps
352
- * `setPendingSteer` retry-safe now that it appends instead of overwriting. */
352
+ * `setPendingSteer` retry-safe now that it appends instead of overwriting. Because the id lives on
353
+ * the PERSISTED entry and every replayed drain of this row carries it verbatim, `(notice code,
354
+ * sessionId, detail.inputId)` is the parked family's cross-replay OCCURRENCE key — consumers dedup
355
+ * on it directly; no per-delivery key exists or is needed. (Write-path guarantee: a row written by
356
+ * a non-conforming store may lack it, and read points do not re-validate.) */
353
357
  inputId: string;
354
358
  /** Carried VERBATIM for the serving layer; it does NOT reorder the drain (drain is `seq` order). */
355
359
  priority?: SystemInjectionPriority;
@@ -1156,6 +1160,43 @@ export interface CheckpointState {
1156
1160
  * delegated child (never `false` — presence-coded like the other monotonic bits here).
1157
1161
  */
1158
1162
  isDelegatedChild?: true;
1163
+ /**
1164
+ * design/380 O1③ — the run TREE's placement fixed point at suspend: the SAME three-segment fold the
1165
+ * env-factory ctx mint uses (explicit `RunInternals.placementRoot` > the delegation lanes'
1166
+ * `rootSessionId` > the leg's own resolved session), stamped from TRUSTED internals so a durable
1167
+ * resume can restore it instead of falling back to the parked leg's own session (a target-bound
1168
+ * factory keys its placement lookup on the fixed point — the self fallback read a resumed leg as a
1169
+ * NEW placement). Restore is the `isDelegatedChild` posture replayed on the placement axis: an
1170
+ * EXPLICIT live `internals.placementRoot` wins when re-supplied; otherwise THIS key restores into
1171
+ * `internals.placementRoot` at prepare so descendants and the NEXT re-suspend mint inherit it too
1172
+ * — outranking a supplied `rootSessionId`, which is DELEGATION IDENTITY (the peer/registry axis a
1173
+ * revive leg re-supplies from its row), never a placement claim (rescan C1: reading it as one
1174
+ * permanently stuck every parked agent spawned under an explicit placement root). The resume entry
1175
+ * ALSO reads this key as an identity record: a resume whose supplied `internals.placementRoot`
1176
+ * CONTRADICTS it is refused pre-CAS (`resume.placement_mismatch`); either side absent skips the
1177
+ * check (an older row / a claim-less resume — the honest degradation arm), and `""` reads as
1178
+ * absence (the identity fields' discipline). Schema ADDITION (additive, old readers ignore it — the Q5 posture;
1179
+ * deliberately NOT version-gated: same rolling-deployment account as {@link Checkpoint.principal},
1180
+ * whose doc records version-gating as a design candidate — any new stamp is a lattice-wide window
1181
+ * rewrite, owned by that ticket, not ad-hoc here). Absent ⇒ minted before the field, or the fold
1182
+ * resolved empty on a factory-less run (never persisted as garbage).
1183
+ */
1184
+ placementRootSessionId?: string;
1185
+ /**
1186
+ * design/380 O9a — MONOTONIC presence bit: this run's hand-tool content fold was EXTERNAL-TARGET
1187
+ * armed (the env declared `ExecutionEnv.externalContentTarget`, or an earlier leg of this suspend
1188
+ * chain did). Stamped at the suspend mint from the run's RESOLVED fold state — never from first
1189
+ * tool use, so a park landing BEFORE the first hand call is covered too. Presence-coded like
1190
+ * {@link isDelegatedChild} (absent = never armed; never `false`). The resume side folds
1191
+ * **persisted OR live declaration** (a monotonic join, never a downgrade): a run minted under a
1192
+ * declaring adapter keeps its memory fold and its prompt disclosure when resumed on an
1193
+ * undeclared/older adapter — the downgrade is closed in the derivation itself, deliberately
1194
+ * WITHOUT a new refusal arm (one fewer failure path, same safety). Feeds the memory face and the
1195
+ * prompt disclosure only — no gate/policy/roster behavior reads it (the design/378 red line).
1196
+ * Schema ADDITION (additive, Q5 posture; the version-gate residual is shared with
1197
+ * {@link placementRootSessionId} above and owned by the same version-lattice ticket).
1198
+ */
1199
+ externalContentTarget?: true;
1159
1200
  }
1160
1201
  /**
1161
1202
  * {@link CheckpointState} with **every** field made required-PRESENT, while each value keeps its original
@@ -1865,6 +1906,30 @@ export declare class CheckpointError extends Error {
1865
1906
  * refusal exists to close. Rejected PRE-CAS (the checkpoint stays `pending`); re-resume with the
1866
1907
  * original principal, or omit the field to inherit the recorded one. */
1867
1908
  | "resume.principal_mismatch"
1909
+ /** design/380 O1③ (placement identity continuity — the `resume.principal_mismatch` rung's twin
1910
+ * on the placement axis): the resume supplied trusted internals whose EXPLICIT
1911
+ * `placementRoot` differs from the {@link CheckpointState.placementRootSessionId} recorded at
1912
+ * the suspend mint. A target-bound env factory keys its placement lookup on that fixed point,
1913
+ * so silently resuming under a different one would re-place the leg — and every descendant
1914
+ * the child-internals chain hands it to — on another target than the parked workspace lives
1915
+ * on. A supplied `rootSessionId` deliberately does NOT take this refusal (rescan C1): it is
1916
+ * delegation identity, re-supplied by every revive leg, and legitimately differs from the
1917
+ * recorded root whenever the spawner carried an explicit placement — the recorded root simply
1918
+ * outranks it in prepare's restore fold. Rejected PRE-CAS (the checkpoint stays `pending`);
1919
+ * re-resume with the recorded root, or omit `internals.placementRoot` to inherit it. Either
1920
+ * side absent (an older row / a claim-less resume) skips the check, and `""` reads as absence
1921
+ * — the honest degradation arm. */
1922
+ | "resume.placement_mismatch"
1923
+ /** design/380 O2: the deployment's `RunnerDeps.resumePreflight` refused this resume — an
1924
+ * explicit refusal verdict, a throw, a timeout past its bounded deadline, or an unreadable
1925
+ * answer (all fail-closed). The LAST rung before the CAS, so nothing was consumed. Two arms,
1926
+ * discriminated by the message: the default `retry_later` arm leaves the checkpoint `pending`
1927
+ * and the SAME token redeemable once the obstacle clears (`detail.retryAfterMs` carries the
1928
+ * deployment's wait hint when it gave one — the #449 G1 carrier); the explicit `terminal` arm
1929
+ * has ALREADY settled the row out of `pending` via the single-shot `expire` CAS (reaper
1930
+ * parity) before this throw — the token is NOT redeemable, by the deployment's own permanent
1931
+ * verdict (a dead binding must not be redialed forever). */
1932
+ | "resume.preflight_rejected"
1868
1933
  /** F-012 L3: the resume carried an approver EDIT (`updatedInput`) and a FROZEN inherited ancestor
1869
1934
  * constraint (the persisted projection chain) — or the deployment's resume-edit policy — DENIES the
1870
1935
  * edited args. Refused PRE-CAS with fresh-redecision semantics: the checkpoint stays `pending` and
@@ -1892,8 +1957,11 @@ export declare class CheckpointError extends Error {
1892
1957
  * discriminant. */
1893
1958
  readonly detail?: {
1894
1959
  field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
1895
- /** #449 G1 — on `resume.usage_window_exhausted` ONLY: how long until the binding window frees
1896
- * (the same wait a suspended row carries as `gate.resumeAfterMs`). Absent on every other code. */
1960
+ /** #449 G1 — on `resume.usage_window_exhausted`: how long until the binding window frees
1961
+ * (the same wait a suspended row carries as `gate.resumeAfterMs`). design/380 O2 on a
1962
+ * retry-later `resume.preflight_rejected`: the deployment preflight's own wait hint, when it
1963
+ * gave one (same carrier, same semantics: retry the SAME token after the wait). Absent on
1964
+ * every other code. */
1897
1965
  retryAfterMs?: number;
1898
1966
  /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
1899
1967
  * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
@@ -2020,6 +2088,30 @@ export declare class CheckpointError extends Error {
2020
2088
  * refusal exists to close. Rejected PRE-CAS (the checkpoint stays `pending`); re-resume with the
2021
2089
  * original principal, or omit the field to inherit the recorded one. */
2022
2090
  | "resume.principal_mismatch"
2091
+ /** design/380 O1③ (placement identity continuity — the `resume.principal_mismatch` rung's twin
2092
+ * on the placement axis): the resume supplied trusted internals whose EXPLICIT
2093
+ * `placementRoot` differs from the {@link CheckpointState.placementRootSessionId} recorded at
2094
+ * the suspend mint. A target-bound env factory keys its placement lookup on that fixed point,
2095
+ * so silently resuming under a different one would re-place the leg — and every descendant
2096
+ * the child-internals chain hands it to — on another target than the parked workspace lives
2097
+ * on. A supplied `rootSessionId` deliberately does NOT take this refusal (rescan C1): it is
2098
+ * delegation identity, re-supplied by every revive leg, and legitimately differs from the
2099
+ * recorded root whenever the spawner carried an explicit placement — the recorded root simply
2100
+ * outranks it in prepare's restore fold. Rejected PRE-CAS (the checkpoint stays `pending`);
2101
+ * re-resume with the recorded root, or omit `internals.placementRoot` to inherit it. Either
2102
+ * side absent (an older row / a claim-less resume) skips the check, and `""` reads as absence
2103
+ * — the honest degradation arm. */
2104
+ | "resume.placement_mismatch"
2105
+ /** design/380 O2: the deployment's `RunnerDeps.resumePreflight` refused this resume — an
2106
+ * explicit refusal verdict, a throw, a timeout past its bounded deadline, or an unreadable
2107
+ * answer (all fail-closed). The LAST rung before the CAS, so nothing was consumed. Two arms,
2108
+ * discriminated by the message: the default `retry_later` arm leaves the checkpoint `pending`
2109
+ * and the SAME token redeemable once the obstacle clears (`detail.retryAfterMs` carries the
2110
+ * deployment's wait hint when it gave one — the #449 G1 carrier); the explicit `terminal` arm
2111
+ * has ALREADY settled the row out of `pending` via the single-shot `expire` CAS (reaper
2112
+ * parity) before this throw — the token is NOT redeemable, by the deployment's own permanent
2113
+ * verdict (a dead binding must not be redialed forever). */
2114
+ | "resume.preflight_rejected"
2023
2115
  /** F-012 L3: the resume carried an approver EDIT (`updatedInput`) and a FROZEN inherited ancestor
2024
2116
  * constraint (the persisted projection chain) — or the deployment's resume-edit policy — DENIES the
2025
2117
  * edited args. Refused PRE-CAS with fresh-redecision semantics: the checkpoint stays `pending` and
@@ -2047,8 +2139,11 @@ export declare class CheckpointError extends Error {
2047
2139
  * discriminant. */
2048
2140
  detail?: {
2049
2141
  field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
2050
- /** #449 G1 — on `resume.usage_window_exhausted` ONLY: how long until the binding window frees
2051
- * (the same wait a suspended row carries as `gate.resumeAfterMs`). Absent on every other code. */
2142
+ /** #449 G1 — on `resume.usage_window_exhausted`: how long until the binding window frees
2143
+ * (the same wait a suspended row carries as `gate.resumeAfterMs`). design/380 O2 on a
2144
+ * retry-later `resume.preflight_rejected`: the deployment preflight's own wait hint, when it
2145
+ * gave one (same carrier, same semantics: retry the SAME token after the wait). Absent on
2146
+ * every other code. */
2052
2147
  retryAfterMs?: number;
2053
2148
  /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
2054
2149
  * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
@@ -38,6 +38,27 @@ export interface FsWriteGatePolicyOptions {
38
38
  * asks for an UNRESOLVABLE target fire before any canonical key exists, so they are never exempted.
39
39
  */
40
40
  isExempt?: (toolName: string, canonicalPath: string) => boolean | Promise<boolean>;
41
+ /**
42
+ * design/380 O13 — the deployment HARD-CLOSE knob for guarded writes. Armed (`true`), a covered
43
+ * write whose env does NOT provide `writeFileGuarded` is REFUSED (deny, typed message) — never the
44
+ * plain-writeFile fallback: for a deployment that exists to close the adjudicate→write TOCTOU
45
+ * window, a silent advisory downgrade would open the hole right beside its patch. The version-skew
46
+ * arm is the same refusal (an older adapter without the method IS an env without the capability).
47
+ * Default off = every verdict ACTION exactly as before (the capability's ABSENCE is still disclosed
48
+ * on the ask face either way — see the advisory sentence at the ask sites). The knob lives on the
49
+ * deployment's gate options, not in core-global state: core stays lane-agnostic (no device concept
50
+ * here); the deployment that runs guarded-capable envs arms it.
51
+ *
52
+ * WHAT ARMED DOES AND DOES NOT CLOSE (honesty boundary, codex r1-1): armed guarantees every covered
53
+ * write lands through an ATOMIC precondition-verified backend step — the tool's own
54
+ * canonicalize→write window is closed. It does NOT yet bind THIS GATE's adjudicated canonical key
55
+ * across an approval wait: the tool re-canonicalizes at execute time, so a symlink swapped between
56
+ * this gate's verdict and the tool's execution is re-resolved (and then atomically verified against
57
+ * the NEW resolution). Closing that remaining window needs the adjudicated key carried through a
58
+ * trusted approval receipt into tool execution — a separate seat, deliberately not smuggled into
59
+ * this knob.
60
+ */
61
+ requireGuardedWrite?: boolean;
41
62
  }
42
63
  /**
43
64
  * Build the file-write approval gate policy. Mount via `combinePolicies` alongside the deployment's
@@ -6,18 +6,29 @@ export function createFsWriteGatePolicy(opts) {
6
6
  const gated = PATH_CONFINABLE_WRITE_TOOLS;
7
7
  const acceptDirs = opts.acceptDirs && opts.acceptDirs.length > 0 ? opts.acceptDirs : undefined;
8
8
  const exemptDirs = opts.exemptDirs && opts.exemptDirs.length > 0 ? opts.exemptDirs : undefined;
9
+ const guardedCapable = env.writeFileGuarded !== undefined;
10
+ const advisory = guardedCapable
11
+ ? ""
12
+ : " (advisory adjudication: this environment provides no guarded write, so approval is judged before a separate, non-atomic write step)";
9
13
  return {
10
14
  async check(req, signal) {
11
15
  const canonical = req.toolName;
12
16
  if (!gated.has(canonical))
13
17
  return { action: "allow" };
18
+ if (opts.requireGuardedWrite === true && !guardedCapable) {
19
+ return {
20
+ action: "deny",
21
+ message: `write tool "${req.toolName}" refused: this deployment requires guarded (atomic precondition-verified) writes (requireGuardedWrite), but the execution environment provides no writeFileGuarded capability — refusing rather than falling back to a non-atomic write`,
22
+ decisionReason: "rule",
23
+ };
24
+ }
14
25
  const path = writeTargetPath(canonical, req.args);
15
26
  if (path === undefined) {
16
- return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine`);
27
+ return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${advisory}`);
17
28
  }
18
29
  const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
19
30
  if (!canon.ok) {
20
- return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})`);
31
+ return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})${advisory}`);
21
32
  }
22
33
  for (const dirs of [exemptDirs, acceptDirs]) {
23
34
  if (!dirs)
@@ -46,7 +57,7 @@ export function createFsWriteGatePolicy(opts) {
46
57
  }
47
58
  if (defaultWrite === "allow")
48
59
  return { action: "allow" };
49
- return ask(`approve write to "${path}"?`);
60
+ return ask(`approve write to "${path}"?${advisory}`);
50
61
  },
51
62
  };
52
63
  }
@@ -278,6 +278,10 @@ export interface Hooks {
278
278
  * answer in a second says so with `timeoutMs`.
279
279
  */
280
280
  export declare const DEFAULT_HOOK_TIMEOUT_MS = 600000;
281
+ /** `setTimeout`'s own ceiling: above it the platform silently clamps to ~1ms, which would turn a
282
+ * deployment's "very long bound" into an instant expiry it never asked for. Refused loudly instead
283
+ * (same ceiling, same posture as the gate's `approvalTimeoutMs`). */
284
+ export declare const MAX_HOOK_TIMEOUT_MS = 2147483647;
281
285
  /**
282
286
  * How long an observation seat still gets when the task is cancelled `elapsed` ms into its wait — the
283
287
  * grace, capped by what is LEFT of the declared deadline.
@@ -303,7 +307,11 @@ export declare function postAbortGraceMs(remainingMs: number): number;
303
307
  */
304
308
  export declare function resolveHookTimeoutMs(supplied: number | undefined, report?: (err: unknown) => void, owner?: object): number;
305
309
  /** The ten {@link Hooks} callback seats, named so a bound wait can say WHICH one did not answer. */
306
- export type HookSeatName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "stop" | "stopFailure" | "preCompact" | "postCompact" | "permissionDenied";
310
+ export type HookSeatName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "stop" | "stopFailure" | "preCompact" | "postCompact" | "permissionDenied"
311
+ /** design/380 O2 — not a {@link Hooks} record seat: the `RunnerDeps.resumePreflight` deployment
312
+ * callback, run through the same bounded-wait home so the pre-CAS resume ladder cannot grow its
313
+ * own dialect of "a deployment callback gets a deadline". */
314
+ | "resumePreflight";
307
315
  /** What one bounded seat invocation produced: the seat's own value, or the fact that the wait ended
308
316
  * first. A seat that THREW is neither — the throw propagates out of {@link runHookSeat} unchanged, so
309
317
  * each site's existing crash arm keeps owning it. */
@@ -7,7 +7,7 @@ import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
7
7
  import { createSafeNotifier } from "./safe-notify.js";
8
8
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
9
9
  export const DEFAULT_HOOK_TIMEOUT_MS = 600_000;
10
- const MAX_HOOK_TIMEOUT_MS = 2_147_483_647;
10
+ export const MAX_HOOK_TIMEOUT_MS = 2_147_483_647;
11
11
  const POST_ABORT_GRACE_MS = 1_000;
12
12
  export function postAbortGraceMs(remainingMs) {
13
13
  return Math.max(0, Math.min(POST_ABORT_GRACE_MS, remainingMs));
@@ -63,6 +63,9 @@ export function mergeHarvestReports(a, b) {
63
63
  restored: [...a.restored, ...b.restored],
64
64
  warnings: [...a.warnings, ...b.warnings],
65
65
  };
66
+ const reportId = a.reportId ?? b.reportId;
67
+ if (reportId !== undefined)
68
+ merged.reportId = reportId;
66
69
  const incident = a.incident ?? b.incident;
67
70
  if (incident !== undefined)
68
71
  merged.incident = incident;
@@ -443,6 +443,7 @@ export declare function memoryHarvestQuarantinedNotice(input: {
443
443
  sessionId?: string;
444
444
  provenance?: "off" | "carry";
445
445
  indexRolledBack?: boolean;
446
+ reportId?: string;
446
447
  }): EngineNotice;
447
448
  /**
448
449
  * design/336 §6.3 — the hold family's operator notices, derived from ONE harvest report's
@@ -111,12 +111,13 @@ export function pollutionContainmentCounts(report) {
111
111
  }
112
112
  export function memoryHarvestQuarantinedNotice(input) {
113
113
  const reason = input.reason !== undefined ? inlineUntrusted(input.reason, 200) : undefined;
114
+ const reportId = input.reportId !== undefined ? { reportId: input.reportId } : {};
114
115
  if (input.count === 0 && input.indexRolledBack === true) {
115
116
  return {
116
117
  code: "memory.harvest_quarantined",
117
118
  message: `Memory harvest rolled the derived index (MEMORY.md) back to its materialize-time baseline${reason !== undefined ? ` (${reason})` : ""}: ` +
118
119
  `this session's index prose additions were captured to control-plane quarantine and not retained. No entry files were withheld.`,
119
- detail: { count: 0, moved: 0, escalated: 0, indexRolledBack: true, ...(reason !== undefined ? { reason } : {}), ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}) },
120
+ detail: { count: 0, moved: 0, escalated: 0, indexRolledBack: true, ...reportId, ...(reason !== undefined ? { reason } : {}), ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}) },
120
121
  };
121
122
  }
122
123
  const lead = input.provenance === "carry"
@@ -135,6 +136,7 @@ export function memoryHarvestQuarantinedNotice(input) {
135
136
  count: input.count,
136
137
  moved: input.moved,
137
138
  escalated: input.escalated,
139
+ ...reportId,
138
140
  ...(reason !== undefined ? { reason } : {}),
139
141
  ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
140
142
  },
@@ -145,6 +147,7 @@ export function memoryHoldNotices(report, sessionId) {
145
147
  if (c === undefined)
146
148
  return [];
147
149
  const caps = (paths) => paths.slice(0, 20).map((p) => inlineUntrusted(p, 160));
150
+ const reportId = report.reportId !== undefined ? { reportId: report.reportId } : {};
148
151
  const notices = [];
149
152
  if (c.heldInstruction.length > 0) {
150
153
  notices.push({
@@ -152,14 +155,14 @@ export function memoryHoldNotices(report, sessionId) {
152
155
  message: `Memory harvest HELD ${c.heldInstruction.length} instruction-form entry file(s): this session's delegation outcome ` +
153
156
  `is still pending, so instruction entries wait off the model-visible plane and commit automatically once every ` +
154
157
  `delegation settles clean (a dirty or expired settlement disposes them to quarantine, announced; resolveHold is the host valve).`,
155
- detail: { count: c.heldInstruction.length, paths: caps(c.heldInstruction), ...(sessionId !== undefined ? { sessionId } : {}) },
158
+ detail: { count: c.heldInstruction.length, paths: caps(c.heldInstruction), ...reportId, ...(sessionId !== undefined ? { sessionId } : {}) },
156
159
  });
157
160
  }
158
161
  if (c.releasedHolds.length > 0) {
159
162
  notices.push({
160
163
  code: "memory.hold_released",
161
164
  message: `Memory harvest RELEASED ${c.releasedHolds.length} held instruction entry file(s) — their writer sessions settled clean (or a host valve released them) and the entries re-walked the full gate set and committed.`,
162
- detail: { count: c.releasedHolds.length, paths: caps(c.releasedHolds), ...(sessionId !== undefined ? { sessionId } : {}) },
165
+ detail: { count: c.releasedHolds.length, paths: caps(c.releasedHolds), ...reportId, ...(sessionId !== undefined ? { sessionId } : {}) },
163
166
  });
164
167
  }
165
168
  if (c.disposedHolds.length > 0) {
@@ -168,7 +171,7 @@ export function memoryHoldNotices(report, sessionId) {
168
171
  message: `Memory harvest DISPOSED ${c.disposedHolds.length} held instruction entry file(s) to control-plane quarantine ` +
169
172
  `(terminals: ${[...new Set(c.disposedHolds.map((d) => d.terminal))].join(", ")}). An "expired" terminal is a TIMEOUT, not a ` +
170
173
  `conviction — resolveHold(holdId, "release") commits such an entry with a "static"-cause marker after host review; the bytes are never silently dropped.`,
171
- detail: { count: c.disposedHolds.length, disposed: c.disposedHolds.slice(0, 20).map((d) => ({ path: inlineUntrusted(d.relPath, 160), terminal: d.terminal })), ...(sessionId !== undefined ? { sessionId } : {}) },
174
+ detail: { count: c.disposedHolds.length, disposed: c.disposedHolds.slice(0, 20).map((d) => ({ path: inlineUntrusted(d.relPath, 160), terminal: d.terminal })), ...reportId, ...(sessionId !== undefined ? { sessionId } : {}) },
172
175
  });
173
176
  }
174
177
  return notices;
@@ -180,7 +183,7 @@ export function memoryConsolidationRecommendedNotice(report, sessionId) {
180
183
  return {
181
184
  code: "memory.consolidation_recommended",
182
185
  message: `Memory consolidation is recommended for scope ${inlineUntrusted(rec.scope, 80)}: ${rec.sessionsSince} distinct session(s) harvested since the last run and the time gate is open. The host decides — nothing runs automatically.`,
183
- detail: { scope: inlineUntrusted(rec.scope, 80), sessionsSince: rec.sessionsSince, ...(sessionId !== undefined ? { sessionId } : {}) },
186
+ detail: { scope: inlineUntrusted(rec.scope, 80), sessionsSince: rec.sessionsSince, ...(report.reportId !== undefined ? { reportId: report.reportId } : {}), ...(sessionId !== undefined ? { sessionId } : {}) },
184
187
  };
185
188
  }
186
189
  export function memoryConsolidationCommittedNotice(input) {
@@ -1279,6 +1282,7 @@ export class MemoryEngine {
1279
1282
  arr.push(rel);
1280
1283
  };
1281
1284
  const report = {
1285
+ reportId: uuidv7(),
1282
1286
  ok: true,
1283
1287
  patches: { add: 0, update: 0, delete: 0 },
1284
1288
  conflicts: [],
@@ -497,6 +497,22 @@ export interface MemoryAnnouncement {
497
497
  * structure; the runner's boundary-announcement wiring (attachments member) lands in S2 (TODO(S2)).
498
498
  */
499
499
  export interface HarvestReport {
500
+ /** #479 — this report's own per-occurrence identity: a uuidv7 minted ONCE where the engine
501
+ * creates the report (`harvestCore`'s single literal — every exit path, including the refused
502
+ * and zero-admission arms, returns that one object), and read wherever a derived face needs to
503
+ * name this harvest: the `memory.harvest_quarantined` + `memory.hold_*` notice family carries
504
+ * it as `detail.reportId`. Occurrence semantics, both directions: two harvests whose counts and
505
+ * paths happen to coincide are two facts and get two ids (the un-dedupable five-key repeat this
506
+ * member exists to break), while every notice derived from ONE report shares the one id — a
507
+ * consumer's dedup key is therefore `(code, reportId)`, never `reportId` alone. The report is
508
+ * transient (delivered via `RunnerDeps.onMemoryHarvestReport` and the notice lane; the engine
509
+ * persists no report row), so replay-stability comes from the id riding whatever record the
510
+ * HOST persists: a durable replay re-reads the recorded value, a genuinely new harvest mints a
511
+ * new one — per-delivery minting (the fail-open shape) is exactly what this seat avoids.
512
+ * Optional in the TYPE only for hand-built reports (external constructors,
513
+ * `mergeHarvestReports` inputs); every engine-minted report carries it, and the notice
514
+ * factories OMIT the detail key rather than invent one when it is absent. */
515
+ reportId?: string;
500
516
  /** False ⇔ the whole harvest was refused (deletion fuse / missing dir / corrupt sidecar incident). */
501
517
  ok: boolean;
502
518
  /** Set when a fuse tripped (§2.4 删除防线 / B3 fail-closed): the harvest is refused wholesale, NO
@@ -58,6 +58,13 @@ export interface WorkspaceHandle {
58
58
  * `snapshotId` as corruption (fail-closed) unless the resumed env is itself non-suspendable
59
59
  * (tolerance for park handles minted by 1.257.1 before this field existed). */
60
60
  restoreMode?: "park_only";
61
+ /**
62
+ * design/380 O1④ — explicit identity of the physical/target device this workspace lives on
63
+ * (device lane; an SSH/ADB adapter MAY also stamp it). MINTER-STATED: consumers read this field,
64
+ * never parse an identity out of `sandboxId`. Plain string → rides CheckpointState.workspaceHandle
65
+ * (all-string whitelist) with zero schema movement.
66
+ */
67
+ deviceId?: string;
61
68
  }
62
69
  /**
63
70
  * A reference to a secret injected at {@link RemoteExecutionEnv.connect}/{@link RemoteExecutionEnv.postResumeInit}
@@ -213,6 +220,16 @@ export type RemoteExecutionErrorCode =
213
220
  * else a retry whitelist misses them.
214
221
  */
215
222
  | "transport_lost"
223
+ /**
224
+ * design/380 O12 — the op was COMMITTED to the remote target and its outcome is UNKNOWABLE (the target
225
+ * went unreachable / restarted / was revoked after dispatch-commit; an execStream cut where the target
226
+ * protocol's own server has ruled the outcome unknowable). NEVER auto-retried and deliberately NOT in
227
+ * {@link RETRYABLE_REMOTE_ERROR_CODES}: the command may have already executed — a retry whitelist that
228
+ * contained it would re-drive committed side effects. Distinct from `transport_lost` (connection story
229
+ * known, idempotency-gated retry after reconnect). Message MUST carry the verify-first sentence ("may
230
+ * have already executed on the target; verify its effect before re-running").
231
+ */
232
+ | "outcome_unknown"
216
233
  /** Unclassified provider/transport failure. */
217
234
  | "unknown";
218
235
  /**
@@ -220,8 +237,10 @@ export type RemoteExecutionErrorCode =
220
237
  * can succeed (`auth_transient`: retry-exactly-once after the device is authorized; `connect_failed`: the
221
238
  * workspace could not be reached/provisioned this attempt; `timeout`: a liveness bound tripped;
222
239
  * `transport_lost`: re-establish the connection). Every OTHER code is permanent for this attempt
223
- * (`auth_failed`/`unsupported`), caller-driven (`aborted`), or unclassifiable (`unknown`) — retrying them
224
- * burns budget or, worse, re-drives a rejected credential.
240
+ * (`auth_failed`/`unsupported`), caller-driven (`aborted`), unclassifiable (`unknown`), or
241
+ * outcome-unknowable (`outcome_unknown` design/380 O12: the op may have ALREADY executed, so a retry
242
+ * re-drives committed side effects) — retrying them burns budget or, worse, re-drives a rejected
243
+ * credential or a committed op.
225
244
  *
226
245
  * ONE list, two consumers, so a caller's retry decision and its DISCLOSURE can never disagree: the engine
227
246
  * retries only IDEMPOTENT ops on these codes (see {@link withRetry}'s red line — a snapshot-taking
@@ -405,6 +424,19 @@ export interface RemoteExecutionEnv extends ExecutionEnv {
405
424
  export interface ExecutionEnvFactoryContext {
406
425
  /** Resolved session id for the task — the stable identity of its per-task workspace. */
407
426
  sessionId: string;
427
+ /**
428
+ * design/380 O1① — the run TREE's placement root (fixed point), minted by prepare AFTER session
429
+ * resolution as `internals.placementRoot ?? internals.rootSessionId ?? sessionId` — a top-level
430
+ * run's own resolved sessionId, every descendant's inherited root, verbatim (delegation and
431
+ * workflow spawn chains re-thread an explicit `placementRoot` — C12; a bare DURABLE RESUME crosses
432
+ * too since design/380 O1③: the suspend mint stamps the fixed point on the checkpoint and the
433
+ * resume restores it into trusted internals — formerly the pinned residual). A target-bound
434
+ * factory (device lane) keys its placement lookup on THIS, never on `sessionId` (a child's fresh
435
+ * session id must not read as a new placement). Always present and non-empty — an empty value is
436
+ * an assembly error the engine refuses loudly at the mint (`config.placement_root_invalid`) and a
437
+ * factory MUST refuse loudly too (loud-bad-value posture), never default around.
438
+ */
439
+ placementRootSessionId: string;
408
440
  /** Caller-supplied task id, when set on the `TaskSpec`. */
409
441
  taskId?: string;
410
442
  /**