@gregiteen/ssss-cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +11 -12
  2. package/VERSION +1 -1
  3. package/conformance/README.md +1 -1
  4. package/conformance/fixtures.json +4 -48
  5. package/conformance/reference-bundle.ucw.json +4 -21
  6. package/docs/help/bundle.md +2 -2
  7. package/docs/help/overview.md +4 -4
  8. package/docs/help/semantic.md +5 -4
  9. package/docs/ssss-spec.md +32 -77
  10. package/package.json +22 -3
  11. package/registry/core.json +21 -28
  12. package/scripts/build-reference-bundle.mjs +0 -20
  13. package/scripts/cmd-adapter.mjs +29 -0
  14. package/scripts/cmd-import.mjs +1 -1
  15. package/scripts/cmd-migrate.mjs +26 -0
  16. package/scripts/cmd-new.mjs +5 -5
  17. package/scripts/cmd-primitive.mjs +48 -0
  18. package/scripts/cmd-registry.mjs +25 -0
  19. package/scripts/cmd-semantic.mjs +6 -8
  20. package/scripts/conformance-09.mjs +199 -0
  21. package/scripts/conformance.mjs +106 -186
  22. package/scripts/ssss.mjs +8 -2
  23. package/skills/ssss/SKILL.md +11 -15
  24. package/skills/ssss/evals/evals.json +3 -3
  25. package/skills/ssss/references/contract-reference.md +7 -8
  26. package/skills/ssss/scripts/validate-schema.mjs +1 -44
  27. package/src/authorization.mjs +61 -0
  28. package/src/bundle.mjs +4 -4
  29. package/src/engine.mjs +101 -503
  30. package/src/events.mjs +90 -0
  31. package/src/guard.mjs +32 -0
  32. package/src/http.mjs +26 -0
  33. package/src/idempotency.mjs +55 -0
  34. package/src/kernel.mjs +260 -0
  35. package/src/leases.mjs +125 -0
  36. package/src/primitive.mjs +227 -0
  37. package/src/projections.mjs +62 -0
  38. package/src/registry.mjs +190 -39
  39. package/src/semantic.mjs +124 -316
  40. package/src/ui.mjs +100 -0
  41. package/src/validator.mjs +87 -0
  42. package/src/vfs.mjs +203 -0
  43. package/types/kernel.d.ts +48 -0
  44. package/docs/help/localization.md +0 -43
  45. package/scripts/cmd-localize.mjs +0 -44
package/src/engine.mjs CHANGED
@@ -1,526 +1,124 @@
1
1
  /**
2
- * The canonical SSSS Operation Contract engine (spec §6).
3
- *
4
- * Harvested and canonicalized from three reference implementations:
5
- * - total-recall `src/core/operation-validator.mjs` — the §6.3 pipeline shape,
6
- * idempotency replay from the audit log, and the `buildRepair` mechanism (§9).
7
- * - festech `SsssOperationService.ts` — the `delete` envelope and the
8
- * `resolveContainedPath` path-traversal guard.
9
- * - festech `SsssValidator.ts` — registry-driven content validation (no per-type code).
10
- *
11
- * Dependency-free by design: a host should not need Zod or a YAML library to be
12
- * SSSS-conformant. Content is validated against `registry/core.json` (+ extensions).
2
+ * Compatibility façade for the pre-0.9 `createEngine().processOperation()` API.
3
+ * All behavior delegates to the shared kernel; this file contains no second
4
+ * mutation implementation. New hosts should import `@gregiteen/ssss-cli/kernel`.
13
5
  */
14
6
  import fs from 'node:fs';
15
7
  import path from 'node:path';
16
8
  import crypto from 'node:crypto';
17
- import { parseDocument, serializeDocument } from './frontmatter.mjs';
18
- import {
19
- loadRegistries,
20
- isAppendType,
21
- validateReferenceConstraints,
22
- } from './registry.mjs';
9
+ import { createKernel } from './kernel.mjs';
10
+ import { FileSystemVfs } from './vfs.mjs';
11
+ import { JsonlEventStore } from './events.mjs';
12
+ import { createValidator } from './validator.mjs';
13
+ import { loadRegistries } from './registry.mjs';
14
+ import { parseDocument } from './frontmatter.mjs';
23
15
 
