@gajae-code/coding-agent 0.11.6 → 0.11.8

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 (259) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/commands/daemon.d.ts +2 -2
  6. package/dist/types/commands/sdk.d.ts +20 -2
  7. package/dist/types/config/keybindings.d.ts +34 -4
  8. package/dist/types/config/model-profile-activation.d.ts +4 -3
  9. package/dist/types/config/model-profile-contract.d.ts +38 -0
  10. package/dist/types/config/settings-schema.d.ts +70 -7
  11. package/dist/types/edit/modes/patch.d.ts +13 -0
  12. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  16. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
  18. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
  20. package/dist/types/main.d.ts +32 -1
  21. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  22. package/dist/types/modes/components/model-selector.d.ts +2 -0
  23. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  25. package/dist/types/modes/components/welcome.d.ts +2 -0
  26. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  29. package/dist/types/modes/interactive-mode.d.ts +7 -2
  30. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  31. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  32. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  33. package/dist/types/modes/types.d.ts +4 -0
  34. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  35. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/sdk/broker/broker.d.ts +2 -0
  40. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  41. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
  42. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  43. package/dist/types/sdk/bus/config.d.ts +2 -0
  44. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  45. package/dist/types/sdk/bus/index.d.ts +26 -1
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  49. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  50. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
  51. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  52. package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
  53. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  54. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  55. package/dist/types/sdk/client/discovery.d.ts +2 -0
  56. package/dist/types/sdk/client/liveness.d.ts +7 -0
  57. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  58. package/dist/types/sdk/host/host.d.ts +1 -1
  59. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  60. package/dist/types/sdk/index.d.ts +2 -0
  61. package/dist/types/sdk/prompt-status.d.ts +88 -0
  62. package/dist/types/sdk/session-directory.d.ts +1 -1
  63. package/dist/types/sdk/startup-capability.d.ts +3 -0
  64. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  65. package/dist/types/sdk/transport/index.d.ts +12 -0
  66. package/dist/types/sdk/transport/relay.d.ts +43 -0
  67. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  68. package/dist/types/sdk/transport/socket.d.ts +7 -0
  69. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  70. package/dist/types/session/agent-session.d.ts +30 -5
  71. package/dist/types/session/btw-contract.d.ts +16 -0
  72. package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
  73. package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
  74. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  75. package/dist/types/session/session-manager.d.ts +25 -4
  76. package/dist/types/session/session-storage.d.ts +18 -0
  77. package/dist/types/setup/credential-import.d.ts +5 -1
  78. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  79. package/dist/types/ssh/utils.d.ts +1 -0
  80. package/dist/types/task/discovery.d.ts +3 -1
  81. package/dist/types/task/index.d.ts +3 -1
  82. package/dist/types/task/receipt.d.ts +2 -0
  83. package/dist/types/task/types.d.ts +94 -0
  84. package/dist/types/tools/ask.d.ts +454 -174
  85. package/dist/types/tools/fetch.d.ts +5 -8
  86. package/dist/types/tools/image-gen.d.ts +63 -1
  87. package/dist/types/tools/index.d.ts +4 -0
  88. package/dist/types/tools/skill-discovery.d.ts +7 -0
  89. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  90. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  91. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  92. package/dist/types/web/insane/bridge.d.ts +1 -0
  93. package/dist/types/web/insane/url-guard.d.ts +17 -0
  94. package/package.json +8 -8
  95. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  96. package/scripts/dogfood-repository-binding.ts +209 -0
  97. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  98. package/scripts/run-test-manifest.ts +4 -4
  99. package/src/capability/index.ts +50 -42
  100. package/src/capability/types.ts +3 -0
  101. package/src/cli/args.ts +2 -92
  102. package/src/cli/notify-cli.ts +5 -38
  103. package/src/cli/setup-cli.ts +10 -4
  104. package/src/cli.ts +52 -9
  105. package/src/commands/daemon.ts +22 -5
  106. package/src/commands/sdk.ts +44 -11
  107. package/src/config/keybindings.ts +158 -25
  108. package/src/config/model-profile-activation.ts +7 -18
  109. package/src/config/model-profile-contract.ts +161 -0
  110. package/src/config/model-profiles.ts +18 -0
  111. package/src/config/model-registry.ts +11 -1
  112. package/src/config/settings-schema.ts +73 -7
  113. package/src/coordinator-mcp/model-preset.ts +21 -74
  114. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  115. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  116. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  117. package/src/discovery/ssh.ts +12 -1
  118. package/src/edit/index.ts +3 -6
  119. package/src/edit/modes/patch.ts +38 -1
  120. package/src/edit/modes/replace.ts +42 -0
  121. package/src/edit/path-mutation-lock.ts +112 -0
  122. package/src/eval/py/executor.ts +30 -11
  123. package/src/eval/py/kernel.ts +1 -21
  124. package/src/eval/py/prelude.py +2 -2
  125. package/src/eval/py/runner-artifact.ts +84 -0
  126. package/src/eval/py/tool-bridge.ts +34 -18
  127. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  128. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  129. package/src/gjc-runtime/launch-tmux.ts +1 -1
  130. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  131. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  132. package/src/gjc-runtime/repository-binding.ts +267 -0
  133. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  134. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  135. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  136. package/src/gjc-runtime/ultragoal-guard.ts +93 -15
  137. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  138. package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
  139. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  140. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  141. package/src/internal-urls/docs-index.generated.ts +12 -10
  142. package/src/internal-urls/local-protocol.ts +64 -21
  143. package/src/internal-urls/mcp-protocol.ts +73 -11
  144. package/src/main.ts +111 -37
  145. package/src/modes/DESIGN.md +5 -0
  146. package/src/modes/acp/acp-agent.ts +50 -16
  147. package/src/modes/action-registry.ts +1 -1
  148. package/src/modes/components/btw-panel.ts +82 -16
  149. package/src/modes/components/model-selector.ts +73 -11
  150. package/src/modes/components/plan-preview-overlay.ts +1 -1
  151. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  152. package/src/modes/components/tool-status-header.ts +25 -4
  153. package/src/modes/components/welcome.ts +22 -18
  154. package/src/modes/controllers/btw-controller.ts +119 -36
  155. package/src/modes/controllers/command-controller.ts +97 -54
  156. package/src/modes/controllers/event-controller.ts +10 -0
  157. package/src/modes/controllers/input-controller.ts +38 -2
  158. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  159. package/src/modes/controllers/selector-controller.ts +149 -63
  160. package/src/modes/interactive-mode.ts +57 -34
  161. package/src/modes/prompt-action-autocomplete.ts +32 -13
  162. package/src/modes/prompt-suggestion-controller.ts +96 -0
  163. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  164. package/src/modes/types.ts +4 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  166. package/src/modes/utils/ui-helpers.ts +5 -3
  167. package/src/prompts/agents/architect.md +0 -1
  168. package/src/prompts/agents/critic.md +0 -1
  169. package/src/prompts/system/btw-user.md +3 -8
  170. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  171. package/src/prompts/system/system-prompt.md +6 -4
  172. package/src/prompts/tools/cron.md +3 -1
  173. package/src/prompts/tools/skill-discovery.md +1 -0
  174. package/src/runtime-mcp/client.ts +41 -55
  175. package/src/runtime-mcp/content-limits.ts +71 -0
  176. package/src/runtime-mcp/json-rpc.ts +23 -9
  177. package/src/runtime-mcp/manager.ts +51 -53
  178. package/src/runtime-mcp/oauth-flow.ts +0 -72
  179. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  180. package/src/runtime-mcp/transports/http.ts +63 -22
  181. package/src/sdk/broker/broker.ts +34 -20
  182. package/src/sdk/broker/lifecycle.ts +185 -36
  183. package/src/sdk/bus/chat-daemon-control.ts +19 -5
  184. package/src/sdk/bus/config-commands.ts +2 -2
  185. package/src/sdk/bus/config.ts +9 -1
  186. package/src/sdk/bus/conversation-store.ts +40 -6
  187. package/src/sdk/bus/index.ts +244 -70
  188. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  189. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  190. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  191. package/src/sdk/bus/notification-orchestration.ts +12 -0
  192. package/src/sdk/bus/notification-service.ts +68 -20
  193. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  194. package/src/sdk/bus/recent-activity.ts +90 -26
  195. package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
  196. package/src/sdk/bus/telegram-daemon-control.ts +147 -35
  197. package/src/sdk/bus/telegram-daemon.ts +2463 -450
  198. package/src/sdk/bus/telegram-reference.ts +20 -2
  199. package/src/sdk/bus/topic-registry.ts +451 -12
  200. package/src/sdk/cli/session-cli.ts +15 -2
  201. package/src/sdk/client/discovery.ts +19 -3
  202. package/src/sdk/client/liveness.ts +49 -0
  203. package/src/sdk/host/control/dispatch.ts +1 -1
  204. package/src/sdk/host/control/operations.ts +1 -1
  205. package/src/sdk/host/host.ts +2 -6
  206. package/src/sdk/host/query/handlers.ts +70 -5
  207. package/src/sdk/index.ts +12 -0
  208. package/src/sdk/prompt-status.ts +97 -0
  209. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  210. package/src/sdk/protocol/operation-registry.ts +18 -4
  211. package/src/sdk/session-directory.ts +5 -1
  212. package/src/sdk/session.ts +31 -5
  213. package/src/sdk/startup-capability.ts +9 -1
  214. package/src/sdk/transport/auth-preface.ts +82 -0
  215. package/src/sdk/transport/index.ts +18 -0
  216. package/src/sdk/transport/relay.ts +252 -0
  217. package/src/sdk/transport/serve-cli.ts +105 -0
  218. package/src/sdk/transport/socket.ts +197 -0
  219. package/src/sdk/transport/stdio.ts +17 -0
  220. package/src/session/agent-session.ts +292 -154
  221. package/src/session/blob-store.ts +25 -7
  222. package/src/session/btw-contract.ts +59 -0
  223. package/src/session/internal/managed-session-scope.ts +1163 -137
  224. package/src/session/internal/managed-session-storage.ts +397 -234
  225. package/src/session/internal/native-publish-outcome.ts +266 -0
  226. package/src/session/session-manager.ts +620 -403
  227. package/src/session/session-storage.ts +128 -11
  228. package/src/setup/credential-import.ts +28 -3
  229. package/src/setup/provider-onboarding.ts +30 -1
  230. package/src/setup/provider-presets.json +2 -1
  231. package/src/slash-commands/builtin-registry.ts +1 -1
  232. package/src/ssh/utils.ts +14 -0
  233. package/src/task/discovery.ts +8 -2
  234. package/src/task/gjc-command.ts +2 -2
  235. package/src/task/index.ts +121 -15
  236. package/src/task/receipt.ts +3 -0
  237. package/src/task/types.ts +34 -3
  238. package/src/tools/ask.ts +199 -89
  239. package/src/tools/ast-edit.ts +2 -2
  240. package/src/tools/bash-pty-selection.ts +2 -2
  241. package/src/tools/browser/screenshot-format.ts +6 -1
  242. package/src/tools/cron.ts +1 -1
  243. package/src/tools/fetch.ts +13 -131
  244. package/src/tools/image-gen.ts +476 -23
  245. package/src/tools/index.ts +4 -0
  246. package/src/tools/read.ts +2 -0
  247. package/src/tools/render-utils.ts +7 -1
  248. package/src/tools/skill-discovery.ts +39 -3
  249. package/src/tools/sqlite-reader.ts +63 -5
  250. package/src/utils/edit-mode.ts +2 -2
  251. package/src/utils/pasted-image-path.ts +15 -6
  252. package/src/utils/prompt-suggestion.ts +265 -0
  253. package/src/utils/shell-snapshot.ts +173 -63
  254. package/src/utils/sixel.ts +3 -3
  255. package/src/web/insane/bridge.ts +5 -1
  256. package/src/web/insane/url-guard.ts +140 -27
  257. package/src/web/scrapers/docs-rs.ts +30 -5
  258. package/src/web/scrapers/types.ts +19 -49
  259. package/src/web/scrapers/utils.ts +12 -26
@@ -3,28 +3,39 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import * as native from "@gajae-code/natives";
6
- import { canonicalExistingDirectoryIdentity, verifyOwnerOnlyPathSecurity } from "@gajae-code/natives";
6
+ import {
7
+ canonicalExistingDirectoryIdentity,
8
+ verifyOwnerOnlyPathSecurity,
9
+ verifyOwnerOnlyPathSecurityExpected,
10
+ } from "@gajae-code/natives";
7
11
  import { pathIsWithin } from "@gajae-code/utils";
8
12
  import type { ResumeSessionIdentity } from "../session-manager";
9
13
  import {
10
14
  FileSessionStorage,
11
15
  type NativeDirectoryTreeSnapshot,
12
16
  type SessionStorageFileIdentity,
17
+ type VerifiedSessionDeleteResult,
13
18
  } from "../session-storage";
