@davidorex/pi-context 0.26.0 → 0.28.1

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 (123) hide show
  1. package/CHANGELOG.md +71 -15
  2. package/README.md +71 -18
  3. package/dist/block-api.d.ts +165 -18
  4. package/dist/block-api.d.ts.map +1 -1
  5. package/dist/block-api.js +800 -57
  6. package/dist/block-api.js.map +1 -1
  7. package/dist/citation-rot-scanner.d.ts +80 -0
  8. package/dist/citation-rot-scanner.d.ts.map +1 -0
  9. package/dist/citation-rot-scanner.js +421 -0
  10. package/dist/citation-rot-scanner.js.map +1 -0
  11. package/dist/content-hash.d.ts +25 -0
  12. package/dist/content-hash.d.ts.map +1 -0
  13. package/dist/content-hash.js +62 -0
  14. package/dist/content-hash.js.map +1 -0
  15. package/dist/context-dir.d.ts +126 -1
  16. package/dist/context-dir.d.ts.map +1 -1
  17. package/dist/context-dir.js +208 -4
  18. package/dist/context-dir.js.map +1 -1
  19. package/dist/context-registry.d.ts +122 -0
  20. package/dist/context-registry.d.ts.map +1 -0
  21. package/dist/context-registry.js +189 -0
  22. package/dist/context-registry.js.map +1 -0
  23. package/dist/context-sdk.d.ts +179 -12
  24. package/dist/context-sdk.d.ts.map +1 -1
  25. package/dist/context-sdk.js +536 -101
  26. package/dist/context-sdk.js.map +1 -1
  27. package/dist/context.d.ts +222 -4
  28. package/dist/context.d.ts.map +1 -1
  29. package/dist/context.js +244 -56
  30. package/dist/context.js.map +1 -1
  31. package/dist/git-env.d.ts +2 -0
  32. package/dist/git-env.d.ts.map +1 -0
  33. package/dist/git-env.js +29 -0
  34. package/dist/git-env.js.map +1 -0
  35. package/dist/index.d.ts +124 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +440 -1125
  38. package/dist/index.js.map +1 -1
  39. package/dist/lens-view.d.ts.map +1 -1
  40. package/dist/lens-view.js +5 -3
  41. package/dist/lens-view.js.map +1 -1
  42. package/dist/migration-registry-loader.d.ts +121 -0
  43. package/dist/migration-registry-loader.d.ts.map +1 -0
  44. package/dist/migration-registry-loader.js +309 -0
  45. package/dist/migration-registry-loader.js.map +1 -0
  46. package/dist/migrations-store.d.ts +141 -0
  47. package/dist/migrations-store.d.ts.map +1 -0
  48. package/dist/migrations-store.js +206 -0
  49. package/dist/migrations-store.js.map +1 -0
  50. package/dist/object-store.d.ts +26 -0
  51. package/dist/object-store.d.ts.map +1 -0
  52. package/dist/object-store.js +104 -0
  53. package/dist/object-store.js.map +1 -0
  54. package/dist/ops-registry.d.ts +44 -0
  55. package/dist/ops-registry.d.ts.map +1 -0
  56. package/dist/ops-registry.js +1223 -0
  57. package/dist/ops-registry.js.map +1 -0
  58. package/dist/orientation.d.ts +16 -0
  59. package/dist/orientation.d.ts.map +1 -0
  60. package/dist/orientation.js +49 -0
  61. package/dist/orientation.js.map +1 -0
  62. package/dist/promote-item.d.ts +28 -0
  63. package/dist/promote-item.d.ts.map +1 -0
  64. package/dist/promote-item.js +257 -0
  65. package/dist/promote-item.js.map +1 -0
  66. package/dist/read-element.d.ts +129 -0
  67. package/dist/read-element.d.ts.map +1 -0
  68. package/dist/read-element.js +296 -0
  69. package/dist/read-element.js.map +1 -0
  70. package/dist/rename-canonical-id.d.ts.map +1 -1
  71. package/dist/rename-canonical-id.js +22 -13
  72. package/dist/rename-canonical-id.js.map +1 -1
  73. package/dist/roadmap-plan.d.ts.map +1 -1
  74. package/dist/roadmap-plan.js +23 -13
  75. package/dist/roadmap-plan.js.map +1 -1
  76. package/dist/schema-validator.d.ts +1 -0
  77. package/dist/schema-validator.d.ts.map +1 -1
  78. package/dist/schema-validator.js +7 -3
  79. package/dist/schema-validator.js.map +1 -1
  80. package/dist/schema-write.d.ts +78 -4
  81. package/dist/schema-write.d.ts.map +1 -1
  82. package/dist/schema-write.js +297 -34
  83. package/dist/schema-write.js.map +1 -1
  84. package/dist/status-vocab.d.ts +1 -0
  85. package/dist/status-vocab.d.ts.map +1 -1
  86. package/dist/status-vocab.js +4 -0
  87. package/dist/status-vocab.js.map +1 -1
  88. package/dist/truncate.d.ts +34 -0
  89. package/dist/truncate.d.ts.map +1 -0
  90. package/dist/truncate.js +85 -0
  91. package/dist/truncate.js.map +1 -0
  92. package/dist/write-schema-migration-tool.d.ts +46 -0
  93. package/dist/write-schema-migration-tool.d.ts.map +1 -0
  94. package/dist/write-schema-migration-tool.js +115 -0
  95. package/dist/write-schema-migration-tool.js.map +1 -0
  96. package/package.json +35 -2
  97. package/samples/blocks/work-orders.json +3 -0
  98. package/samples/conception.json +22 -3
  99. package/samples/migrations.json +133 -0
  100. package/samples/schemas/context-contracts.schema.json +19 -4
  101. package/samples/schemas/conventions.schema.json +68 -12
  102. package/samples/schemas/decisions.schema.json +125 -28
  103. package/samples/schemas/features.schema.json +140 -26
  104. package/samples/schemas/framework-gaps.schema.json +171 -26
  105. package/samples/schemas/issues.schema.json +86 -13
  106. package/samples/schemas/layer-plans.schema.json +195 -35
  107. package/samples/schemas/phase.schema.json +18 -3
  108. package/samples/schemas/rationale.schema.json +37 -7
  109. package/samples/schemas/requirements.schema.json +77 -10
  110. package/samples/schemas/research.schema.json +169 -36
  111. package/samples/schemas/spec-reviews.schema.json +88 -18
  112. package/samples/schemas/story.schema.json +18 -3
  113. package/samples/schemas/tasks.schema.json +66 -10
  114. package/samples/schemas/verification.schema.json +70 -12
  115. package/samples/schemas/work-orders.schema.json +195 -0
  116. package/schemas/bootstrap.schema.json +18 -6
  117. package/schemas/config.schema.json +58 -26
  118. package/schemas/context-registry.schema.json +46 -0
  119. package/schemas/migrations.schema.json +123 -0
  120. package/schemas/relations.schema.json +41 -10
  121. package/skill-narrative.md +42 -21
  122. package/skills/pi-context/SKILL.md +159 -65
  123. package/skills/pi-context/references/bundled-resources.md +7 -2
package/dist/block-api.js CHANGED
@@ -16,12 +16,16 @@
16
16
  *
17
17
  * Future extraction seam for pi-project extension.
18
18
  */
19
+ import { randomUUID } from "node:crypto";
19
20
  import fs from "node:fs";
20
21
  import path from "node:path";
21
22
  import _lockfile from "proper-lockfile";
22
- import { assertSubstrateName, resolveContextDir, schemaPath, tryResolveContextDir } from "./context-dir.js";
23
+ import { canonicalJson, computeContentHash, sha256Hex } from "./content-hash.js";
24
+ import { assertSubstrateName, resolveContextDir, schemaPathForDir, substrateIdForDir, tryResolveContextDir, } from "./context-dir.js";
23
25
  import { stampItem } from "./dispatch-context.js";
24
- import { validateFromFile } from "./schema-validator.js";
26
+ import { getProjectMigrationRegistryForDir } from "./migration-registry-loader.js";
27
+ import { hasObject, putObject } from "./object-store.js";
28
+ import { validateBlockWithMigrationForDir, validateFromFile } from "./schema-validator.js";
25
29
  // Node16 module resolution + CJS interop: default import may be wrapped
26
30
  const lockfile = _lockfile.default ?? _lockfile;
27
31
  /**
@@ -41,12 +45,12 @@ function withBlockLock(filePath, fn) {
41
45
  lockfile.unlockSync(filePath);
42
46
  }
43
47
  }
44
- function blockFilePath(cwd, blockName) {
48
+ function blockFilePathForDir(substrateDir, blockName) {
45
49
  assertSubstrateName(blockName);
46
- return path.join(resolveContextDir(cwd), `${blockName}.json`);
50
+ return path.join(substrateDir, `${blockName}.json`);
47
51
  }
48
- function blockSchemaPath(cwd, blockName) {
49
- return schemaPath(cwd, blockName);
52
+ function blockSchemaPathForDir(substrateDir, blockName) {
53
+ return schemaPathForDir(substrateDir, blockName);
50
54
  }
51
55
  // ── Schema introspection cache (DispatchContext support, FGAP-004) ───────────
52
56
  /**
@@ -57,6 +61,54 @@ function blockSchemaPath(cwd, blockName) {
57
61
  * validation on injected fields.
58
62
  */
