@gregiteen/ssss-cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bundle.mjs CHANGED
@@ -16,8 +16,15 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import crypto from 'node:crypto';
19
- import { loadRegistries, resolvePortability } from './registry.mjs';
20
- import { parseDocument } from './frontmatter.mjs';
19
+ import {
20
+ loadRegistries,
21
+ resolvePortability,
22
+ validateDataConstraints,
23
+ validateReferenceConstraints,
24
+ } from './registry.mjs';
25
+ import { parseDocument, serializeDocument } from './frontmatter.mjs';
26
+
27
+ export const DEFAULT_EXPORTER = '@gregiteen/ssss-cli';
21
28
 
22
29
  const PROFILE_INCLUDES = {
23
30
  backup: new Set(['structural', 'tenant_private', 'resource_bound']),
@@ -27,6 +34,13 @@ const PROFILE_INCLUDES = {
27
34
  const STEP_SYSTEMS = new Set(['workspace', 'branding', 'email', 'phone', 'domains', 'accounts', 'deployments', 'marketplace', 'tabs', 'automation']);
28
35
  const STEP_MODES = new Set(['existing', 'provision', 'install', 'generate', 'configure']);
29
36
  const PARAM_SOURCES = new Set(['user', 'llm', 'graph', 'system']);
37
+ const RESOURCE_SYSTEM_BY_KIND = {
38
+ api_connection: 'automation',
39
+ domain: 'domains',
40
+ email: 'email',
41
+ mailbox: 'email',
42
+ phone: 'phone',
43
+ };
30
44
 
31
45
  function isSafeBundlePath(p) {
32
46
  if (typeof p !== 'string' || !p || p.includes('\0')) return false;
@@ -41,6 +55,62 @@ function canonicalFiles(files) {
41
55
  return sorted.map((f) => ({ path: f.path, content: f.content }));
42
56
  }
43
57
 
58
+ function requirementParameterKey(type, field) {
59
+ return `${type}_${field}`.replace(/[^A-Za-z0-9_]+/g, '_');
60
+ }
61
+
62
+ function resourceFieldParameterAliases(type, field) {
63
+ const aliases = [requirementParameterKey(type, field), field];
64
+ if (type === 'domain' && field === 'domain_name') aliases.push('domain');
65
+ if (type === 'phone_number' && field === 'phone_number') aliases.push('phone');
66
+ return aliases;
67
+ }
68
+
69
+ function ensureRequirementDeclarations(manifest, typeDef, resourceFields) {
70
+ const type = typeDef.type;
71
+ const resource = typeDef.resource || {};
72
+ const existingParams = new Set((manifest.parameters || []).map((p) => p.key));
73
+ for (const field of resourceFields) {
74
+ if (resourceFieldParameterAliases(type, field).some((key) => existingParams.has(key))) continue;
75
+ const key = requirementParameterKey(type, field);
76
+ if (!existingParams.has(key)) {
77
+ manifest.parameters.push({
78
+ key,
79
+ label: `${resource.kind || type} ${field}`.replace(/_/g, ' '),
80
+ type: 'string',
81
+ scope: 'workspace',
82
+ source: 'user',
83
+ required: false,
84
+ description: `Satisfies the ${type} resource requirement field '${field}'.`,
85
+ });
86
+ existingParams.add(key);
87
+ }
88
+ }
89
+
90
+ const stepId = `bind-${type}`.replace(/[^A-Za-z0-9_-]+/g, '-');
91
+ if (!(manifest.provisioning || []).some((s) => s.id === stepId)) {
92
+ manifest.provisioning.push({
93
+ id: stepId,
94
+ label: `Bind ${resource.kind || type}`,
95
+ system: RESOURCE_SYSTEM_BY_KIND[resource.kind] || 'accounts',
96
+ mode: 'provision',
97
+ required: true,
98
+ notes: resource.provision_relation || 'resource_bound requirement',
99
+ });
100
+ }
101
+ }
102
+
103
+ function reduceResourceBoundFile(file, typeDef, profile) {
104
+ const binds = typeDef?.resource?.binds;
105
+ if (!Array.isArray(binds) || binds.length === 0) {
106
+ throw new Error(`Cannot export resource_bound '${file.path}' in ${profile}: registry type '${typeDef?.type || '(unknown)'}' does not declare resource.binds.`);
107
+ }
108
+ const { data, body } = parseDocument(file.content);
109
+ const reduced = { ...data, x_portability: 'resource_bound' };
110
+ for (const field of binds) reduced[field] = 'REQUIREMENT';
111
+ return { path: file.path, content: serializeDocument(reduced, body), resourceFields: binds };
112
+ }
113
+
44
114
  /** sha256 over the canonical file serialization (§16.3). */
45
115
  export function contentHash(files) {
46
116
  const pre = JSON.stringify(canonicalFiles(files));
@@ -70,23 +140,59 @@ function walkVault(vaultRoot) {
70
140
  */
71
141
  export function exportBundle(vaultRoot, opts = {}) {
72
142
  const { profile = 'backup', registryDir, name = 'untitled', description = '', version = '0.1.0',
73
- exporter = '@ssss/cli', requiredExtensions = [], parameters = [], provisioning = [],
143
+ exporter = DEFAULT_EXPORTER, requiredExtensions = [], parameters = [], provisioning = [],
74
144
  sourceWorkspaceId, exportedAt = '1970-01-01T00:00:00.000Z' } = opts;
75
145
  const includes = PROFILE_INCLUDES[profile];
76
146
  if (!includes) throw new Error(`Unknown export profile '${profile}'. Use backup | template | sale.`);
77
147
 
78
- const { types } = loadRegistries(registryDir);
148
+ const { types, core } = loadRegistries(registryDir);
79
149
  const dropped = [];
80
150
  const kept = [];
81
151
  const inventory = {};
152
+ const manifestParameters = [...parameters];
153
+ const manifestProvisioning = [...provisioning];
154
+ const manifestDraft = { parameters: manifestParameters, provisioning: manifestProvisioning };
155
+ const vaultFiles = walkVault(vaultRoot);
156
+ const vaultDocuments = new Map(vaultFiles.map((file) => {
157
+ const parsed = parseDocument(file.content);
158
+ return [file.path, { ...file, ...parsed }];
159
+ }));
160
+
161
+ for (const document of vaultDocuments.values()) {
162
+ const base = path.basename(document.path);
163
+ if (base === 'index.md' || base === 'log.md') continue;
164
+ const definition = types.get(document.data.type);
165
+ if (!definition) throw new Error(`Cannot export '${document.path}': unknown type '${document.data.type || '(missing)'}'.`);
166
+ const issues = [
167
+ ...validateDataConstraints(definition, document.data, core.universal_frontmatter?.required || ['type']),
168
+ ...validateReferenceConstraints(
169
+ definition,
170
+ document.data,
171
+ (referencePath) => vaultDocuments.get(referencePath) || null,
172
+ types
173
+ ),
174
+ ];
175
+ if (issues.length) {
176
+ throw new Error(
177
+ `Cannot export '${document.path}': ` +
178
+ issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')
179
+ );
180
+ }
181
+ }
82
182
 
83
- for (const file of walkVault(vaultRoot)) {
183
+ for (const file of vaultFiles) {
84
184
  const { data } = parseDocument(file.content);
85
185
  const type = data.type;
86
186
  const typeDef = types.get(type);
87
187
  const klass = resolvePortability(typeDef, data);
88
188
  if (!includes.has(klass)) { dropped.push({ path: file.path, type, portability: klass }); continue; }
89
- kept.push(file);
189
+ if ((profile === 'sale' || profile === 'template') && klass === 'resource_bound') {
190
+ const reduced = reduceResourceBoundFile(file, typeDef, profile);
191
+ kept.push({ path: reduced.path, content: reduced.content });
192
+ ensureRequirementDeclarations(manifestDraft, typeDef, reduced.resourceFields);
193
+ } else {
194
+ kept.push(file);
195
+ }
90
196
  if (type) inventory[type] = (inventory[type] || 0) + 1;
91
197
  }
92
198
 
@@ -94,12 +200,12 @@ export function exportBundle(vaultRoot, opts = {}) {
94
200
  const manifest = {
95
201
  name, description, version,
96
202
  exported_at: exportedAt,
97
- ssss_core_version: loadRegistries(registryDir).core.spec_version,
203
+ ssss_core_version: core.spec_version,
98
204
  required_extensions: requiredExtensions,
99
205
  export_profile: profile,
100
206
  primitive_inventory: inventory,
101
- provisioning,
102
- parameters,
207
+ provisioning: manifestProvisioning,
208
+ parameters: manifestParameters,
103
209
  file_count: files.length,
104
210
  provenance: { content_hash: contentHash(files), exporter },
105
211
  };
@@ -114,75 +220,145 @@ export function exportBundle(vaultRoot, opts = {}) {
114
220
  */
115
221
  export function validateBundle(bundle, opts = {}) {
116
222
  const errors = [];
117
- const { types, core } = loadRegistries(opts.registryDir);
223
+ const { types, core, extensions } = loadRegistries(opts.registryDir);
118
224
  const m = bundle && bundle.manifest;
119
- if (!m) return { valid: false, errors: ['bundle has no manifest'] };
225
+ if (!m || typeof m !== 'object' || Array.isArray(m)) {
226
+ return { valid: false, errors: ['bundle has no object manifest'] };
227
+ }
228
+ const files = Array.isArray(bundle.files) ? bundle.files : [];
229
+ const requiredExtensions = Array.isArray(m.required_extensions) ? m.required_extensions : [];
230
+ const provisioning = Array.isArray(m.provisioning) ? m.provisioning : [];
231
+ const parameters = Array.isArray(m.parameters) ? m.parameters : [];
120
232
 
121
233
  for (const f of core.bundle.manifest_required_fields) {
122
234
  if (m[f] === undefined || m[f] === null) errors.push(`manifest missing required field '${f}'`);
123
235
  }
236
+ if (!Array.isArray(bundle.files)) errors.push('bundle.files must be an array');
237
+ if (!Array.isArray(m.required_extensions)) errors.push('manifest.required_extensions must be an array');
238
+ if (!Array.isArray(m.provisioning)) errors.push('manifest.provisioning must be an array');
239
+ if (!Array.isArray(m.parameters)) errors.push('manifest.parameters must be an array');
240
+ if (!m.primitive_inventory || typeof m.primitive_inventory !== 'object' || Array.isArray(m.primitive_inventory)) {
241
+ errors.push('manifest.primitive_inventory must be an object');
242
+ }
243
+ for (const ext of requiredExtensions) {
244
+ if (typeof ext !== 'string' || !ext) {
245
+ errors.push('required extension ids must be non-empty strings');
246
+ continue;
247
+ }
248
+ if (!extensions.has(ext)) errors.push(`required extension '${ext}' is not loaded`);
249
+ }
124
250
  if (!PROFILE_INCLUDES[m.export_profile]) errors.push(`invalid export_profile '${m.export_profile}'`);
125
251
  if (m.ssss_core_version && m.ssss_core_version !== core.spec_version) {
126
252
  errors.push(`bundle targets ssss_core_version ${m.ssss_core_version}; host is ${core.spec_version}`);
127
253
  }
128
- if (Array.isArray(bundle.files) && m.file_count !== bundle.files.length) {
129
- errors.push(`file_count ${m.file_count} ≠ actual ${bundle.files.length}`);
254
+ if (m.file_count !== files.length) {
255
+ errors.push(`file_count ${m.file_count} ≠ actual ${files.length}`);
130
256
  }
131
257
  const seenPaths = new Set();
132
- for (const file of bundle.files || []) {
258
+ const bundleDocuments = new Map();
259
+ let fileShapeValid = true;
260
+ for (const file of files) {
261
+ if (!file || typeof file !== 'object' || Array.isArray(file)) {
262
+ errors.push('bundle file entries must be objects');
263
+ fileShapeValid = false;
264
+ continue;
265
+ }
133
266
  if (!isSafeBundlePath(file.path)) {
134
267
  errors.push(`file '${file.path || '(missing)'}' has an unsafe bundle path`);
268
+ fileShapeValid = false;
269
+ continue;
270
+ }
271
+ if (typeof file.content !== 'string') {
272
+ errors.push(`file '${file.path}' content must be a string`);
273
+ fileShapeValid = false;
135
274
  continue;
136
275
  }
137
276
  if (seenPaths.has(file.path)) errors.push(`duplicate file path '${file.path}'`);
138
277
  seenPaths.add(file.path);
278
+ try { bundleDocuments.set(file.path, { ...file, ...parseDocument(file.content) }); }
279
+ catch (error) {
280
+ errors.push(`file '${file.path}' could not be parsed: ${error.message}`);
281
+ fileShapeValid = false;
282
+ }
139
283
  }
140
284
  // Provenance: recompute the content hash and reject on mismatch (§16.3).
141
285
  if (m.provenance) {
142
- for (const f of core.bundle.provenance_required_fields) {
143
- if (m.provenance[f] === undefined) errors.push(`provenance missing '${f}'`);
144
- }
145
- const recomputed = contentHash(bundle.files || []);
146
- if (m.provenance.content_hash && m.provenance.content_hash !== recomputed) {
147
- errors.push(`content_hash mismatch: manifest ${m.provenance.content_hash} ≠ recomputed ${recomputed}`);
286
+ if (typeof m.provenance !== 'object' || Array.isArray(m.provenance)) {
287
+ errors.push('manifest.provenance must be an object');
288
+ } else {
289
+ for (const f of core.bundle.provenance_required_fields) {
290
+ if (m.provenance[f] === undefined) errors.push(`provenance missing '${f}'`);
291
+ }
292
+ if (fileShapeValid) {
293
+ const recomputed = contentHash(files);
294
+ if (m.provenance.content_hash && m.provenance.content_hash !== recomputed) {
295
+ errors.push(`content_hash mismatch: manifest ${m.provenance.content_hash} ≠ recomputed ${recomputed}`);
296
+ }
297
+ }
148
298
  }
149
299
  }
150
300
  // Portability conformance: no file may exceed what the profile permits (§5.5).
151
301
  const includes = PROFILE_INCLUDES[m.export_profile];
152
302
  if (includes) {
153
- for (const file of bundle.files || []) {
154
- if (!isSafeBundlePath(file.path)) continue;
155
- const base = path.basename(file.path || '');
303
+ for (const document of bundleDocuments.values()) {
304
+ const base = path.basename(document.path);
156
305
  if (base === 'index.md' || base === 'log.md') continue;
157
- const { data } = parseDocument(file.content);
306
+ const { data } = document;
158
307
  const def = types.get(data.type);
159
308
  if (!def) {
160
- errors.push(`file '${file.path}' declares unknown type '${data.type || '(missing)'}'`);
309
+ errors.push(`file '${document.path}' declares unknown type '${data.type || '(missing)'}'`);
161
310
  continue;
162
311
  }
163
- const requiredFields = [
164
- ...new Set([
165
- ...(core.universal_frontmatter?.required || ['type']),
166
- ...(def.required_fields || []),
167
- ]),
168
- ];
169
- for (const f of requiredFields) {
170
- if (data[f] === undefined || data[f] === null || data[f] === '' || (Array.isArray(data[f]) && data[f].length === 0)) {
171
- errors.push(`file '${file.path}' missing required frontmatter field '${f}'`);
172
- }
312
+ for (const issue of validateDataConstraints(
313
+ def,
314
+ data,
315
+ core.universal_frontmatter?.required || ['type']
316
+ )) {
317
+ errors.push(`file '${document.path}' ${issue.field}: ${issue.issue}`);
173
318
  }
174
319
  const klass = resolvePortability(types.get(data.type), data);
175
320
  if (!includes.has(klass)) {
176
- errors.push(`file '${file.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
321
+ errors.push(`file '${document.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
322
+ }
323
+ if ((m.export_profile === 'sale' || m.export_profile === 'template') && klass === 'resource_bound') {
324
+ const binds = def.resource?.binds || [];
325
+ if (!binds.length) {
326
+ errors.push(`file '${document.path}' is resource_bound but registry type '${data.type}' declares no resource.binds`);
327
+ }
328
+ for (const field of binds) {
329
+ if (data[field] !== 'REQUIREMENT') {
330
+ errors.push(`file '${document.path}' leaks resource_bound field '${field}' in ${m.export_profile} bundle`);
331
+ }
332
+ }
177
333
  }
178
334
  }
179
335
  }
336
+ for (const document of bundleDocuments.values()) {
337
+ const definition = types.get(document.data.type);
338
+ const issues = validateReferenceConstraints(
339
+ definition,
340
+ document.data,
341
+ (referencePath) => bundleDocuments.get(referencePath) || null,
342
+ types
343
+ );
344
+ for (const issue of issues) {
345
+ errors.push(`file '${document.path}' ${issue.field}: ${issue.issue}`);
346
+ }
347
+ }
180
348
  // Provisioning step + parameter vocabularies (§17.2 / §16.5).
181
- for (const s of m.provisioning || []) {
349
+ for (const s of provisioning) {
350
+ if (!s || typeof s !== 'object' || Array.isArray(s)) {
351
+ errors.push('provisioning entries must be objects');
352
+ continue;
353
+ }
182
354
  if (!STEP_SYSTEMS.has(s.system)) errors.push(`provisioning step '${s.id}' has invalid system '${s.system}'`);
183
355
  if (!STEP_MODES.has(s.mode)) errors.push(`provisioning step '${s.id}' has invalid mode '${s.mode}'`);
184
356
  }
185
- for (const p of m.parameters || []) {
357
+ for (const p of parameters) {
358
+ if (!p || typeof p !== 'object' || Array.isArray(p)) {
359
+ errors.push('parameter entries must be objects');
360
+ continue;
361
+ }
186
362
  for (const f of core.bundle.parameter_fields.required) {
187
363
  if (p[f] === undefined) errors.push(`parameter '${p.key || '?'}' missing '${f}'`);
188
364
  }
@@ -254,8 +430,57 @@ export function provisionBundle(bundle, opts = {}) {
254
430
  * (§6.3). Idempotent: each envelope's idempotency_key makes a re-run a no-op.
255
431
  */
256
432
  export function importBundle(plan, vaultRoot, engine) {
433
+ const dryRunFlags = new Set(plan.map((envelope) => !!envelope.dry_run));
434
+ if (dryRunFlags.size > 1) {
435
+ return {
436
+ ok: false,
437
+ committed: 0,
438
+ wouldCommit: 0,
439
+ results: [{
440
+ success: false,
441
+ validation: { valid: false, errors: ['Import plan may not mix dry_run and commit envelopes.'] },
442
+ }],
443
+ };
444
+ }
445
+ const plannedDocuments = new Map();
446
+ for (const envelope of plan) {
447
+ if (envelope.type !== 'operation' || typeof envelope.content !== 'string') continue;
448
+ plannedDocuments.set(envelope.path, {
449
+ path: envelope.path,
450
+ content: envelope.content,
451
+ ...parseDocument(envelope.content),
452
+ });
453
+ }
454
+ const operationOptions = {
455
+ referenceResolver: (referencePath) => plannedDocuments.get(referencePath),
456
+ };
457
+ // Two-phase import: validate the complete plan with in-plan references first.
458
+ // A single invalid document prevents every commit, eliminating partial bundle
459
+ // installation and allowing dry-run to validate cross-file constraints.
460
+ const preflight = plan.map((envelope) => {
461
+ const preflightKey = 'preflight-' + crypto.createHash('sha256')
462
+ .update(`${envelope.workspace_id}:${envelope.idempotency_key}:${envelope.path}`)
463
+ .digest('hex')
464
+ .slice(0, 32);
465
+ return engine.processOperation(
466
+ { ...envelope, idempotency_key: preflightKey, dry_run: true },
467
+ vaultRoot,
468
+ operationOptions
469
+ );
470
+ });
471
+ if (!preflight.every((result) => result.success)) {
472
+ return { ok: false, committed: 0, wouldCommit: 0, results: preflight };
473
+ }
474
+ if (dryRunFlags.has(true)) {
475
+ return {
476
+ ok: true,
477
+ committed: 0,
478
+ wouldCommit: preflight.filter((result) => !result.replay).length,
479
+ results: preflight,
480
+ };
481
+ }
257
482
  const results = [];
258
- for (const env of plan) results.push(engine.processOperation(env, vaultRoot));
259
- const committed = results.filter((r) => r.success && !r.replay).length;
260
- return { ok: results.every((r) => r.success), committed, results };
483
+ for (const env of plan) results.push(engine.processOperation(env, vaultRoot, operationOptions));
484
+ const committed = results.filter((r) => r.success && !r.replay && !r.dry_run).length;
485
+ return { ok: results.every((r) => r.success), committed, wouldCommit: 0, results };
261
486
  }
package/src/engine.mjs CHANGED
@@ -15,7 +15,11 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import crypto from 'node:crypto';
17
17
  import { parseDocument, serializeDocument } from './frontmatter.mjs';
18
- import { loadRegistries, isAppendType } from './registry.mjs';
18
+ import {
19
+ loadRegistries,
20
+ isAppendType,
21
+ validateReferenceConstraints,
22
+ } from './registry.mjs';
19
23
 
20
24
  const ENVELOPE_TYPES = ['operation', 'patch', 'event', 'delete'];
21
25
  const ENVELOPE_REQUIRED = {
@@ -150,7 +154,7 @@ export function createEngine({ registryDir, leaseStore } = {}) {
150
154
  return { field, value };
151
155
  }
152
156
 
153
- function validateContent(resolvedType, data, filePath) {
157
+ function validateContent(resolvedType, data, filePath, vaultRoot, referenceResolver) {
154
158
  const def = types.get(resolvedType);
155
159
  const errors = [];
156
160
  const repair = [];
@@ -193,6 +197,43 @@ export function createEngine({ registryDir, leaseStore } = {}) {
193
197
  }
194
198
  }
195
199
  }
200
+ // patterns: portable string constraints declared by the registry. These
201
+ // are anchored by each pattern author and compiled during registry load.
202
+ for (const [field, pattern] of Object.entries(def.patterns || {})) {
203
+ if (isEmpty(data[field])) continue;
204
+ const values = Array.isArray(data[field]) ? data[field] : [data[field]];
205
+ const expression = new RegExp(pattern, 'u');
206
+ for (const value of values) {
207
+ if (typeof value !== 'string' || !expression.test(value)) {
208
+ errors.push(`Invalid format for field '${field}' on type '${resolvedType}'.`);
209
+ repair.push({ field, issue: `Must match registry pattern '${pattern}'.` });
210
+ }
211
+ }
212
+ }
213
+ if (vaultRoot && def.references) {
214
+ const referenceIssues = validateReferenceConstraints(
215
+ def,
216
+ data,
217
+ (referencePath) => {
218
+ if (referenceResolver) {
219
+ const planned = referenceResolver(referencePath);
220
+ if (planned !== undefined && planned !== null) return planned;
221
+ }
222
+ const target = resolveContainedPath(vaultRoot, referencePath);
223
+ if (!fs.existsSync(target)) return null;
224
+ const stat = fs.lstatSync(target);
225
+ if (stat.isSymbolicLink()) throw new Error('symlinked references are not allowed');
226
+ if (!stat.isFile()) throw new Error('reference target is not a file');
227
+ const content = fs.readFileSync(target, 'utf8');
228
+ return { path: referencePath, content, data: parseDocument(content).data };
229
+ },
230
+ types
231
+ );
232
+ for (const issue of referenceIssues) {
233
+ errors.push(`${issue.field}: ${issue.issue}`);
234
+ repair.push(issue);
235
+ }
236
+ }
196
237
  return { errors, repair };
197
238
  }
198
239
 
@@ -297,7 +338,13 @@ export function createEngine({ registryDir, leaseStore } = {}) {
297
338
  const { data } = parseDocument(envelope.content);
298
339
  resolvedType = data.type || null;
299
340
  if (!resolvedType) { errors.push(`Missing required frontmatter field 'type' in ${envelope.path}.`); repair.push({ field: 'type', issue: 'Frontmatter must declare a type.' }); }
300
- else ({ errors, repair } = validateContent(resolvedType, data, envelope.path));
341
+ else ({ errors, repair } = validateContent(
342
+ resolvedType,
343
+ data,
344
+ envelope.path,
345
+ vaultRoot,
346
+ options.referenceResolver
347
+ ));
301
348
  }
302
349
  if (!errors.length && fs.existsSync(abs) && !envelope.path.endsWith('/index.md')) {
303
350
  const { data: existingData } = parseDocument(fs.readFileSync(abs, 'utf8'));
@@ -305,6 +352,13 @@ export function createEngine({ registryDir, leaseStore } = {}) {
305
352
  errors.push(`Type rewrite refused for ${envelope.path}: existing type '${existingData.type}' cannot be replaced by '${resolvedType}'.`);
306
353
  repair.push({ field: 'type', issue: 'Use a migration controlled by a privileged host; operation writes may not change an existing file type.' });
307
354
  }
355
+ const { data: nextData } = parseDocument(envelope.content);
356
+ for (const field of types.get(resolvedType)?.immutable_fields || []) {
357
+ if (stableStringify(existingData[field]) !== stableStringify(nextData[field])) {
358
+ errors.push(`Operation may not change immutable field '${field}' for ${envelope.path}.`);
359
+ repair.push({ field, issue: 'Use an explicit migration rather than replacing an immutable identity field.' });
360
+ }
361
+ }
308
362
  }
309
363
  // append-type rewrite guard
310
364
  if (!errors.length && isAppendType(types.get(resolvedType)) && fs.existsSync(abs)) {
@@ -326,13 +380,33 @@ export function createEngine({ registryDir, leaseStore } = {}) {
326
380
  const merged = { ...data };
327
381
  for (const [k, v] of Object.entries(envelope.patches)) if (k !== '__body__') merged[k] = v;
328
382
  resolvedType = merged.type || null;
383
+ for (const field of types.get(resolvedType)?.immutable_fields || []) {
384
+ if (Object.prototype.hasOwnProperty.call(envelope.patches, field) &&
385
+ stableStringify(data[field]) !== stableStringify(merged[field])) {
386
+ errors.push(`Patch may not change immutable field '${field}' for ${envelope.path}.`);
387
+ repair.push({ field, issue: 'Use an explicit migration rather than patching an immutable identity field.' });
388
+ }
389
+ }
329
390
  const append = isAppendType(types.get(resolvedType));
330
391
  let newBody = body;
331
392
  if (envelope.patches.__body__ !== undefined) {
332
393
  if (append) newBody = body.replace(/\s+$/, '') + '\n' + envelope.patches.__body__;
333
394
  else newBody = envelope.patches.__body__;
334
395
  }
335
- ({ errors, repair } = resolvedType ? validateContent(resolvedType, merged, envelope.path) : { errors: ["Missing required field 'type'."], repair: [{ field: 'type', issue: 'No type after merge.' }] });
396
+ if (resolvedType) {
397
+ const contentValidation = validateContent(
398
+ resolvedType,
399
+ merged,
400
+ envelope.path,
401
+ vaultRoot,
402
+ options.referenceResolver
403
+ );
404
+ errors.push(...contentValidation.errors);
405
+ repair.push(...contentValidation.repair);
406
+ } else {
407
+ errors.push("Missing required field 'type'.");
408
+ repair.push({ field: 'type', issue: 'No type after merge.' });
409
+ }
336
410
  mergedForCommit = { data: merged, body: newBody };
337
411
  }
338
412
  } else if (envelope.type === 'event') {