14
19
  import {
15
20
  acquireManagedLock,
21
+ assertManagedDirectoryRoot,
16
22
  captureManagedFileNoFollow,
17
23
  captureManagedFilePrefixNoFollow,
18
24
  copyManagedFileNoReplace,
19
25
  ensureManagedDirectory,
20
26
  fsyncManagedArtifactTree,
27
+ type ManagedDirectoryRoot,
28
+ type ManagedFileSnapshot,
29
+ ManagedPublishError,
21
30
  ManagedSessionDescendantStore,
31
+ type ManagedSessionSecurityPolicy,
22
32
  type ManagedStorageLock,
23
- managedDirectoryRoot,
33
+ prepareManagedDirectoryRoot,
24
34
  publishManagedFileNoReplace,
25
35
  publishManagedTombstone,
26
36
  retainManagedDirectoryAuthority,
27
37
  validateManagedArtifactTree,
38
+ validateNativeSecurityResult,
28
39
  } from "./managed-session-storage";
29
40
 
30
41
  export const MANAGED_SESSION_LAYOUT_VERSION = 2 as const;
@@ -44,9 +55,52 @@ export interface ManagedScope {
44
55
  platform: "posix" | "win32";
45
56
  }
46
57
 
47
- const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof managedDirectoryRoot>>();
58
+ /**
59
+ * Opaque managed writer authority captured by a trusted destination. The open
60
+ * transaction must use this authority rather than reacquiring its root from a
61
+ * pathname after resume inspection.
62
+ */
63
+ export interface ManagedCandidateWriteAuthority {
64
+ readonly rootAuthority: ManagedDirectoryRoot;
65
+ readonly retainedAuthority?: native.RecoveryFsRoot;
66
+ readonly retainedDirectory?: string;
67
+ }
68
+
69
+ const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof prepareManagedDirectoryRoot>>();
48
70
  const managedDirectoryIdentities = new WeakMap<ManagedScope, { dev: bigint; ino: bigint }>();
49
71
  const managedDirectoryAuthorities = new WeakMap<ManagedScope, native.RecoveryFsRoot | undefined>();
72
+ const boundManagedWriteAuthorities = new WeakMap<ManagedScope, ManagedCandidateWriteAuthority>();
73
+
74
+ function bindManagedWriteAuthority(scope: ManagedScope, authority: ManagedCandidateWriteAuthority): void {
75
+ if (
76
+ authority.retainedAuthority &&
77
+ authority.retainedDirectory !== undefined &&
78
+ path.resolve(authority.retainedDirectory) === path.resolve(scope.directoryPath)
79
+ ) {
80
+ new ManagedSessionDescendantStore(authority.rootAuthority, scope.directoryPath, {
81
+ authority: authority.retainedAuthority,
82
+ authorityBaseDir: scope.directoryPath,
83
+ }).assertBound();
84
+ } else {
85
+ assertManagedDirectoryRoot(authority.rootAuthority);
86
+ }
87
+ managedRoots.set(scope, authority.rootAuthority);
88
+ boundManagedWriteAuthorities.set(scope, authority);
89
+ }
90
+
91
+ /** A prepared scope is a retained authority boundary, not a path that may be re-adopted. */
92
+ function assertRetainedManagedDirectoryIdentity(scope: ManagedScope): void {
93
+ const expected = managedDirectoryIdentities.get(scope);
94
+ if (!expected) return;
95
+ const current = fs.lstatSync(scope.directoryPath, { bigint: true });
96
+ if (
97
+ !current.isDirectory() ||
98
+ current.isSymbolicLink() ||
99
+ current.dev !== expected.dev ||
100
+ current.ino !== expected.ino
101
+ )
102
+ throw new Error("Managed session directory changed");
103
+ }
50
104
 