59
63
  const AUTHOR_FIELDS = ["created_by", "created_at", "modified_by", "modified_at"];
64
+ /**
65
+ * The MANDATORY content/metadata floor (content-addressed substrate identity,
66
+ * Cycle 3 / carried item 1; v3 spec §A2). The identity/addressing fields
67
+ * `id`, `oid`, `content_hash`, `content_parent` are ALWAYS metadata —
68
+ * EXCLUDED from the content hash — and a schema's `x-identity.metadata_fields`
69
+ * override can NEVER pull them into the content. This is the floor that makes
70
+ * the content hash a faithful identity for content rather than for addressing:
71
+ * a refname rename (`id`), a freshly-minted `oid`, or a recomputed
72
+ * `content_hash` / advanced `content_parent` must never move the hash, or the
73
+ * object store would fork on pure addressing churn. `metadataFieldsForSchema`
74
+ * unions this floor into whatever partition an override declares so the floor
75
+ * is non-overridable by construction.
76
+ */
77
+ export const MANDATORY_METADATA_FIELDS = new Set([
78
+ "id",
79
+ "oid",
80
+ "content_hash",
81
+ "content_parent",
82
+ ]);
83
+ /**
84
+ * The DISCRETIONARY metadata fields (v3 spec §A2): the four author/attestation
85
+ * fields (`AUTHOR_FIELDS`) plus the lifecycle-closure fields (`closed_by`,
86
+ * `closed_at`). These are the fields a schema MAY redefine via an
87
+ * `x-identity.metadata_fields` override — when an override is declared it
88
+ * REPLACES this discretionary set (the floor is still unioned back in); when
89
+ * absent these are the discretionary defaults. Built from `AUTHOR_FIELDS` so
90
+ * the four author strings keep a single source of truth and cannot drift from
91
+ * the stamping path.
92
+ */
93
+ export const DISCRETIONARY_METADATA_FIELDS = new Set([
94
+ ...AUTHOR_FIELDS,
95
+ "closed_by",
96
+ "closed_at",
97
+ ]);
98
+ /**
99
+ * The default content/metadata partition when no `x-identity.metadata_fields`
100
+ * override is declared: `MANDATORY ∪ DISCRETIONARY` — the same 10 fields the
101
+ * Cycle-2 surface enumerated (`id`, `oid`, `content_hash`, `content_parent`,
102
+ * the four author fields, `closed_by`, `closed_at`). Retained as the
103
+ * no-override default so the membership is identical to the pre-Cycle-3 set;
104
+ * `metadataFieldsForSchema` now composes it from the two named subsets rather
105
+ * than a flat literal so the mandatory floor is provably a subset of every
106
+ * resolved partition (see that function).
107
+ */
108
+ export const DEFAULT_METADATA_FIELDS = new Set([
109
+ ...MANDATORY_METADATA_FIELDS,
110
+ ...DISCRETIONARY_METADATA_FIELDS,
111
+ ]);
60
112
  const schemaCache = new Map();
61
113
  function safeStat(p) {
62
114
  try {
@@ -150,6 +202,222 @@ function collectArrayItemAuthorDecisions(schema, into) {
150
202
  }
151
203
  }
152
204
  }
