daftari 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +139 -4
  3. package/dist/access/rbac.d.ts +1 -0
  4. package/dist/access/rbac.d.ts.map +1 -1
  5. package/dist/access/rbac.js +7 -0
  6. package/dist/access/rbac.js.map +1 -1
  7. package/dist/audit/checks/broken_refs.d.ts +1 -1
  8. package/dist/audit/checks/broken_refs.d.ts.map +1 -1
  9. package/dist/audit/checks/broken_refs.js +77 -22
  10. package/dist/audit/checks/broken_refs.js.map +1 -1
  11. package/dist/audit/collect.d.ts +1 -0
  12. package/dist/audit/collect.d.ts.map +1 -1
  13. package/dist/audit/collect.js +22 -2
  14. package/dist/audit/collect.js.map +1 -1
  15. package/dist/audit/index.d.ts.map +1 -1
  16. package/dist/audit/index.js +9 -3
  17. package/dist/audit/index.js.map +1 -1
  18. package/dist/audit/links.d.ts.map +1 -1
  19. package/dist/audit/links.js +9 -3
  20. package/dist/audit/links.js.map +1 -1
  21. package/dist/audit/report.d.ts.map +1 -1
  22. package/dist/audit/report.js +2 -0
  23. package/dist/audit/report.js.map +1 -1
  24. package/dist/audit/types.d.ts +4 -1
  25. package/dist/audit/types.d.ts.map +1 -1
  26. package/dist/audit/types.js.map +1 -1
  27. package/dist/backfill/derive.d.ts.map +1 -1
  28. package/dist/backfill/derive.js +1 -0
  29. package/dist/backfill/derive.js.map +1 -1
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +18 -0
  32. package/dist/cli.js.map +1 -1
  33. package/dist/court/precedent.d.ts.map +1 -1
  34. package/dist/court/precedent.js +6 -1
  35. package/dist/court/precedent.js.map +1 -1
  36. package/dist/curation/consumes.d.ts +24 -0
  37. package/dist/curation/consumes.d.ts.map +1 -0
  38. package/dist/curation/consumes.js +122 -0
  39. package/dist/curation/consumes.js.map +1 -0
  40. package/dist/curation/edge-staleness.d.ts +47 -0
  41. package/dist/curation/edge-staleness.d.ts.map +1 -0
  42. package/dist/curation/edge-staleness.js +258 -0
  43. package/dist/curation/edge-staleness.js.map +1 -0
  44. package/dist/curation/edges.d.ts.map +1 -1
  45. package/dist/curation/edges.js +164 -10
  46. package/dist/curation/edges.js.map +1 -1
  47. package/dist/curation/lint.d.ts +4 -1
  48. package/dist/curation/lint.d.ts.map +1 -1
  49. package/dist/curation/lint.js +61 -6
  50. package/dist/curation/lint.js.map +1 -1
  51. package/dist/curation/provenance.d.ts +2 -0
  52. package/dist/curation/provenance.d.ts.map +1 -1
  53. package/dist/curation/provenance.js +2 -0
  54. package/dist/curation/provenance.js.map +1 -1
  55. package/dist/curation/read-log.d.ts +19 -0
  56. package/dist/curation/read-log.d.ts.map +1 -0
  57. package/dist/curation/read-log.js +120 -0
  58. package/dist/curation/read-log.js.map +1 -0
  59. package/dist/curation/review-throughput.d.ts +24 -0
  60. package/dist/curation/review-throughput.d.ts.map +1 -0
  61. package/dist/curation/review-throughput.js +102 -0
  62. package/dist/curation/review-throughput.js.map +1 -0
  63. package/dist/curation/staged-actions.d.ts +11 -1
  64. package/dist/curation/staged-actions.d.ts.map +1 -1
  65. package/dist/curation/staged-actions.js +102 -24
  66. package/dist/curation/staged-actions.js.map +1 -1
  67. package/dist/curation/structural.d.ts +16 -0
  68. package/dist/curation/structural.d.ts.map +1 -0
  69. package/dist/curation/structural.js +64 -0
  70. package/dist/curation/structural.js.map +1 -0
  71. package/dist/curation/tension-blast.d.ts +4 -0
  72. package/dist/curation/tension-blast.d.ts.map +1 -1
  73. package/dist/curation/tension-blast.js +9 -0
  74. package/dist/curation/tension-blast.js.map +1 -1
  75. package/dist/curation/tension.d.ts +5 -2
  76. package/dist/curation/tension.d.ts.map +1 -1
  77. package/dist/curation/tension.js +68 -25
  78. package/dist/curation/tension.js.map +1 -1
  79. package/dist/curation/tier0.d.ts +27 -0
  80. package/dist/curation/tier0.d.ts.map +1 -0
  81. package/dist/curation/tier0.js +196 -0
  82. package/dist/curation/tier0.js.map +1 -0
  83. package/dist/curation/tier1.d.ts +30 -0
  84. package/dist/curation/tier1.d.ts.map +1 -0
  85. package/dist/curation/tier1.js +142 -0
  86. package/dist/curation/tier1.js.map +1 -0
  87. package/dist/curation/tier2.d.ts +43 -0
  88. package/dist/curation/tier2.d.ts.map +1 -0
  89. package/dist/curation/tier2.js +182 -0
  90. package/dist/curation/tier2.js.map +1 -0
  91. package/dist/curation/vault-docs.d.ts +5 -1
  92. package/dist/curation/vault-docs.d.ts.map +1 -1
  93. package/dist/curation/vault-docs.js +14 -0
  94. package/dist/curation/vault-docs.js.map +1 -1
  95. package/dist/eval/generate.js +22 -5
  96. package/dist/eval/generate.js.map +1 -1
  97. package/dist/eval/index.d.ts.map +1 -1
  98. package/dist/eval/index.js +173 -25
  99. package/dist/eval/index.js.map +1 -1
  100. package/dist/eval/llm.js +1 -1
  101. package/dist/eval/llm.js.map +1 -1
  102. package/dist/eval/prune.d.ts +24 -0
  103. package/dist/eval/prune.d.ts.map +1 -0
  104. package/dist/eval/prune.js +83 -0
  105. package/dist/eval/prune.js.map +1 -0
  106. package/dist/eval/storage.d.ts +3 -0
  107. package/dist/eval/storage.d.ts.map +1 -1
  108. package/dist/eval/storage.js +14 -0
  109. package/dist/eval/storage.js.map +1 -1
  110. package/dist/frontmatter/schema.d.ts.map +1 -1
  111. package/dist/frontmatter/schema.js +19 -1
  112. package/dist/frontmatter/schema.js.map +1 -1
  113. package/dist/frontmatter/types.d.ts +4 -1
  114. package/dist/frontmatter/types.d.ts.map +1 -1
  115. package/dist/frontmatter/types.js +8 -0
  116. package/dist/frontmatter/types.js.map +1 -1
  117. package/dist/import/index.d.ts.map +1 -1
  118. package/dist/import/index.js +44 -9
  119. package/dist/import/index.js.map +1 -1
  120. package/dist/import/langgraph-store.d.ts +53 -0
  121. package/dist/import/langgraph-store.d.ts.map +1 -0
  122. package/dist/import/langgraph-store.js +292 -0
  123. package/dist/import/langgraph-store.js.map +1 -0
  124. package/dist/index.d.ts +5 -0
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +56 -18
  127. package/dist/index.js.map +1 -1
  128. package/dist/lifecycle/lock.d.ts +9 -1
  129. package/dist/lifecycle/lock.d.ts.map +1 -1
  130. package/dist/lifecycle/lock.js +34 -9
  131. package/dist/lifecycle/lock.js.map +1 -1
  132. package/dist/okf/export.d.ts +12 -0
  133. package/dist/okf/export.d.ts.map +1 -0
  134. package/dist/okf/export.js +115 -0
  135. package/dist/okf/export.js.map +1 -0
  136. package/dist/okf/import.d.ts +24 -0
  137. package/dist/okf/import.d.ts.map +1 -0
  138. package/dist/okf/import.js +107 -0
  139. package/dist/okf/import.js.map +1 -0
  140. package/dist/okf/index.d.ts +2 -0
  141. package/dist/okf/index.d.ts.map +1 -0
  142. package/dist/okf/index.js +147 -0
  143. package/dist/okf/index.js.map +1 -0
  144. package/dist/okf/map.d.ts +16 -0
  145. package/dist/okf/map.d.ts.map +1 -0
  146. package/dist/okf/map.js +149 -0
  147. package/dist/okf/map.js.map +1 -0
  148. package/dist/okf/types.d.ts +9 -0
  149. package/dist/okf/types.d.ts.map +1 -0
  150. package/dist/okf/types.js +48 -0
  151. package/dist/okf/types.js.map +1 -0
  152. package/dist/search/coverage.d.ts.map +1 -1
  153. package/dist/search/coverage.js +1 -1
  154. package/dist/search/coverage.js.map +1 -1
  155. package/dist/search/hybrid.d.ts +19 -0
  156. package/dist/search/hybrid.d.ts.map +1 -1
  157. package/dist/search/hybrid.js +58 -10
  158. package/dist/search/hybrid.js.map +1 -1
  159. package/dist/search/reindex.d.ts +1 -0
  160. package/dist/search/reindex.d.ts.map +1 -1
  161. package/dist/search/reindex.js +41 -11
  162. package/dist/search/reindex.js.map +1 -1
  163. package/dist/search/watcher.d.ts +2 -0
  164. package/dist/search/watcher.d.ts.map +1 -1
  165. package/dist/search/watcher.js +21 -10
  166. package/dist/search/watcher.js.map +1 -1
  167. package/dist/serve/index.d.ts +36 -0
  168. package/dist/serve/index.d.ts.map +1 -0
  169. package/dist/serve/index.js +507 -0
  170. package/dist/serve/index.js.map +1 -0
  171. package/dist/server.d.ts +10 -1
  172. package/dist/server.d.ts.map +1 -1
  173. package/dist/server.js +89 -14
  174. package/dist/server.js.map +1 -1
  175. package/dist/sleep/index.d.ts +2 -0
  176. package/dist/sleep/index.d.ts.map +1 -1
  177. package/dist/sleep/index.js +212 -21
  178. package/dist/sleep/index.js.map +1 -1
  179. package/dist/sleep/report.d.ts +3 -0
  180. package/dist/sleep/report.d.ts.map +1 -1
  181. package/dist/sleep/report.js +52 -0
  182. package/dist/sleep/report.js.map +1 -1
  183. package/dist/sleep/tension-scan-state.d.ts +19 -0
  184. package/dist/sleep/tension-scan-state.d.ts.map +1 -0
  185. package/dist/sleep/tension-scan-state.js +0 -0
  186. package/dist/sleep/tension-scan-state.js.map +1 -0
  187. package/dist/sleep/tension-scan.d.ts +44 -0
  188. package/dist/sleep/tension-scan.d.ts.map +1 -0
  189. package/dist/sleep/tension-scan.js +0 -0
  190. package/dist/sleep/tension-scan.js.map +1 -0
  191. package/dist/storage/backend.d.ts +12 -0
  192. package/dist/storage/backend.d.ts.map +1 -0
  193. package/dist/storage/backend.js +231 -0
  194. package/dist/storage/backend.js.map +1 -0
  195. package/dist/storage/backends/fs.d.ts +4 -0
  196. package/dist/storage/backends/fs.d.ts.map +1 -0
  197. package/dist/storage/backends/fs.js +99 -0
  198. package/dist/storage/backends/fs.js.map +1 -0
  199. package/dist/storage/fs-walk.d.ts +5 -0
  200. package/dist/storage/fs-walk.d.ts.map +1 -0
  201. package/dist/storage/fs-walk.js +28 -0
  202. package/dist/storage/fs-walk.js.map +1 -0
  203. package/dist/storage/index-db.d.ts +16 -0
  204. package/dist/storage/index-db.d.ts.map +1 -1
  205. package/dist/storage/index-db.js +94 -5
  206. package/dist/storage/index-db.js.map +1 -1
  207. package/dist/storage/local.d.ts +1 -0
  208. package/dist/storage/local.d.ts.map +1 -1
  209. package/dist/storage/local.js +12 -0
  210. package/dist/storage/local.js.map +1 -1
  211. package/dist/storage/sync.d.ts +25 -0
  212. package/dist/storage/sync.d.ts.map +1 -0
  213. package/dist/storage/sync.js +308 -0
  214. package/dist/storage/sync.js.map +1 -0
  215. package/dist/sync/index.d.ts +2 -0
  216. package/dist/sync/index.d.ts.map +1 -0
  217. package/dist/sync/index.js +136 -0
  218. package/dist/sync/index.js.map +1 -0
  219. package/dist/themes/clustering.d.ts +6 -11
  220. package/dist/themes/clustering.d.ts.map +1 -1
  221. package/dist/themes/clustering.js +76 -84
  222. package/dist/themes/clustering.js.map +1 -1
  223. package/dist/tools/consumes.d.ts +14 -0
  224. package/dist/tools/consumes.d.ts.map +1 -0
  225. package/dist/tools/consumes.js +99 -0
  226. package/dist/tools/consumes.js.map +1 -0
  227. package/dist/tools/curation.d.ts +2 -0
  228. package/dist/tools/curation.d.ts.map +1 -1
  229. package/dist/tools/curation.js +53 -15
  230. package/dist/tools/curation.js.map +1 -1
  231. package/dist/tools/edge-staleness.d.ts +28 -0
  232. package/dist/tools/edge-staleness.d.ts.map +1 -0
  233. package/dist/tools/edge-staleness.js +205 -0
  234. package/dist/tools/edge-staleness.js.map +1 -0
  235. package/dist/tools/edges.d.ts.map +1 -1
  236. package/dist/tools/edges.js +27 -21
  237. package/dist/tools/edges.js.map +1 -1
  238. package/dist/tools/read.d.ts +15 -1
  239. package/dist/tools/read.d.ts.map +1 -1
  240. package/dist/tools/read.js +142 -29
  241. package/dist/tools/read.js.map +1 -1
  242. package/dist/tools/receipt.d.ts.map +1 -1
  243. package/dist/tools/receipt.js +16 -7
  244. package/dist/tools/receipt.js.map +1 -1
  245. package/dist/tools/search.d.ts +1 -0
  246. package/dist/tools/search.d.ts.map +1 -1
  247. package/dist/tools/search.js +147 -5
  248. package/dist/tools/search.js.map +1 -1
  249. package/dist/tools/staged-actions.d.ts +3 -0
  250. package/dist/tools/staged-actions.d.ts.map +1 -1
  251. package/dist/tools/staged-actions.js +227 -22
  252. package/dist/tools/staged-actions.js.map +1 -1
  253. package/dist/tools/themes.d.ts +8 -0
  254. package/dist/tools/themes.d.ts.map +1 -1
  255. package/dist/tools/themes.js +208 -122
  256. package/dist/tools/themes.js.map +1 -1
  257. package/dist/tools/tier1.d.ts +14 -0
  258. package/dist/tools/tier1.d.ts.map +1 -0
  259. package/dist/tools/tier1.js +169 -0
  260. package/dist/tools/tier1.js.map +1 -0
  261. package/dist/tools/tier2.d.ts +27 -0
  262. package/dist/tools/tier2.d.ts.map +1 -0
  263. package/dist/tools/tier2.js +344 -0
  264. package/dist/tools/tier2.js.map +1 -0
  265. package/dist/tools/write.d.ts +9 -1
  266. package/dist/tools/write.d.ts.map +1 -1
  267. package/dist/tools/write.js +492 -6
  268. package/dist/tools/write.js.map +1 -1
  269. package/dist/utils/config.d.ts +52 -0
  270. package/dist/utils/config.d.ts.map +1 -1
  271. package/dist/utils/config.js +372 -2
  272. package/dist/utils/config.js.map +1 -1
  273. package/dist/utils/run-id.d.ts +3 -0
  274. package/dist/utils/run-id.d.ts.map +1 -0
  275. package/dist/utils/run-id.js +19 -0
  276. package/dist/utils/run-id.js.map +1 -0
  277. package/dist/utils/vault-gitignore.d.ts +1 -1
  278. package/dist/utils/vault-gitignore.d.ts.map +1 -1
  279. package/dist/utils/vault-gitignore.js +4 -1
  280. package/dist/utils/vault-gitignore.js.map +1 -1
  281. package/package.json +21 -1
  282. package/templates/config.yaml +8 -0