24
- const ENVELOPE_TYPES = ['operation', 'patch', 'event', 'delete'];
25
- const ENVELOPE_REQUIRED = {
26
- operation: ['type', 'idempotency_key', 'path', 'workspace_id', 'content'],
27
- patch: ['type', 'idempotency_key', 'path', 'workspace_id', 'patches'],
28
- event: ['type', 'idempotency_key', 'path', 'workspace_id', 'content'],
29
- delete: ['type', 'idempotency_key', 'path', 'workspace_id'],
30
- };
31
-
32
- const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
33
- const SAFE_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
34
-
35
- function cacheKey(wid, key) { return JSON.stringify([wid, key]); }
36
-
37
- function isSafeIdentifier(value) {
38
- return typeof value === 'string' && SAFE_IDENTIFIER_RE.test(value);
39
- }
40
-
41
- function stableStringify(value) {
42
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
43
- if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v)).join(',')}]`;
44
- return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
45
- }
46
-
47
- function requestHash(envelope) {
48
- const payload = {
49
- type: envelope.type,
50
- path: envelope.path,
51
- workspace_id: envelope.workspace_id,
52
- actor: envelope.actor || null,
53
- lease_id: envelope.lease_id || null,
54
- dry_run: !!envelope.dry_run,
55
- };
56
- if (Object.prototype.hasOwnProperty.call(envelope, 'content')) payload.content = envelope.content;
57
- if (Object.prototype.hasOwnProperty.call(envelope, 'patches')) payload.patches = envelope.patches;
58
- return 'sha256:' + crypto.createHash('sha256').update(stableStringify(payload)).digest('hex');
59
- }
60
-
61
- /** festech `resolveContainedPath`: resolve a VFS path, refusing escape from the vault. */
62
16
  export function resolveContainedPath(vaultRoot, vfsPath) {
63
- const root = path.resolve(vaultRoot);
64
- const raw = String(vfsPath);
65
- if (!raw || raw === '.' || raw.includes('\0')) throw new Error(`Invalid VFS path: ${vfsPath}`);
66
- if (raw.startsWith('/') || raw.startsWith('\\') || raw.includes('\\')) throw new Error(`Absolute or platform-native paths are not valid VFS paths: ${vfsPath}`);
67
- const parts = raw.split('/');
68
- if (parts.some((p) => p === '' || p === '.' || p === '..')) throw new Error(`Invalid VFS path segment in: ${vfsPath}`);
69
- const cleaned = parts.join('/');
70
- const resolved = path.resolve(root, cleaned);
71
- if (resolved !== root && !resolved.startsWith(root + path.sep)) {
72
- throw new Error(`Path traversal refused: ${vfsPath}`);
17
+ if (typeof vfsPath !== 'string' || !vfsPath || vfsPath.includes('\0') || vfsPath.includes('\\') || vfsPath.startsWith('/')) {
18
+ throw new Error(`Unsafe VFS path '${vfsPath}'.`);
73
19
  }
74
- if (resolved === root) throw new Error(`VFS path must identify a file, not the vault root: ${vfsPath}`);
75
- return resolved;
76
- }
77
-
78
- function atomicWrite(absPath, content) {
79
- const dir = path.dirname(absPath);
80
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
81
- const tmp = path.join(dir, `.${path.basename(absPath)}.${crypto.randomUUID()}.tmp`);
82
- fs.writeFileSync(tmp, content);
83
- fs.renameSync(tmp, absPath);
20
+ const parts = vfsPath.split('/');
21
+ if (parts.some((part) => !part || part === '.' || part === '..')) throw new Error(`Unsafe VFS path '${vfsPath}'.`);
22
+ const root = path.resolve(vaultRoot);
23
+ const target = path.resolve(root, ...parts);
24
+ if (target === root || !target.startsWith(`${root}${path.sep}`)) throw new Error(`VFS path '${vfsPath}' escapes the vault root.`);
25
+ return target;
84
26
  }
85
27
 
86
- function ok(type, opId, p, resolvedType, committedAt, warnings = []) {
28
+ function legacyPrincipal(envelope, vaultRoot, callOptions = {}) {
29
+ if (callOptions.principal) return callOptions.principal;
30
+ const role = envelope.actor?.role;
31
+ if (!role) return null;
32
+ let capabilities = [];
33
+ let kind = 'human';
34
+ if (role === 'system') { kind = 'system'; capabilities = ['*:*']; }
35
+ else if (role === 'admin') capabilities = ['*:*'];
36
+ else if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(role)) {
37
+ const roleFile = path.join(vaultRoot, 'roles', role, 'ROLE.md');
38
+ if (fs.existsSync(roleFile)) {
39
+ try { capabilities = parseDocument(fs.readFileSync(roleFile, 'utf8')).data.permissions || []; }
40
+ catch { capabilities = []; }
41
+ }
42
+ }
87
43
  return {
88
- success: true, type, operation_id: opId, path: p,
89
- committed_at: committedAt, dry_run: false,
90
- validation: { valid: true, type: resolvedType, errors: [], warnings },
91
- };
92
- }
93
-
94
- function fail(type, opId, p, resolvedType, errors, repairEntries) {
95
- const resp = {
96
- success: false, type, operation_id: opId, path: p,
97
- committed_at: null, dry_run: false,
98
- validation: { valid: false, type: resolvedType, errors, warnings: [] },
44
+ id: `legacy-role:${role}`,
45
+ kind,
46
+ workspaceIds: [envelope.workspace_id],
47
+ capabilities,
48
+ authentication: { provider: 'legacy-engine-adapter', assurance: 'adapter-verified' },
99
49
  };
100
- resp.repair = {
101
- field_errors: repairEntries || errors.map((e) => ({
102
- field: e.split(':')[0]?.trim() || '(root)', issue: e,
103
- })),
104
- };
105
- return resp;
106
50
  }
107
51
 
108
- /**
109
- * Create an engine bound to a registry directory. The returned object holds the
110
- * in-process idempotency cache, so re-applying an envelope within TTL is a no-op.
111
- */
112
- export function createEngine({ registryDir, leaseStore } = {}) {
113
- const { types, core } = loadRegistries(registryDir);
114
- const idempotencyCache = new Map();
115
-
116
- function pruneExpired() {
117
- const now = Date.now();
118
- for (const [k, v] of idempotencyCache) if (now - v.ts > IDEMPOTENCY_TTL_MS) idempotencyCache.delete(k);
52
+ class LegacyLeaseAdapter {
53
+ constructor(root) { this.root = root; }
54
+ #file(workspaceId, target) {
55
+ const key = crypto.createHash('sha256').update(String(target)).digest('hex');
56
+ return path.join(this.root, workspaceId, `${key}.lease.json`);
119
57
  }
120
-
121
- function warmFromAudit(vaultRoot, eventLogDir) {
122
- const auditFile = path.join(eventLogDir || path.join(vaultRoot, '.events'), 'audit.jsonl');
123
- if (!fs.existsSync(auditFile)) return;
124
- const now = Date.now();
125
- for (const line of fs.readFileSync(auditFile, 'utf8').split('\n')) {
126
- if (!line.trim()) continue;
127
- try {
128
- const r = JSON.parse(line);
129
- if (r.event_type !== 'audit' || !r.payload) continue;
130
- const ts = Date.parse(r.ts);
131
- if (isNaN(ts) || now - ts > IDEMPOTENCY_TTL_MS) continue;
132
- const ck = cacheKey(r.payload.workspace_id, r.payload.idempotency_key);
133
- if (!idempotencyCache.has(ck)) {
134
- idempotencyCache.set(ck, {
135
- ts,
136
- request_hash: r.payload.request_hash || null,
137
- response: ok(r.payload.envelope_type, r.correlation_id, r.subject, r.payload.resolved_type, r.ts),
138
- });
139
- }
140
- } catch { /* skip corrupt line */ }
141
- }
142
- }
143
-
144
- function isEmpty(v) {
145
- return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
146
- }
147
-
148
- /** Parse a registry `required_when` condition key, e.g. "schema_version==2". */
149
- function parseCondition(condition) {
150
- const m = String(condition).match(/^(\w+)==(.+)$/);
151
- if (!m) return null;
152
- const [, field, raw] = m;
153
- const value = /^-?\d+$/.test(raw) ? parseInt(raw, 10) : raw;
154
- return { field, value };
155
- }
156
-
157
- function validateContent(resolvedType, data, filePath, vaultRoot, referenceResolver) {
158
- const def = types.get(resolvedType);
159
- const errors = [];
160
- const repair = [];
161
- if (!def) {
162
- errors.push(`Unknown SSSS type '${resolvedType}' in ${filePath}.`);
163
- repair.push({ field: 'type', issue: `Unknown SSSS type '${resolvedType}'.` });
164
- return { errors, repair };
165
- }
166
- const requiredFields = [
167
- ...new Set([
168
- ...(core.universal_frontmatter?.required || ['type']),
169
- ...(def.required_fields || []),
170
- ]),
171
- ];
172
- for (const f of requiredFields) {
173
- if (isEmpty(data[f])) {
174
- errors.push(`Missing required field '${f}' for type '${resolvedType}' in ${filePath}.`);
175
- repair.push({ field: f, issue: `Missing required field '${f}' for type '${resolvedType}'.` });
176
- }
177
- }
178
- // required_when (§9): fields required only when a condition field matches a value.
179
- for (const [condition, fields] of Object.entries(def.required_when || {})) {
180
- const cond = parseCondition(condition);
181
- if (!cond || data[cond.field] !== cond.value) continue;
182
- for (const f of fields) {
183
- if (isEmpty(data[f])) {
184
- errors.push(`Missing required field '${f}' for type '${resolvedType}' when ${condition} in ${filePath}.`);
185
- repair.push({ field: f, issue: `Required when ${condition}.` });
186
- }
187
- }
188
- }
189
- // enums: a present field's value(s) must be drawn from the registry's declared set.
190
- for (const [field, allowedValues] of Object.entries(def.enums || {})) {
191
- if (isEmpty(data[field])) continue;
192
- const values = Array.isArray(data[field]) ? data[field] : [data[field]];
193
- for (const v of values) {
194
- if (!allowedValues.includes(v)) {
195
- errors.push(`Invalid value '${v}' for field '${field}' on type '${resolvedType}'; must be one of: ${allowedValues.join(', ')}.`);
196
- repair.push({ field, issue: `Must be one of: ${allowedValues.join(', ')}.` });
197
- }
198
- }
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
- }
237
- return { errors, repair };
58
+ isRequired(workspaceId, target) {
59
+ const file = this.#file(workspaceId, target);
60
+ if (!fs.existsSync(file)) return false;
61
+ try {
62
+ const lease = JSON.parse(fs.readFileSync(file, 'utf8'));
63
+ return new Date(lease.expires_at).getTime() > Date.now();
64
+ } catch { return true; }
238
65
  }
239
-
240
- function appendEvent(vaultRoot, eventLogDir, record) {
241
- const dir = eventLogDir || path.join(vaultRoot, '.events');
242
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
243
- fs.appendFileSync(path.join(dir, `${record.workspace_id}.jsonl`), JSON.stringify(record) + '\n');
66
+ async verify(input) {
67
+ const file = this.#file(input.workspace_id, input.target);
68
+ if (!fs.existsSync(file)) return { valid: false, reason: 'Required lease is missing.' };
69
+ try {
70
+ const lease = JSON.parse(fs.readFileSync(file, 'utf8'));
71
+ if (new Date(lease.expires_at).getTime() <= Date.now()) return { valid: true, expired: true };
72
+ if (!input.lease_id) return { valid: false, reason: 'Path is leased; lease_id is required.' };
73
+ if (lease.lease_id !== input.lease_id) return { valid: false, reason: 'Lease mismatch.' };
74
+ return { valid: true, lease };
75
+ } catch { return { valid: false, reason: 'Lease state is unreadable.' }; }
244
76
  }
77
+ }
245
78
 
246
- function audit(vaultRoot, eventLogDir, opId, committedAt, envelope, resolvedType, hash) {
247
- const dir = eventLogDir || path.join(vaultRoot, '.events');
248
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
249
- fs.appendFileSync(path.join(dir, 'audit.jsonl'), JSON.stringify({
250
- event_id: crypto.randomUUID(), event_type: 'audit', correlation_id: opId,
251
- ts: committedAt, subject: envelope.path,
252
- payload: {
253
- envelope_type: envelope.type, idempotency_key: envelope.idempotency_key,
254
- workspace_id: envelope.workspace_id, resolved_type: resolvedType,
255
- request_hash: hash,
256
- },
257
- }) + '\n');
79
+ export function createEngine(options = {}) {
80
+ const registrySet = options.registrySet || loadRegistries(options.registryDir);
81
+ const validator = options.validator || createValidator({ registrySet });
82
+ const kernels = new Map();
83
+ const legacyLease = typeof options.leaseStore === 'string' ? new LegacyLeaseAdapter(options.leaseStore) : options.leaseStore;
84
+
85
+ function bound(vaultRoot, eventLogDir) {
86
+ const key = `${path.resolve(vaultRoot)}\0${eventLogDir ? path.resolve(eventLogDir) : ''}`;
87
+ if (!kernels.has(key)) {
88
+ fs.mkdirSync(vaultRoot, { recursive: true });
89
+ const eventRoot = eventLogDir || path.join(vaultRoot, '.events');
90
+ kernels.set(key, createKernel({
91
+ vfs: new FileSystemVfs(vaultRoot),
92
+ eventStore: new JsonlEventStore(eventRoot),
93
+ leaseStore: legacyLease,
94
+ validator,
95
+ }));
96
+ }
97
+ return kernels.get(key);
258
98
  }
259
99
 
260
- /**
261
- * Process one envelope through the §6.3 pipeline.
262
- * @param {object} envelope
263
- * @param {string} vaultRoot - absolute path to the vault
264
- * @param {object} [options] - { agentRole, eventLogDir, leaseStore }
265
- */
266
- function processOperation(envelope, vaultRoot, options = {}) {
267
- const opId = crypto.randomUUID();
268
- const eventLogDir = options.eventLogDir;
269
- const warnings = [];
270
-
271
- // Stage 1 — Envelope validation
272
- if (!envelope || !ENVELOPE_TYPES.includes(envelope.type)) {
273
- return fail(envelope?.type, opId, envelope?.path || '', null,
274
- [`Invalid or missing 'type'. Must be one of: ${ENVELOPE_TYPES.join(', ')}.`],
275
- [{ field: 'type', issue: `Must be one of: ${ENVELOPE_TYPES.join(', ')}.` }]);
276
- }
277
- const missingEnv = ENVELOPE_REQUIRED[envelope.type].filter((f) => envelope[f] === undefined || envelope[f] === null);
278
- if (missingEnv.length) {
279
- return fail(envelope.type, opId, envelope.path || '', null,
280
- missingEnv.map((f) => `Missing required envelope field '${f}' for ${envelope.type}.`),
281
- missingEnv.map((f) => ({ field: f, issue: `Required for envelope type '${envelope.type}'.` })));
282
- }
283
- if (!isSafeIdentifier(envelope.workspace_id)) {
284
- return fail(envelope.type, opId, envelope.path || '', null,
285
- [`Invalid workspace_id '${envelope.workspace_id}'. Use 1-128 characters: letters, numbers, dot, underscore, hyphen.`],
286
- [{ field: 'workspace_id', issue: 'Invalid or unsafe filesystem identifier.' }]);
287
- }
288
- if (!isSafeIdentifier(envelope.idempotency_key)) {
289
- return fail(envelope.type, opId, envelope.path || '', null,
290
- [`Invalid idempotency_key '${envelope.idempotency_key}'. Use 1-128 characters: letters, numbers, dot, underscore, hyphen.`],
291
- [{ field: 'idempotency_key', issue: 'Invalid or unsafe replay identifier.' }]);
292
- }
293
-
294
- let abs;
295
- try { abs = resolveContainedPath(vaultRoot, envelope.path); }
296
- catch (err) { return fail(envelope.type, opId, envelope.path, null, [err.message], [{ field: 'path', issue: err.message }]); }
297
- if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
298
- return fail(envelope.type, opId, envelope.path, null,
299
- [`VFS path '${envelope.path}' resolves to a directory, not a file.`],
300
- [{ field: 'path', issue: 'Must identify a file.' }]);
301
- }
302
-
303
- // Stage 2 — Idempotency
304
- const hash = requestHash(envelope);
305
- warmFromAudit(vaultRoot, eventLogDir);
306
- pruneExpired();
307
- const ck = cacheKey(envelope.workspace_id, envelope.idempotency_key);
308
- const cached = idempotencyCache.get(ck);
309
- if (cached) {
310
- if (cached.request_hash && cached.request_hash !== hash) {
311
- return fail(envelope.type, opId, envelope.path, null,
312
- [`Idempotency conflict: key '${envelope.idempotency_key}' was already committed for a different request payload.`],
313
- [{ field: 'idempotency_key', issue: 'Same key cannot be reused for a different request payload.' }]);
314
- }
315
- return { ...cached.response, replay: cached.response };
316
- }
317
-
318
- // Stage 3 — Authorization (protocol-path hook)
319
- // Granular RBAC is now evaluated in Stage 5.5 once the primitive type is resolved.
320
-
321
- // Stage 4 — Lease check
322
- const store = options.leaseStore || leaseStore;
323
- if (store) {
324
- const lc = checkLease(envelope, store);
325
- if (!lc.ok) return fail(envelope.type, opId, envelope.path, null, [lc.error], [{ field: 'lease_id', issue: lc.error }]);
326
- }
327
-
328
- // Stage 5 — Content validation
329
- let resolvedType = null;
330
- let errors = [];
331
- let repair = [];
332
- let mergedForCommit = null;
333
-
334
- if (envelope.type === 'operation') {
335
- if (envelope.path.endsWith('/index.md')) {
336
- resolvedType = 'index';
337
- } else {
338
- const { data } = parseDocument(envelope.content);
339
- resolvedType = data.type || null;
340
- if (!resolvedType) { errors.push(`Missing required frontmatter field 'type' in ${envelope.path}.`); repair.push({ field: 'type', issue: 'Frontmatter must declare a type.' }); }
341
- else ({ errors, repair } = validateContent(
342
- resolvedType,
343
- data,
344
- envelope.path,
345
- vaultRoot,
346
- options.referenceResolver
347
- ));
348
- }
349
- if (!errors.length && fs.existsSync(abs) && !envelope.path.endsWith('/index.md')) {
350
- const { data: existingData } = parseDocument(fs.readFileSync(abs, 'utf8'));
351
- if (existingData.type && existingData.type !== resolvedType) {
352
- errors.push(`Type rewrite refused for ${envelope.path}: existing type '${existingData.type}' cannot be replaced by '${resolvedType}'.`);
353
- repair.push({ field: 'type', issue: 'Use a migration controlled by a privileged host; operation writes may not change an existing file type.' });
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
- }
362
- }
363
- // append-type rewrite guard
364
- if (!errors.length && isAppendType(types.get(resolvedType)) && fs.existsSync(abs)) {
365
- const existing = parseDocument(fs.readFileSync(abs, 'utf8')).body.replace(/\s+$/, '');
366
- const next = parseDocument(envelope.content).body.replace(/\s+$/, '');
367
- if (existing && next !== existing && !next.startsWith(existing + '\n')) {
368
- errors.push(`Append-type '${resolvedType}' may not rewrite existing records.`);
369
- repair.push({ field: '__body__', issue: 'Append-only: existing records are immutable.' });
370
- }
371
- }
372
- } else if (envelope.type === 'patch') {
373
- if (!fs.existsSync(abs)) { errors.push(`Target file does not exist for patch: ${envelope.path}.`); repair.push({ field: 'path', issue: 'Patch target not found.' }); }
374
- else {
375
- const { data, body } = parseDocument(fs.readFileSync(abs, 'utf8'));
376
- if (Object.prototype.hasOwnProperty.call(envelope.patches, 'type') && envelope.patches.type !== data.type) {
377
- errors.push(`Patch may not change immutable field 'type' for ${envelope.path}.`);
378
- repair.push({ field: 'type', issue: 'Use an explicit migration rather than patching the primitive discriminator.' });
379
- }
380
- const merged = { ...data };
381
- for (const [k, v] of Object.entries(envelope.patches)) if (k !== '__body__') merged[k] = v;
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
- }
390
- const append = isAppendType(types.get(resolvedType));
391
- let newBody = body;
392
- if (envelope.patches.__body__ !== undefined) {
393
- if (append) newBody = body.replace(/\s+$/, '') + '\n' + envelope.patches.__body__;
394
- else newBody = envelope.patches.__body__;
395
- }
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
- }
410
- mergedForCommit = { data: merged, body: newBody };
411
- }
412
- } else if (envelope.type === 'event') {
413
- try { JSON.parse(envelope.content); }
414
- catch { errors.push('Event content must be valid JSON.'); repair.push({ field: 'content', issue: 'Must be a JSON string.' }); }
415
- } else if (envelope.type === 'delete') {
416
- if (!fs.existsSync(abs)) { errors.push(`Target file does not exist for delete: ${envelope.path}.`); repair.push({ field: 'path', issue: 'Delete target not found.' }); }
417
- else {
418
- const { data } = parseDocument(fs.readFileSync(abs, 'utf8'));
419
- resolvedType = data.type || null;
420
- if (isAppendType(types.get(resolvedType))) { errors.push(`Append-type '${resolvedType}' may not be deleted.`); repair.push({ field: 'type', issue: 'Append-only documents are immutable.' }); }
421
- }
422
- }
423
-
424
- let valid = errors.length === 0;
425
-
426
- // Stage 5.5 — Granular Authorization (Default RBAC)
427
- if (valid) {
428
- // Fail-closed by design: a host that forgets to overwrite `actor` with a
429
- // verified identity (spec §6.3 Stage 3 MUST) gets denied, not admin. Only
430
- // an EXPLICIT actor.role === 'admin' still resolves to full access.
431
- const actorRole = envelope.actor?.role || null;
432
- // `event` envelopes never resolve a document-primitive type (they're a raw
433
- // log entry, not a typed file), so gate them on the `event` contract
434
- // primitive itself rather than the ever-null resolvedType (which would
435
- // otherwise require the unsatisfiable permission 'write:null').
436
- const permType = envelope.type === 'event' ? 'event' : resolvedType;
437
- const requiredPerm = `write:${permType}`;
438
- let allowed = false;
439
- if (actorRole === 'system') {
440
- allowed = true;
441
- } else if (actorRole) {
442
- let perms = [];
443
- if (actorRole === 'admin') perms = ['write:*', 'read:*'];
444
- const safeRole = isSafeIdentifier(actorRole);
445
- if (!safeRole) {
446
- perms = [];
447
- } else {
448
- const roleFile = path.join(vaultRoot, 'roles', actorRole, 'ROLE.md');
449
- if (fs.existsSync(roleFile)) {
450
- try {
451
- const { data } = parseDocument(fs.readFileSync(roleFile, 'utf8'));
452
- if (data.permissions) perms = data.permissions;
453
- } catch {}
454
- }
100
+ return {
101
+ async processOperation(envelope, vaultRoot, callOptions = {}) {
102
+ const principal = options.verifyPrincipal
103
+ ? await options.verifyPrincipal(envelope, callOptions)
104
+ : legacyPrincipal(envelope, vaultRoot, callOptions);
105
+ const response = await bound(vaultRoot, callOptions.eventLogDir).execute(envelope, {
106
+ ...callOptions,
107
+ principal,
108
+ requireLease: legacyLease?.isRequired?.(envelope.workspace_id, envelope.path) || false,
109
+ });
110
+ // Preserve the pre-0.9 façade's unqualified validation type while the
111
+ // canonical kernel and events use qualified identities.
112
+ if (response.validation) {
113
+ if (response.validation.type?.startsWith('ssss:')) response.validation.type = response.validation.type.slice('ssss:'.length);
114
+ else if (!response.validation.type && typeof envelope.content === 'string' && envelope.type === 'operation') {
115
+ try { response.validation.type = parseDocument(envelope.content).data.type || null; } catch {}
455
116
  }
456
- allowed = perms.includes('*:*') || perms.includes('write:*') || perms.includes(`*:${permType}`) || perms.includes(requiredPerm);
457
- }
458
- // else: actorRole is absent — allowed stays false (fail closed).
459
-
460
- if (!allowed) {
461
- errors.push(`Access denied: role '${actorRole || '(none)'}' lacks permission '${requiredPerm}'.`);
462
- repair.push({
463
- field: 'actor.role',
464
- issue: actorRole ? 'Insufficient permissions.' : 'Missing actor.role — the envelope was not authorized by a verified identity.',
465
- });
466
- valid = false;
467
- }
468
- }
469
-
470
- // dry_run: stop after stage 5
471
- if (envelope.dry_run || !valid) {
472
- const resp = {
473
- success: valid, type: envelope.type, operation_id: opId, path: envelope.path,
474
- committed_at: null, dry_run: !!envelope.dry_run,
475
- validation: { valid, type: resolvedType, errors, warnings },
476
- };
477
- if (!valid) resp.repair = { field_errors: repair };
478
- return resp;
479
- }
480
-
481
- // Stage 6 — Commit
482
- const committedAt = new Date().toISOString();
483
- try {
484
- if (envelope.type === 'operation') atomicWrite(abs, envelope.content);
485
- else if (envelope.type === 'patch') atomicWrite(abs, serializeDocument(mergedForCommit.data, mergedForCommit.body));
486
- else if (envelope.type === 'event') {
487
- appendEvent(vaultRoot, eventLogDir, {
488
- event_id: opId, event_type: 'log', ts: committedAt, workspace_id: envelope.workspace_id,
489
- subject: envelope.path, content: envelope.content, idempotency_key: envelope.idempotency_key,
490
- });
491
- } else if (envelope.type === 'delete') {
492
- fs.rmSync(abs, { force: true });
493
- // §6.2: a delete emits an auditable deletion event — history is never lost.
494
- appendEvent(vaultRoot, eventLogDir, {
495
- event_id: opId, event_type: 'delete', ts: committedAt, workspace_id: envelope.workspace_id,
496
- subject: envelope.path, resolved_type: resolvedType, idempotency_key: envelope.idempotency_key,
497
- });
498
117
  }
499
- } catch (err) {
500
- return fail(envelope.type, opId, envelope.path, resolvedType, [`Commit failed: ${err.message}`], [{ field: '(commit)', issue: err.message }]);
501
- }
502
-
503
- // Stage 7 — Audit
504
- try { audit(vaultRoot, eventLogDir, opId, committedAt, envelope, resolvedType, hash); } catch { /* non-fatal */ }
505
-
506
- const response = ok(envelope.type, opId, envelope.path, resolvedType, committedAt, warnings);
507
- idempotencyCache.set(ck, { response, request_hash: hash, ts: Date.now() });
508
- return response;
509
- }
510
-
511
- return { processOperation, resolveContainedPath, _types: types };
512
- }
513
-
514
- function checkLease(envelope, leaseStore) {
515
- if (!isSafeIdentifier(envelope.workspace_id)) return { ok: false, error: `Invalid workspace_id '${envelope.workspace_id}'.` };
516
- const pathKey = crypto.createHash('sha256').update(String(envelope.path)).digest('hex');
517
- const lp = path.join(leaseStore, envelope.workspace_id, `${pathKey}.lease.json`);
518
- if (!fs.existsSync(lp)) return { ok: true };
519
- try {
520
- const lease = JSON.parse(fs.readFileSync(lp, 'utf8'));
521
- if (new Date(lease.expires_at) < new Date()) { fs.rmSync(lp, { force: true }); return { ok: true }; }
522
- if (!envelope.lease_id) return { ok: false, error: `Path '${envelope.path}' is leased (${lease.lease_id}). Supply a matching lease_id.` };
523
- if (envelope.lease_id !== lease.lease_id) return { ok: false, error: `Lease mismatch for '${envelope.path}'.` };
524
- return { ok: true };
525
- } catch { return { ok: false, error: `Lease state for '${envelope.path}' is unreadable; refusing to fail open.` }; }
118
+ return response;
119
+ },
120
+ resolveContainedPath,
121
+ _types: registrySet.types,
122
+ _registry: registrySet,
123
+ };
526
124
  }