51
105
  export function managedDirectoryAuthorityForScope(scope: ManagedScope): native.RecoveryFsRoot | undefined {
52
106
  if (!managedDirectoryAuthorities.has(scope)) throw new Error("Managed session directory authority was not prepared");
@@ -61,24 +115,32 @@ export function managedDirectoryIdentityForScope(scope: ManagedScope): { dev: bi
61
115
 
62
116
  function configuredRootPath(scope: ManagedScope): string {
63
117
  let candidate = pathIsWithin(scope.agentDir, scope.sessionsRoot) ? scope.agentDir : path.dirname(scope.sessionsRoot);
118
+ const suffix: string[] = [];
64
119
  for (;;) {
65
120
  try {
66
121
  const stat = fs.lstatSync(candidate);
67
122
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe configured root: ${candidate}`);
68
- return fs.realpathSync.native(candidate);
123
+ const canonical = fs.realpathSync.native(candidate);
124
+ return suffix.length === 0 ? canonical : path.join(canonical, ...suffix);
69
125
  } catch (error) {
70
126
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
71
127
  const parent = path.dirname(candidate);
72
128
  if (parent === candidate) throw new Error("Configured managed root is unavailable.");
129
+ suffix.unshift(path.basename(candidate));
73
130
  candidate = parent;
74
131
  }
75
132
  }
76
133
  }
77
134
 
78
- function scopeRoot(scope: ManagedScope) {
135
+ function scopeRoot(scope: ManagedScope, policy: ManagedSessionSecurityPolicy = "default") {
136
+ const bound = boundManagedWriteAuthorities.get(scope);
137
+ if (bound) {
138
+ bindManagedWriteAuthority(scope, bound);
139
+ return bound.rootAuthority;
140
+ }
79
141
  const retained = managedRoots.get(scope);
80
142
  if (retained) return retained;
81
- const root = managedDirectoryRoot(configuredRootPath(scope));
143
+ const root = prepareManagedDirectoryRoot(configuredRootPath(scope), policy);
82
144
  managedRoots.set(scope, root);
83
145
  return root;
84
146
  }
@@ -96,11 +158,20 @@ export type ManagedScopeErrorCode =
96
158
  | "network_unsupported"
97
159
  | "sessions_root_unavailable"
98
160
  | "binding_conflict"
99
- | "binding_invalid";
161
+ | "binding_invalid"
162
+ | "atomic_unavailable"
163
+ | "invalid_request"
164
+ | "durability_failed"
165
+ | "durability_not_provable";
100
166
 
101
167
  export type ManagedScopeResolution =
102
168
  | { kind: "resolved"; scope: ManagedScope }
103
- | { kind: "error"; code: ManagedScopeErrorCode; message: string };
169
+ | {
170
+ kind: "error";
171
+ code: ManagedScopeErrorCode;
172
+ message: string;
173
+ cause?: { readonly classification: string; readonly diagnostic?: string };
174
+ };
104
175
 
105
176
  export interface ManagedCandidate {
106
177
  sessionId: string;
@@ -129,6 +200,9 @@ export type ManagedOpenFailure =
129
200
  | "source_changed"
130
201
  | "unsafe_artifacts"
131
202
  | "durability_failed"
203
+ | "atomic_unavailable"
204
+ | "invalid_request"
205
+ | "durability_not_provable"
132
206
  | "migration_retired"
133
207
  | "legacy_migration_disabled"
134
208
  | "managed_storage_unsupported";
@@ -140,6 +214,7 @@ export type ManagedOpenCandidateResult =
140
214
  export type ManagedDeleteCandidateResult =
141
215
  | { kind: "deleted"; tombstonePath: string }
142
216
  | { kind: "already_deleted"; tombstonePath: string }
217
+ | { kind: "cleanup_pending"; tombstonePath: string; phase: "artifacts" | "transcript"; message: string }
143
218
  | { kind: "error"; code: ManagedOpenFailure; message: string };
144
219
 
145
220
  type NativeIdentity =
@@ -155,8 +230,6 @@ type NativeIdentityFailureCode =
155
230
  | "identity_unavailable"
156
231
  | "io_error";
157
232
 
158
- type NativeSecurity = { ok: true } | { ok: false; code: string };
159
-
160
233
  interface Binding {
161
234
  schemaVersion: 1;
162
235
  layoutVersion: 2;
@@ -196,6 +269,22 @@ function identityFor(cwd: string): NativeIdentity {
196
269
  return canonicalExistingDirectoryIdentity(cwd) as NativeIdentity;
197
270
  }
198
271
 
272
+ function verifyExistingManagedScopeDirectory(pathname: string) {
273
+ if (process.platform !== "win32") return verifyOwnerOnlyPathSecurity(pathname, "directory");
274
+ const expected = fs.lstatSync(pathname, { bigint: true });
275
+ if (!expected.isDirectory() || expected.isSymbolicLink()) throw new Error("Unsafe managed directory");
276
+ const verified = verifyOwnerOnlyPathSecurityExpected(pathname, "directory", expected.dev, expected.ino);
277
+ const current = fs.lstatSync(pathname, { bigint: true });
278
+ if (
279
+ !current.isDirectory() ||
280
+ current.isSymbolicLink() ||
281
+ current.dev !== expected.dev ||
282
+ current.ino !== expected.ino
283
+ )
284
+ throw new Error("Managed session directory changed");
285
+ return verified;
286
+ }
287
+
199
288
  function canonicalExistingPathForIo(base: string, identity: CanonicalNativeIdentity): string {
200
289
  if (identity.platform !== "win32") return identity.canonicalPath;
201
290
  try {
@@ -317,11 +406,16 @@ function validateExistingBinding(scope: ManagedScope): ManagedScopeResolution |
317
406
  return validateBindingRaw(scope, raw);
318
407
  }
319
408
 
320
- export function resolveManagedScope(input: {
409
+ interface ManagedScopeInput {
321
410
  cwd: string;
322
411
  agentDir: string;
323
412
  sessionsRoot: string;
324
- }): ManagedScopeResolution {
413
+ }
414
+
415
+ function resolveManagedScopeInternal(
416
+ input: ManagedScopeInput,
417
+ allowRepairableAclFailure: boolean,
418
+ ): ManagedScopeResolution {
325
419
  const identity = identityFor(input.cwd);
326
420
  if (!identity.ok) return nativeFailure(identity.code);
327
421
  try {
@@ -379,13 +473,18 @@ export function resolveManagedScope(input: {
379
473
  if (!directory.isDirectory() || directory.isSymbolicLink()) {
380
474
  return { kind: "error", code: "binding_invalid", message: "The managed scope path is not a safe directory." };
381
475
  }
382
- const security = verifyOwnerOnlyPathSecurity(scope.directoryPath, "directory") as NativeSecurity;
383
- if (!security.ok)
476
+ const security = validateNativeSecurityResult(
477
+ verifyExistingManagedScopeDirectory(scope.directoryPath),
478
+ "verify",
479
+ "directory",
480
+ );
481
+ if (!security.ok && (!allowRepairableAclFailure || security.code !== "acl_verify_failed")) {
384
482
  return {
385
483
  kind: "error",
386
484
  code: "binding_invalid",
387
485
  message: "The managed scope security could not be verified.",
388
486
  };
487
+ }
389
488
  } catch (error) {
390
489
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
391
490
  return { kind: "error", code: "binding_invalid", message: "The managed scope path could not be inspected." };
@@ -394,6 +493,15 @@ export function resolveManagedScope(input: {
394
493
  return validateExistingBinding(scope) ?? { kind: "resolved", scope };
395
494
  }
396
495
 
496
+ export function resolveManagedScope(input: ManagedScopeInput): ManagedScopeResolution {
497
+ return resolveManagedScopeInternal(input, false);
498
+ }
499
+
500
+ /** Resolve a scope for a synchronous write without mutating an existing ACL mismatch. */
501
+ export function resolveManagedScopeForWrite(input: ManagedScopeInput): ManagedScopeResolution {
502
+ return resolveManagedScopeInternal(input, true);
503
+ }
504
+
397
505
  function legacyDirectoryNames(
398
506
  platform: ManagedScope["platform"],
399
507
  canonicalCwd: string,
@@ -461,6 +569,57 @@ function fsyncManagedParent(pathname: string): void {
461
569
  }
462
570
  }
463
571
 
572
+ function fsyncCanonicalBinding(bindingPath: string, expected: string): void {
573
+ let captured: ManagedFileSnapshot;
574
+ try {
575
+ captured = captureManagedFileNoFollow(bindingPath);
576
+ } catch {
577
+ throw new Error("binding_invalid");
578
+ }
579
+ if (captured.bytes.toString("utf8") !== expected) throw new Error("binding_invalid");
580
+ let descriptor: number | undefined;
581
+ try {
582
+ descriptor = fs.openSync(bindingPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
583
+ const before = fs.fstatSync(descriptor, { bigint: true });
584
+ if (
585
+ before.dev !== captured.identity.dev ||
586
+ before.ino !== captured.identity.ino ||
587
+ before.size !== BigInt(captured.identity.size) ||
588
+ before.mtimeNs !== captured.identity.mtimeNs
589
+ )
590
+ throw new Error("binding_invalid");
591
+ fs.fsyncSync(descriptor);
592
+ const after = fs.fstatSync(descriptor, { bigint: true });
593
+ if (
594
+ after.dev !== captured.identity.dev ||
595
+ after.ino !== captured.identity.ino ||
596
+ after.size !== BigInt(captured.identity.size) ||
597
+ after.mtimeNs !== captured.identity.mtimeNs
598
+ )
599
+ throw new Error("binding_invalid");
600
+ } catch (error) {
601
+ if (error instanceof Error && error.message === "binding_invalid") throw error;
602
+ throw new Error("durability_failed", { cause: error });
603
+ } finally {
604
+ if (descriptor !== undefined) fs.closeSync(descriptor);
605
+ }
606
+ fsyncManagedParent(bindingPath);
607
+ let recaptured: ManagedFileSnapshot;
608
+ try {
609
+ recaptured = captureManagedFileNoFollow(bindingPath);
610
+ } catch {
611
+ throw new Error("binding_invalid");
612
+ }
613
+ if (
614
+ recaptured.bytes.toString("utf8") !== expected ||
615
+ recaptured.identity.dev !== captured.identity.dev ||
616
+ recaptured.identity.ino !== captured.identity.ino ||
617
+ recaptured.identity.size !== captured.identity.size ||
618
+ recaptured.identity.mtimeNs !== captured.identity.mtimeNs
619
+ )
620
+ throw new Error("binding_invalid");
621
+ }
622
+
464
623
  type CandidatePreflight =
465
624
  | { kind: "capture"; identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint } }
466
625
  | {
@@ -594,46 +753,159 @@ function listDirectoryCandidates(
594
753
  });
595
754
  }
596
755
 
597
- export async function ensureManagedScope(scope: ManagedScope): Promise<ManagedScopeResolution> {
756
+ export async function ensureManagedScope(
757
+ scope: ManagedScope,
758
+ policy: ManagedSessionSecurityPolicy = "default",
759
+ ): Promise<ManagedScopeResolution> {
598
760
  try {
599
- const root = scopeRoot(scope);
600
- ensureManagedDirectory(scope.sessionsRoot, root);
601
- ensureManagedDirectory(scope.directoryPath, root);
761
+ assertRetainedManagedDirectoryIdentity(scope);
762
+ const root = scopeRoot(scope, policy);
763
+ ensureManagedDirectory(scope.sessionsRoot, root, policy);
764
+ ensureManagedDirectory(scope.directoryPath, root, policy);
602
765
  const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE);
603
766
  const binding = `${JSON.stringify(bindingFor(scope))}\n`;
767
+ let bindingCollision = false;
604
768
  try {
605
- await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root);
769
+ await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root, policy);
606
770
  } catch (error) {
607
771
  if ((error as Error).message !== "destination_conflict") throw error;
772
+ bindingCollision = true;
608
773
  }
609
- return validateExistingBinding(scope) ?? { kind: "resolved", scope };
774
+ const validated = validateExistingBinding(scope);
775
+ if (validated) return validated;
776
+ if (bindingCollision) fsyncCanonicalBinding(bindingPath, binding);
777
+
778
+ const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
779
+ if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
780
+ throw new Error("Managed session directory changed");
781
+ managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
782
+ return { kind: "resolved", scope };
610
783
  } catch (error) {
784
+ const publication = error instanceof ManagedPublishError ? error : undefined;
785
+ const message =
786
+ publication?.classification ??
787
+ (error instanceof Error ? error.message : "The managed scope could not be initialized.");
788
+ const code =
789
+ message === "atomic_unavailable" ||
790
+ message === "invalid_request" ||
791
+ message === "durability_failed" ||
792
+ message === "durability_not_provable"
793
+ ? message
794
+ : "binding_invalid";
795
+
611
796
  return {
612
797
  kind: "error",
613
- code: "binding_invalid",
614
- message: error instanceof Error ? error.message : "The managed scope could not be initialized.",
798
+ code,
799
+ message,
800
+ ...(publication
801
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
802
+ : { cause: { classification: code } }),
615
803
  };
616
804
  }
617
805
  }
618
806
 
807
+ /**
808
+ * Re-apply owner-only security to every descendant of a managed scope directory.
809
+ *
810
+ * A managed scope can accumulate group/other-readable descendants when a
811
+ * different code path writes into it without the secured managed-storage
812
+ * helpers — notably the resident-cache `EphemeralBlobStore` created on the
813
+ * explicit session path. The managed-tree snapshot fails closed on the first
814
+ * such descendant (`mode_mismatch`), which would otherwise abort launch with an
815
+ * uncaught exception. Re-securing the tree in place lets a drifted scope
816
+ * recover on the next launch instead of trapping the user behind a fatal error.
817
+ */
818
+ function reapplyOwnerOnlyManagedTree(directory: string): void {
819
+ let entries: fs.Dirent[];
820
+ try {
821
+ entries = fs.readdirSync(directory, { withFileTypes: true });
822
+ } catch {
823
+ return;
824
+ }
825
+ for (const entry of entries) {
826
+ const child = path.join(directory, entry.name);
827
+ let stat: fs.Stats;
828
+ try {
829
+ stat = fs.lstatSync(child);
830
+ } catch {
831
+ continue;
832
+ }
833
+ if (stat.isSymbolicLink()) continue;
834
+ if (stat.isDirectory()) {
835
+ reapplyOwnerOnlyManagedTree(child);
836
+ try {
837
+ native.applyOwnerOnlyPathSecurity(child, "directory");
838
+ } catch {
839
+ // Best-effort: the managed-tree snapshot re-verifies and reports genuine failures.
840
+ }
841
+ } else if (stat.isFile()) {
842
+ try {
843
+ native.applyOwnerOnlyPathSecurity(child, "file");
844
+ } catch {
845
+ // Best-effort, as above.
846
+ }
847
+ }
848
+ }
849
+ try {
850
+ native.applyOwnerOnlyPathSecurity(directory, "directory");
851
+ } catch {
852
+ // Best-effort, as above.
853
+ }
854
+ }
855
+
856
+ /**
857
+ * True when a managed setup error reflects a fixable owner-only *mode* drift
858
+ * (group/other permission bits) rather than an ownership or identity change.
859
+ * Only mode drift can be self-healed by re-applying owner-only permissions.
860
+ */
861
+ function isRecoverableOwnerOnlyModeDrift(error: unknown): boolean {
862
+ const message = error instanceof Error ? error.message : "";
863
+ return message === "mode_mismatch" || message.endsWith(": mode_mismatch");
864
+ }
865
+
619
866
  /** Synchronously create and validate the v2 binding before a default session writer exists. */
620
- export function prepareManagedSessionScopeForWriteSync(scope: ManagedScope): ManagedScopeResolution {
867
+ export function prepareManagedSessionScopeForWriteSync(
868
+ scope: ManagedScope,
869
+ policy: ManagedSessionSecurityPolicy = "default",
870
+ authority?: ManagedCandidateWriteAuthority,
871
+ ): ManagedScopeResolution {
621
872
  try {
622
- const root = scopeRoot(scope);
623
- ensureManagedDirectory(scope.sessionsRoot, root);
624
- ensureManagedDirectory(scope.directoryPath, root);
873
+ assertRetainedManagedDirectoryIdentity(scope);
874
+ const root = authority?.rootAuthority ?? scopeRoot(scope, policy);
875
+ if (authority) bindManagedWriteAuthority(scope, authority);
876
+ ensureManagedDirectory(scope.sessionsRoot, root, policy);
877
+ ensureManagedDirectory(scope.directoryPath, root, policy);
625
878
  const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
626
879
  if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
627
880
  throw new Error("Managed session directory changed");
628
- const retainedAuthority = retainManagedDirectoryAuthority(root, scope.directoryPath, {
629
- dev: preparedDirectory.dev,
630
- ino: preparedDirectory.ino,
631
- });
632
- const store = new ManagedSessionDescendantStore(
633
- root,
634
- scope.directoryPath,
635
- retainedAuthority ? { authority: retainedAuthority, authorityBaseDir: scope.directoryPath } : undefined,
636
- );
881
+ const retainedAuthority =
882
+ authority?.retainedAuthority &&
883
+ authority.retainedDirectory !== undefined &&
884
+ path.resolve(authority.retainedDirectory) === path.resolve(scope.directoryPath)
885
+ ? authority.retainedAuthority
886
+ : retainManagedDirectoryAuthority(root, scope.directoryPath, {
887
+ dev: preparedDirectory.dev,
888
+ ino: preparedDirectory.ino,
889
+ });
890
+ const buildStore = () =>
891
+ new ManagedSessionDescendantStore(
892
+ root,
893
+ scope.directoryPath,
894
+ retainedAuthority ? { authority: retainedAuthority, authorityBaseDir: scope.directoryPath } : undefined,
895
+ policy,
896
+ );
897
+ let store: ManagedSessionDescendantStore;
898
+ try {
899
+ store = buildStore();
900
+ } catch (error) {
901
+ if (process.platform === "win32" && policy === "windows-existing-verify-first") throw error;
902
+ if (!isRecoverableOwnerOnlyModeDrift(error)) throw error;
903
+ // A prior writer left group/other-readable descendants under the scope
904
+ // (e.g. resident-cache blobs written on the explicit session path).
905
+ // Re-secure the tree in place and retry once before failing closed.
906
+ reapplyOwnerOnlyManagedTree(scope.directoryPath);
907
+ store = buildStore();
908
+ }
637
909
  const binding = new TextEncoder().encode(`${JSON.stringify(bindingFor(scope))}\n`);
638
910
  try {
639
911
  store.publishNoReplaceSync(MANAGED_SESSION_BINDING_FILE, binding);
@@ -655,16 +927,32 @@ export function prepareManagedSessionScopeForWriteSync(scope: ManagedScope): Man
655
927
  managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
656
928
  if (validated) return validated;
657
929
  const internal = managedInternalDirectory(scope);
658
- ensureManagedDirectory(internal, root);
659
- ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root);
660
- ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root);
661
- ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root);
930
+ ensureManagedDirectory(internal, root, policy);
931
+ ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root, policy);
932
+ ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root, policy);
933
+ ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
662
934
  return { kind: "resolved", scope };
663
935
  } catch (error) {
936
+ const publication = error instanceof ManagedPublishError ? error : undefined;
937
+ const message =
938
+ publication?.classification ??
939
+ (error instanceof Error ? error.message : "Managed write protocol setup failed.");
940
+ const code =
941
+ message === "atomic_unavailable" ||
942
+ message === "invalid_request" ||
943
+ message === "durability_failed" ||
944
+ message === "durability_not_provable"
945
+ ? message
946
+ : "binding_invalid";
664
947
  return {
665
948
  kind: "error",
666
- code: "binding_invalid",
667
- message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
949
+ code,
950
+ message,
951
+ ...(publication
952
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
953
+ : code === "binding_invalid"
954
+ ? {}
955
+ : { cause: { classification: code } }),
668
956
  };
669
957
  }
670
958
  }
@@ -788,6 +1076,9 @@ function expectedFailure(error: unknown): ManagedOpenFailure {
788
1076
  message === "source_changed" ||
789
1077
  message === "unsafe_artifacts" ||
790
1078
  message === "durability_failed" ||
1079
+ message === "atomic_unavailable" ||
1080
+ message === "invalid_request" ||
1081
+ message === "durability_not_provable" ||
791
1082
  message === "migration_retired"
792
1083
  ? message
793
1084
  : "managed_storage_unsupported";
@@ -804,10 +1095,33 @@ function sameCandidate(left: ManagedCandidate, right: ManagedCandidate): boolean
804
1095
  );
805
1096
  }
806
1097
 
1098
+ function matchesExpectedResumeIdentity(candidate: ManagedCandidate, expected: ResumeSessionIdentity): boolean {
1099
+ return (
1100
+ path.resolve(candidate.identity.canonicalPath) === path.resolve(expected.canonicalPath) &&
1101
+ candidate.identity.sessionId === expected.sessionId &&
1102
+ candidate.identity.dev === expected.dev &&
1103
+ candidate.identity.ino === expected.ino &&
1104
+ candidate.identity.size === expected.size &&
1105
+ candidate.identity.mtimeMs === expected.mtimeMs &&
1106
+ candidate.identity.mtimeNs === expected.mtimeNs &&
1107
+ candidate.identity.sha256 === expected.sha256
1108
+ );
1109
+ }
1110
+
1111
+ function revalidatePickerConsent(
1112
+ scope: ManagedScope,
1113
+ candidate: ManagedCandidate,
1114
+ expectedIdentity: ResumeSessionIdentity,
1115
+ ): ManagedCandidate {
1116
+ const current = validateCandidateForScope(scope, candidate);
1117
+ if (!current || !matchesExpectedResumeIdentity(current, expectedIdentity)) throw new Error("source_changed");
1118
+ return current;
1119
+ }
1120
+
807
1121
  function receiptPathFor(
808
1122
  scope: ManagedScope,
809
1123
  source: ManagedCandidate,
810
- state: "prepared" | "published" | "committed" = "committed",
1124
+ state: "prepared" | "detached" | "published" | "committed" = "committed",
811
1125
  ): string {
812
1126
  const suffix = state === "committed" ? "" : `.${state}`;
813
1127
  return path.join(
@@ -846,6 +1160,14 @@ function receiptMatches(
846
1160
  identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
847
1161
  };
848
1162
  artifactManifest?: unknown;
1163
+ sourceArtifactQuarantine?: { role?: unknown };
1164
+ sourceArtifactCleanup?: {
1165
+ state?: unknown;
1166
+ role?: unknown;
1167
+ retainedPath?: unknown;
1168
+ identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
1169
+ tree?: unknown;
1170
+ };
849
1171
  };
850
1172
  const exact = (
851
1173
  recorded: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown } | undefined,
@@ -886,12 +1208,43 @@ function receiptMatches(
886
1208
  item.size >= 0
887
1209
  );
888
1210
  });
1211
+ const quarantine = record.sourceArtifactQuarantine;
1212
+ const cleanup = record.sourceArtifactCleanup;
1213
+ const cleanupIdentity = cleanup?.identity;
1214
+ const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
1215
+ const validCleanup =
1216
+ cleanup === undefined ||
1217
+ (quarantine?.role === "detached_artifact_root" &&
1218
+ cleanup.state === "cleanup_pending" &&
1219
+ cleanup.role === "exchange_placeholder" &&
1220
+ typeof cleanup.retainedPath === "string" &&
1221
+ cleanupIdentity?.dev !== undefined &&
1222
+ cleanupIdentity.ino !== undefined &&
1223
+ cleanupIdentity.size !== undefined &&
1224
+ cleanupIdentity.mtimeNs !== undefined &&
1225
+ !!cleanupTree &&
1226
+ cleanupAuthorityMatches(
1227
+ {
1228
+ state: "cleanup_pending",
1229
+ role: "exchange_placeholder",
1230
+ retainedPath: cleanup.retainedPath,
1231
+ identity: {
1232
+ dev: BigInt(String(cleanupIdentity.dev)),
1233
+ ino: BigInt(String(cleanupIdentity.ino)),
1234
+ size: BigInt(String(cleanupIdentity.size)),
1235
+ mtimeNs: BigInt(String(cleanupIdentity.mtimeNs)),
1236
+ },
1237
+ tree: cleanupTree,
1238
+ },
1239
+ path.dirname(source.path),
1240
+ ));
889
1241
  return (
890
1242
  record.schemaVersion === 2 &&
891
1243
  record.state === "committed" &&
892
1244
  record.policy === "copy-retain" &&
893
1245
  record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
894
1246
  validManifest &&
1247
+ validCleanup &&
895
1248
  manifestContains(destination.path, manifest as readonly ArtifactManifestEntry[]) &&
896
1249
  record.source?.path === source.path &&
897
1250
  record.source?.sessionId === source.sessionId &&
@@ -1034,6 +1387,65 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired
1034
1387
  * a returned partial result appends `pending(N + 1)` with the observed detached
1035
1388
  * identity/path. Restart accepts only a contiguous, target-bound sequence.
1036
1389
  */
1390
+ type RetainedArtifactsRootReceipt = {
1391
+ path: string;
1392
+ identity: SessionStorageFileIdentity;
1393
+ tree: NativeDirectoryTreeSnapshot;
1394
+ };
1395
+
1396
+ function retainedArtifactsRootReceipt(receipt: CleanupReceipt): RetainedArtifactsRootReceipt | undefined {
1397
+ const pathname = deterministicRemovalRoot(receipt.plannedArtifactsPath);
1398
+ if (!fs.existsSync(pathname)) return undefined;
1399
+ if (!receipt.expectedArtifactsIdentity) throw new Error("durability_failed");
1400
+ const identity = artifactIdentityAt(pathname);
1401
+ if (!identity || !sameArtifactRootIdentity(identity, receipt.expectedArtifactsIdentity))
1402
+ throw new Error("durability_failed");
1403
+ const tree = snapshotArtifactTree(pathname);
1404
+ return { path: pathname, identity, tree };
1405
+ }
1406
+
1407
+ function retainedArtifactsRootMatches(record: Record<string, unknown>): boolean {
1408
+ if (record.retainedArtifactsRoot === undefined) return true;
1409
+ if (!record.retainedArtifactsRoot || typeof record.retainedArtifactsRoot !== "object")
1410
+ throw new Error("durability_failed");
1411
+ const retained = record.retainedArtifactsRoot as Record<string, unknown>;
1412
+ const identity = retained.identity;
1413
+ const tree = artifactTreeSnapshot(retained.tree);
1414
+ if (
1415
+ typeof record.plannedArtifactsPath !== "string" ||
1416
+ retained.path !== deterministicRemovalRoot(record.plannedArtifactsPath) ||
1417
+ !identity ||
1418
+ typeof identity !== "object" ||
1419
+ Array.isArray(identity) ||
1420
+ !tree
1421
+ )
1422
+ throw new Error("durability_failed");
1423
+ const artifactIdentity = identity as Record<string, unknown>;
1424
+ if (
1425
+ typeof artifactIdentity.dev !== "string" ||
1426
+ typeof artifactIdentity.ino !== "string" ||
1427
+ typeof artifactIdentity.size !== "number" ||
1428
+ typeof artifactIdentity.mtimeNs !== "string" ||
1429
+ typeof artifactIdentity.sha256 !== "string"
1430
+ )
1431
+ throw new Error("durability_failed");
1432
+ if (!fs.existsSync(retained.path)) return true;
1433
+ const observed = artifactIdentityAt(retained.path);
1434
+ if (
1435
+ !observed ||
1436
+ !sameArtifactRootIdentity(observed, {
1437
+ dev: BigInt(artifactIdentity.dev),
1438
+ ino: BigInt(artifactIdentity.ino),
1439
+ size: artifactIdentity.size,
1440
+ mtimeNs: BigInt(artifactIdentity.mtimeNs),
1441
+ sha256: artifactIdentity.sha256,
1442
+ }) ||
1443
+ JSON.stringify(snapshotArtifactTree(retained.path)) !== JSON.stringify(tree)
1444
+ )
1445
+ throw new Error("durability_failed");
1446
+ return true;
1447
+ }
1448
+
1037
1449
  type CleanupReceipt = {
1038
1450
  attempt: number;
1039
1451
  target: RetiredTarget;
@@ -1041,6 +1453,13 @@ type CleanupReceipt = {
1041
1453
  expectedArtifactsTree?: NativeDirectoryTreeSnapshot;
1042
1454
  detachedArtifactsPath?: string;
1043
1455
  detachedTranscriptPath?: string;
1456
+ retainedArtifactsSuccessorPath?: string;
1457
+ retainedArtifactsPlaceholderPath?: string;
1458
+ retainedArtifactsUnknownPath?: string;
1459
+ retainedTranscriptSuccessorPath?: string;
1460
+ retainedTranscriptPlaceholderPath?: string;
1461
+ retainedTranscriptUnknownPath?: string;
1462
+
1044
1463
  plannedArtifactsPath: string;
1045
1464
  plannedTranscriptPath: string;
1046
1465
  };
@@ -1074,6 +1493,24 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup
1074
1493
  ...(receipt.expectedArtifactsTree ? { expectedArtifactsTree: receipt.expectedArtifactsTree } : {}),
1075
1494
  ...(receipt.detachedArtifactsPath ? { detachedArtifactsPath: receipt.detachedArtifactsPath } : {}),
1076
1495
  ...(receipt.detachedTranscriptPath ? { detachedTranscriptPath: receipt.detachedTranscriptPath } : {}),
1496
+ ...(receipt.retainedArtifactsSuccessorPath
1497
+ ? { retainedArtifactsSuccessorPath: receipt.retainedArtifactsSuccessorPath }
1498
+ : {}),
1499
+ ...(receipt.retainedArtifactsPlaceholderPath
1500
+ ? { retainedArtifactsPlaceholderPath: receipt.retainedArtifactsPlaceholderPath }
1501
+ : {}),
1502
+ ...(receipt.retainedArtifactsUnknownPath
1503
+ ? { retainedArtifactsUnknownPath: receipt.retainedArtifactsUnknownPath }
1504
+ : {}),
1505
+ ...(receipt.retainedTranscriptSuccessorPath
1506
+ ? { retainedTranscriptSuccessorPath: receipt.retainedTranscriptSuccessorPath }
1507
+ : {}),
1508
+ ...(receipt.retainedTranscriptPlaceholderPath
1509
+ ? { retainedTranscriptPlaceholderPath: receipt.retainedTranscriptPlaceholderPath }
1510
+ : {}),
1511
+ ...(receipt.retainedTranscriptUnknownPath
1512
+ ? { retainedTranscriptUnknownPath: receipt.retainedTranscriptUnknownPath }
1513
+ : {}),
1077
1514
  plannedArtifactsPath: receipt.plannedArtifactsPath,
1078
1515
  plannedTranscriptPath: receipt.plannedTranscriptPath,
1079
1516
  };
@@ -1094,6 +1531,7 @@ function cleanupArtifactsRemoved(
1094
1531
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1095
1532
  const record = value as Record<string, unknown>;
1096
1533
  const recorded = record.target as Record<string, unknown> | undefined;
1534
+ const identity = recorded?.identity as Record<string, unknown> | undefined;
1097
1535
  return (
1098
1536
  record.schemaVersion === 2 &&
1099
1537
  record.state === "artifacts_removed" &&
@@ -1102,9 +1540,17 @@ function cleanupArtifactsRemoved(
1102
1540
  record.attempt === attempt &&
1103
1541
  recorded?.path === target.path &&
1104
1542
  recorded.sessionId === target.sessionId &&
1105
- recorded.cwd === target.cwd
1543
+ recorded.cwd === target.cwd &&
1544
+ identity?.canonicalPath === target.identity.canonicalPath &&
1545
+ identity.dev === String(target.identity.dev) &&
1546
+ identity.ino === String(target.identity.ino) &&
1547
+ identity.size === target.identity.size &&
1548
+ identity.mtimeNs === String(target.identity.mtimeNs) &&
1549
+ identity.sha256 === target.identity.sha256 &&
1550
+ retainedArtifactsRootMatches(record)
1106
1551
  );
1107
- } catch {
1552
+ } catch (error) {
1553
+ if ((error as Error).message === "durability_failed") throw error;
1108
1554
  return false;
1109
1555
  }
1110
1556
  }
@@ -1115,9 +1561,14 @@ async function publishCleanupArtifactsRemoved(
1115
1561
  receipt: CleanupReceipt,
1116
1562
  lock: ManagedStorageLock,
1117
1563
  ): Promise<void> {
1564
+ const retainedArtifactsRoot = retainedArtifactsRootReceipt(receipt);
1118
1565
  await publishManagedTombstone(
1119
1566
  cleanupReceiptPath(tombstone, receipt.target, "artifacts_removed", receipt.attempt),
1120
- { ...cleanupReceipt(scope, tombstone, receipt), state: "artifacts_removed" },
1567
+ {
1568
+ ...cleanupReceipt(scope, tombstone, receipt),
1569
+ state: "artifacts_removed",
1570
+ ...(retainedArtifactsRoot ? { retainedArtifactsRoot } : {}),
1571
+ },
1121
1572
  lock.assertOwned,
1122
1573
  ).catch(error => {
1123
1574
  if ((error as Error).message !== "destination_conflict") throw error;
@@ -1134,6 +1585,14 @@ function isQuarantinePath(target: RetiredTarget, pathname: unknown): pathname is
1134
1585
  );
1135
1586
  }
1136
1587
 
1588
+ function isRetainedNativePath(target: RetiredTarget, pathname: unknown): pathname is string {
1589
+ return (
1590
+ typeof pathname === "string" &&
1591
+ path.dirname(pathname) === path.dirname(target.path) &&
1592
+ path.basename(pathname).startsWith(".gjc-")
1593
+ );
1594
+ }
1595
+
1137
1596
  function deterministicRemovalRoot(plannedRoot: string): string {
1138
1597
  return `${plannedRoot}.removing`;
1139
1598
  }
@@ -1145,17 +1604,44 @@ function isAuthorizedArtifactRoot(target: RetiredTarget, plannedRoot: string, pa
1145
1604
  );
1146
1605
  }
1147
1606
 
1148
- function artifactRootsAbsent(
1149
- _scope: ManagedScope,
1150
- tombstone: string,
1151
- target: RetiredTarget,
1152
- pending: CleanupReceipt,
1153
- ): boolean {
1607
+ function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): boolean {
1608
+ try {
1609
+ const observed = captureManagedFileNoFollow(pathname);
1610
+ const digest = createHash("sha256").update(observed.bytes).digest("hex");
1611
+ return (
1612
+ observed.identity.dev === target.identity.dev &&
1613
+ observed.identity.ino === target.identity.ino &&
1614
+ observed.identity.size === target.identity.size &&
1615
+ observed.identity.mtimeNs === target.identity.mtimeNs &&
1616
+ digest === target.identity.sha256
1617
+ );
1618
+ } catch (error) {
1619
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
1620
+ throw new Error("durability_failed");
1621
+ }
1622
+ }
1623
+
1624
+ function cleanupRootsAbsent(tombstone: string, target: RetiredTarget, pending: CleanupReceipt): boolean {
1154
1625
  const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`;
1626
+ const activeTranscriptRoots = new Set(
1627
+ [pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string =>
1628
+ isQuarantinePath(target, pathname),
1629
+ ),
1630
+ );
1155
1631
  const roots = new Set<string>([
1632
+ target.path,
1633
+ target.path.slice(0, -6),
1156
1634
  pending.plannedArtifactsPath,
1157
1635
  deterministicRemovalRoot(pending.plannedArtifactsPath),
1158
1636
  ...(pending.detachedArtifactsPath ? [pending.detachedArtifactsPath] : []),
1637
+ ...[
1638
+ pending.retainedArtifactsSuccessorPath,
1639
+ pending.retainedArtifactsPlaceholderPath,
1640
+ pending.retainedArtifactsUnknownPath,
1641
+ pending.retainedTranscriptSuccessorPath,
1642
+ pending.retainedTranscriptPlaceholderPath,
1643
+ pending.retainedTranscriptUnknownPath,
1644
+ ].filter((pathname): pathname is string => isRetainedNativePath(target, pathname)),
1159
1645
  ]);
1160
1646
  for (const name of fs.readdirSync(path.dirname(tombstone))) {
1161
1647
  if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
@@ -1164,26 +1650,91 @@ function artifactRootsAbsent(
1164
1650
  ) as {
1165
1651
  plannedArtifactsPath?: unknown;
1166
1652
  detachedArtifactsPath?: unknown;
1653
+ plannedTranscriptPath?: unknown;
1654
+ detachedTranscriptPath?: unknown;
1655
+ retainedArtifactsSuccessorPath?: unknown;
1656
+ retainedArtifactsPlaceholderPath?: unknown;
1657
+ retainedArtifactsUnknownPath?: unknown;
1658
+ retainedTranscriptSuccessorPath?: unknown;
1659
+ retainedTranscriptPlaceholderPath?: unknown;
1660
+ retainedTranscriptUnknownPath?: unknown;
1167
1661
  };
1168
1662
  if (isQuarantinePath(target, record.plannedArtifactsPath)) {
1169
1663
  roots.add(record.plannedArtifactsPath);
1170
1664
  roots.add(deterministicRemovalRoot(record.plannedArtifactsPath));
1171
1665
  }
1172
1666
  if (isQuarantinePath(target, record.detachedArtifactsPath)) roots.add(record.detachedArtifactsPath);
1667
+ for (const pathname of [record.plannedTranscriptPath, record.detachedTranscriptPath]) {
1668
+ if (isQuarantinePath(target, pathname) && !activeTranscriptRoots.has(pathname)) roots.add(pathname);
1669
+ }
1670
+ for (const pathname of [
1671
+ record.retainedArtifactsSuccessorPath,
1672
+ record.retainedArtifactsPlaceholderPath,
1673
+ record.retainedArtifactsUnknownPath,
1674
+ record.retainedTranscriptSuccessorPath,
1675
+ record.retainedTranscriptPlaceholderPath,
1676
+ record.retainedTranscriptUnknownPath,
1677
+ ]) {
1678
+ if (isRetainedNativePath(target, pathname)) roots.add(pathname);
1679
+ }
1173
1680
  }
1174
- return [...roots].every(pathname => !fs.existsSync(pathname));
1681
+ for (const pathname of roots) {
1682
+ try {
1683
+ fs.lstatSync(pathname);
1684
+ throw new Error("durability_failed");
1685
+ } catch (error) {
1686
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1687
+ throw error;
1688
+ }
1689
+ }
1690
+ for (const pathname of activeTranscriptRoots) {
1691
+ try {
1692
+ fs.lstatSync(pathname);
1693
+ } catch (error) {
1694
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1695
+ throw error;
1696
+ }
1697
+ if (!transcriptRootMatchesTarget(pathname, target)) throw new Error("durability_failed");
1698
+ return false;
1699
+ }
1700
+ return true;
1175
1701
  }
1176
1702
 
1177
1703
  function sameArtifactRootIdentity(left: SessionStorageFileIdentity, right: SessionStorageFileIdentity): boolean {
1178
1704
  return left.dev === right.dev && left.ino === right.ino;
1179
1705
  }
1180
1706
 
1181
- /**
1182
- * A pending receipt is written before the native no-replace detach. If a crash
1183
- * happens after that detach, the next receipt may only inherit the exact
1184
- * planned quarantine pathname and identity from its predecessor.
1185
- */
1707
+ function sameArtifactTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
1708
+ if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno || left.entries.length !== right.entries.length)
1709
+ return false;
1710
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
1711
+ JSON.stringify([entry.relativePath, entry.kind, entry.dev, entry.ino, entry.size, entry.mtimeNs, entry.sha256]);
1712
+ const leftEntries = left.entries.map(entryKey).sort();
1713
+ const rightEntries = right.entries.map(entryKey).sort();
1714
+ return leftEntries.every((entry, index) => entry === rightEntries[index]);
1715
+ }
1716
+
1717
+ function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void {
1718
+ if (!pending.detachedArtifactsPath) return;
1719
+ if (!pending.expectedArtifactsIdentity || !pending.expectedArtifactsTree) throw new Error("durability_failed");
1720
+ try {
1721
+ fs.lstatSync(pending.detachedArtifactsPath);
1722
+ } catch (error) {
1723
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1724
+ throw new Error("durability_failed");
1725
+ }
1726
+ const observed = artifactIdentityAt(pending.detachedArtifactsPath);
1727
+ if (
1728
+ !observed ||
1729
+ !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) ||
1730
+ !sameArtifactTreeSnapshot(snapshotArtifactTree(pending.detachedArtifactsPath), pending.expectedArtifactsTree)
1731
+ )
1732
+ throw new Error("binding_invalid");
1733
+ }
1734
+
1186
1735
  function probePlannedCleanupDetach(target: RetiredTarget, pending: CleanupReceipt): CleanupReceipt {
1736
+ assertRetainedArtifactsAuthority(pending);
1737
+
1187
1738
  let detachedArtifactsPath = pending.detachedArtifactsPath;
1188
1739
  let detachedTranscriptPath = pending.detachedTranscriptPath;
1189
1740
  for (const pathname of [pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath)]) {
@@ -1247,6 +1798,16 @@ function artifactTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot | und
1247
1798
  })