@@ -11,21 +11,29 @@ import { mkdir, writeFile } from "node:fs/promises";
11
11
  import { dirname, relative, resolve, sep } from "node:path";
12
12
  import matter from "gray-matter";
13
13
  import { acquireLock, openLockDb, releaseLock } from "../access/locks.js";
14
- import { canPromote, canWrite } from "../access/rbac.js";
14
+ import { canPromote, canWrite, isProposeOnly } from "../access/rbac.js";
15
+ import { mintConsumesEdges } from "../curation/consumes.js";
15
16
  import { frontmatterDiff, recordProvenance } from "../curation/provenance.js";
16
17
  import { recordShadowAction } from "../curation/shadow.js";
18
+ import { stageActionWithConflictCheck } from "../curation/staged-actions.js";
19
+ import { sourceReadable } from "../curation/tension-access.js";
20
+ import { EXTERNAL_REF } from "../curation/tier0.js";
21
+ import { buildPathIndexes, extractLinks, outgoingLinkTargets, resolveLink, } from "../curation/vault-docs.js";
17
22
  import { parseDocument } from "../frontmatter/parser.js";
18
23
  import { validateFrontmatter } from "../frontmatter/schema.js";
19
- import { CONFIDENCES, DOMAINS, err, ok, PROVENANCES, STATUSES, } from "../frontmatter/types.js";
24
+ import { CONFIDENCES, DOMAINS, err, ok, PROVENANCES, STATUSES, TIERS, } from "../frontmatter/types.js";
20
25
  import { loadHooks, loadPreWriteTransformHooks } from "../hooks/loader.js";