205
+ /**
206
+ * Read the `x-identity.metadata_fields` override from an item subschema, if
207
+ * present. `x-identity` follows the established `x-prompt-budget` /
208
+ * `x-lifecycle` extension-keyword convention; `metadata_fields` is an array of
209
+ * field names to treat as metadata (excluded from the content hash) for items
210
+ * of this kind. Returns the array as a `Set` when validly declared, else
211
+ * `null` (caller falls back to `DEFAULT_METADATA_FIELDS`). Non-string entries
212
+ * are ignored defensively; a non-array `metadata_fields` yields `null`.
213
+ */
214
+ function readItemMetadataFieldsOverride(itemSchema) {
215
+ if (!itemSchema || typeof itemSchema !== "object")
216
+ return null;
217
+ const xIdentity = itemSchema["x-identity"];
218
+ if (!xIdentity || typeof xIdentity !== "object")
219
+ return null;
220
+ const fields = xIdentity.metadata_fields;
221
+ if (!Array.isArray(fields))
222
+ return null;
223
+ const out = new Set();
224
+ for (const f of fields) {
225
+ if (typeof f === "string")
226
+ out.add(f);
227
+ }
228
+ return out;
229
+ }
230
+ /**
231
+ * Walk every `items` subschema reachable from a schema (same traversal shape
232
+ * as `collectArrayItemAuthorDecisions`) and record, per array property name,
233
+ * the RAW `x-identity.metadata_fields` override for that array's items — ONLY
234
+ * when one is declared. A key with no declared override is left ABSENT from
235
+ * `into` (no default is written) so `metadataFieldsForSchema` can distinguish
236
+ * "override declared" from "no override" and apply the mandatory-floor union
237
+ * over the discretionary default at the read site. Mutates `into` in place.
238
+ * Nested arrays appear under their own key. When the same key appears at
239
+ * multiple depths, the first explicitly-declared override encountered is
240
+ * retained (a later declaration does not clobber it).
241
+ */
242
+ function collectArrayItemMetadataOverrides(schema, into) {
243
+ if (!schema || typeof schema !== "object")
244
+ return;
245
+ const s = schema;
246
+ const props = s.properties;
247
+ if (!props)
248
+ return;
249
+ for (const [propKey, propSpecRaw] of Object.entries(props)) {
250
+ if (!propSpecRaw || typeof propSpecRaw !== "object")
251
+ continue;
252
+ const spec = propSpecRaw;
253
+ if (spec.type === "array") {
254
+ const items = spec.items;
255
+ if (items && typeof items === "object") {
256
+ const itemProps = items.properties;
257
+ if (itemProps) {
258
+ const override = readItemMetadataFieldsOverride(items);
259
+ // Record only an explicit override; absence means "no
260
+ // override" so the read site applies the discretionary
261
+ // default. First explicit declaration wins.
262
+ if (override && !into.has(propKey)) {
263
+ into.set(propKey, override);
264
+ }
265
+ collectArrayItemMetadataOverrides(items, into);
266
+ }
267
+ }
268
+ }
269
+ else {
270
+ collectArrayItemMetadataOverrides(spec, into);
271
+ }
272
+ }
273
+ }
274
+ /**
275
+ * The three item fields whose presence (ALL THREE) in an array item's
276
+ * `properties` arms the Cycle-3 identity-stamping path for that array (locked
277
+ * decision 1). Distinct from `MANDATORY_METADATA_FIELDS` (which additionally
278
+ * carries `id`, present on every block item regardless of identity): these are
279
+ * the net-new fields a Cycle-3 schema edit adds, so their joint presence is the
280
+ * signal that a schema opted into identity stamping.
281
+ */
282
+ const IDENTITY_DECLARATION_FIELDS = ["oid", "content_hash", "content_parent"];
283
+ /**
284
+ * Walk every `items` subschema reachable from a schema (same traversal shape as
285
+ * `collectArrayItemMetadataOverrides`) and record, per array property name,
286
+ * whether the item subschema's `properties` declares ALL THREE
287
+ * `IDENTITY_DECLARATION_FIELDS`. This is the schema-gate cataloguer for
288
+ * `prepareItemIdentityForWrite` (locked decision 1) — mirrors
289
+ * `collectArrayItemAuthorDecisions` for the author-stamp gate. A key absent
290
+ * from `into` (or recorded `false`) means "this array does not stamp identity".
291
+ * `true` is recorded once and never downgraded (first positive declaration
292
+ * across reachable shapes wins, paralleling the author cataloguer's union).
293
+ */
294
+ function collectArrayItemIdentityDecisions(schema, into) {
295
+ if (!schema || typeof schema !== "object")
296
+ return;
297
+ const s = schema;
298
+ const props = s.properties;
299
+ if (!props)
300
+ return;
301
+ for (const [propKey, propSpecRaw] of Object.entries(props)) {
302
+ if (!propSpecRaw || typeof propSpecRaw !== "object")
303
+ continue;
304
+ const spec = propSpecRaw;
305
+ if (spec.type === "array") {
306
+ const items = spec.items;
307
+ if (items && typeof items === "object") {
308
+ const itemProps = items.properties;
309
+ if (itemProps) {
310
+ const declaresAll = IDENTITY_DECLARATION_FIELDS.every((f) => Object.hasOwn(itemProps, f));
311
+ if (declaresAll) {
312
+ into.set(propKey, true);
313
+ }
314
+ else if (!into.has(propKey)) {
315
+ into.set(propKey, false);
316
+ }
317
+ collectArrayItemIdentityDecisions(items, into);
318
+ }
319
+ }
320
+ }
321
+ else {
322
+ collectArrayItemIdentityDecisions(spec, into);
323
+ }
324
+ }
325
+ }
326
+ /**
327
+ * The content/metadata partition for items of array `arrayKey` under `schema`:
328
+ * `MANDATORY_METADATA_FIELDS ∪ (override ?? DISCRETIONARY_METADATA_FIELDS)`
329
+ * (carried item 1 / v3 spec §A2). The item subschema's
330
+ * `x-identity.metadata_fields` override, when declared, REPLACES the
331
+ * discretionary set; the mandatory floor (`id`/`oid`/`content_hash`/
332
+ * `content_parent`) is then unioned back in so an override can never pull a
333
+ * floor field into the content — even an override that omits `id` still
334
+ * excludes the floor, so two items differing only in `id` hash equal under
335
+ * that override. When no override is declared the result equals
336
+ * `DEFAULT_METADATA_FIELDS` (floor ∪ discretionary) byte-for-byte.
337
+ *
338
+ * Single reader — both the cache populate site (`getSchemaCacheEntry` via
339
+ * `collectArrayItemMetadataOverrides`) and `contentProjection` route through
340
+ * this so there is exactly one resolution path and no parallel default.
341
+ * Resolves the item subschema by the same array-key traversal used for
342
+ * author-field decisions, reading the RAW override (not the already-defaulted
343
+ * collected value) so the union is over the override itself.
344
+ */
345
+ export function metadataFieldsForSchema(schema, arrayKey) {
346
+ const overrides = new Map();
347
+ collectArrayItemMetadataOverrides(schema, overrides);
348
+ const override = overrides.get(arrayKey);
349
+ const discretionary = override ?? DISCRETIONARY_METADATA_FIELDS;
350
+ return new Set([...MANDATORY_METADATA_FIELDS, ...discretionary]);
351
+ }
352
+ /**
353
+ * Human-readable description of how a schema's `x-identity.metadata_fields`
354
+ * override changes the discretionary metadata partition relative to the
355
+ * default — or `null` when NO array item subschema declares an override
356
+ * (carried item 2 / informed-authorization confirm). Pure function: no
357
+ * filesystem, no cache; takes a parsed schema object and inspects every
358
+ * reachable array item subschema for an `x-identity.metadata_fields`
359
+ * declaration.
360
+ *
361
+ * When at least one override is present, returns a one-line-per-array summary
362
+ * naming, per array key, which DISCRETIONARY fields the override DROPS
363
+ * (present in `DISCRETIONARY_METADATA_FIELDS` but absent from the override)
364
+ * and which non-floor fields it ADDS (present in the override, not a floor
365
+ * field, not a default discretionary field). The mandatory floor
366
+ * (`id`/`oid`/`content_hash`/`content_parent`) is never reported as
367
+ * add/drop — it is structurally unaffected by an override — so the caller
368
+ * (auth-gate) can append a standing "floor remains excluded" affirmation.
369
+ *
370
+ * Used by `authGateHandler` to enrich the `write-schema` confirm message; the
371
+ * exact wording is the affordance, not a parse contract.
372
+ */
373
+ export function describeIdentityOverride(schema) {
374
+ const overrides = new Map();
375
+ collectArrayItemMetadataOverrides(schema, overrides);
376
+ if (overrides.size === 0)
377
+ return null;
378
+ const lines = [];
379
+ for (const [arrayKey, override] of overrides) {
380
+ const dropped = [];
381
+ for (const f of DISCRETIONARY_METADATA_FIELDS) {
382
+ if (!override.has(f))
383
+ dropped.push(f);
384
+ }
385
+ const added = [];
386
+ for (const f of override) {
387
+ if (!MANDATORY_METADATA_FIELDS.has(f) && !DISCRETIONARY_METADATA_FIELDS.has(f))
388
+ added.push(f);
389
+ }
390
+ const parts = [];
391
+ if (added.length > 0)
392
+ parts.push(`treats as metadata (excluded from hash): ${added.join(", ")}`);
393
+ if (dropped.length > 0)
394
+ parts.push(`now hashed (no longer metadata): ${dropped.join(", ")}`);
395
+ if (parts.length === 0)
396
+ parts.push("redefines the discretionary metadata set with no net field-level change");
397
+ lines.push(`array '${arrayKey}': ${parts.join("; ")}`);
398
+ }
399
+ return lines.join("\n");
400
+ }
401
+ /**
402
+ * Project an item to its hashable content: a SHALLOW COPY of `item` with the
403
+ * metadata keys (`metadataFieldsForSchema(schema, arrayKey)`) deleted. Does
404
+ * NOT mutate `item`. The result is what Cycle 3 will feed to
405
+ * `computeContentHash`, so a metadata-only mutation (refreshed author stamp,
406
+ * freshly-assigned `oid`, etc.) leaves the projection — and therefore the
407
+ * content hash — unchanged.
408
+ *
409
+ * `schema` is the parsed schema object (not a path), so this is usable both
410
+ * from the cache populate path and from ad-hoc callers / tests holding an
411
+ * inline schema.
412
+ */
413
+ export function contentProjection(schema, arrayKey, item) {
414
+ const metadataFields = metadataFieldsForSchema(schema, arrayKey);
415
+ const projection = { ...item };
416
+ for (const f of metadataFields) {
417
+ delete projection[f];
418
+ }
419
+ return projection;
420
+ }
153
421
  /**
154
422
  * Load the cached schema-introspection answer for a schema file, refreshing
155
423
  * the cache when the file's mtime changes (or it appears / disappears).
@@ -188,12 +456,26 @@ function getSchemaCacheEntry(schemaPath) {
188
456
  hasAuthorFields: false,
189
457
  envelopeDeclares: new Set(),
190
458
  perArrayKey: new Map(),
459
+ metadataFieldsByArrayKey: new Map(),
460
+ identityFieldsByArrayKey: new Map(),
191
461
  };
192
462
  schemaCache.set(key, entry);
193
463
  return entry;
194
464
  }
195
465
  const perArrayKey = new Map();
196
466
  collectArrayItemAuthorDecisions(schema, perArrayKey);
467
+ // Resolved content/metadata partition per array key (carried item 1).
468
+ // Collect the declared override keys, then resolve each through
469
+ // `metadataFieldsForSchema` so the cached value already carries the
470
+ // mandatory-floor union; identical resolution path as the read site.
471
+ const overrides = new Map();
472
+ collectArrayItemMetadataOverrides(schema, overrides);
473
+ const metadataFieldsByArrayKey = new Map();
474
+ for (const arrayKey of overrides.keys()) {
475
+ metadataFieldsByArrayKey.set(arrayKey, metadataFieldsForSchema(schema, arrayKey));
476
+ }
477
+ const identityFieldsByArrayKey = new Map();
478
+ collectArrayItemIdentityDecisions(schema, identityFieldsByArrayKey);
197
479
  const envelopeDeclares = schemaTopLevelDeclaredAuthorFields(schema);
198
480
  let anyArrayItemDeclares = false;
199
481
  for (const v of perArrayKey.values()) {
@@ -207,10 +489,26 @@ function getSchemaCacheEntry(schemaPath) {
207
489
  hasAuthorFields: envelopeDeclares.size > 0 || anyArrayItemDeclares,
208
490
  envelopeDeclares,
209
491
  perArrayKey,
492
+ metadataFieldsByArrayKey,
493
+ identityFieldsByArrayKey,
210
494
  };
211
495
  schemaCache.set(key, entry);
212
496
  return entry;
213
497
  }
498
+ /**
499
+ * Does the item subschema for `arrayKey` under the schema at `schemaPath`
500
+ * declare all three identity fields (`oid`/`content_hash`/`content_parent`)?
501
+ * The Cycle-3 stamping gate (locked decision 1) — `false` (incl. for a missing
502
+ * schema or an uncatalogued key) means `prepareItemIdentityForWrite` is a
503
+ * no-op for this array. Mirrors `declaredAuthorFieldsForArray`'s
504
+ * cache-backed lookup shape.
505
+ */
506
+ function arrayDeclaresIdentityFields(schemaPath, arrayKey) {
507
+ const entry = getSchemaCacheEntry(schemaPath);
508
+ if (!entry)
509
+ return false;
510
+ return entry.identityFieldsByArrayKey.get(arrayKey) ?? false;
511
+ }
214
512
  /**
215
513
  * Subset of `AUTHOR_FIELDS` declared at the schema's top-level envelope.
216
514
  * Used by `writeTypedFile` to decide which envelope fields to stamp on a
@@ -252,6 +550,134 @@ function maybeStampItem(schemaPath, arrayKey, item, ctx, mode) {
252
550
  return item;
253
551
  return stampItem(item, ctx, mode, declared);
254
552
  }
553
+ // ── Content-addressed identity stamping (Cycle 3 / Phase C) ──────────────────
554
+ /**
555
+ * Mint a fresh OID for an item being born in `substrateId`. An OID is the
556
+ * substrate-stable, content-INDEPENDENT identity of an item across all its
557
+ * content versions (vs. `content_hash`, which moves with content; vs. `id`,
558
+ * the human refname). Derived as the first 32 hex chars of
559
+ * `sha256Hex(canonicalJson([substrateId, nonce ?? randomUUID()]))`:
560
+ * - salting with `substrateId` makes two substrates that mint with the same
561
+ * nonce produce distinct OIDs (cross-substrate uniqueness — the reason
562
+ * Cycle 3 needs the substrate_id core);
563
+ * - `nonce` is optional and exists for deterministic tests; production calls
564
+ * pass none and get a fresh `randomUUID()` so each birth is unique even
565
+ * within one substrate.
566
+ * 128 bits (32 hex) of digest is collision-free in the item-count regime.
567
+ *
568
+ * RETURN SHAPE: a bare 32-character lowercase-hex digest — no substrate prefix,
569
+ * no colon separator. `substrateId` salts the hash and does NOT appear in the
570
+ * returned value. (The `<substrate_id>:<oid>` colon-form seen elsewhere is the
571
+ * edge dedup key in `endpointIdentity`, not the oid itself.)
572
+ */
573
+ export function mintOid(substrateId, nonce) {
574
+ const seed = nonce ?? randomUUID();
575
+ return sha256Hex(canonicalJson([substrateId, seed])).slice(0, 32);
576
+ }
577
+ /**
578
+ * Compute the content/identity fields for an item about to be written, per the
579
+ * content-addressed substrate identity model (Cycle 3 / Phase C). Returns the
580
+ * item with `oid` / `content_hash` / `content_parent` set; the input is never
581
+ * mutated (a shallow copy is returned). Also persists the content projection to
582
+ * the object store as a side effect on every stamping write.
583
+ *
584
+ * NO-OP GATE (locked decision 1): when the item's array subschema does not
585
+ * declare all three identity fields, the original `item` is returned unchanged
586
+ * (no oid mint, no hash, no object write, no substrate_id read). This scopes
587
+ * the behavior change to exactly the schemas Cycle 3 edits — bespoke test
588
+ * schemas without the fields are untouched — mirroring `maybeStampItem`'s
589
+ * author-field gate. The gate is NOT ctx-gated: content hash / oid are
590
+ * integrity, not attestation.
591
+ *
592
+ * Ordering vs author-stamp: call AFTER `maybeStampItem`/`maybeStampTypedItem`.
593
+ * Author fields are in the metadata partition (excluded from the projection),
594
+ * so the content hash is invariant to whether author-stamping ran first — but
595
+ * `content_hash` itself is a metadata field, so it must be assigned AFTER the
596
+ * projection is computed from the (already author-stamped) item.
597
+ *
598
+ * `mode`:
599
+ * - `"create"`: mint a fresh `oid` (via `substrateIdForDir(substrateDir)`),
600
+ * compute `content_hash`, set NO `content_parent` (a v1 item has no prior).
601
+ * - `"update"`: preserve `prior.oid` — and THROW if the incoming item carries
602
+ * a different `oid` (locked decision 3: oid is immutable). Recompute
603
+ * `content_hash`. When the content changed (new hash !== prior.content_hash)
604
+ * set `content_parent = prior.content_hash`; when content is unchanged
605
+ * (no-op write) leave `content_parent` at the prior value (not advanced).
606
+ *
607
+ * `prior` is the on-disk item being replaced (required for `"update"`); for
608
+ * `"create"` it is ignored.
609
+ */
610
+ export function prepareItemIdentityForWrite(substrateDir, blockName, item, schemaPath, arrayKey, mode, prior) {
611
+ // Schema-gate: no-op unless the array's items declare all three identity
612
+ // fields. Cheap cache-backed check; runs before any substrate_id read or
613
+ // hashing so non-identity schemas pay nothing.
614
+ if (!arrayDeclaresIdentityFields(schemaPath, arrayKey)) {
615
+ return item;
616
+ }
617
+ const out = { ...item };
618
+ // `content_hash` is itself a metadata field; project (which drops it +
619
+ // the rest of the floor + discretionary metadata) BEFORE assigning the
620
+ // fresh hash. The schema must be parseable here — the gate passed, so the
621
+ // file exists and is valid JSON (getSchemaCacheEntry already parsed it).
622
+ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
623
+ if (mode === "create") {
624
+ const substrateId = substrateIdForDir(substrateDir);
625
+ out.oid = mintOid(substrateId);
626
+ // content_parent intentionally absent on a v1 item.
627
+ const projection = contentProjection(schema, arrayKey, out);
628
+ const hash = computeContentHash(projection);
629
+ out.content_hash = hash;
630
+ // Object persistence is deferred to writeTypedFile's post-validation walk
631
+ // (Cycle 9.1 P6): stamping must not write to objects/ before the whole
632
+ // block clears AJV, else an AJV-fail leaves an orphan content object.
633
+ return out;
634
+ }
635
+ // mode === "update"
636
+ const priorOid = prior && typeof prior.oid === "string" ? prior.oid : undefined;
637
+ const incomingOid = typeof out.oid === "string" ? out.oid : undefined;
638
+ if (priorOid !== undefined && incomingOid !== undefined && incomingOid !== priorOid) {
639
+ throw new Error(`prepareItemIdentityForWrite: oid is immutable — update to block '${blockName}' (array '${arrayKey}') would change oid '${priorOid}' to '${incomingOid}'`);
640
+ }
641
+ // Preserve the prior oid (the canonical, content-independent identity).
642
+ // When there is no prior oid (e.g. an update against an item that predates
643
+ // identity stamping / was never stamped) the path ALWAYS mints — a
644
+ // caller-supplied oid is never honored on first stamp, upholding the
645
+ // "OIDs are system-minted, never caller-asserted" invariant identically to
646
+ // create-mode. (`incomingOid` is still consulted by the immutability throw
647
+ // guard above when a prior oid exists.)
648
+ if (priorOid !== undefined) {
649
+ out.oid = priorOid;
650
+ }
651
+ else {
652
+ out.oid = mintOid(substrateIdForDir(substrateDir));
653
+ }
654
+ const priorHash = prior && typeof prior.content_hash === "string" ? prior.content_hash : undefined;
655
+ const priorParent = prior && typeof prior.content_parent === "string" ? prior.content_parent : undefined;
656
+ const projection = contentProjection(schema, arrayKey, out);
657
+ const hash = computeContentHash(projection);
658
+ out.content_hash = hash;
659
+ // Object persistence deferred to writeTypedFile's post-validation walk
660
+ // (Cycle 9.1 P6); see create-mode note above.
661
+ // content_parent advances to the prior content_hash ONLY when content
662
+ // actually changed. On an unchanged-content write (no-op / author-only
663
+ // re-stamp) it is NOT advanced — instead the prior version's own
664
+ // content_parent is PRESERVED (carried forward) so the version chain is not
665
+ // truncated by a metadata-only write. When there is neither a prior hash
666
+ // (first stamping write on a pre-existing item) nor a prior parent, it is
667
+ // left absent.
668
+ if (priorHash !== undefined && priorHash !== hash) {
669
+ out.content_parent = priorHash;
670
+ }
671
+ else if (priorParent !== undefined) {
672
+ out.content_parent = priorParent;
673
+ }
674
+ else {
675
+ // No-op write whose prior had no parent (e.g. re-stamp of a v1 item):
676
+ // ensure a stale content_parent does not linger on the carried item.
677
+ delete out.content_parent;
678
+ }
679
+ return out;
680
+ }
255
681
  /**
256
682
  * Read and parse a .project/{blockName}.json file.
257
683
  * Throws if the file does not exist or contains invalid JSON.
@@ -260,14 +686,14 @@ function maybeStampItem(schemaPath, arrayKey, item, ctx, mode) {
260
686
  * matching items in the specified array key. Non-array or missing keys return the
261
687
  * block unchanged. The filter is applied after parsing, before returning.
262
688
  */