1248
1799
  )
1249
1800
  return undefined;
1801
+ const roots = snapshot.entries.filter(entry => {
1802
+ const item = entry as Record<string, unknown>;
1803
+ return (
1804
+ item.relativePath === "" &&
1805
+ item.kind === "directory" &&
1806
+ item.dev === snapshot.rootDev &&
1807
+ item.ino === snapshot.rootIno
1808
+ );
1809
+ });
1810
+ if (roots.length !== 1) return undefined;
1250
1811
  return snapshot as unknown as NativeDirectoryTreeSnapshot;
1251
1812
  }
1252
1813
 
@@ -1298,7 +1859,19 @@ function pendingCleanupReceipt(
1298
1859
  plannedPaths.has(record.plannedArtifactsPath as string) ||
1299
1860
  plannedPaths.has(record.plannedTranscriptPath as string) ||
1300
1861
  (record.detachedArtifactsPath !== undefined && !isQuarantinePath(target, record.detachedArtifactsPath)) ||
1301
- (record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath))
1862
+ (record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath)) ||
1863
+ (record.retainedArtifactsSuccessorPath !== undefined &&
1864
+ !isRetainedNativePath(target, record.retainedArtifactsSuccessorPath)) ||
1865
+ (record.retainedArtifactsPlaceholderPath !== undefined &&
1866
+ !isRetainedNativePath(target, record.retainedArtifactsPlaceholderPath)) ||
1867
+ (record.retainedArtifactsUnknownPath !== undefined &&
1868
+ !isRetainedNativePath(target, record.retainedArtifactsUnknownPath)) ||
1869
+ (record.retainedTranscriptSuccessorPath !== undefined &&
1870
+ !isRetainedNativePath(target, record.retainedTranscriptSuccessorPath)) ||
1871
+ (record.retainedTranscriptPlaceholderPath !== undefined &&
1872
+ !isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) ||
1873
+ (record.retainedTranscriptUnknownPath !== undefined &&
1874
+ !isRetainedNativePath(target, record.retainedTranscriptUnknownPath))
1302
1875
  )