21
26
  import { runPreWriteHooks, runPreWriteTransformHooks } from "../hooks/runner.js";
22
27
  import { getIndexStatus, indexingBusyMessage } from "../search/index-state.js";
23
28
  import { indexDocument } from "../search/reindex.js";
24
29
  import { noteSelfWrite } from "../search/self-write.js";
30
+ import { allDocumentPaths, getDocumentsByPaths } from "../storage/index-db.js";
25
31
  import { readFile, resolveVaultPath } from "../storage/local.js";
26
32
  import { loadConfig } from "../utils/config.js";
27
33
  import { commit } from "../utils/git.js";
28
34
  import { sha256Hex } from "../utils/hash.js";
35
+ import { readRunId } from "../utils/run-id.js";
36
+ import { openIndexForAccessOrNull } from "./search.js";
29
37
  // ---------------------------------------------------------------------------
30
38
  // shared helpers
31
39
  // ---------------------------------------------------------------------------
@@ -112,6 +120,10 @@ export function serializeDocument(fm, body, extensions = [], raw = {}) {
112
120
  updated: fm.updated,
113
121
  updated_by: fm.updated_by,
114
122
  provenance: fm.provenance,
123
+ // `?? null` — a hand-built Frontmatter that predates tier may carry
124
+ // undefined here, which js-yaml refuses to dump (null it serializes fine,
125
+ // matching the superseded_by convention).
126
+ tier: fm.tier ?? null,
115
127
  sources: fm.sources,
116
128
  superseded_by: fm.superseded_by,
117
129
  ttl_days: fm.ttl_days,
@@ -224,6 +236,7 @@ async function performWrite(params) {
224
236
  file: params.relPath,
225
237
  agent: params.agent,
226
238
  ...(params.principal ? { principal: params.principal } : {}),
239
+ ...(params.runId ? { run_id: params.runId } : {}),
227
240
  action: "rejected_stale",
228
241
  reason: `stale: base_version ${shortHash(params.baseVersion)} != ` +
229
242
  `current ${currentHash ? shortHash(currentHash) : "<absent>"}`,
@@ -254,9 +267,21 @@ async function performWrite(params) {
254
267
  file: params.relPath,
255
268
  agent: params.agent,
256
269
  ...(params.principal ? { principal: params.principal } : {}),
270
+ ...(params.runId ? { run_id: params.runId } : {}),
271
+ ...(params.bodyChanged !== undefined ? { body_changed: params.bodyChanged } : {}),
257
272
  action: params.action,
258
273
  frontmatter_diff: frontmatterDiff(params.oldFrontmatter, params.newFrontmatter),
259
274
  });
275
+ // #233: a run-correlated write compiles its input set — every path the
276
+ // run read beforehand becomes a consumes edge. Best-effort like the
277
+ // provenance/index steps: the write is already durable, and the graph
278
+ // is advisory substrate, so a mint failure never fails the write.
279
+ if (params.runId) {
280
+ await mintConsumesEdges(params.vaultRoot, {
281
+ artifact: params.relPath,
282
+ runId: params.runId,
283
+ });
284
+ }
260
285
  return ok({
261
286
  path: params.relPath,
262
287
  action: params.action,
@@ -300,12 +325,118 @@ function readBaseVersion(args, tool) {
300
325
  }
301
326
  return ok(v.length === 0 ? undefined : v);
302
327
  }
328
+ // #235: a propose-only role cannot mutate the vault directly. vault_write
329
+ // coerces into a staged proposal (see vaultWrite); every OTHER write tool is
330
+ // denied with a pointer to the staging surface. Structural enforcement — the
331
+ // permission layer decides, not convention.
332
+ function denyIfProposeOnly(access, tool) {
333
+ if (access && isProposeOnly(access.role)) {
334
+ return err(new Error(`${tool}: role '${access.roleName}' is propose-only — direct writes are ` +
335
+ `disabled. Propose the change instead: vault_write stages a 'write' ` +
336
+ `proposal, and vault_stage_action stages lifecycle actions ` +
337
+ `(promote, deprecate, supersede, merge, confidence-up) for ratification.`));
338
+ }
339
+ return ok(undefined);
340
+ }
303
341
  // ---------------------------------------------------------------------------
304
342
  // vault_write
305
343
  // ---------------------------------------------------------------------------
306
344
  // Creates a new document or overwrites an existing one. The caller supplies
307
345
  // the full frontmatter; `updated` and `updated_by` are always stamped by the
308
346
  // server, and `created` is preserved from the existing document on an update.
347
+ // --- tier write-protection (#141) ------------------------------------------
348
+ //
349
+ // `tier: source` bodies are immutable to EVERY writer; `tier: manual` bodies
350
+ // only accept rewrites from a human:* identity. The escape hatch is
351
+ // deliberately NOT an inline force flag — a refusal that names a bypass
352
+ // parameter teaches the calling model to pass it reflexively. Instead the
353
+ // caller must demote the tier first (vault_set_tier, reason required, change
354
+ // provenance-logged), then write. Frontmatter-only writes pass unchanged, so
355
+ // curation tools (promote, deprecate, supersede, set_confidence) and tag
356
+ // edits keep working on tiered docs.
357
+ // Body equality up to a leading-newline / trailing-whitespace difference:
358
+ // gray-matter's parsed content starts with the newline after the frontmatter
359
+ // fence and serializeDocument re-adds one, so a byte comparison would flag
360
+ // every round-tripped body as changed.
361
+ function sameBody(a, b) {
362
+ const norm = (s) => s.replace(/^\n+/, "").replace(/\s+$/, "");
363
+ return norm(a) === norm(b);
364
+ }
365
+ function checkTierGuard(args) {
366
+ const { tool, path, tier, oldBody, newBody, agent } = args;
367
+ if (tier !== "source" && tier !== "manual")
368
+ return ok(undefined);
369
+ if (sameBody(oldBody, newBody))
370
+ return ok(undefined);
371
+ if (tier === "source") {
372
+ return err(new Error(`${tool}: ${path} is tier 'source' — its body is immutable. If this ` +
373
+ `rewrite is deliberate, demote the tier first with vault_set_tier ` +
374
+ `(a reason is required and the change is logged), then write. ` +
375
+ `vault_append can add to it without a tier change.`));
376
+ }
377
+ if (!agent.startsWith("human:")) {
378
+ return err(new Error(`${tool}: ${path} is tier 'manual' — body rewrites require a ` +
379
+ `human:* identity (got '${agent}'). vault_append can add to it.`));
380
+ }
381
+ return ok(undefined);
382
+ }
383
+ // #4: the generative-domain documents an accumulation-domain doc references
384
+ // (typed `sources` entries plus body links), resolved against the indexed
385
+ // path universe. Advisory only — callers attach the result as
386
+ // domain_warnings; index unavailability degrades to silence.
387
+ function generativeDomainRefs(vaultRoot, doc, access) {
388
+ if (doc.domain !== "accumulation")
389
+ return null;
390
+ // Most accumulation writes reference nothing; skip the index entirely
391
+ // rather than loading every vault path to resolve an empty candidate set.
392
+ const localSources = doc.sources.filter((s) => !EXTERNAL_REF.test(s));
393
+ if (localSources.length === 0 && extractLinks(doc.body).length === 0)
394
+ return null;
395
+ const db = openIndexForAccessOrNull(vaultRoot);
396
+ if (!db)
397
+ return null;
398
+ try {
399
+ const indexes = buildPathIndexes(allDocumentPaths(db).map((p) => ({ path: p })));
400
+ const candidates = new Set(outgoingLinkTargets(doc.body, doc.relPath, indexes));
401
+ for (const raw of localSources) {
402
+ const target = resolveLink(raw, doc.relPath, indexes.byPath, indexes.byBasename);
403
+ if (target && target !== doc.relPath)
404
+ candidates.add(target);
405
+ }
406
+ if (candidates.size === 0)
407
+ return null;
408
+ const generative = getDocumentsByPaths(db, [...candidates])
409
+ .filter((d) => d.domain === "generative")
410
+ .map((d) => d.path)
411
+ // Vantage rule (#217, security review on #261): a warning names the
412
+ // RESOLVED path and discloses the target's domain — metadata about a
413
+ // doc the caller may not read, and resolveLink's basename fallback can
414
+ // reveal a location the caller never typed. Targets outside the
415
+ // caller's read scope are omitted entirely, the same rule lint's
416
+ // domainLeaks inherits from runLint's pre-filtered doc set.
417
+ .filter((p) => !access || sourceReadable(db, access, p))
418
+ .sort();
419
+ if (generative.length === 0)
420
+ return null;
421
+ return generative.map((p) => `${p} is generative-domain — speculative material referenced from accumulation canon`);
422
+ }
423
+ catch {
424
+ // This runs after performWrite returned ok — the write is durable. A
425
+ // query failure here (index rebuilt mid-query, locked db) must degrade
426
+ // to "no warnings", never surface as a failed write to the caller.
427
+ return null;
428
+ }
429
+ finally {
430
+ db.close();
431
+ }
432
+ }
433
+ // The #169 nudge, verbatim on every vault_write overwrite. One fixed string:
434
+ // the signal is the field's presence, and a stable text is grep-able in
435
+ // agent traces.
436
+ const SUPERSEDE_HINT = "This write replaced an existing document in place. If it records a changed " +
437
+ "fact, prefer vault_supersede: it preserves the prior version and mints the " +
438
+ "superseded_by edge instead of erasing lineage. Advisory only — this write " +
439
+ "has already landed.";
309
440
  export async function vaultWrite(vaultRoot, args, access) {
310
441
  const ready = requireIndexReady();
311
442
  if (!ready.ok)
@@ -319,6 +450,9 @@ export async function vaultWrite(vaultRoot, args, access) {
319
450
  const baseVersion = readBaseVersion(args, "vault_write");
320
451
  if (!baseVersion.ok)
321
452
  return baseVersion;
453
+ const runId = readRunId(args, "vault_write");
454
+ if (!runId.ok)
455
+ return runId;
322
456
  const body = args.body;
323
457
  if (typeof body !== "string") {
324
458
  return err(new Error("vault_write requires a string 'body' argument"));
@@ -346,9 +480,81 @@ export async function vaultWrite(vaultRoot, args, access) {
346
480
  const resolved = resolveVaultPath(vaultRoot, path.value);
347
481
  if (!resolved.ok)
348
482
  return resolved;
483
+ // #235: a propose-only role's write lands as a staged `write` proposal, not
484
+ // a mutation. Coerced EARLY — before file I/O, tier guards, hooks, or
485
+ // validation — because all of that runs for real at ratify time when
486
+ // vault_ratify dispatches the payload back through this tool with the
487
+ // ratifier's access. The write grant above still scopes which collections
488
+ // the role may propose into. Inter-proposal conflicts are checked the same
489
+ // way vault_stage_action checks them: both proposals stay pending, a
490
+ // tension is logged, the result names the contenders. Staged under the
491
+ // CANONICAL relPath, matching vault_stage_action — a raw caller spelling
492
+ // (`pricing/./x.md`) would dodge the exact-string conflict match and the
493
+ // ratify gate's existing-doc lookup (#127/#128 rule).
494
+ if (access && isProposeOnly(access.role)) {
495
+ // The validation report is REAL, not fabricated: run the payload through
496
+ // the same merge-then-validate the eventual dispatch will apply (merged
497
+ // under any existing frontmatter, server-stamped fields defaulted), so a
498
+ // propose-only agent gets its schema feedback NOW instead of at ratify
499
+ // time up to ttl_days later. Advisory here — staging proceeds regardless;
500
+ // the blocking check runs for real when vault_ratify dispatches.
501
+ const proposeConfig = loadConfig(vaultRoot);
502
+ if (!proposeConfig.ok)
503
+ return proposeConfig;
504
+ let previewRaw = { ...rawFrontmatter };
505
+ const onDisk = await readFile(resolved.value.absPath);
506
+ if (onDisk.ok) {
507
+ const parsedExisting = parseDocument(onDisk.value);
508
+ if (parsedExisting.ok) {
509
+ const merged = { ...parsedExisting.value.raw };
510
+ for (const [key, value] of Object.entries(rawFrontmatter)) {
511
+ if (value === null)
512
+ delete merged[key];
513
+ else
514
+ merged[key] = value;
515
+ }
516
+ previewRaw = merged;
517
+ }
518
+ }
519
+ if (previewRaw.updated === undefined || previewRaw.updated === null) {
520
+ previewRaw.updated = todayISO();
521
+ }
522
+ if (previewRaw.updated_by === undefined || previewRaw.updated_by === null) {
523
+ previewRaw.updated_by = agent.value;
524
+ }
525
+ const preview = validateFrontmatter(previewRaw, proposeConfig.value.schemaExtensions);
526
+ const staged = await stageActionWithConflictCheck(vaultRoot, {
527
+ actionType: "write",
528
+ targetPath: resolved.value.relPath,
529
+ proposedBy: agent.value,
530
+ rationale: typeof args.reason === "string" && args.reason.trim().length > 0
531
+ ? args.reason.trim()
532
+ : `propose-only role '${access.roleName}': write staged for ratification`,
533
+ proposedDiff: { frontmatter: rawFrontmatter, body },
534
+ ...(runId.value !== undefined ? { runId: runId.value } : {}),
535
+ });
536
+ if (!staged.ok)
537
+ return staged;
538
+ return ok({
539
+ path: resolved.value.relPath,
540
+ action: "staged",
541
+ commit: null,
542
+ committed: false,
543
+ status: "pending",
544
+ updated: todayISO(),
545
+ validation: preview.report,
546
+ indexUpdated: false,
547
+ staged_id: staged.value.id,
548
+ expires_at: staged.value.expires_at,
549
+ conflicts_with: staged.value.conflicts_with,
550
+ tension_id: staged.value.tension_id,
551
+ ...(staged.value.tension_error ? { tension_error: staged.value.tension_error } : {}),
552
+ });
553
+ }
349
554
  const existing = await readFile(resolved.value.absPath);
350
555
  let oldFrontmatter = null;
351
556
  let oldRaw = null;
557
+ let oldContent = "";
352
558
  if (existing.ok) {
353
559
  const parsed = parseDocument(existing.value);
354
560
  if (!parsed.ok) {
@@ -363,9 +569,22 @@ export async function vaultWrite(vaultRoot, args, access) {
363
569
  }
364
570
  oldFrontmatter = parsed.value.frontmatter;
365
571
  oldRaw = parsed.value.raw;
572
+ oldContent = parsed.value.content;
366
573
  }
367
574
  const isUpdate = oldFrontmatter !== null;
368
575
  const hookOperation = isUpdate ? "update" : "create";
576
+ if (isUpdate && oldFrontmatter) {
577
+ const guard = checkTierGuard({
578
+ tool: "vault_write",
579
+ path: path.value,
580
+ tier: oldFrontmatter.tier,
581
+ oldBody: oldContent,
582
+ newBody: body,
583
+ agent: agent.value,
584
+ });
585
+ if (!guard.ok)
586
+ return guard;
587
+ }
369
588
  // On update, merge the document's existing frontmatter under the payload, so
370
589
  // a tool-mediated write never silently drops a field the author put there
371
590
  // (#113). `oldRaw` is the frontmatter exactly as parsed — built-in fields,
@@ -388,6 +607,19 @@ export async function vaultWrite(vaultRoot, args, access) {
388
607
  delete rawFrontmatter[key];
389
608
  Object.assign(rawFrontmatter, merged);
390
609
  }
610
+ // #141: a protected doc's tier only changes via vault_set_tier. Allowing it
611
+ // here would let a frontmatter-only write (which sameBody waves through)
612
+ // demote the tier — dodging set_tier's reason requirement and manual's
613
+ // human gate — and then rewrite the body in a second call.
614
+ if (isUpdate &&
615
+ oldFrontmatter &&
616
+ (oldFrontmatter.tier === "source" || oldFrontmatter.tier === "manual")) {
617
+ if (rawFrontmatter.tier !== oldFrontmatter.tier) {
618
+ return err(new Error(`vault_write: ${path.value} is tier '${oldFrontmatter.tier}' — changing ` +
619
+ `its tier requires vault_set_tier (a reason is required` +
620
+ `${oldFrontmatter.tier === "manual" ? ", and a human:* identity" : ""}).`));
621
+ }
622
+ }
391
623
  // Config-declared schema extensions participate in validation and
392
624
  // serialization. A malformed config fails the write loudly, matching the
393
625
  // server's loud-config contract.
@@ -454,7 +686,7 @@ export async function vaultWrite(vaultRoot, args, access) {
454
686
  updated: todayISO(),
455
687
  updated_by: agent.value,
456
688
  };
457
- return performWrite({
689
+ const written = await performWrite({
458
690
  vaultRoot,
459
691
  // Lock key, provenance, and commit path are all keyed on the CANONICAL
460
692
  // relPath (resolved.value.relPath), never the raw caller string: aliased
@@ -474,6 +706,31 @@ export async function vaultWrite(vaultRoot, args, access) {
474
706
  baseVersion: baseVersion.value,
475
707
  shadowMode: config.value.shadowMode,
476
708
  principal: access?.user,
709
+ ...(runId.value !== undefined ? { runId: runId.value } : {}),
710
+ bodyChanged: !isUpdate || !sameBody(oldContent, body),
711
+ });
712
+ // #169: an in-place overwrite destroys the prior version's lineage, and
713
+ // daftari HAS the preserve-not-overwrite primitive — steer toward it at
714
+ // the moment of overwrite, the decay banner's channel: additive, advisory,
715
+ // never blocking, never auto-minting an edge. The agent still chooses.
716
+ // Attached here (not in performWrite) so vault_supersede and the other
717
+ // lifecycle tools never nudge about themselves. A shadow-mode result is
718
+ // excluded: nothing landed and nothing was replaced, so the hints' text
719
+ // would be false. #4's domain warnings ride the same channel.
720
+ if (!written.ok || written.value.shadow)
721
+ return written;
722
+ const warnings = generativeDomainRefs(vaultRoot, {
723
+ domain: stamped.domain,
724
+ sources: stamped.sources ?? [],
725
+ body,
726
+ relPath: resolved.value.relPath,
727
+ }, access);
728
+ if (!isUpdate && warnings === null)
729
+ return written;
730
+ return ok({
731
+ ...written.value,
732
+ ...(isUpdate ? { supersede_hint: SUPERSEDE_HINT } : {}),
733
+ ...(warnings ? { domain_warnings: warnings } : {}),
477
734
  });
478
735
  }
479
736
  // ---------------------------------------------------------------------------
@@ -497,6 +754,12 @@ export async function vaultAppend(vaultRoot, args, access) {
497
754
  const baseVersion = readBaseVersion(args, "vault_append");
498
755
  if (!baseVersion.ok)
499
756
  return baseVersion;
757
+ const runId = readRunId(args, "vault_append");
758
+ if (!runId.ok)
759
+ return runId;
760
+ const proposeGate = denyIfProposeOnly(access, "vault_append");
761
+ if (!proposeGate.ok)
762
+ return proposeGate;
500
763
  const resolved = resolveVaultPath(vaultRoot, path.value);
501
764
  if (!resolved.ok)
502
765
  return resolved;
@@ -546,7 +809,7 @@ export async function vaultAppend(vaultRoot, args, access) {
546
809
  const summary = appendIssues.map((i) => `${i.field}: ${i.message}`).join("; ");
547
810
  return err(new Error(`invalid frontmatter: ${summary}`));
548
811
  }
549
- return performWrite({
812
+ const appended = await performWrite({
550
813
  vaultRoot,
551
814
  // Lock key, provenance, and commit path are all keyed on the CANONICAL
552
815
  // relPath (resolved.value.relPath), never the raw caller string: aliased
@@ -566,7 +829,26 @@ export async function vaultAppend(vaultRoot, args, access) {
566
829
  baseVersion: baseVersion.value,
567
830
  shadowMode: config.value.shadowMode,
568
831
  principal: access?.user,
832
+ ...(runId.value !== undefined ? { runId: runId.value } : {}),
833
+ bodyChanged: true,
569
834
  });
835
+ // #4: an appended section can introduce body links into generative-domain
836
+ // docs — same advisory channel as vault_write, scoped to what THIS append
837
+ // added: only the new section is scanned (a doc that already leaned on
838
+ // generative material warned at write time; re-warning on every later,
839
+ // unrelated append would drown the signal), and sources are skipped
840
+ // entirely because an append cannot change frontmatter.
841
+ if (!appended.ok || appended.value.shadow)
842
+ return appended;
843
+ const appendWarnings = generativeDomainRefs(vaultRoot, {
844
+ domain: newFrontmatter.domain,
845
+ sources: [],
846
+ body: section.value,
847
+ relPath: resolved.value.relPath,
848
+ }, access);
849
+ if (appendWarnings === null)
850
+ return appended;
851
+ return ok({ ...appended.value, domain_warnings: appendWarnings });
570
852
  }
571
853
  // ---------------------------------------------------------------------------
572
854
  // vault_promote
@@ -578,6 +860,9 @@ export async function vaultPromote(vaultRoot, args, access) {
578
860
  const ready = requireIndexReady();
579
861
  if (!ready.ok)
580
862
  return ready;
863
+ const proposeGate = denyIfProposeOnly(access, "vault_promote");
864
+ if (!proposeGate.ok)
865
+ return proposeGate;
581
866
  const path = requireString(args, "path", "vault_promote");
582
867
  if (!path.ok)
583
868
  return path;
@@ -648,6 +933,7 @@ export async function vaultPromote(vaultRoot, args, access) {
648
933
  baseVersion: baseVersion.value,
649
934
  shadowMode: config.value.shadowMode,
650
935
  principal: access?.user,
936
+ bodyChanged: false,
651
937
  });
652
938
  }
653
939
  // ---------------------------------------------------------------------------
@@ -660,6 +946,9 @@ export async function vaultDeprecate(vaultRoot, args, access) {
660
946
  const ready = requireIndexReady();
661
947
  if (!ready.ok)
662
948
  return ready;
949
+ const proposeGate = denyIfProposeOnly(access, "vault_deprecate");
950
+ if (!proposeGate.ok)
951
+ return proposeGate;
663
952
  const path = requireString(args, "path", "vault_deprecate");
664
953
  if (!path.ok)
665
954
  return path;
@@ -729,6 +1018,7 @@ export async function vaultDeprecate(vaultRoot, args, access) {
729
1018
  baseVersion: baseVersion.value,
730
1019
  shadowMode: config.value.shadowMode,
731
1020
  principal: access?.user,
1021
+ bodyChanged: false,
732
1022
  });
733
1023
  }
734
1024
  // ---------------------------------------------------------------------------
@@ -742,6 +1032,9 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
742
1032
  const ready = requireIndexReady();
743
1033
  if (!ready.ok)
744
1034
  return ready;
1035
+ const proposeGate = denyIfProposeOnly(access, "vault_set_confidence");
1036
+ if (!proposeGate.ok)
1037
+ return proposeGate;
745
1038
  const path = requireString(args, "path", "vault_set_confidence");
746
1039
  if (!path.ok)
747
1040
  return path;
@@ -824,6 +1117,112 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
824
1117
  baseVersion: baseVersion.value,
825
1118
  shadowMode: config.value.shadowMode,
826
1119
  principal: access?.user,
1120
+ bodyChanged: false,
1121
+ });
1122
+ }
1123
+ // ---------------------------------------------------------------------------
1124
+ // vault_set_tier (#141)
1125
+ // ---------------------------------------------------------------------------
1126
+ // Changes only a document's write-protection tier, leaving body and status
1127
+ // untouched. This is the escape hatch for the tier guards — a separate,
1128
+ // reason-carrying, provenance-logged act rather than an inline force flag on
1129
+ // the destructive call. Moving a doc AWAY from `manual` requires a human:*
1130
+ // identity (otherwise demote-then-write would bypass the consent boundary);
1131
+ // moving away from `source` is open to any identity — deliberate demotions
1132
+ // are allowed, and vault_lint surfaces them for review.
1133
+ export async function vaultSetTier(vaultRoot, args, access) {
1134
+ const ready = requireIndexReady();
1135
+ if (!ready.ok)
1136
+ return ready;
1137
+ const proposeGate = denyIfProposeOnly(access, "vault_set_tier");
1138
+ if (!proposeGate.ok)
1139
+ return proposeGate;
1140
+ const path = requireString(args, "path", "vault_set_tier");
1141
+ if (!path.ok)
1142
+ return path;
1143
+ const agent = requireString(args, "agent", "vault_set_tier");
1144
+ if (!agent.ok)
1145
+ return agent;
1146
+ const reason = requireString(args, "reason", "vault_set_tier");
1147
+ if (!reason.ok)
1148
+ return reason;
1149
+ const tier = requireString(args, "tier", "vault_set_tier");
1150
+ if (!tier.ok)
1151
+ return tier;
1152
+ if (!TIERS.includes(tier.value)) {
1153
+ return err(new Error(`vault_set_tier: 'tier' must be one of: ${TIERS.join(", ")}`));
1154
+ }
1155
+ const newTier = tier.value;
1156
+ const baseVersion = readBaseVersion(args, "vault_set_tier");
1157
+ if (!baseVersion.ok)
1158
+ return baseVersion;
1159
+ const resolved = resolveVaultPath(vaultRoot, path.value);
1160
+ if (!resolved.ok)
1161
+ return resolved;
1162
+ const existing = await readFile(resolved.value.absPath);
1163
+ if (!existing.ok) {
1164
+ return err(new Error(`vault_set_tier: document not found: ${path.value}`));
1165
+ }
1166
+ const parsed = parseDocument(existing.value);
1167
+ if (!parsed.ok)
1168
+ return parsed;
1169
+ const config = loadConfig(vaultRoot);
1170
+ if (!config.ok)
1171
+ return config;
1172
+ const extensions = config.value.schemaExtensions;
1173
+ const oldFrontmatter = parsed.value.frontmatter;
1174
+ if (access) {
1175
+ const collection = collectionOf(path.value, oldFrontmatter);
1176
+ if (!canWrite(access.role, collection)) {
1177
+ return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
1178
+ `collection '${collection}'`));
1179
+ }
1180
+ }
1181
+ // The manual consent boundary: only a human identity may lift `manual`.
1182
+ if (oldFrontmatter.tier === "manual" &&
1183
+ newTier !== "manual" &&
1184
+ !agent.value.startsWith("human:")) {
1185
+ return err(new Error(`vault_set_tier: ${path.value} is tier 'manual' — moving it away from ` +
1186
+ `'manual' requires a human:* identity (got '${agent.value}').`));
1187
+ }
1188
+ // No-op guard, same shape as vault_set_confidence: compare against the raw
1189
+ // on-disk value so an invalid raw tier (validated down to null) can still be
1190
+ // set to any member.
1191
+ const rawTier = parsed.value.raw.tier;
1192
+ const currentTier = typeof rawTier === "string" && TIERS.includes(rawTier)
1193
+ ? rawTier
1194
+ : undefined;
1195
+ if (currentTier === newTier) {
1196
+ return err(new Error(`vault_set_tier: ${path.value} tier is already '${newTier}'`));
1197
+ }
1198
+ const newFrontmatter = {
1199
+ ...oldFrontmatter,
1200
+ tier: newTier,
1201
+ updated: todayISO(),
1202
+ updated_by: agent.value,
1203
+ };
1204
+ return performWrite({
1205
+ vaultRoot,
1206
+ // Lock key, provenance, and commit path are all keyed on the CANONICAL
1207
+ // relPath (resolved.value.relPath), never the raw caller string: aliased
1208
+ // spellings of one file must contend on one lock (#127/#128).
1209
+ relPath: resolved.value.relPath,
1210
+ absPath: resolved.value.absPath,
1211
+ agent: agent.value,
1212
+ tool: "vault_set_tier",
1213
+ action: "tier-set",
1214
+ fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
1215
+ newFrontmatter,
1216
+ oldFrontmatter,
1217
+ validation: parsed.value.validation,
1218
+ commitMessage: `vault_set_tier: ${path.value} ${oldFrontmatter.tier ?? "unset"}→${newTier} ` +
1219
+ `by ${agent.value} — ${reason.value}`,
1220
+ autoCommit: config.value.autoCommit,
1221
+ gitDir: config.value.gitDir,
1222
+ baseVersion: baseVersion.value,
1223
+ shadowMode: config.value.shadowMode,
1224
+ principal: access?.user,
1225
+ bodyChanged: false,
827
1226
  });
828
1227
  }
829
1228
  // ---------------------------------------------------------------------------
@@ -837,6 +1236,9 @@ export async function vaultSupersede(vaultRoot, args, access) {
837
1236
  const ready = requireIndexReady();
838
1237
  if (!ready.ok)
839
1238
  return ready;
1239
+ const proposeGate = denyIfProposeOnly(access, "vault_supersede");
1240
+ if (!proposeGate.ok)
1241
+ return proposeGate;
840
1242
  const oldPath = requireString(args, "old_path", "vault_supersede");
841
1243
  if (!oldPath.ok)
842
1244
  return oldPath;
@@ -917,6 +1319,7 @@ export async function vaultSupersede(vaultRoot, args, access) {
917
1319
  baseVersion: baseVersion.value,
918
1320
  shadowMode: config.value.shadowMode,
919
1321
  principal: access?.user,
1322
+ bodyChanged: false,
920
1323
  });
921
1324
  }
922
1325
  // Combines two source documents into a target and supersedes both sources to
@@ -933,6 +1336,9 @@ export async function vaultMerge(vaultRoot, args, access) {
933
1336
  const ready = requireIndexReady();
934
1337
  if (!ready.ok)
935
1338
  return ready;
1339
+ const proposeGate = denyIfProposeOnly(access, "vault_merge");
1340
+ if (!proposeGate.ok)
1341
+ return proposeGate;
936
1342
  const pathA = requireString(args, "path_a", "vault_merge");
937
1343
  if (!pathA.ok)
938
1344
  return pathA;
@@ -1019,14 +1425,31 @@ export async function vaultMerge(vaultRoot, args, access) {
1019
1425
  // inherit path_a's. The supplied body replaces whatever was there.
1020
1426
  const existingTarget = await readFile(resolvedTarget.value.absPath);
1021
1427
  let targetOldFrontmatter = null;
1428
+ let targetOldContent = "";
1022
1429
  let targetCreated = parsedA.value.frontmatter.created;
1023
1430
  if (existingTarget.ok) {
1024
1431
  const parsedTarget = parseDocument(existingTarget.value);
1025
1432
  if (parsedTarget.ok) {
1026
1433
  targetOldFrontmatter = parsedTarget.value.frontmatter;
1434
+ targetOldContent = parsedTarget.value.content;
1027
1435
  targetCreated = parsedTarget.value.frontmatter.created;
1028
1436
  }
1029
1437
  }
1438
+ // Tier guard (#141): the merged body wholly replaces the target's. path_a
1439
+ // and path_b only receive a frontmatter-level supersede, so their tiers
1440
+ // don't gate the merge.
1441
+ if (targetOldFrontmatter) {
1442
+ const guard = checkTierGuard({
1443
+ tool: "vault_merge",
1444
+ path: targetPath.value,
1445
+ tier: targetOldFrontmatter.tier,
1446
+ oldBody: targetOldContent,
1447
+ newBody: body,
1448
+ agent: agent.value,
1449
+ });
1450
+ if (!guard.ok)
1451
+ return guard;
1452
+ }
1030
1453
  const targetRaw = {
1031
1454
  ...parsedA.value.raw,
1032
1455
  provenance: "synthesized",
@@ -1176,6 +1599,9 @@ export async function vaultMerge(vaultRoot, args, access) {
1176
1599
  agent: agent.value,
1177
1600
  ...(access?.user ? { principal: access.user } : {}),
1178
1601
  action: w.action,
1602
+ // The merge target gets a new body; the superseded sources only get
1603
+ // frontmatter stamps.
1604
+ body_changed: w.action === "merge",
1179
1605
  frontmatter_diff: frontmatterDiff(w.oldFrontmatter, w.newFrontmatter),
1180
1606
  });
1181
1607
  }
@@ -1221,6 +1647,11 @@ const baseVersionProperty = {
1221
1647
  "write is rejected as stale and nothing is changed. Omit for " +
1222
1648
  "last-write-wins behavior.",
1223
1649
  };
1650
+ const runIdProperty = {
1651
+ type: "string",
1652
+ description: "Optional trace/run identifier of the calling run. Recorded in the " +
1653
+ "provenance log so one run's writes correlate.",
1654
+ };
1224
1655
  // Built-in frontmatter properties, projected from the canonical TS constants
1225
1656
  // in src/frontmatter/types.ts so the MCP input schema, the runtime validator,
1226
1657
  // and the source-of-truth enums never drift. `additionalProperties: true`
@@ -1310,7 +1741,15 @@ export const writeTools = [
1310
1741
  "full frontmatter and markdown body; the server stamps 'updated' and " +
1311
1742
  "'updated_by' (omit them — anything supplied is overwritten), preserves " +
1312
1743
  "'created' on updates, refreshes the search index, and auto-commits the " +
1313
- "change to git." +
1744
+ "change to git. If the caller's role is propose-only, nothing is " +
1745
+ "written: the payload lands as a staged 'write' proposal awaiting " +
1746
+ "vault_ratify, and the result carries action: 'staged' with the " +
1747
+ "proposal id (plus any competing pending proposals on the same target). " +
1748
+ "Overwriting an existing document returns an advisory supersede_hint — " +
1749
+ "when the write records a changed fact, vault_supersede preserves the " +
1750
+ "prior version and its lineage instead. An accumulation-domain write " +
1751
+ "that cites or links generative-domain docs returns advisory " +
1752
+ "domain_warnings naming them; the write still lands." +
1314
1753
  shadowNote,
1315
1754
  inputSchema: {
1316
1755
  type: "object",
@@ -1323,6 +1762,12 @@ export const writeTools = [
1323
1762
  frontmatter: frontmatterProperty,
1324
1763
  agent: agentProperty,
1325
1764
  base_version: baseVersionProperty,
1765
+ run_id: runIdProperty,
1766
+ reason: {
1767
+ type: "string",
1768
+ description: "Optional rationale. Used as the proposal rationale when a " +
1769
+ "propose-only role's write is staged for ratification.",
1770
+ },
1326
1771
  },
1327
1772
  required: ["path", "body", "frontmatter", "agent"],
1328
1773
  additionalProperties: false,
@@ -1334,7 +1779,10 @@ export const writeTools = [
1334
1779
  title: "Append to a document",
1335
1780
  annotations: { destructiveHint: true },
1336
1781
  description: "Append a markdown section to an existing vault document. Frontmatter " +
1337
- "is preserved; 'updated' and 'updated_by' are re-stamped. Auto-commits." +
1782
+ "is preserved; 'updated' and 'updated_by' are re-stamped. Auto-commits. " +
1783
+ "Appending links to generative-domain docs onto an accumulation-domain " +
1784
+ "doc returns advisory domain_warnings naming them; the append still " +
1785
+ "lands." +
1338
1786
  shadowNote,
1339
1787
  inputSchema: {
1340
1788
  type: "object",
@@ -1349,6 +1797,7 @@ export const writeTools = [
1349
1797
  },
1350
1798
  agent: agentProperty,
1351
1799
  base_version: baseVersionProperty,
1800
+ run_id: runIdProperty,
1352
1801
  },
1353
1802
  required: ["path", "section", "agent"],
1354
1803
  additionalProperties: false,
@@ -1440,6 +1889,43 @@ export const writeTools = [
1440
1889
  },
1441
1890
  handler: (vaultRoot, args, access) => vaultSetConfidence(vaultRoot, args, access),
1442
1891
  },
1892
+ {
1893
+ name: "vault_set_tier",
1894
+ title: "Set a document's write-protection tier",
1895
+ annotations: { destructiveHint: true },
1896
+ description: "Change only a document's write-protection tier (source | compiled | " +
1897
+ "manual), leaving its body untouched. `source` bodies are immutable; " +
1898
+ "`manual` bodies only accept rewrites from a human:* identity; " +
1899
+ "`compiled` and unset are unenforced. This is the escape hatch for the " +
1900
+ "tier guards: demote first (a reason is required and the change is " +
1901
+ "logged for lint review), then write. Moving a doc away from 'manual' " +
1902
+ "requires a human:* identity. Rejects if the tier is already at the " +
1903
+ "target. Auto-commits." +
1904
+ shadowNote,
1905
+ inputSchema: {
1906
+ type: "object",
1907
+ properties: {
1908
+ path: {
1909
+ type: "string",
1910
+ description: "Vault-relative path of the document",
1911
+ },
1912
+ tier: {
1913
+ type: "string",
1914
+ enum: [...TIERS],
1915
+ description: "The new tier",
1916
+ },
1917
+ reason: {
1918
+ type: "string",
1919
+ description: "Why the tier is changing (recorded in the commit and provenance)",
1920
+ },
1921
+ agent: agentProperty,
1922
+ base_version: baseVersionProperty,
1923
+ },
1924
+ required: ["path", "tier", "reason", "agent"],
1925
+ additionalProperties: false,
1926
+ },
1927
+ handler: (vaultRoot, args, access) => vaultSetTier(vaultRoot, args, access),
1928
+ },
1443
1929
  {
1444
1930
  name: "vault_supersede",
1445
1931
  title: "Supersede a document",