263
- export function readBlock(cwd, blockName, filter) {
264
- const filePath = blockFilePath(cwd, blockName);
689
+ export function readBlockForDir(substrateDir, blockName, filter) {
690
+ const filePath = blockFilePathForDir(substrateDir, blockName);
265
691
  let content;
266
692
  try {
267
693
  content = fs.readFileSync(filePath, "utf-8");
268
694
  }
269
695
  catch {
270
- throw new Error(`Block file not found: .project/${blockName}.json`);
696
+ throw new Error(`Block file not found: ${filePath}`);
271
697
  }
272
698
  let data;
273
699
  try {
@@ -275,7 +701,24 @@ export function readBlock(cwd, blockName, filter) {
275
701
  }
276
702
  catch (err) {
277
703
  const msg = err instanceof Error ? err.message : String(err);
278
- throw new Error(`Invalid JSON in block file: .project/${blockName}.json: ${msg}`);
704
+ throw new Error(`Invalid JSON in block file: ${filePath}: ${msg}`);
705
+ }
706
+ // Version-aware validation hook (FGAP-136 plan step 4). When the block has
707
+ // an existing schema AND the parsed data carries a schema_version field,
708
+ // route through validateBlockWithMigration with the substrate-loaded
709
+ // project MigrationRegistry. The hook is conditional on schema_version
710
+ // presence so pre-versioned blocks (no schema_version field on items)
711
+ // pass through unchanged — the migration boundary only fires when there
712
+ // IS a version assertion to compare against. Whole-block validation
713
+ // uses the parsed `data` as-is; per-array-item migration is out of
714
+ // scope today (the block envelope is what carries schema_version, not
715
+ // each item).
716
+ if (existingBlockSchemaPathForDir(substrateDir, blockName) !== null) {
717
+ const envelope = data;
718
+ if (envelope && typeof envelope === "object" && typeof envelope.schema_version === "string") {
719
+ const registry = getProjectMigrationRegistryForDir(substrateDir);
720
+ data = validateBlockWithMigrationForDir(substrateDir, blockName, envelope, registry);
721
+ }
279
722
  }
280
723
  if (filter) {
281
724
  const record = data;
@@ -286,13 +729,20 @@ export function readBlock(cwd, blockName, filter) {
286
729
  }
287
730
  return data;
288
731
  }
732
+ export function readBlock(cwd, blockName, filter) {
733
+ // Assert the name BEFORE resolving the substrate dir so the FGAP-079
734
+ // path-traversal guard fires ahead of BootstrapNotFoundError (preserves the
735
+ // pre-Phase-0 ordering: name-guard precedes pointer resolution).
736
+ assertSubstrateName(blockName);
737
+ return readBlockForDir(resolveContextDir(cwd), blockName, filter);
738
+ }
289
739
  /**
290
740
  * Resolve the existing schema path for a block (or null when no schema
291
741
  * file is present at the conventional location). Used by every wrapper
292
742
  * that delegates to the typed-file primitives.
293
743
  */
294
- function existingBlockSchemaPath(cwd, blockName) {
295
- const schemaFile = blockSchemaPath(cwd, blockName);
744
+ function existingBlockSchemaPathForDir(substrateDir, blockName) {
745
+ const schemaFile = blockSchemaPathForDir(substrateDir, blockName);
296
746
  return fs.existsSync(schemaFile) ? schemaFile : null;
297
747
  }
298
748
  /**
@@ -358,6 +808,28 @@ export function writeTypedFile(filePath, schemaPath, data, ctx, errorLabel) {
358
808
  if (schemaPath) {
359
809
  validateFromFile(schemaPath, toWrite, label);
360
810
  }
811
+ // Post-validation object persistence (Cycle 9.1 P6). Content objects are
812
+ // written to objects/ ONLY after the whole block clears AJV and BEFORE the
813
+ // tmp+rename — so an AJV-fail leaves no orphan object, and a committed block
814
+ // never references a missing object. Stamping (prepareItemIdentityForWrite)
815
+ // computed each item's content_hash but no longer persists; we persist here.
816
+ // Gated on schemaPath: a schema-less write carries no identity items, and
817
+ // the per-item content_hash check protects the non-stamping config/registry/
818
+ // relations/migrations writers (their items carry no content_hash).
819
+ if (schemaPath) {
820
+ const substrateDir = path.dirname(filePath);
821
+ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
822
+ forEachBlockArray(toWrite, (arrayKey, arr) => {
823
+ for (const item of arr) {
824
+ if (item && typeof item === "object" && !Array.isArray(item)) {
825
+ const rec = item;
826
+ if (typeof rec.content_hash === "string" && !hasObject(substrateDir, rec.content_hash)) {
827
+ putObject(substrateDir, rec.content_hash, contentProjection(schema, arrayKey, rec));
828
+ }
829
+ }
830
+ }
831
+ });
832
+ }
361
833
  // Ensure directory exists
362
834
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
363
835
  // Atomic write: tmp + rename. Callers needing read-modify-write atomicity
@@ -396,6 +868,77 @@ export function writeTypedFile(filePath, schemaPath, data, ctx, errorLabel) {
396
868
  * intentionally a no-op until a schema actually declares author fields
397
869
  * on a top-level array shape (no current consumer does so).
398
870
  */
871
+ /**
872
+ * Atomic id-uniqueness guard for the append path. Reads the `id` off the
873
+ * incoming item (when it carries one) and rejects the append if any element
874
+ * already present in `arr` shares that id. Items genuinely lacking an `id`
875
+ * property are skipped (NOT fabricated) — mirrors the `"id" in item` gate the
876
+ * tool layer used. The check runs inside the caller's `withBlockLock` critical
877
+ * section against the array already in hand, so it is atomic (unlike a racy
878
+ * `readBlock`-then-`append` at the tool layer). `labelForArray` is the
879
+ * `<label>.<arrayKey>` context the message names.
880
+ */
881
+ function assertAppendIdUnique(arr, item, labelForArray) {
882
+ if (!item || typeof item !== "object" || Array.isArray(item))
883
+ return;
884
+ const rec = item;
885
+ if (!("id" in rec))
886
+ return;
887
+ const id = rec.id;
888
+ for (const existing of arr) {
889
+ if (existing && typeof existing === "object" && existing.id === id) {
890
+ throw new Error(`Item '${String(id)}' already exists in ${labelForArray}`);
891
+ }
892
+ }
893
+ }
894
+ /**
895
+ * Atomic whole-file id-uniqueness guard: rejects a whole-array write that
896
+ * itself carries two elements sharing an `id`. Only elements that HAVE an `id`
897
+ * property participate (mirrors `assertAppendIdUnique`'s gate); id-less
898
+ * elements are skipped. `labelForArray` is the `<label>.<arrayKey>` context the
899
+ * message names.
900
+ */
901
+ function assertNoDuplicateIdsInArray(arr, labelForArray) {
902
+ const seen = new Set();
903
+ for (const el of arr) {
904
+ if (!el || typeof el !== "object" || Array.isArray(el))
905
+ continue;
906
+ const rec = el;
907
+ if (!("id" in rec))
908
+ continue;
909
+ const id = rec.id;
910
+ if (seen.has(id)) {
911
+ throw new Error(`Item '${String(id)}' already exists in ${labelForArray}`);
912
+ }
913
+ seen.add(id);
914
+ }
915
+ }
916
+ /**
917
+ * Pure-data recursive walk over every array-valued property at any depth.
918
+ * For a plain (non-array) object `data`, each `[key, value]` pair whose `value`
919
+ * is an array is reported via `visit(key, value)`, then each element of that
920
+ * array that is itself a plain (non-array) object is recursed into (so an
921
+ * item's own nested id-bearing arrays — e.g. `layer-plans` items'
922
+ * `layers` / `migration_phases` — are visited under their item-local key).
923
+ * No schema is consulted; this matches the block shapes the schemas use
924
+ * (top-level arrays of items, items carrying nested arrays of items). Non-array
925
+ * object-valued properties that are NOT array elements are not descended into —
926
+ * block items live in arrays, so only array elements are recursion frontiers.
927
+ */
928
+ function forEachBlockArray(data, visit) {
929
+ if (!data || typeof data !== "object" || Array.isArray(data))
930
+ return;
931
+ for (const [key, value] of Object.entries(data)) {
932
+ if (!Array.isArray(value))
933
+ continue;
934
+ visit(key, value);
935
+ for (const el of value) {
936
+ if (el && typeof el === "object" && !Array.isArray(el)) {
937
+ forEachBlockArray(el, visit);
938
+ }
939
+ }
940
+ }
941
+ }
399
942
  export function appendToTypedFile(filePath, schemaPath, arrayPath, item, ctx, errorLabel) {
400
943
  const label = errorLabel ?? filePath;
401
944
  withBlockLock(filePath, () => {
@@ -405,9 +948,14 @@ export function appendToTypedFile(filePath, schemaPath, arrayPath, item, ctx, er
405
948
  if (!Array.isArray(data)) {
406
949
  throw new Error(`${label}: expected top-level array, got ${typeof data}`);
407
950
  }
408
- const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
951
+ // Atomic id-uniqueness guard (reads the in-hand array under the lock).
952
+ assertAppendIdUnique(data, item, `${label}.__top__`);
953
+ const authored = ctx && item && typeof item === "object" && !Array.isArray(item)
409
954
  ? maybeStampItem(schemaPath, "__top__", item, ctx, "create")
410
955
  : item;
956
+ // Identity-stamp AFTER author-stamp (ordering is hash-neutral; see
957
+ // prepareItemIdentityForWrite). Flat-array shape never identity-stamps.
958
+ const itemToAppend = maybeIdentityStampTypedItem(filePath, schemaPath, null, authored, "create");
411
959
  const next = [...data, itemToAppend];
412
960
  // Validate the WHOLE array against schemaPath, then write.
413
961
  writeTypedFile(filePath, schemaPath, next, undefined, label);
@@ -424,9 +972,12 @@ export function appendToTypedFile(filePath, schemaPath, arrayPath, item, ctx, er
424
972
  if (!Array.isArray(record[arrayPath])) {
425
973
  throw new Error(`${label} key '${arrayPath}' is not an array`);
426
974
  }
427
- const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
975
+ // Atomic id-uniqueness guard (reads the in-hand array under the lock).
976
+ assertAppendIdUnique(record[arrayPath], item, `${label}.${arrayPath}`);
977
+ const authored = ctx && item && typeof item === "object" && !Array.isArray(item)
428
978
  ? maybeStampItem(schemaPath, arrayPath, item, ctx, "create")
429
979
  : item;
980
+ const itemToAppend = maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, authored, "create");
430
981
  record[arrayPath] = [...record[arrayPath], itemToAppend];
431
982
  writeTypedFile(filePath, schemaPath, record, undefined, label);
432
983
  });
@@ -511,7 +1062,10 @@ export function appendManyToTypedFileIfAbsent(filePath, schemaPath, arrayPath, i
511
1062
  continue;
512
1063
  }
513
1064
  seen.add(key);
514
- arr.push(candidate);
1065
+ // Identity-stamp each newly-appended candidate (create mode; an
1066
+ // append-if-absent candidate is always a fresh item). No-op unless
1067
+ // the schema declares identity fields.
1068
+ arr.push(maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, candidate, "create"));
515
1069
  appended++;
516
1070
  }
517
1071
  if (appended > 0) {
@@ -581,6 +1135,27 @@ function maybeStampTypedItem(schemaPath, arrayPath, item, ctx, mode) {
581
1135
  const key = arrayPath ?? "__top__";
582
1136
  return maybeStampItem(schemaPath, key, item, ctx, mode);
583
1137
  }
1138
+ /**
1139
+ * Per-item identity stamping (Cycle 3) for the typed-file mutation primitives.
1140
+ * Thin wrapper over `prepareItemIdentityForWrite` that derives
1141
+ * `substrateDir` / `blockName` from `filePath` (block files live at
1142
+ * `<substrateDir>/<block>.json`) and skips non-object items (a scalar / array
1143
+ * item cannot carry identity fields). The flat-array key (`arrayPath === null`)
1144
+ * maps to the `__top__` sentinel which `collectArrayItemIdentityDecisions`
1145
+ * never populates, so flat-array shapes never identity-stamp — same
1146
+ * intentional no-op as author stamping. NOT ctx-gated: identity is integrity,
1147
+ * not attestation, so this runs whether or not a DispatchContext was supplied;
1148
+ * the schema-gate inside `prepareItemIdentityForWrite` is the only fire/no-op
1149
+ * decision.
1150
+ */
1151
+ function maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, item, mode, prior) {
1152
+ if (!item || typeof item !== "object" || Array.isArray(item))
1153
+ return item;
1154
+ const substrateDir = path.dirname(filePath);
1155
+ const blockName = path.basename(filePath, ".json");
1156
+ const key = arrayPath ?? "__top__";
1157
+ return prepareItemIdentityForWrite(substrateDir, blockName, item, schemaPath, key, mode, prior);
1158
+ }
584
1159
  /**
585
1160
  * Validated atomic find-by-predicate update of one item inside an array
586
1161
  * reachable from `(filePath, schemaPath, arrayPath)`. Generalises
@@ -615,8 +1190,12 @@ export function updateItemInTypedFile(filePath, schemaPath, arrayPath, predicate
615
1190
  if (matchCount > 1) {
616
1191
  console.error(`[block-api] updateItemInBlock: ${matchCount} items matched predicate, only first updated`);
617
1192
  }
618
- const merged = { ...arr[idx], ...updates };
619
- const updated = ctx ? maybeStampTypedItem(schemaPath, arrayPath, merged, ctx, "update") : merged;
1193
+ const prior = arr[idx];
1194
+ const merged = { ...prior, ...updates };
1195
+ const authored = ctx ? maybeStampTypedItem(schemaPath, arrayPath, merged, ctx, "update") : merged;
1196
+ // Identity-stamp in update mode: preserve prior oid (throw on change),
1197
+ // recompute hash, advance content_parent on content change.
1198
+ const updated = maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, authored, "update", prior);
620
1199
  const patched = [...arr];
621
1200
  patched[idx] = updated;
622
1201
  writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
@@ -677,7 +1256,14 @@ export function upsertItemInTypedFile(filePath, schemaPath, arrayPath, item, idF
677
1256
  itemForStamp = { ...carriedFields, ...item };
678
1257
  }
679
1258
  }
680
- const stamped = ctx ? maybeStampTypedItem(schemaPath, arrayPath, itemForStamp, ctx, stampMode) : itemForStamp;
1259
+ const authored = ctx ? maybeStampTypedItem(schemaPath, arrayPath, itemForStamp, ctx, stampMode) : itemForStamp;
1260
+ // Identity-stamp: append branch is create (no prior); replace branch is
1261
+ // update with the on-disk item as prior, so prepareItemIdentityForWrite
1262
+ // preserves the prior oid even though upsert REPLACES (the supplied item
1263
+ // carries no oid) and advances content_parent on content change. This is
1264
+ // the identity analogue of the FGAP-018 created_* carry-forward above.
1265
+ const priorForIdentity = idx === -1 ? undefined : arr[idx];
1266
+ const stamped = maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, authored, stampMode, priorForIdentity);
681
1267
  const patched = [...arr];
682
1268
  if (idx === -1) {
683
1269
  patched.push(stamped);
@@ -753,9 +1339,15 @@ export function appendToNestedTypedFile(filePath, schemaPath, parentArrayKey, pr
753
1339
  if (!Array.isArray(parent[nestedArrayKey])) {
754
1340
  throw new Error(`Matched item in ${label} key '${parentArrayKey}' nested key '${nestedArrayKey}' is not an array`);
755
1341
  }
756
- const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
1342
+ // Atomic id-uniqueness guard on the nested array (Cycle 9.1 P4): reads the
1343
+ // in-hand nested array under the lock. Label names the full parent.nested path.
1344
+ assertAppendIdUnique(parent[nestedArrayKey], item, `${label}.${parentArrayKey}.${nestedArrayKey}`);
1345
+ const authored = ctx && item && typeof item === "object" && !Array.isArray(item)
757
1346
  ? maybeStampItem(schemaPath, nestedArrayKey, item, ctx, "create")
758
1347
  : item;
1348
+ // Identity-stamp the appended nested item (create mode), keyed on the
1349
+ // nested array key so the gate consults the nested item subschema.
1350
+ const itemToAppend = maybeIdentityStampTypedItem(filePath, schemaPath, nestedArrayKey, authored, "create");
759
1351
  const updatedParent = {
760
1352
  ...parent,
761
1353
  [nestedArrayKey]: [...parent[nestedArrayKey], itemToAppend],
@@ -813,8 +1405,12 @@ export function updateNestedItemInTypedFile(filePath, schemaPath, parentArrayKey
813
1405
  if (nestedMatchCount > 1) {
814
1406
  console.error(`[block-api] updateNestedArrayItem: ${nestedMatchCount} nested items matched predicate, only first updated`);
815
1407
  }
816
- const mergedNested = { ...nestedArr[nestedIdx], ...updates };
817
- const updatedNested = ctx ? maybeStampItem(schemaPath, nestedArrayKey, mergedNested, ctx, "update") : mergedNested;
1408
+ const priorNested = nestedArr[nestedIdx];
1409
+ const mergedNested = { ...priorNested, ...updates };
1410
+ const authoredNested = ctx ? maybeStampItem(schemaPath, nestedArrayKey, mergedNested, ctx, "update") : mergedNested;
1411
+ // Identity-stamp the updated nested item (update mode; prior = on-disk
1412
+ // nested item) so oid is preserved/immutable and content_parent advances.
1413
+ const updatedNested = maybeIdentityStampTypedItem(filePath, schemaPath, nestedArrayKey, authoredNested, "update", priorNested);
818
1414
  const patchedNested = [...nestedArr];
819
1415
  patchedNested[nestedIdx] = updatedNested;
820
1416
  const updatedParent = { ...parent, [nestedArrayKey]: patchedNested };
@@ -883,10 +1479,110 @@ export function removeFromNestedTypedFile(filePath, schemaPath, parentArrayKey,
883
1479
  * stamping; callers wanting per-item attribution should prefer the
884
1480
  * array-grained writers.
885
1481
  */
1482
+ export function writeBlockForDir(substrateDir, blockName, data, ctx) {
1483
+ const filePath = blockFilePathForDir(substrateDir, blockName);
1484
+ const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
1485
+ // Whole-block identity stamping (Cycle 3): for every top-level array whose
1486
+ // item subschema declares the identity fields, stamp each item — mint-or-
1487
+ // preserve oid, recompute content_hash, advance content_parent on change.
1488
+ // Prior item state is read from the on-disk block and matched by oid (then
1489
+ // id) so a re-written item preserves its oid and only advances its parent
1490
+ // when its content actually changed. A no-op for non-identity schemas /
1491
+ // non-object data (the gate inside prepareItemIdentityForWrite short-
1492
+ // circuits). Author-only re-stamping is hash-neutral, so a whole-block
1493
+ // re-write that merely refreshes attestation leaves content hashes stable.
1494
+ // Whole-file id-uniqueness guard: a whole-block write carrying two items
1495
+ // sharing an `id` within one array is rejected before stamping/validation.
1496
+ // Recurses through nested id-bearing arrays (Cycle 9.1 P4) so a duplicate id
1497
+ // inside e.g. a `layer-plans` item's `layers` is rejected too.
1498
+ if (data && typeof data === "object" && !Array.isArray(data)) {
1499
+ forEachBlockArray(data, (arrayKey, arr) => assertNoDuplicateIdsInArray(arr, `${blockName}.${arrayKey}`));
1500
+ }
1501
+ let identityStamped = data;
1502
+ if (schemaPath !== null && data && typeof data === "object" && !Array.isArray(data)) {
1503
+ identityStamped = stampWholeBlockIdentity(substrateDir, blockName, filePath, schemaPath, data);
1504
+ }
1505
+ // Version-aware pre-write validation (FGAP-136 plan step 4). When a
1506
+ // schema is present AND the data envelope carries a schema_version,
1507
+ // run validateBlockWithMigration with the substrate-loaded
1508
+ // MigrationRegistry. A version-mismatch with no declared migration
1509
+ // throws upstream of writeTypedFile so the file never lands; a
1510
+ // declared migration walks the data forward before AJV runs. The
1511
+ // migrated form is what writeTypedFile receives. The ForDir registry +
1512
+ // validation read the TARGET substrate's schema + migrations.json, never
1513
+ // the active dir's.
1514
+ let toWrite = identityStamped;
1515
+ if (schemaPath !== null &&
1516
+ identityStamped &&
1517
+ typeof identityStamped === "object" &&
1518
+ typeof identityStamped.schema_version === "string") {
1519
+ const registry = getProjectMigrationRegistryForDir(substrateDir);
1520
+ toWrite = validateBlockWithMigrationForDir(substrateDir, blockName, identityStamped, registry);
1521
+ }
1522
+ writeTypedFile(filePath, schemaPath, toWrite, ctx, `block file '${blockName}.json'`);
1523
+ }
1524
+ /**
1525
+ * Identity-stamp every item of every top-level array in a whole-block write
1526
+ * (Cycle 3 / locked decision: writeBlockForDir stamps each array item). Reads
1527
+ * the on-disk block (if present) to build a prior-item index keyed by `oid`
1528
+ * (primary) then `id` (fallback), so each re-written item:
1529
+ * - reuses its prior `oid` (immutable; `prepareItemIdentityForWrite` throws
1530
+ * if the incoming item carries a DIFFERENT non-empty oid),
1531
+ * - recomputes its `content_hash`,
1532
+ * - advances `content_parent` to the prior hash only when content changed.
1533
+ * An item with no on-disk match is treated as a fresh create (mints an oid).
1534
+ * Returns a shallow-cloned block; the input is not mutated. Arrays whose item
1535
+ * subschema does not declare the identity fields pass through untouched (the
1536
+ * gate inside `prepareItemIdentityForWrite` short-circuits per item).
1537
+ */
1538
+ function stampWholeBlockIdentity(substrateDir, blockName, filePath, schemaPath, data) {
1539
+ // Read prior on-disk block directly (no migration hook, no lock — caller
1540
+ // holds the lock when one is needed; a fresh read here just supplies the
1541
+ // prior-item index). Absent file → no priors → every item is a create.
1542
+ let priorData = null;
1543
+ if (fs.existsSync(filePath)) {
1544
+ try {
1545
+ priorData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1546
+ }
1547
+ catch {
1548
+ priorData = null;
1549
+ }
1550
+ }
1551
+ const out = { ...data };
1552
+ for (const [arrayKey, value] of Object.entries(data)) {
1553
+ if (!Array.isArray(value))
1554
+ continue;
1555
+ if (!arrayDeclaresIdentityFields(schemaPath, arrayKey))
1556
+ continue;
1557
+ // Build prior index for this array, keyed by oid then id.
1558
+ const priorByOid = new Map();
1559
+ const priorById = new Map();
1560
+ const priorArr = priorData && Array.isArray(priorData[arrayKey]) ? priorData[arrayKey] : [];
1561
+ for (const p of priorArr) {
1562
+ if (!p || typeof p !== "object")
1563
+ continue;
1564
+ const rec = p;
1565
+ if (typeof rec.oid === "string")
1566
+ priorByOid.set(rec.oid, rec);
1567
+ if (typeof rec.id === "string")
1568
+ priorById.set(rec.id, rec);
1569
+ }
1570
+ out[arrayKey] = value.map((item) => {
1571
+ if (!item || typeof item !== "object" || Array.isArray(item))
1572
+ return item;
1573
+ const rec = item;
1574
+ const prior = (typeof rec.oid === "string" ? priorByOid.get(rec.oid) : undefined) ??
1575
+ (typeof rec.id === "string" ? priorById.get(rec.id) : undefined);
1576
+ const mode = prior ? "update" : "create";
1577
+ return prepareItemIdentityForWrite(substrateDir, blockName, rec, schemaPath, arrayKey, mode, prior);
1578
+ });
1579
+ }
1580
+ return out;
1581
+ }
886
1582
  export function writeBlock(cwd, blockName, data, ctx) {
887
- const filePath = blockFilePath(cwd, blockName);
888
- const schemaPath = existingBlockSchemaPath(cwd, blockName);
889
- writeTypedFile(filePath, schemaPath, data, ctx, `block file '${blockName}.json'`);
1583
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1584
+ assertSubstrateName(blockName);
1585
+ writeBlockForDir(resolveContextDir(cwd), blockName, data, ctx);
890
1586
  }
891
1587
  /**
892
1588
  * Read current file, push item onto data[arrayKey], validate whole file
@@ -900,9 +1596,9 @@ export function writeBlock(cwd, blockName, data, ctx) {
900
1596
  * `additionalProperties: false` AJV failures on blocks whose item shape
901
1597
  * doesn't carry author markers yet.
902
1598
  */
903
- export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
904
- withBlockLock(blockFilePath(cwd, blockName), () => {
905
- const data = readBlock(cwd, blockName);
1599
+ export function appendToBlockForDir(substrateDir, blockName, arrayKey, item, ctx) {
1600
+ withBlockLock(blockFilePathForDir(substrateDir, blockName), () => {
1601
+ const data = readBlockForDir(substrateDir, blockName);
906
1602
  if (!data || typeof data !== "object") {
907
1603
  throw new Error(`Block '${blockName}' is not an object`);
908
1604
  }
@@ -913,17 +1609,26 @@ export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
913
1609
  if (!Array.isArray(record[arrayKey])) {
914
1610
  throw new Error(`Block '${blockName}' key '${arrayKey}' is not an array`);
915
1611
  }
1612
+ // Atomic id-uniqueness guard (reads the in-hand array under the lock).
1613
+ // This primitive writes inline (does NOT route through appendToTypedFile),
1614
+ // so the guard is applied here separately.
1615
+ assertAppendIdUnique(record[arrayKey], item, `${blockName}.${arrayKey}`);
916
1616
  // Optional ctx-stamping for object-shaped items (skipped silently for
917
1617
  // scalar items even when the schema technically permits author fields —
918
1618
  // stamping a string / number is meaningless).
919
- const schemaPath = existingBlockSchemaPath(cwd, blockName);
1619
+ const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
920
1620
  const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
921
1621
  ? maybeStampItem(schemaPath, arrayKey, item, ctx, "create")
922
1622
  : item;
923
1623
  record[arrayKey] = [...record[arrayKey], itemToAppend];
924
- writeBlock(cwd, blockName, record);
1624
+ writeBlockForDir(substrateDir, blockName, record);
925
1625
  });
926
1626
  }
1627
+ export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
1628
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1629
+ assertSubstrateName(blockName);
1630
+ appendToBlockForDir(resolveContextDir(cwd), blockName, arrayKey, item, ctx);
1631
+ }
927
1632
  /**
928
1633
  * Find an item in data[arrayKey] by predicate, shallow-merge updates onto it,
929
1634
  * validate whole file against schema, write atomically. Throws if no item
@@ -934,11 +1639,16 @@ export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
934
1639
  * update-mode after the shallow merge — `created_by` / `created_at` are
935
1640
  * preserved, `modified_by` / `modified_at` refresh.
936
1641
  */
937
- export function updateItemInBlock(cwd, blockName, arrayKey, predicate, updates, ctx) {
938
- const filePath = blockFilePath(cwd, blockName);
939
- const schemaPath = existingBlockSchemaPath(cwd, blockName);
1642
+ export function updateItemInBlockForDir(substrateDir, blockName, arrayKey, predicate, updates, ctx) {
1643
+ const filePath = blockFilePathForDir(substrateDir, blockName);
1644
+ const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
940
1645
  updateItemInTypedFile(filePath, schemaPath, arrayKey, predicate, updates, ctx, `block file '${blockName}.json'`);
941
1646
  }
1647
+ export function updateItemInBlock(cwd, blockName, arrayKey, predicate, updates, ctx) {
1648
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1649
+ assertSubstrateName(blockName);
1650
+ updateItemInBlockForDir(resolveContextDir(cwd), blockName, arrayKey, predicate, updates, ctx);
1651
+ }
942
1652
  /**
943
1653
  * Atomically find-or-append a single item in `data[arrayKey]` keyed by
944
1654
  * `idField`. Acquires the block lock; reads the array; locates the first
@@ -965,11 +1675,16 @@ export function updateItemInBlock(cwd, blockName, arrayKey, predicate, updates,
965
1675
  * to merge against. Callers that need merge-on-update should continue to
966
1676
  * use `updateItemInBlock`.
967
1677
  */
968
- export function upsertItemInBlock(cwd, blockName, arrayKey, item, idField, ctx) {
969
- const filePath = blockFilePath(cwd, blockName);
970
- const schemaPath = existingBlockSchemaPath(cwd, blockName);
1678
+ export function upsertItemInBlockForDir(substrateDir, blockName, arrayKey, item, idField, ctx) {
1679
+ const filePath = blockFilePathForDir(substrateDir, blockName);
1680
+ const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
971
1681
  return upsertItemInTypedFile(filePath, schemaPath, arrayKey, item, idField, ctx, `block file '${blockName}.json'`);
972
1682
  }
1683
+ export function upsertItemInBlock(cwd, blockName, arrayKey, item, idField, ctx) {
1684
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1685
+ assertSubstrateName(blockName);
1686
+ return upsertItemInBlockForDir(resolveContextDir(cwd), blockName, arrayKey, item, idField, ctx);
1687
+ }
973
1688
  /**
974
1689
  * Atomically append an item to a nested array inside a parent-array item.
975
1690
  *
@@ -982,11 +1697,16 @@ export function upsertItemInBlock(cwd, blockName, arrayKey, item, idField, ctx)
982
1697
  * file lock, predicate findIndex, multi-match warning, clone-before-write so
983
1698
  * the original array remains unmodified if writeBlock throws.
984
1699
  */
985
- export function appendToNestedArray(cwd, blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx) {
986
- const filePath = blockFilePath(cwd, blockName);
987
- const schemaPath = existingBlockSchemaPath(cwd, blockName);
1700
+ export function appendToNestedArrayForDir(substrateDir, blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx) {
1701
+ const filePath = blockFilePathForDir(substrateDir, blockName);
1702
+ const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
988
1703
  appendToNestedTypedFile(filePath, schemaPath, parentArrayKey, predicate, nestedArrayKey, item, ctx, `block file '${blockName}.json'`);
989
1704
  }
1705
+ export function appendToNestedArray(cwd, blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx) {
1706
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1707
+ assertSubstrateName(blockName);
1708
+ appendToNestedArrayForDir(resolveContextDir(cwd), blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx);
1709
+ }
990
1710
  /**
991
1711
  * Atomically update a single item inside a nested array on a parent-array
992
1712
  * item: locate parent by parentPredicate, locate nested by nestedPredicate,
@@ -999,11 +1719,16 @@ export function appendToNestedArray(cwd, blockName, parentArrayKey, predicate, n
999
1719
  * updateItemInBlock convention). Clone-then-write keeps the original arrays
1000
1720
  * unmodified if writeBlock throws.
1001
1721
  */
1002
- export function updateNestedArrayItem(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx) {
1003
- const filePath = blockFilePath(cwd, blockName);
1004
- const schemaPath = existingBlockSchemaPath(cwd, blockName);
1722
+ export function updateNestedArrayItemForDir(substrateDir, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx) {
1723
+ const filePath = blockFilePathForDir(substrateDir, blockName);
1724
+ const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
1005
1725
  updateNestedItemInTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx, `block file '${blockName}.json'`);
1006
1726
  }
1727
+ export function updateNestedArrayItem(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx) {
1728
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1729
+ assertSubstrateName(blockName);
1730
+ updateNestedArrayItemForDir(resolveContextDir(cwd), blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx);
1731
+ }
1007
1732
  /**
1008
1733
  * Atomically remove all items matching `predicate` from a top-level array
1009
1734
  * inside `data[arrayKey]`, validate the whole file against schema, write
@@ -1013,11 +1738,16 @@ export function updateNestedArrayItem(cwd, blockName, parentArrayKey, parentPred
1013
1738
  * without throwing — removal of a non-existent item is treated as an
1014
1739
  * idempotent successful no-op, distinct from update which throws on miss.
1015
1740
  */
1016
- export function removeFromBlock(cwd, blockName, arrayKey, predicate, ctx) {
1017
- const filePath = blockFilePath(cwd, blockName);
1018
- const schemaPath = existingBlockSchemaPath(cwd, blockName);
1741
+ export function removeFromBlockForDir(substrateDir, blockName, arrayKey, predicate, ctx) {
1742
+ const filePath = blockFilePathForDir(substrateDir, blockName);
1743
+ const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
1019
1744
  return removeFromTypedFile(filePath, schemaPath, arrayKey, predicate, ctx, `block file '${blockName}.json'`);
1020
1745
  }
1746
+ export function removeFromBlock(cwd, blockName, arrayKey, predicate, ctx) {
1747
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1748
+ assertSubstrateName(blockName);
1749
+ return removeFromBlockForDir(resolveContextDir(cwd), blockName, arrayKey, predicate, ctx);
1750
+ }
1021
1751
  /**
1022
1752
  * Atomically remove all items matching `nestedPredicate` from a nested array
1023
1753
  * inside the parent-array item matched by `parentPredicate`. Validates and
@@ -1028,11 +1758,16 @@ export function removeFromBlock(cwd, blockName, arrayKey, predicate, ctx) {
1028
1758
  * mirrors removeFromBlock semantics). Multi-match warning at parent level
1029
1759
  * via console.error.
1030
1760
  */
1031
- export function removeFromNestedArray(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx) {
1032
- const filePath = blockFilePath(cwd, blockName);
1033
- const schemaPath = existingBlockSchemaPath(cwd, blockName);
1761
+ export function removeFromNestedArrayForDir(substrateDir, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx) {
1762
+ const filePath = blockFilePathForDir(substrateDir, blockName);
1763
+ const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
1034
1764
  return removeFromNestedTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx, `block file '${blockName}.json'`);
1035
1765
  }
1766
+ export function removeFromNestedArray(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx) {
1767
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1768
+ assertSubstrateName(blockName);
1769
+ return removeFromNestedArrayForDir(resolveContextDir(cwd), blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx);
1770
+ }
1036
1771
  /**
1037
1772
  * Read all `.json` files in a `.project/<subdir>/` directory and return the
1038
1773
  * parsed contents as a sorted array. Sort order is filesystem-name ascending
@@ -1044,11 +1779,8 @@ export function removeFromNestedArray(cwd, blockName, parentArrayKey, parentPred
1044
1779
  * byte-identically; both pi-workflows and the `read-block-dir` registered
1045
1780
  * tool consume this single export.
1046
1781
  */
1047
- export function readBlockDir(cwd, subdir) {
1048
- const root = tryResolveContextDir(cwd);
1049
- if (root === null)
1050
- return []; // no .pi-context.json pointer → no items (consistent with missing-dir → [])
1051
- const dirPath = path.join(root, subdir);
1782
+ export function readBlockDirForDir(substrateDir, subdir) {
1783
+ const dirPath = path.join(substrateDir, subdir);
1052
1784
  let entries;
1053
1785
  try {
1054
1786
  entries = fs
@@ -1068,17 +1800,23 @@ export function readBlockDir(cwd, subdir) {
1068
1800
  content = fs.readFileSync(filePath, "utf-8");
1069
1801
  }
1070
1802
  catch {
1071
- throw new Error(`Cannot read file: .project/${subdir}/${filename}`);
1803
+ throw new Error(`Cannot read file: ${filePath}`);
1072
1804
  }
1073
1805
  try {
1074
1806
  results.push(JSON.parse(content));
1075
1807
  }
1076
1808
  catch {
1077
- throw new Error(`Invalid JSON in: .project/${subdir}/${filename}`);
1809
+ throw new Error(`Invalid JSON in: ${filePath}`);
1078
1810
  }
1079
1811
  }
1080
1812
  return results;
1081
1813
  }
1814
+ export function readBlockDir(cwd, subdir) {
1815
+ const root = tryResolveContextDir(cwd);
1816
+ if (root === null)
1817
+ return []; // no .pi-context.json pointer → no items (consistent with missing-dir → [])
1818
+ return readBlockDirForDir(root, subdir);
1819
+ }
1082
1820
  // ── Item-schema resolution + id allocation (FGAP-083 / FGAP-084) ──────────────
1083
1821
  /**
1084
1822
  * Resolve the item subschema for a block schema: find the first array property
@@ -1131,8 +1869,8 @@ export function resolveBlockItemSchema(schema) {
1131
1869
  * prefix+width parseable. Orchestrator CLIs + the in-pi append tool route
1132
1870
  * through this instead of re-implementing allocation.
1133
1871
  */
1134
- export function nextId(cwd, blockName) {
1135
- const schemaFile = blockSchemaPath(cwd, blockName);
1872
+ export function nextIdForDir(substrateDir, blockName) {
1873
+ const schemaFile = blockSchemaPathForDir(substrateDir, blockName);
1136
1874
  if (!fs.existsSync(schemaFile)) {
1137
1875
  throw new Error(`nextId: schema not found for block '${blockName}' at ${schemaFile}`);
1138
1876
  }
@@ -1149,7 +1887,7 @@ export function nextId(cwd, blockName) {
1149
1887
  }
1150
1888
  const prefix = m[1];
1151
1889
  const width = Number.parseInt(m[2], 10);
1152
- const data = readBlock(cwd, blockName);
1890
+ const data = readBlockForDir(substrateDir, blockName);
1153
1891
  const items = (data[arrayKey] ?? []);
1154
1892
  const re = new RegExp(`^${prefix}(\\d+)$`);
1155
1893
  let maxN = 0;
@@ -1164,4 +1902,9 @@ export function nextId(cwd, blockName) {
1164
1902
  }
1165
1903
  return `${prefix}${String(maxN + 1).padStart(width, "0")}`;
1166
1904
  }
1905
+ export function nextId(cwd, blockName) {
1906
+ // Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
1907
+ assertSubstrateName(blockName);
1908
+ return nextIdForDir(resolveContextDir(cwd), blockName);
1909
+ }
1167
1910
  //# sourceMappingURL=block-api.js.map