1303
1876
  throw new Error("durability_failed");
1304
1877
  const artifact = record.expectedArtifactsIdentity as Record<string, unknown> | undefined;
@@ -1321,7 +1894,7 @@ function pendingCleanupReceipt(
1321
1894
  const expectedArtifactsTree =
1322
1895
  record.expectedArtifactsTree === undefined ? undefined : artifactTreeSnapshot(record.expectedArtifactsTree);
1323
1896
  if (record.expectedArtifactsTree !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
1324
- if (record.detachedArtifactsPath !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
1897
+
1325
1898
  if (
1326
1899
  latest &&
1327
1900
  ((record.detachedArtifactsPath !== undefined &&
@@ -1340,6 +1913,12 @@ function pendingCleanupReceipt(
1340
1913
  expectedArtifactsTree,
1341
1914
  detachedArtifactsPath: record.detachedArtifactsPath as string | undefined,
1342
1915
  detachedTranscriptPath: record.detachedTranscriptPath as string | undefined,
1916
+ retainedArtifactsSuccessorPath: record.retainedArtifactsSuccessorPath as string | undefined,
1917
+ retainedArtifactsPlaceholderPath: record.retainedArtifactsPlaceholderPath as string | undefined,
1918
+ retainedArtifactsUnknownPath: record.retainedArtifactsUnknownPath as string | undefined,
1919
+ retainedTranscriptSuccessorPath: record.retainedTranscriptSuccessorPath as string | undefined,
1920
+ retainedTranscriptPlaceholderPath: record.retainedTranscriptPlaceholderPath as string | undefined,
1921
+ retainedTranscriptUnknownPath: record.retainedTranscriptUnknownPath as string | undefined,
1343
1922
  plannedArtifactsPath: record.plannedArtifactsPath as string,
1344
1923
  plannedTranscriptPath: record.plannedTranscriptPath as string,
1345
1924
  };
@@ -1389,7 +1968,13 @@ function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | und
1389
1968
  expectedArtifactsIdentity,
1390
1969
  expectedArtifactsTree,
1391
1970
  detachedArtifactsPath: pending?.detachedArtifactsPath,
1971
+ retainedArtifactsSuccessorPath: pending?.retainedArtifactsSuccessorPath,
1972
+ retainedArtifactsPlaceholderPath: pending?.retainedArtifactsPlaceholderPath,
1973
+ retainedArtifactsUnknownPath: pending?.retainedArtifactsUnknownPath,
1392
1974
  detachedTranscriptPath: pending?.detachedTranscriptPath,
1975
+ retainedTranscriptSuccessorPath: pending?.retainedTranscriptSuccessorPath,
1976
+ retainedTranscriptPlaceholderPath: pending?.retainedTranscriptPlaceholderPath,
1977
+ retainedTranscriptUnknownPath: pending?.retainedTranscriptUnknownPath,
1393
1978
  plannedArtifactsPath: path.join(directory, `.gjc-delete-${operation}-artifacts-${attempt}`),
1394
1979
  plannedTranscriptPath: path.join(directory, `.gjc-delete-${operation}-transcript-${attempt}`),
1395
1980
  };
@@ -1402,6 +1987,36 @@ function requiresFreshCleanupPlan(pending: CleanupReceipt): boolean {
1402
1987
  );
1403
1988
  }
1404
1989
 
1990
+ /** Evidence-carrier for a verified `cleanup_pending` deletion, bound to the active plan. */
1991
+ function cleanupPendingEvidence(
1992
+ retry: CleanupReceipt,
1993
+ active: CleanupReceipt,
1994
+ deletion: Extract<VerifiedSessionDeleteResult, { kind: "cleanup_pending" }>,
1995
+ ): CleanupReceipt {
1996
+ return {
1997
+ ...retry,
1998
+ expectedArtifactsIdentity:
1999
+ deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2000
+ expectedArtifactsTree: deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2001
+ detachedArtifactsPath:
2002
+ deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2003
+ detachedTranscriptPath:
2004
+ deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2005
+ retainedArtifactsSuccessorPath:
2006
+ deletion.phase === "artifacts" ? deletion.retainedSuccessorPath : active.retainedArtifactsSuccessorPath,
2007
+ retainedArtifactsPlaceholderPath:
2008
+ deletion.phase === "artifacts" ? deletion.retainedPlaceholderPath : active.retainedArtifactsPlaceholderPath,
2009
+ retainedArtifactsUnknownPath:
2010
+ deletion.phase === "artifacts" ? deletion.retainedUnknownPath : active.retainedArtifactsUnknownPath,
2011
+ retainedTranscriptSuccessorPath:
2012
+ deletion.phase === "transcript" ? deletion.retainedSuccessorPath : active.retainedTranscriptSuccessorPath,
2013
+ retainedTranscriptPlaceholderPath:
2014
+ deletion.phase === "transcript" ? deletion.retainedPlaceholderPath : active.retainedTranscriptPlaceholderPath,
2015
+ retainedTranscriptUnknownPath:
2016
+ deletion.phase === "transcript" ? deletion.retainedUnknownPath : active.retainedTranscriptUnknownPath,
2017
+ };
2018
+ }
2019
+
1405
2020
  async function publishCleanupPending(
1406
2021
  scope: ManagedScope,
1407
2022
  tombstone: string,
@@ -1653,11 +2268,19 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin
1653
2268
  throw error;
1654
2269
  }
1655
2270
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe_artifacts");
2271
+ const tree = snapshotArtifactTree(originalPath);
2272
+ const root = tree.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
2273
+ if (!root) throw new Error("unsafe_artifacts");
1656
2274
  return {
1657
2275
  originalPath,
1658
2276
  detachedPath: path.join(path.dirname(originalPath), `.gjc-migrate-${operation}-artifacts`),
1659
- identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeNs: stat.mtimeNs },
1660
- tree: snapshotArtifactTree(originalPath),
2277
+ identity: {
2278
+ dev: stat.dev,
2279
+ ino: stat.ino,
2280
+ size: process.platform === "win32" ? BigInt(root.size) : stat.size,
2281
+ mtimeNs: stat.mtimeNs,
2282
+ },
2283
+ tree,
1661
2284
  };
1662
2285
  }
1663
2286
 
@@ -1681,13 +2304,17 @@ function sameDirectoryObject(leftPath: string, rightPath: string): boolean {
1681
2304
  function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
1682
2305
  try {
1683
2306
  const stat = fs.lstatSync(pathname, { bigint: true });
2307
+ const snapshot = native.snapshotDirectoryTree(pathname);
2308
+ const root = snapshot.snapshot?.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
1684
2309
  return (
1685
2310
  stat.isDirectory() &&
1686
2311
  !stat.isSymbolicLink() &&
1687
2312
  stat.dev === plan.identity.dev &&
1688
2313
  stat.ino === plan.identity.ino &&
1689
- stat.size === plan.identity.size &&
1690
2314
  stat.mtimeNs === plan.identity.mtimeNs &&
2315
+ (process.platform === "win32"
2316
+ ? snapshot.ok && !!root && BigInt(root.size) === plan.identity.size
2317
+ : stat.size === plan.identity.size) &&
1691
2318
  sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
1692
2319
  );
1693
2320
  } catch {
@@ -1695,26 +2322,130 @@ function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoo
1695
2322
  }
1696
2323
  }
1697
2324
 
1698
- function detachArtifactRootForMigration(plan: DetachedArtifactRoot): DetachedArtifactRoot {
2325
+ function sameArtifactTree(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
2326
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
2327
+ JSON.stringify([
2328
+ entry.relativePath,
2329
+ entry.kind,
2330
+ entry.dev,
2331
+ entry.ino,
2332
+ entry.size,
2333
+ entry.mtimeNs,
2334
+ entry.ctimeNs,
2335
+ entry.sha256,
2336
+ ]);
2337
+ return (
2338
+ left.rootDev === right.rootDev &&
2339
+ left.rootIno === right.rootIno &&
2340
+ left.entries.length === right.entries.length &&
2341
+ left.entries
2342
+ .map(entryKey)
2343
+ .sort()
2344
+ .every((entry, index) => entry === right.entries.map(entryKey).sort()[index])
2345
+ );
2346
+ }
2347
+
2348
+ type SourceArtifactCleanup = {
2349
+ state: "cleanup_pending";
2350
+ role: "exchange_placeholder";
2351
+ retainedPath: string;
2352
+ identity: DetachedArtifactRoot["identity"];
2353
+ tree: NativeDirectoryTreeSnapshot;
2354
+ };
2355
+
2356
+ function cleanupAuthorityMatches(cleanup: SourceArtifactCleanup, parent: string): boolean {
2357
+ try {
2358
+ const stat = fs.lstatSync(cleanup.retainedPath, { bigint: true });
2359
+ if (
2360
+ path.dirname(cleanup.retainedPath) !== parent ||
2361
+ !stat.isDirectory() ||
2362
+ stat.isSymbolicLink() ||
2363
+ stat.dev !== cleanup.identity.dev ||
2364
+ stat.ino !== cleanup.identity.ino ||
2365
+ stat.size !== cleanup.identity.size ||
2366
+ stat.mtimeNs !== cleanup.identity.mtimeNs
2367
+ )
2368
+ return false;
2369
+ const snapshot = native.snapshotDirectoryTree(cleanup.retainedPath);
2370
+ return (
2371
+ !!snapshot.ok &&
2372
+ !!snapshot.snapshot &&
2373
+ sameArtifactTree(snapshot.snapshot, cleanup.tree) &&
2374
+ cleanup.tree.entries.length === 1 &&
2375
+ cleanup.tree.entries[0]?.relativePath === "" &&
2376
+ cleanup.tree.entries[0]?.kind === "directory"
2377
+ );
2378
+ } catch {
2379
+ return false;
2380
+ }
2381
+ }
2382
+
2383
+ function detachArtifactRootForMigration(plan: DetachedArtifactRoot): {
2384
+ detached: DetachedArtifactRoot;
2385
+ cleanup?: SourceArtifactCleanup;
2386
+ } {
1699
2387
  const result = native.exactUnlink(plan.originalPath, {
1700
2388
  ...plan.identity,
1701
2389
  directory: true,
1702
2390
  detachOnly: true,
1703
2391
  quarantineName: path.basename(plan.detachedPath),
1704
2392
  });
1705
- if (!result.ok || !result.detachedPath || !matchesDetachedArtifactRoot(result.detachedPath, plan))
2393
+ const cleanSuccess =
2394
+ result.ok && !result.retainedSuccessorPath && !result.retainedPlaceholderPath && !result.retainedUnknownPath;
2395
+ const cleanupPending =
2396
+ !result.ok &&
2397
+ result.code === "cleanup_pending" &&
2398
+ !!result.detachedPath &&
2399
+ !!result.retainedPlaceholderPath &&
2400
+ !result.retainedSuccessorPath &&
2401
+ !result.retainedUnknownPath;
2402
+ if (
2403
+ (!cleanSuccess && !cleanupPending) ||
2404
+ !result.detachedPath ||
2405
+ !matchesDetachedArtifactRoot(result.detachedPath, plan)
2406
+ )
2407
+ throw new Error("durability_failed");
2408
+
2409
+ const detachedPath =
2410
+ process.platform === "win32" ? fs.realpathSync.native(result.detachedPath) : result.detachedPath;
2411
+ if (!matchesDetachedArtifactRoot(detachedPath, plan)) throw new Error("durability_failed");
2412
+ const detached = { ...plan, detachedPath };
2413
+ if (!cleanupPending) return { detached };
2414
+ const placeholder = result.retainedPlaceholderPath!;
2415
+ const stat = fs.lstatSync(placeholder, { bigint: true });
2416
+ if (!stat.isDirectory() || stat.isSymbolicLink() || path.dirname(placeholder) !== path.dirname(plan.originalPath))
1706
2417
  throw new Error("durability_failed");
1707
- return { ...plan, detachedPath: result.detachedPath };
2418
+ const snapshot = native.snapshotDirectoryTree(placeholder);
2419
+ const cleanup: SourceArtifactCleanup = {
2420
+ state: "cleanup_pending",
2421
+ role: "exchange_placeholder",
2422
+ retainedPath: placeholder,
2423
+ identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeNs: stat.mtimeNs },
2424
+ tree: snapshot.snapshot!,
2425
+ };
2426
+ if (!snapshot.ok || !snapshot.snapshot || !cleanupAuthorityMatches(cleanup, path.dirname(plan.originalPath)))
2427
+ throw new Error("durability_failed");
2428
+ return { detached, cleanup };
1708
2429
  }
1709
2430
 
1710
2431
  function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandidate): void {
1711
- const receipt = receiptPathFor(scope, source, "prepared");
2432
+ const preparedReceipt = receiptPathFor(scope, source, "prepared");
2433
+ const detachedReceipt = receiptPathFor(scope, source, "detached");
2434
+ const receipt = fs.existsSync(detachedReceipt) ? detachedReceipt : preparedReceipt;
1712
2435
  let record: {
1713
2436
  sourceArtifactQuarantine?: {
1714
2437
  path?: unknown;
1715
2438
  detachedPath?: unknown;
1716
2439
  identity?: Record<string, unknown>;
1717
2440
  tree?: unknown;
2441
+ role?: unknown;
2442
+ };
2443
+ sourceArtifactCleanup?: {
2444
+ state?: unknown;
2445
+ role?: unknown;
2446
+ retainedPath?: unknown;
2447
+ identity?: Record<string, unknown>;
2448
+ tree?: unknown;
1718
2449
  };
1719
2450
  };
1720
2451
  try {
@@ -1739,25 +2470,62 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
1739
2470
  typeof identity.mtimeNs !== "string"
1740
2471
  )
1741
2472
  throw new Error("durability_failed");
2473
+ if (receipt === detachedReceipt && quarantine.role !== "detached_artifact_root")
2474
+ throw new Error("durability_failed");
2475
+ if (receipt === detachedReceipt) {
2476
+ const cleanup = record.sourceArtifactCleanup;
2477
+ const cleanupIdentity = cleanup?.identity;
2478
+ const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
2479
+ if (
2480
+ cleanup?.state !== "cleanup_pending" ||
2481
+ cleanup.role !== "exchange_placeholder" ||
2482
+ typeof cleanup.retainedPath !== "string" ||
2483
+ !cleanupIdentity ||
2484
+ typeof cleanupIdentity.dev !== "string" ||
2485
+ typeof cleanupIdentity.ino !== "string" ||
2486
+ typeof cleanupIdentity.size !== "string" ||
2487
+ typeof cleanupIdentity.mtimeNs !== "string" ||
2488
+ !cleanupTree ||
2489
+ !cleanupAuthorityMatches(
2490
+ {
2491
+ state: "cleanup_pending",
2492
+ role: "exchange_placeholder",
2493
+ retainedPath: cleanup.retainedPath,
2494
+ identity: {
2495
+ dev: BigInt(cleanupIdentity.dev),
2496
+ ino: BigInt(cleanupIdentity.ino),
2497
+ size: BigInt(cleanupIdentity.size),
2498
+ mtimeNs: BigInt(cleanupIdentity.mtimeNs),
2499
+ },
2500
+ tree: cleanupTree,
2501
+ },
2502
+ path.dirname(source.path),
2503
+ )
2504
+ )
2505
+ throw new Error("durability_failed");
2506
+ }
2507
+
1742
2508
  const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
1743
2509
  const assertPreparedTree = (pathname: string): void => {
1744
2510
  validateManagedArtifactTree(pathname);
1745
2511
  const observed = native.snapshotDirectoryTree(pathname);
1746
- if (!observed.ok || !observed.snapshot || JSON.stringify(observed.snapshot) !== JSON.stringify(expectedTree))
2512
+ if (!observed.ok || !observed.snapshot || !sameArtifactTree(observed.snapshot, expectedTree))
1747
2513
  throw new Error("durability_failed");
1748
2514
  };
1749
2515
  if (fs.existsSync(quarantine.path)) {
1750
2516
  const existing = fs.lstatSync(quarantine.path, { bigint: true });
1751
2517
  if (
1752
- existing.isSymbolicLink() ||
1753
- !existing.isDirectory() ||
1754
- existing.dev !== BigInt(identity.dev) ||
1755
- existing.ino !== BigInt(identity.ino) ||
1756
- existing.size !== BigInt(identity.size) ||
1757
- existing.mtimeNs !== BigInt(identity.mtimeNs)
1758
- )
1759
- throw new Error("durability_failed");
1760
- assertPreparedTree(quarantine.path);
2518
+ !existing.isSymbolicLink() &&
2519
+ existing.isDirectory() &&
2520
+ existing.dev === BigInt(identity.dev) &&
2521
+ existing.ino === BigInt(identity.ino) &&
2522
+ existing.size === BigInt(identity.size) &&
2523
+ existing.mtimeNs === BigInt(identity.mtimeNs)
2524
+ ) {
2525
+ assertPreparedTree(quarantine.path);
2526
+ }
2527
+ // A changed source pathname is independent retained authority. Do not replace
2528
+ // it with the detached original; retain both roots for recovery.
1761
2529
  return;
1762
2530
  }
1763
2531
  assertPreparedTree(quarantine.detachedPath);
@@ -1771,7 +2539,9 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
1771
2539
  if (!result.ok) throw new Error("durability_failed");
1772
2540
  }
1773
2541
 
1774
- function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot): void {
2542
+ function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: SourceArtifactCleanup): void {
2543
+ if (cleanup && !cleanupAuthorityMatches(cleanup, path.dirname(detached.originalPath)))
2544
+ throw new Error("durability_failed");
1775
2545
  const result = native.exactRestore(detached.detachedPath, detached.originalPath, {
1776
2546
  ...detached.identity,
1777
2547
  directory: true,
@@ -1785,6 +2555,8 @@ async function copyArtifacts(
1785
2555
  destinationTranscript: string,
1786
2556
  manifest: readonly ArtifactManifestEntry[],
1787
2557
  lock: ManagedStorageLock,
2558
+ expectedCandidate: ManagedCandidate,
2559
+ expectedIdentity: ResumeSessionIdentity,
1788
2560
  sourceRootOverride?: string,
1789
2561
  ): Promise<void> {
1790
2562
  const root = scopeRoot(scope);
@@ -1793,12 +2565,18 @@ async function copyArtifacts(
1793
2565
  if (!manifestMatches(sourceTranscript, manifest, sourceRoot)) throw new Error("source_changed");
1794
2566
  const destinationRoot = destinationTranscript.slice(0, -6);
1795
2567
  for (const entry of manifest) {
2568
+ revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
1796
2569
  lock.assertOwned();
1797
2570
  const source = path.join(sourceRoot, entry.path);
1798
2571
  const destination = path.join(destinationRoot, entry.path);
1799
2572
  if (entry.kind === "directory") {
1800
- if (entry.path === "") ensureManagedDirectory(destinationRoot, root);
1801
- else ensureManagedDirectory(destination, root);
2573
+ if (entry.path === "") {
2574
+ revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2575
+ ensureManagedDirectory(destinationRoot, root);
2576
+ } else {
2577
+ revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2578
+ ensureManagedDirectory(destination, root);
2579
+ }
1802
2580
  continue;
1803
2581
  }
1804
2582
  const snapshot = captureManagedFileNoFollow(source);
@@ -1808,6 +2586,7 @@ async function copyArtifacts(
1808
2586
  )
1809
2587
  throw new Error("source_changed");
1810
2588
  try {
2589
+ revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
1811
2590
  await copyManagedFileNoReplace(source, destination, snapshot, root);
1812
2591
  } catch (error) {
1813
2592
  if ((error as Error).message !== "destination_conflict") throw error;
@@ -1827,7 +2606,7 @@ async function copyArtifacts(
1827
2606
  function migrationReceipt(
1828
2607
  scope: ManagedScope,
1829
2608
  lock: ManagedStorageLock,
1830
- state: "prepared" | "published" | "committed",
2609
+ state: "prepared" | "artifact_detached" | "published" | "committed",
1831
2610
  source: ManagedCandidate,
1832
2611
  destination: ManagedCandidate | { path: string; sessionId: string; cwd: string },
1833
2612
  manifest: readonly ArtifactManifestEntry[],
@@ -1836,7 +2615,9 @@ function migrationReceipt(
1836
2615
  detachedPath: string;
1837
2616
  identity: DetachedArtifactRoot["identity"];
1838
2617
  tree: NativeDirectoryTreeSnapshot;
2618
+ role?: "detached_artifact_root";
1839
2619
  },
2620
+ sourceArtifactCleanup?: SourceArtifactCleanup,
1840
2621
  ): Uint8Array {
1841
2622
  lock.assertOwned();
1842
2623
  const destinationRecord =
@@ -1854,12 +2635,20 @@ function migrationReceipt(
1854
2635
  header: { id: destination.sessionId, cwd: destination.cwd },
1855
2636
  };
1856
2637
  return new TextEncoder().encode(
1857
- `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
2638
+ `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}), ...(sourceArtifactCleanup ? { sourceArtifactCleanup } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
1858
2639
  );
1859
2640
  }
1860
2641
 
2642
+ function detachedReceiptMatches(receipt: string, expected: Uint8Array): boolean {
2643
+ try {
2644
+ return captureManagedFileNoFollow(receipt).bytes.equals(expected);
2645
+ } catch {
2646
+ return false;
2647
+ }
2648
+ }
2649
+
1861
2650
  async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandidate): Promise<void> {
1862
- for (const state of ["prepared", "published"] as const) {
2651
+ for (const state of ["prepared", "detached", "published"] as const) {
1863
2652
  const pathname = receiptPathFor(scope, candidate, state);
1864
2653
  try {
1865
2654
  const stat = fs.lstatSync(pathname);
@@ -1905,6 +2694,7 @@ function receiptPair(scope: ManagedScope, candidate: ManagedCandidate): ManagedC
1905
2694
  }
1906
2695
 
1907
2696
  function validateCandidateForScope(scope: ManagedScope, candidate: ManagedCandidate): ManagedCandidate | undefined {
2697
+ scopeRoot(scope);
1908
2698
  const inspected = inspectCandidate(candidate.path, candidate.provenance);
1909
2699
  if ("code" in inspected || !sameCandidate(inspected, candidate)) return undefined;
1910
2700
  const identity = identityFor(inspected.cwd);
@@ -1941,8 +2731,7 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
1941
2731
  if (!observedPending) continue;
1942
2732
  if (
1943
2733
  cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
1944
- artifactRootsAbsent(scope, tombstone, target, observedPending) &&
1945
- !fs.existsSync(observedPending.plannedTranscriptPath)
2734
+ cleanupRootsAbsent(tombstone, target, observedPending)
1946
2735
  ) {
1947
2736
  fsyncManagedParent(target.path);
1948
2737
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -1981,6 +2770,12 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
1981
2770
  ? pending.plannedTranscriptPath
1982
2771
  : undefined) ??
1983
2772
  (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
2773
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2774
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2775
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2776
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2777
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2778
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
1984
2779
  plannedArtifactsPath: active.plannedArtifactsPath,
1985
2780
  plannedTranscriptPath: active.plannedTranscriptPath,
1986
2781
  ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
@@ -1997,6 +2792,14 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
1997
2792
  transcriptIdentity: target.identity,
1998
2793
  plannedArtifactsPath: active.plannedArtifactsPath,
1999
2794
  plannedTranscriptPath: active.plannedTranscriptPath,
2795
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
2796
+
2797
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2798
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2799
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2800
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2801
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2802
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2000
2803
  artifactsRemoved: true,
2001
2804
  });
2002
2805
  }
@@ -2017,10 +2820,34 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2017
2820
  deletion.phase === "transcript"
2018
2821
  ? deletion.detachedTranscriptPath
2019
2822
  : active.detachedTranscriptPath,
2823
+ retainedArtifactsSuccessorPath:
2824
+ deletion.phase === "artifacts"
2825
+ ? deletion.retainedSuccessorPath
2826
+ : active.retainedArtifactsSuccessorPath,
2827
+ retainedArtifactsPlaceholderPath:
2828
+ deletion.phase === "artifacts"
2829
+ ? deletion.retainedPlaceholderPath
2830
+ : active.retainedArtifactsPlaceholderPath,
2831
+ retainedArtifactsUnknownPath:
2832
+ deletion.phase === "artifacts"
2833
+ ? deletion.retainedUnknownPath
2834
+ : active.retainedArtifactsUnknownPath,
2835
+ retainedTranscriptSuccessorPath:
2836
+ deletion.phase === "transcript"
2837
+ ? deletion.retainedSuccessorPath
2838
+ : active.retainedTranscriptSuccessorPath,
2839
+ retainedTranscriptPlaceholderPath:
2840
+ deletion.phase === "transcript"
2841
+ ? deletion.retainedPlaceholderPath
2842
+ : active.retainedTranscriptPlaceholderPath,
2843
+ retainedTranscriptUnknownPath:
2844
+ deletion.phase === "transcript"
2845
+ ? deletion.retainedUnknownPath
2846
+ : active.retainedTranscriptUnknownPath,
2020
2847
  },
2021
2848
  lock,
2022
2849
  );
2023
- throw new Error("durability_failed");
2850
+ continue;
2024
2851
  }
2025
2852
  fsyncManagedParent(target.path);
2026
2853
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2032,24 +2859,45 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2032
2859
  }
2033
2860
 
2034
2861
  /** Create the v2 binding and private write protocol directories before managed writes. */
2035
- export async function prepareManagedSessionScopeForWrite(scope: ManagedScope): Promise<ManagedScopeResolution> {
2036
- const prepared = await ensureManagedScope(scope);
2862
+ export async function prepareManagedSessionScopeForWrite(
2863
+ scope: ManagedScope,
2864
+ policy: ManagedSessionSecurityPolicy = "default",
2865
+ authority?: ManagedCandidateWriteAuthority,
2866
+ expectedCandidate?: ManagedCandidate,
2867
+ expectedIdentity?: ResumeSessionIdentity,
2868
+ ): Promise<ManagedScopeResolution> {
2869
+ if (expectedCandidate && expectedIdentity) revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2870
+ if (authority) bindManagedWriteAuthority(scope, authority);
2871
+ const prepared = await ensureManagedScope(scope, policy);
2037
2872
  if (prepared.kind === "error") return prepared;
2038
2873
  try {
2039
2874
  const internal = managedInternalDirectory(scope);
2040
2875
  const root = scopeRoot(scope);
2041
- ensureManagedDirectory(internal, root);
2042
- ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root);
2043
- ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root);
2044
- ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root);
2876
+ ensureManagedDirectory(internal, root, policy);
2877
+ ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root, policy);
2878
+ ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root, policy);
2879
+ ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
2045
2880
  await reconcileManagedTombstones(scope);
2046
2881
  return { kind: "resolved", scope };
2047
2882
  } catch (error) {
2048
- if (error instanceof Error && error.message === "durability_failed") return { kind: "resolved", scope };
2883
+ const publication = error instanceof ManagedPublishError ? error : undefined;
2884
+ const message =
2885
+ publication?.classification ??
2886
+ (error instanceof Error ? error.message : "Managed write protocol setup failed.");
2887
+ const code =
2888
+ message === "atomic_unavailable" ||
2889
+ message === "invalid_request" ||
2890
+ message === "durability_failed" ||
2891
+ message === "durability_not_provable"
2892
+ ? message
2893
+ : "binding_invalid";
2049
2894
  return {
2050
2895
  kind: "error",
2051
- code: "binding_invalid",
2052
- message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
2896
+ code,
2897
+ message,
2898
+ ...(publication
2899
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
2900
+ : { cause: { classification: code } }),
2053
2901
  };
2054
2902
  }
2055
2903
  }
@@ -2061,28 +2909,45 @@ export async function prepareManagedSessionScopeForWrite(scope: ManagedScope): P
2061
2909
  export async function openManagedCandidateForWrite(
2062
2910
  scope: ManagedScope,
2063
2911
  candidate: ManagedCandidate,
2064
- migrationPolicy: ManagedMigrationPolicy = "copy-retain",
2912
+ expectedIdentityOrMigrationPolicy: ResumeSessionIdentity | ManagedMigrationPolicy = "copy-retain",
2913
+ migrationPolicy: ManagedMigrationPolicy = typeof expectedIdentityOrMigrationPolicy === "string"
2914
+ ? expectedIdentityOrMigrationPolicy
2915
+ : "copy-retain",
2916
+ authority?: ManagedCandidateWriteAuthority,
2065
2917
  ): Promise<ManagedOpenCandidateResult> {
2918
+ const expectedIdentity =
2919
+ typeof expectedIdentityOrMigrationPolicy === "string" ? candidate.identity : expectedIdentityOrMigrationPolicy;
2066
2920
  if (migrationPolicy === "disabled" && candidate.provenance === "legacy")
2067
2921
  return {
2068
2922
  kind: "error",
2069
2923
  code: "legacy_migration_disabled",
2070
2924
  message: "Legacy session migration is disabled for this workspace.",
2071
2925
  };
2072
- const prepared = await prepareManagedSessionScopeForWrite(scope);
2073
- if (prepared.kind === "error")
2074
- return {
2075
- kind: "error",
2076
- code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
2077
- message: prepared.message,
2078
- };
2079
- const current = validateCandidateForScope(scope, candidate);
2080
- if (!current)
2926
+ let prepared: ManagedScopeResolution;
2927
+ let current: ManagedCandidate;
2928
+ try {
2929
+ prepared = await prepareManagedSessionScopeForWrite(
2930
+ scope,
2931
+ scope.platform === "win32" ? "windows-existing-verify-first" : "default",
2932
+ authority,
2933
+ candidate,
2934
+ expectedIdentity,
2935
+ );
2936
+ if (prepared.kind === "error")
2937
+ return {
2938
+ kind: "error",
2939
+ code: expectedFailure(new Error(prepared.code)),
2940
+
2941
+ message: prepared.message,
2942
+ };
2943
+ current = revalidatePickerConsent(scope, candidate, expectedIdentity);
2944
+ } catch (error) {
2081
2945
  return {
2082
2946
  kind: "error",
2083
- code: "source_changed",
2084
- message: "The managed candidate changed before it could be opened.",
2947
+ code: expectedFailure(error),
2948
+ message: error instanceof Error ? error.message : "Managed migration failed.",
2085
2949
  };
2950
+ }
2086
2951
  if (isRetired(scope, current))
2087
2952
  return { kind: "error", code: "migration_retired", message: "The managed session has been retired." };
2088
2953
  if (current.provenance === "v2") return { kind: "opened", path: current.path, candidate: current, migrated: false };
@@ -2091,11 +2956,15 @@ export async function openManagedCandidateForWrite(
2091
2956
  const internal = managedInternalDirectory(scope);
2092
2957
  let lock: ManagedStorageLock | undefined;
2093
2958
  let detachedArtifacts: DetachedArtifactRoot | undefined;
2959
+ let sourceArtifactCleanup: SourceArtifactCleanup | undefined;
2960
+
2094
2961
  try {
2962
+ revalidatePickerConsent(scope, current, expectedIdentity);
2095
2963
  lock = await acquireManagedLock(path.join(internal, MANAGED_LOCKS_DIRECTORY), operation, scopeRoot(scope));
2096
- const afterLock = validateCandidateForScope(scope, current);
2097
- if (!afterLock)
2098
- return { kind: "error", code: "source_changed", message: "The legacy candidate changed during migration." };
2964
+ const heldLock = lock;
2965
+
2966
+ const afterLock = revalidatePickerConsent(scope, current, expectedIdentity);
2967
+
2099
2968
  const listing = listManagedCandidates(scope);
2100
2969
  if (listing.kind === "error") return { kind: "error", code: "binding_invalid", message: listing.message };
2101
2970
  const destination = path.join(scope.directoryPath, path.basename(afterLock.path));
@@ -2107,11 +2976,29 @@ export async function openManagedCandidateForWrite(
2107
2976
  code: "destination_conflict",
2108
2977
  message: "A distinct v2 transcript already owns this session id.",
2109
2978
  };
2979
+
2980
+ scopeRoot(scope);
2981
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2110
2982
  restorePreparedArtifactRoot(scope, afterLock);
2111
- const sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
2983
+ scopeRoot(scope);
2984
+
2985
+ let sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
2986
+ if (
2987
+ sourceSnapshot.identity.dev !== afterLock.identity.dev ||
2988
+ sourceSnapshot.identity.ino !== afterLock.identity.ino ||
2989
+ sourceSnapshot.identity.size !== afterLock.identity.size ||
2990
+ sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
2991
+ sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
2992
+ sourceSnapshot.bytes.byteLength !== afterLock.identity.size
2993
+ )
2994
+ throw new Error("source_changed");
2112
2995
  let manifest: readonly ArtifactManifestEntry[] = [];
2113
2996
  const artifactPlan = planArtifactRootForMigration(afterLock.path, operation);
2114
2997
  const intendedDestination = { path: destination, sessionId: afterLock.sessionId, cwd: afterLock.cwd };
2998
+ const assertPublicationConsent = (): void => {
2999
+ heldLock.assertOwned();
3000
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3001
+ };
2115
3002
  if (existing && existing.identity.sha256 !== afterLock.identity.sha256) {
2116
3003
  return {
2117
3004
  kind: "error",
@@ -2126,8 +3013,10 @@ export async function openManagedCandidateForWrite(
2126
3013
  message: "The migration destination already exists without validated ownership.",
2127
3014
  };
2128
3015
  }
3016
+
2129
3017
  const preparedReceipt = receiptPathFor(scope, afterLock, "prepared");
2130
3018
  try {
3019
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2131
3020
  lock.assertOwned();
2132
3021
  await publishManagedFileNoReplace(
2133
3022
  preparedReceipt,
@@ -2147,27 +3036,96 @@ export async function openManagedCandidateForWrite(
2147
3036
  }
2148
3037
  : undefined,
2149
3038
  ),
2150
- lock.assertOwned,
3039
+ assertPublicationConsent,
3040
+ scopeRoot(scope),
2151
3041
  );
2152
3042
  } catch (error) {
2153
3043
  if ((error as Error).message !== "destination_conflict") throw error;
2154
3044
  }
3045
+
3046
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2155
3047
  lock.assertOwned();
2156
3048
  if (!preparedReceiptMatches(preparedReceipt, scope, afterLock, intendedDestination, artifactPlan))
2157
3049
  throw new Error("durability_failed");
2158
3050
  if (artifactPlan && fs.existsSync(artifactPlan.detachedPath)) throw new Error("destination_conflict");
2159
- detachedArtifacts = artifactPlan ? detachArtifactRootForMigration(artifactPlan) : undefined;
3051
+
3052
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3053
+
3054
+ scopeRoot(scope);
3055
+
3056
+ if (artifactPlan) {
3057
+ const detached = detachArtifactRootForMigration(artifactPlan);
3058
+ detachedArtifacts = detached.detached;
3059
+ sourceArtifactCleanup = detached.cleanup;
3060
+ const detachedReceipt = receiptPathFor(scope, afterLock, "detached");
3061
+ const detachedRecord = migrationReceipt(
3062
+ scope,
3063
+ lock,
3064
+ "artifact_detached",
3065
+ afterLock,
3066
+ intendedDestination,
3067
+ [],
3068
+ {
3069
+ path: detachedArtifacts.originalPath,
3070
+ detachedPath: detachedArtifacts.detachedPath,
3071
+ identity: detachedArtifacts.identity,
3072
+ tree: detachedArtifacts.tree,
3073
+ role: "detached_artifact_root",
3074
+ },
3075
+ sourceArtifactCleanup,
3076
+ );
3077
+ try {
3078
+ await publishManagedFileNoReplace(
3079
+ detachedReceipt,
3080
+ detachedRecord,
3081
+ assertPublicationConsent,
3082
+ scopeRoot(scope),
3083
+ );
3084
+ } catch (error) {
3085
+ if ((error as Error).message !== "destination_conflict") throw error;
3086
+ }
3087
+ if (!detachedReceiptMatches(detachedReceipt, detachedRecord)) throw new Error("durability_failed");
3088
+ }
2160
3089
  manifest = detachedArtifacts ? artifactManifestFromSnapshot(detachedArtifacts.tree) : [];
2161
- await copyArtifacts(scope, afterLock.path, destination, manifest, lock, detachedArtifacts?.detachedPath);
3090
+
3091
+ await copyArtifacts(
3092
+ scope,
3093
+ afterLock.path,
3094
+ destination,
3095
+ manifest,
3096
+ lock,
3097
+ afterLock,
3098
+ expectedIdentity,
3099
+ detachedArtifacts?.detachedPath,
3100
+ );
3101
+
3102
+ if (!existing) {
3103
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3104
+ lock.assertOwned();
3105
+ sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
3106
+ if (
3107
+ sourceSnapshot.identity.dev !== afterLock.identity.dev ||
3108
+ sourceSnapshot.identity.ino !== afterLock.identity.ino ||
3109
+ sourceSnapshot.identity.size !== afterLock.identity.size ||
3110
+ sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
3111
+ sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
3112
+ sourceSnapshot.bytes.byteLength !== afterLock.identity.size
3113
+ )
3114
+ throw new Error("source_changed");
3115
+ }
3116
+
2162
3117
  if (!existing) {
2163
3118
  try {
3119
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2164
3120
  lock.assertOwned();
2165
3121
  await copyManagedFileNoReplace(afterLock.path, destination, sourceSnapshot, scopeRoot(scope));
2166
3122
  } catch (error) {
2167
3123
  if ((error as Error).message !== "destination_conflict") throw error;
2168
3124
  }
2169
3125
  }
3126
+
2170
3127
  // Artifact files, directories, and the transcript must be durable before a receipt can grant shadow authority.
3128
+ scopeRoot(scope);
2171
3129
  if (manifest.length > 0) fsyncManagedArtifactTree(destination.slice(0, -6));
2172
3130
  lock.assertOwned();
2173
3131
  const migrated = inspectCandidate(destination, "v2");
@@ -2179,10 +3137,14 @@ export async function openManagedCandidateForWrite(
2179
3137
  !manifestMatches(destination, manifest)
2180
3138
  )
2181
3139
  throw new Error("durability_failed");
3140
+
2182
3141
  if (detachedArtifacts) {
2183
- restoreDetachedArtifactRoot(detachedArtifacts);
3142
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3143
+ scopeRoot(scope);
3144
+ restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
2184
3145
  detachedArtifacts = undefined;
2185
3146
  }
3147
+
2186
3148
  const latest = validateCandidateForScope(scope, afterLock);
2187
3149
  if (!latest || !sameCandidate(latest, afterLock) || !manifestMatches(afterLock.path, manifest))
2188
3150
  return {
@@ -2190,19 +3152,40 @@ export async function openManagedCandidateForWrite(
2190
3152
  code: "source_changed",
2191
3153
  message: "The legacy candidate or its artifacts changed before migration commit.",
2192
3154
  };
3155
+
2193
3156
  for (const state of ["published", "committed"] as const) {
2194
3157
  const receipt = receiptPathFor(scope, afterLock, state);
2195
3158
  try {
3159
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2196
3160
  lock.assertOwned();
2197
3161
  await publishManagedFileNoReplace(
2198
3162
  receipt,
2199
- migrationReceipt(scope, lock, state, afterLock, migrated, manifest),
2200
- lock.assertOwned,
3163
+ migrationReceipt(
3164
+ scope,
3165
+ lock,
3166
+ state,
3167
+ afterLock,
3168
+ migrated,
3169
+ manifest,
3170
+ artifactPlan
3171
+ ? {
3172
+ path: artifactPlan.originalPath,
3173
+ detachedPath: artifactPlan.detachedPath,
3174
+ identity: artifactPlan.identity,
3175
+ tree: artifactPlan.tree,
3176
+ role: "detached_artifact_root",
3177
+ }
3178
+ : undefined,
3179
+ sourceArtifactCleanup,
3180
+ ),
3181
+ assertPublicationConsent,
3182
+ scopeRoot(scope),
2201
3183
  );
2202
3184
  } catch (error) {
2203
3185
  if ((error as Error).message !== "destination_conflict") throw error;
2204
3186
  }
2205
3187
  }
3188
+
2206
3189
  const receipt = receiptPathFor(scope, afterLock);
2207
3190
  lock.assertOwned();
2208
3191
  if (!receiptMatches(receipt, afterLock, migrated, scope))
@@ -2211,6 +3194,9 @@ export async function openManagedCandidateForWrite(
2211
3194
  code: "durability_failed",
2212
3195
  message: "The migration receipt does not bind the copied v2 transcript and artifacts.",
2213
3196
  };
3197
+
3198
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3199
+ scopeRoot(scope);
2214
3200
  await removeStagedReceipts(scope, afterLock);
2215
3201
  return {
2216
3202
  kind: "opened",
@@ -2220,7 +3206,7 @@ export async function openManagedCandidateForWrite(
2220
3206
  };
2221
3207
  } catch (error) {
2222
3208
  try {
2223
- if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts);
3209
+ if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
2224
3210
  } catch {
2225
3211
  return {
2226
3212
  kind: "error",
@@ -2244,7 +3230,7 @@ export async function deleteManagedSessionCandidate(
2244
3230
  if (prepared.kind === "error")
2245
3231
  return {
2246
3232
  kind: "error",
2247
- code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
3233
+ code: expectedFailure(new Error(prepared.code)),
2248
3234
  message: prepared.message,
2249
3235
  };
2250
3236
  const current = validateCandidateForScope(scope, candidate);
@@ -2304,8 +3290,7 @@ export async function deleteManagedSessionCandidate(
2304
3290
  if (!observedPending) continue;
2305
3291
  if (
2306
3292
  cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2307
- artifactRootsAbsent(scope, tombstone, target, observedPending) &&
2308
- !fs.existsSync(observedPending.plannedTranscriptPath)
3293
+ cleanupRootsAbsent(tombstone, target, observedPending)
2309
3294
  ) {
2310
3295
  fsyncManagedParent(target.path);
2311
3296
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2340,6 +3325,12 @@ export async function deleteManagedSessionCandidate(
2340
3325
  active.detachedTranscriptPath ??
2341
3326
  observedPending?.detachedTranscriptPath ??
2342
3327
  (pending && fs.existsSync(pending.plannedTranscriptPath) ? pending.plannedTranscriptPath : undefined),
3328
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3329
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3330
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3331
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3332
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3333
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2343
3334
  plannedArtifactsPath: active.plannedArtifactsPath,
2344
3335
  plannedTranscriptPath: active.plannedTranscriptPath,
2345
3336
  ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
@@ -2356,6 +3347,13 @@ export async function deleteManagedSessionCandidate(
2356
3347
  transcriptIdentity: target.identity,
2357
3348
  plannedArtifactsPath: active.plannedArtifactsPath,
2358
3349
  plannedTranscriptPath: active.plannedTranscriptPath,
3350
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3351
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3352
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3353
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3354
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3355
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3356
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2359
3357
  artifactsRemoved: true,
2360
3358
  });
2361
3359
  }
@@ -2371,23 +3369,51 @@ export async function deleteManagedSessionCandidate(
2371
3369
  )
2372
3370
  throw new Error("durability_failed");
2373
3371
  const retry = nextCleanupReceipt(target, active);
2374
- await publishCleanupPending(
2375
- scope,
2376
- tombstone,
2377
- {
2378
- ...retry,
2379
- expectedArtifactsIdentity:
2380
- deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2381
- expectedArtifactsTree:
2382
- deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2383
- detachedArtifactsPath:
2384
- deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2385
- detachedTranscriptPath:
2386
- deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2387
- },
2388
- lock,
2389
- );
2390
- throw new Error("durability_failed");
3372
+ await publishCleanupPending(scope, tombstone, cleanupPendingEvidence(retry, active, deletion), lock);
3373
+ if (deletion.phase === "artifacts") {
3374
+ // A typed retained artifact root proves durable canonical absence; advance
3375
+ // to the transcript phase instead of stalling on the retained evidence.
3376
+ await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
3377
+ deletion = await new FileSessionStorage().deleteSessionVerified({
3378
+ sessionsRoot: scope.sessionsRoot,
3379
+ transcriptPath: target.path,
3380
+ sessionId: target.sessionId,
3381
+ cwd: target.cwd,
3382
+ transcriptIdentity: target.identity,
3383
+ plannedArtifactsPath: active.plannedArtifactsPath,
3384
+ plannedTranscriptPath: active.plannedTranscriptPath,
3385
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3386
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3387
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3388
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3389
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3390
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3391
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
3392
+ artifactsRemoved: true,
3393
+ });
3394
+ if (deletion.kind === "cleanup_pending") {
3395
+ const followup = nextCleanupReceipt(target, active);
3396
+ await publishCleanupPending(
3397
+ scope,
3398
+ tombstone,
3399
+ cleanupPendingEvidence(followup, active, deletion),
3400
+ lock,
3401
+ );
3402
+ if (
3403
+ deletion.phase !== "transcript" ||
3404
+ deletion.detachedTranscriptPath !== active.plannedTranscriptPath
3405
+ )
3406
+ return {
3407
+ kind: "cleanup_pending",
3408
+ tombstonePath: tombstone,
3409
+ phase: deletion.phase,
3410
+ message:
3411
+ "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.",
3412
+ };
3413
+ }
3414
+ }
3415
+ // A typed retained transcript quarantine is durable canonical-absence
3416
+ // evidence — never a terminal byte-deletion claim; complete the receipt.
2391
3417
  }
2392
3418
  fsyncManagedParent(target.path);
2393
3419
  await publishCleanupCompleted(scope, tombstone, target, lock);