@gregiteen/ssss-cli 0.7.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 (43) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/LICENSE +15 -0
  3. package/README.md +162 -0
  4. package/VERSION +1 -0
  5. package/conformance/README.md +72 -0
  6. package/conformance/fixtures.json +763 -0
  7. package/conformance/reference-bundle.ucw.json +135 -0
  8. package/docs/help/autolink.md +15 -0
  9. package/docs/help/bundle.md +47 -0
  10. package/docs/help/conformance.md +34 -0
  11. package/docs/help/export.md +20 -0
  12. package/docs/help/import.md +19 -0
  13. package/docs/help/overview.md +33 -0
  14. package/docs/help/portability.md +27 -0
  15. package/docs/help/provision.md +19 -0
  16. package/docs/help/provisioning.md +43 -0
  17. package/docs/help/roles.md +61 -0
  18. package/docs/help/runtime.md +78 -0
  19. package/docs/help/scaffold.md +47 -0
  20. package/docs/ssss-spec.md +1325 -0
  21. package/package.json +62 -0
  22. package/registry/core.json +225 -0
  23. package/registry/extensions/festech.json +134 -0
  24. package/scripts/audit-registry-field-usage.mjs +136 -0
  25. package/scripts/autolink.mjs +341 -0
  26. package/scripts/build-reference-bundle.mjs +169 -0
  27. package/scripts/cmd-export.mjs +72 -0
  28. package/scripts/cmd-help.mjs +60 -0
  29. package/scripts/cmd-import.mjs +71 -0
  30. package/scripts/cmd-inspect.mjs +78 -0
  31. package/scripts/cmd-new.mjs +262 -0
  32. package/scripts/cmd-provision.mjs +68 -0
  33. package/scripts/cmd-validate.mjs +43 -0
  34. package/scripts/conformance.mjs +408 -0
  35. package/scripts/lib/cli.mjs +92 -0
  36. package/scripts/release.sh +35 -0
  37. package/scripts/ssss.mjs +93 -0
  38. package/scripts/validate-skills.mjs +115 -0
  39. package/src/bundle.mjs +261 -0
  40. package/src/engine.mjs +452 -0
  41. package/src/frontmatter.mjs +109 -0
  42. package/src/registry.mjs +67 -0
  43. package/src/runtime.mjs +212 -0
package/src/engine.mjs ADDED
@@ -0,0 +1,452 @@
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).
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import crypto from 'node:crypto';
17
+ import { parseDocument, serializeDocument } from './frontmatter.mjs';
18
+ import { loadRegistries, isAppendType } from './registry.mjs';
19
+
20
+ const ENVELOPE_TYPES = ['operation', 'patch', 'event', 'delete'];
21
+ const ENVELOPE_REQUIRED = {
22
+ operation: ['type', 'idempotency_key', 'path', 'workspace_id', 'content'],
23
+ patch: ['type', 'idempotency_key', 'path', 'workspace_id', 'patches'],
24
+ event: ['type', 'idempotency_key', 'path', 'workspace_id', 'content'],
25
+ delete: ['type', 'idempotency_key', 'path', 'workspace_id'],
26
+ };
27
+
28
+ const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
29
+ const SAFE_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
30
+
31
+ function cacheKey(wid, key) { return JSON.stringify([wid, key]); }
32
+
33
+ function isSafeIdentifier(value) {
34
+ return typeof value === 'string' && SAFE_IDENTIFIER_RE.test(value);
35
+ }
36
+
37
+ function stableStringify(value) {
38
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
39
+ if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v)).join(',')}]`;
40
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
41
+ }
42
+
43
+ function requestHash(envelope) {
44
+ const payload = {
45
+ type: envelope.type,
46
+ path: envelope.path,
47
+ workspace_id: envelope.workspace_id,
48
+ actor: envelope.actor || null,
49
+ lease_id: envelope.lease_id || null,
50
+ dry_run: !!envelope.dry_run,
51
+ };
52
+ if (Object.prototype.hasOwnProperty.call(envelope, 'content')) payload.content = envelope.content;
53
+ if (Object.prototype.hasOwnProperty.call(envelope, 'patches')) payload.patches = envelope.patches;
54
+ return 'sha256:' + crypto.createHash('sha256').update(stableStringify(payload)).digest('hex');
55
+ }
56
+
57
+ /** festech `resolveContainedPath`: resolve a VFS path, refusing escape from the vault. */
58
+ export function resolveContainedPath(vaultRoot, vfsPath) {
59
+ const root = path.resolve(vaultRoot);
60
+ const raw = String(vfsPath);
61
+ if (!raw || raw === '.' || raw.includes('\0')) throw new Error(`Invalid VFS path: ${vfsPath}`);
62
+ if (raw.startsWith('/') || raw.startsWith('\\') || raw.includes('\\')) throw new Error(`Absolute or platform-native paths are not valid VFS paths: ${vfsPath}`);
63
+ const parts = raw.split('/');
64
+ if (parts.some((p) => p === '' || p === '.' || p === '..')) throw new Error(`Invalid VFS path segment in: ${vfsPath}`);
65
+ const cleaned = parts.join('/');
66
+ const resolved = path.resolve(root, cleaned);
67
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
68
+ throw new Error(`Path traversal refused: ${vfsPath}`);
69
+ }
70
+ if (resolved === root) throw new Error(`VFS path must identify a file, not the vault root: ${vfsPath}`);
71
+ return resolved;
72
+ }
73
+
74
+ function atomicWrite(absPath, content) {
75
+ const dir = path.dirname(absPath);
76
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
77
+ const tmp = path.join(dir, `.${path.basename(absPath)}.${crypto.randomUUID()}.tmp`);
78
+ fs.writeFileSync(tmp, content);
79
+ fs.renameSync(tmp, absPath);
80
+ }
81
+
82
+ function ok(type, opId, p, resolvedType, committedAt, warnings = []) {
83
+ return {
84
+ success: true, type, operation_id: opId, path: p,
85
+ committed_at: committedAt, dry_run: false,
86
+ validation: { valid: true, type: resolvedType, errors: [], warnings },
87
+ };
88
+ }
89
+
90
+ function fail(type, opId, p, resolvedType, errors, repairEntries) {
91
+ const resp = {
92
+ success: false, type, operation_id: opId, path: p,
93
+ committed_at: null, dry_run: false,
94
+ validation: { valid: false, type: resolvedType, errors, warnings: [] },
95
+ };
96
+ resp.repair = {
97
+ field_errors: repairEntries || errors.map((e) => ({
98
+ field: e.split(':')[0]?.trim() || '(root)', issue: e,
99
+ })),
100
+ };
101
+ return resp;
102
+ }
103
+
104
+ /**
105
+ * Create an engine bound to a registry directory. The returned object holds the
106
+ * in-process idempotency cache, so re-applying an envelope within TTL is a no-op.
107
+ */
108
+ export function createEngine({ registryDir, leaseStore } = {}) {
109
+ const { types, core } = loadRegistries(registryDir);
110
+ const idempotencyCache = new Map();
111
+
112
+ function pruneExpired() {
113
+ const now = Date.now();
114
+ for (const [k, v] of idempotencyCache) if (now - v.ts > IDEMPOTENCY_TTL_MS) idempotencyCache.delete(k);
115
+ }
116
+
117
+ function warmFromAudit(vaultRoot, eventLogDir) {
118
+ const auditFile = path.join(eventLogDir || path.join(vaultRoot, '.events'), 'audit.jsonl');
119
+ if (!fs.existsSync(auditFile)) return;
120
+ const now = Date.now();
121
+ for (const line of fs.readFileSync(auditFile, 'utf8').split('\n')) {
122
+ if (!line.trim()) continue;
123
+ try {
124
+ const r = JSON.parse(line);
125
+ if (r.event_type !== 'audit' || !r.payload) continue;
126
+ const ts = Date.parse(r.ts);
127
+ if (isNaN(ts) || now - ts > IDEMPOTENCY_TTL_MS) continue;
128
+ const ck = cacheKey(r.payload.workspace_id, r.payload.idempotency_key);
129
+ if (!idempotencyCache.has(ck)) {
130
+ idempotencyCache.set(ck, {
131
+ ts,
132
+ request_hash: r.payload.request_hash || null,
133
+ response: ok(r.payload.envelope_type, r.correlation_id, r.subject, r.payload.resolved_type, r.ts),
134
+ });
135
+ }
136
+ } catch { /* skip corrupt line */ }
137
+ }
138
+ }
139
+
140
+ function isEmpty(v) {
141
+ return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
142
+ }
143
+
144
+ /** Parse a registry `required_when` condition key, e.g. "schema_version==2". */
145
+ function parseCondition(condition) {
146
+ const m = String(condition).match(/^(\w+)==(.+)$/);
147
+ if (!m) return null;
148
+ const [, field, raw] = m;
149
+ const value = /^-?\d+$/.test(raw) ? parseInt(raw, 10) : raw;
150
+ return { field, value };
151
+ }
152
+
153
+ function validateContent(resolvedType, data, filePath) {
154
+ const def = types.get(resolvedType);
155
+ const errors = [];
156
+ const repair = [];
157
+ if (!def) {
158
+ errors.push(`Unknown SSSS type '${resolvedType}' in ${filePath}.`);
159
+ repair.push({ field: 'type', issue: `Unknown SSSS type '${resolvedType}'.` });
160
+ return { errors, repair };
161
+ }
162
+ const requiredFields = [
163
+ ...new Set([
164
+ ...(core.universal_frontmatter?.required || ['type']),
165
+ ...(def.required_fields || []),
166
+ ]),
167
+ ];
168
+ for (const f of requiredFields) {
169
+ if (isEmpty(data[f])) {
170
+ errors.push(`Missing required field '${f}' for type '${resolvedType}' in ${filePath}.`);
171
+ repair.push({ field: f, issue: `Missing required field '${f}' for type '${resolvedType}'.` });
172
+ }
173
+ }
174
+ // required_when (§9): fields required only when a condition field matches a value.
175
+ for (const [condition, fields] of Object.entries(def.required_when || {})) {
176
+ const cond = parseCondition(condition);
177
+ if (!cond || data[cond.field] !== cond.value) continue;
178
+ for (const f of fields) {
179
+ if (isEmpty(data[f])) {
180
+ errors.push(`Missing required field '${f}' for type '${resolvedType}' when ${condition} in ${filePath}.`);
181
+ repair.push({ field: f, issue: `Required when ${condition}.` });
182
+ }
183
+ }
184
+ }
185
+ // enums: a present field's value(s) must be drawn from the registry's declared set.
186
+ for (const [field, allowedValues] of Object.entries(def.enums || {})) {
187
+ if (isEmpty(data[field])) continue;
188
+ const values = Array.isArray(data[field]) ? data[field] : [data[field]];
189
+ for (const v of values) {
190
+ if (!allowedValues.includes(v)) {
191
+ errors.push(`Invalid value '${v}' for field '${field}' on type '${resolvedType}'; must be one of: ${allowedValues.join(', ')}.`);
192
+ repair.push({ field, issue: `Must be one of: ${allowedValues.join(', ')}.` });
193
+ }
194
+ }
195
+ }
196
+ return { errors, repair };
197
+ }
198
+
199
+ function appendEvent(vaultRoot, eventLogDir, record) {
200
+ const dir = eventLogDir || path.join(vaultRoot, '.events');
201
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
202
+ fs.appendFileSync(path.join(dir, `${record.workspace_id}.jsonl`), JSON.stringify(record) + '\n');
203
+ }
204
+
205
+ function audit(vaultRoot, eventLogDir, opId, committedAt, envelope, resolvedType, hash) {
206
+ const dir = eventLogDir || path.join(vaultRoot, '.events');
207
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
208
+ fs.appendFileSync(path.join(dir, 'audit.jsonl'), JSON.stringify({
209
+ event_id: crypto.randomUUID(), event_type: 'audit', correlation_id: opId,
210
+ ts: committedAt, subject: envelope.path,
211
+ payload: {
212
+ envelope_type: envelope.type, idempotency_key: envelope.idempotency_key,
213
+ workspace_id: envelope.workspace_id, resolved_type: resolvedType,
214
+ request_hash: hash,
215
+ },
216
+ }) + '\n');
217
+ }
218
+
219
+ /**
220
+ * Process one envelope through the §6.3 pipeline.
221
+ * @param {object} envelope
222
+ * @param {string} vaultRoot - absolute path to the vault
223
+ * @param {object} [options] - { agentRole, eventLogDir, leaseStore }
224
+ */
225
+ function processOperation(envelope, vaultRoot, options = {}) {
226
+ const opId = crypto.randomUUID();
227
+ const eventLogDir = options.eventLogDir;
228
+ const warnings = [];
229
+
230
+ // Stage 1 — Envelope validation
231
+ if (!envelope || !ENVELOPE_TYPES.includes(envelope.type)) {
232
+ return fail(envelope?.type, opId, envelope?.path || '', null,
233
+ [`Invalid or missing 'type'. Must be one of: ${ENVELOPE_TYPES.join(', ')}.`],
234
+ [{ field: 'type', issue: `Must be one of: ${ENVELOPE_TYPES.join(', ')}.` }]);
235
+ }
236
+ const missingEnv = ENVELOPE_REQUIRED[envelope.type].filter((f) => envelope[f] === undefined || envelope[f] === null);
237
+ if (missingEnv.length) {
238
+ return fail(envelope.type, opId, envelope.path || '', null,
239
+ missingEnv.map((f) => `Missing required envelope field '${f}' for ${envelope.type}.`),
240
+ missingEnv.map((f) => ({ field: f, issue: `Required for envelope type '${envelope.type}'.` })));
241
+ }
242
+ if (!isSafeIdentifier(envelope.workspace_id)) {
243
+ return fail(envelope.type, opId, envelope.path || '', null,
244
+ [`Invalid workspace_id '${envelope.workspace_id}'. Use 1-128 characters: letters, numbers, dot, underscore, hyphen.`],
245
+ [{ field: 'workspace_id', issue: 'Invalid or unsafe filesystem identifier.' }]);
246
+ }
247
+ if (!isSafeIdentifier(envelope.idempotency_key)) {
248
+ return fail(envelope.type, opId, envelope.path || '', null,
249
+ [`Invalid idempotency_key '${envelope.idempotency_key}'. Use 1-128 characters: letters, numbers, dot, underscore, hyphen.`],
250
+ [{ field: 'idempotency_key', issue: 'Invalid or unsafe replay identifier.' }]);
251
+ }
252
+
253
+ let abs;
254
+ try { abs = resolveContainedPath(vaultRoot, envelope.path); }
255
+ catch (err) { return fail(envelope.type, opId, envelope.path, null, [err.message], [{ field: 'path', issue: err.message }]); }
256
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
257
+ return fail(envelope.type, opId, envelope.path, null,
258
+ [`VFS path '${envelope.path}' resolves to a directory, not a file.`],
259
+ [{ field: 'path', issue: 'Must identify a file.' }]);
260
+ }
261
+
262
+ // Stage 2 — Idempotency
263
+ const hash = requestHash(envelope);
264
+ warmFromAudit(vaultRoot, eventLogDir);
265
+ pruneExpired();
266
+ const ck = cacheKey(envelope.workspace_id, envelope.idempotency_key);
267
+ const cached = idempotencyCache.get(ck);
268
+ if (cached) {
269
+ if (cached.request_hash && cached.request_hash !== hash) {
270
+ return fail(envelope.type, opId, envelope.path, null,
271
+ [`Idempotency conflict: key '${envelope.idempotency_key}' was already committed for a different request payload.`],
272
+ [{ field: 'idempotency_key', issue: 'Same key cannot be reused for a different request payload.' }]);
273
+ }
274
+ return { ...cached.response, replay: cached.response };
275
+ }
276
+
277
+ // Stage 3 — Authorization (protocol-path hook)
278
+ // Granular RBAC is now evaluated in Stage 5.5 once the primitive type is resolved.
279
+
280
+ // Stage 4 — Lease check
281
+ const store = options.leaseStore || leaseStore;
282
+ if (store) {
283
+ const lc = checkLease(envelope, store);
284
+ if (!lc.ok) return fail(envelope.type, opId, envelope.path, null, [lc.error], [{ field: 'lease_id', issue: lc.error }]);
285
+ }
286
+
287
+ // Stage 5 — Content validation
288
+ let resolvedType = null;
289
+ let errors = [];
290
+ let repair = [];
291
+ let mergedForCommit = null;
292
+
293
+ if (envelope.type === 'operation') {
294
+ if (envelope.path.endsWith('/index.md')) {
295
+ resolvedType = 'index';
296
+ } else {
297
+ const { data } = parseDocument(envelope.content);
298
+ resolvedType = data.type || null;
299
+ 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));
301
+ }
302
+ if (!errors.length && fs.existsSync(abs) && !envelope.path.endsWith('/index.md')) {
303
+ const { data: existingData } = parseDocument(fs.readFileSync(abs, 'utf8'));
304
+ if (existingData.type && existingData.type !== resolvedType) {
305
+ errors.push(`Type rewrite refused for ${envelope.path}: existing type '${existingData.type}' cannot be replaced by '${resolvedType}'.`);
306
+ repair.push({ field: 'type', issue: 'Use a migration controlled by a privileged host; operation writes may not change an existing file type.' });
307
+ }
308
+ }
309
+ // append-type rewrite guard
310
+ if (!errors.length && isAppendType(types.get(resolvedType)) && fs.existsSync(abs)) {
311
+ const existing = parseDocument(fs.readFileSync(abs, 'utf8')).body.replace(/\s+$/, '');
312
+ const next = parseDocument(envelope.content).body.replace(/\s+$/, '');
313
+ if (existing && next !== existing && !next.startsWith(existing + '\n')) {
314
+ errors.push(`Append-type '${resolvedType}' may not rewrite existing records.`);
315
+ repair.push({ field: '__body__', issue: 'Append-only: existing records are immutable.' });
316
+ }
317
+ }
318
+ } else if (envelope.type === 'patch') {
319
+ if (!fs.existsSync(abs)) { errors.push(`Target file does not exist for patch: ${envelope.path}.`); repair.push({ field: 'path', issue: 'Patch target not found.' }); }
320
+ else {
321
+ const { data, body } = parseDocument(fs.readFileSync(abs, 'utf8'));
322
+ if (Object.prototype.hasOwnProperty.call(envelope.patches, 'type') && envelope.patches.type !== data.type) {
323
+ errors.push(`Patch may not change immutable field 'type' for ${envelope.path}.`);
324
+ repair.push({ field: 'type', issue: 'Use an explicit migration rather than patching the primitive discriminator.' });
325
+ }
326
+ const merged = { ...data };
327
+ for (const [k, v] of Object.entries(envelope.patches)) if (k !== '__body__') merged[k] = v;
328
+ resolvedType = merged.type || null;
329
+ const append = isAppendType(types.get(resolvedType));
330
+ let newBody = body;
331
+ if (envelope.patches.__body__ !== undefined) {
332
+ if (append) newBody = body.replace(/\s+$/, '') + '\n' + envelope.patches.__body__;
333
+ else newBody = envelope.patches.__body__;
334
+ }
335
+ ({ errors, repair } = resolvedType ? validateContent(resolvedType, merged, envelope.path) : { errors: ["Missing required field 'type'."], repair: [{ field: 'type', issue: 'No type after merge.' }] });
336
+ mergedForCommit = { data: merged, body: newBody };
337
+ }
338
+ } else if (envelope.type === 'event') {
339
+ try { JSON.parse(envelope.content); }
340
+ catch { errors.push('Event content must be valid JSON.'); repair.push({ field: 'content', issue: 'Must be a JSON string.' }); }
341
+ } else if (envelope.type === 'delete') {
342
+ if (!fs.existsSync(abs)) { errors.push(`Target file does not exist for delete: ${envelope.path}.`); repair.push({ field: 'path', issue: 'Delete target not found.' }); }
343
+ else {
344
+ const { data } = parseDocument(fs.readFileSync(abs, 'utf8'));
345
+ resolvedType = data.type || null;
346
+ if (isAppendType(types.get(resolvedType))) { errors.push(`Append-type '${resolvedType}' may not be deleted.`); repair.push({ field: 'type', issue: 'Append-only documents are immutable.' }); }
347
+ }
348
+ }
349
+
350
+ let valid = errors.length === 0;
351
+
352
+ // Stage 5.5 — Granular Authorization (Default RBAC)
353
+ if (valid) {
354
+ // Fail-closed by design: a host that forgets to overwrite `actor` with a
355
+ // verified identity (spec §6.3 Stage 3 MUST) gets denied, not admin. Only
356
+ // an EXPLICIT actor.role === 'admin' still resolves to full access.
357
+ const actorRole = envelope.actor?.role || null;
358
+ // `event` envelopes never resolve a document-primitive type (they're a raw
359
+ // log entry, not a typed file), so gate them on the `event` contract
360
+ // primitive itself rather than the ever-null resolvedType (which would
361
+ // otherwise require the unsatisfiable permission 'write:null').
362
+ const permType = envelope.type === 'event' ? 'event' : resolvedType;
363
+ const requiredPerm = `write:${permType}`;
364
+ let allowed = false;
365
+ if (actorRole === 'system') {
366
+ allowed = true;
367
+ } else if (actorRole) {
368
+ let perms = [];
369
+ if (actorRole === 'admin') perms = ['write:*', 'read:*'];
370
+ const safeRole = isSafeIdentifier(actorRole);
371
+ if (!safeRole) {
372
+ perms = [];
373
+ } else {
374
+ const roleFile = path.join(vaultRoot, 'roles', actorRole, 'ROLE.md');
375
+ if (fs.existsSync(roleFile)) {
376
+ try {
377
+ const { data } = parseDocument(fs.readFileSync(roleFile, 'utf8'));
378
+ if (data.permissions) perms = data.permissions;
379
+ } catch {}
380
+ }
381
+ }
382
+ allowed = perms.includes('*:*') || perms.includes('write:*') || perms.includes(`*:${permType}`) || perms.includes(requiredPerm);
383
+ }
384
+ // else: actorRole is absent — allowed stays false (fail closed).
385
+
386
+ if (!allowed) {
387
+ errors.push(`Access denied: role '${actorRole || '(none)'}' lacks permission '${requiredPerm}'.`);
388
+ repair.push({
389
+ field: 'actor.role',
390
+ issue: actorRole ? 'Insufficient permissions.' : 'Missing actor.role — the envelope was not authorized by a verified identity.',
391
+ });
392
+ valid = false;
393
+ }
394
+ }
395
+
396
+ // dry_run: stop after stage 5
397
+ if (envelope.dry_run || !valid) {
398
+ const resp = {
399
+ success: valid, type: envelope.type, operation_id: opId, path: envelope.path,
400
+ committed_at: null, dry_run: !!envelope.dry_run,
401
+ validation: { valid, type: resolvedType, errors, warnings },
402
+ };
403
+ if (!valid) resp.repair = { field_errors: repair };
404
+ return resp;
405
+ }
406
+
407
+ // Stage 6 — Commit
408
+ const committedAt = new Date().toISOString();
409
+ try {
410
+ if (envelope.type === 'operation') atomicWrite(abs, envelope.content);
411
+ else if (envelope.type === 'patch') atomicWrite(abs, serializeDocument(mergedForCommit.data, mergedForCommit.body));
412
+ else if (envelope.type === 'event') {
413
+ appendEvent(vaultRoot, eventLogDir, {
414
+ event_id: opId, event_type: 'log', ts: committedAt, workspace_id: envelope.workspace_id,
415
+ subject: envelope.path, content: envelope.content, idempotency_key: envelope.idempotency_key,
416
+ });
417
+ } else if (envelope.type === 'delete') {
418
+ fs.rmSync(abs, { force: true });
419
+ // §6.2: a delete emits an auditable deletion event — history is never lost.
420
+ appendEvent(vaultRoot, eventLogDir, {
421
+ event_id: opId, event_type: 'delete', ts: committedAt, workspace_id: envelope.workspace_id,
422
+ subject: envelope.path, resolved_type: resolvedType, idempotency_key: envelope.idempotency_key,
423
+ });
424
+ }
425
+ } catch (err) {
426
+ return fail(envelope.type, opId, envelope.path, resolvedType, [`Commit failed: ${err.message}`], [{ field: '(commit)', issue: err.message }]);
427
+ }
428
+
429
+ // Stage 7 — Audit
430
+ try { audit(vaultRoot, eventLogDir, opId, committedAt, envelope, resolvedType, hash); } catch { /* non-fatal */ }
431
+
432
+ const response = ok(envelope.type, opId, envelope.path, resolvedType, committedAt, warnings);
433
+ idempotencyCache.set(ck, { response, request_hash: hash, ts: Date.now() });
434
+ return response;
435
+ }
436
+
437
+ return { processOperation, resolveContainedPath, _types: types };
438
+ }
439
+
440
+ function checkLease(envelope, leaseStore) {
441
+ if (!isSafeIdentifier(envelope.workspace_id)) return { ok: false, error: `Invalid workspace_id '${envelope.workspace_id}'.` };
442
+ const pathKey = crypto.createHash('sha256').update(String(envelope.path)).digest('hex');
443
+ const lp = path.join(leaseStore, envelope.workspace_id, `${pathKey}.lease.json`);
444
+ if (!fs.existsSync(lp)) return { ok: true };
445
+ try {
446
+ const lease = JSON.parse(fs.readFileSync(lp, 'utf8'));
447
+ if (new Date(lease.expires_at) < new Date()) { fs.rmSync(lp, { force: true }); return { ok: true }; }
448
+ if (!envelope.lease_id) return { ok: false, error: `Path '${envelope.path}' is leased (${lease.lease_id}). Supply a matching lease_id.` };
449
+ if (envelope.lease_id !== lease.lease_id) return { ok: false, error: `Lease mismatch for '${envelope.path}'.` };
450
+ return { ok: true };
451
+ } catch { return { ok: false, error: `Lease state for '${envelope.path}' is unreadable; refusing to fail open.` }; }
452
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Dependency-free YAML-frontmatter parsing and serialization for SSSS files.
3
+ *
4
+ * SSSS files are Markdown with a leading `---`-fenced YAML frontmatter block
5
+ * (spec §4.1). The canonical engine avoids a YAML dependency: it parses the
6
+ * subset SSSS actually uses — top-level scalars, inline `[a, b]` arrays, block
7
+ * `- item` arrays, and block scalars — which is sufficient to resolve a file's
8
+ * `type` and validate field presence against the registry (§5, §9).
9
+ */
10
+
11
+ /** Split a document into its raw frontmatter block and body. */
12
+ export function splitFrontmatter(content) {
13
+ if (typeof content !== 'string' || !content.startsWith('---')) {
14
+ return { fm: '', body: content || '', hasFm: false };
15
+ }
16
+ const end = content.indexOf('\n---', 3);
17
+ if (end < 0) return { fm: '', body: content, hasFm: false };
18
+ const fmEnd = content.indexOf('\n', end + 1);
19
+ const sliceEnd = fmEnd < 0 ? content.length : fmEnd + 1;
20
+ // Inner YAML is between the opening fence line and the closing `---`.
21
+ const firstNl = content.indexOf('\n');
22
+ const inner = content.slice(firstNl + 1, end + 1);
23
+ return { fm: inner, body: content.slice(sliceEnd), hasFm: true };
24
+ }
25
+
26
+ function coerce(raw) {
27
+ const v = raw.trim();
28
+ if (v === '') return '';
29
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
30
+ return v.slice(1, -1);
31
+ }
32
+ if (v === 'true') return true;
33
+ if (v === 'false') return false;
34
+ if (v === 'null' || v === '~') return null;
35
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
36
+ if (/^-?\d*\.\d+$/.test(v)) return parseFloat(v);
37
+ if (v.startsWith('[') && v.endsWith(']')) {
38
+ const inner = v.slice(1, -1).trim();
39
+ if (!inner) return [];
40
+ return inner.split(',').map((s) => coerce(s));
41
+ }
42
+ return v;
43
+ }
44
+
45
+ /**
46
+ * Parse a frontmatter block (the inner YAML, no fences) into an object.
47
+ * Returns top-level keys only; nested maps are returned as `{}` placeholders
48
+ * (presence is what the registry validator needs).
49
+ */
50
+ export function parseFrontmatter(fm) {
51
+ const data = {};
52
+ if (!fm) return data;
53
+ const lines = fm.split('\n');
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i];
56
+ if (!line.trim() || line.trim().startsWith('#')) continue;
57
+ if (/^\s/.test(line)) continue; // sub-entry of a block handled below
58
+ const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
59
+ if (!m) continue;
60
+ const key = m[1];
61
+ const rest = m[2];
62
+ if (rest === '') {
63
+ // Could open a block list or a nested map. Peek ahead.
64
+ const items = [];
65
+ let isList = false;
66
+ let j = i + 1;
67
+ for (; j < lines.length; j++) {
68
+ const sub = lines[j];
69
+ if (!sub.trim()) continue;
70
+ if (!/^\s/.test(sub)) break; // dedent → block ended
71
+ const li = sub.match(/^\s*-\s*(.*)$/);
72
+ if (li) { isList = true; items.push(coerce(li[1])); }
73
+ else break; // nested map; we only need presence
74
+ }
75
+ data[key] = isList ? items : {};
76
+ i = isList ? j - 1 : i;
77
+ } else {
78
+ data[key] = coerce(rest);
79
+ }
80
+ }
81
+ return data;
82
+ }
83
+
84
+ /** Parse a full document (with fences) into { data, body }. */
85
+ export function parseDocument(content) {
86
+ const { fm, body } = splitFrontmatter(content);
87
+ return { data: parseFrontmatter(fm), body };
88
+ }
89
+
90
+ function serializeValue(v) {
91
+ if (v === null) return 'null';
92
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
93
+ if (Array.isArray(v)) return `[${v.map((x) => serializeValue(x)).join(', ')}]`;
94
+ const s = String(v);
95
+ if (s === '' || /[:#\[\]{}"']/.test(s) || /^\s|\s$/.test(s)) return JSON.stringify(s);
96
+ return s;
97
+ }
98
+
99
+ /** Serialize a frontmatter object + body back into an SSSS document. */
100
+ export function serializeDocument(data, body) {
101
+ const lines = ['---'];
102
+ for (const [k, v] of Object.entries(data)) {
103
+ if (v && typeof v === 'object' && !Array.isArray(v)) continue; // skip nested maps we can't round-trip
104
+ lines.push(`${k}: ${serializeValue(v)}`);
105
+ }
106
+ lines.push('---');
107
+ const b = body == null ? '' : (body.startsWith('\n') ? body : `\n${body}`);
108
+ return lines.join('\n') + b;
109
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Registry-driven type resolution for the canonical SSSS engine.
3
+ *
4
+ * Harvested from festech's `SsssValidator` (which validates against a JSON
5
+ * primitive registry rather than hardcoded schemas) and generalized to read the
6
+ * canonical `registry/core.json` plus any `registry/extensions/*.json`. This is
7
+ * the validation authority for §9: a type's `required_fields`, `append_only`
8
+ * flag, and `portability` class (§5.5) all come from the registry, so the engine
9
+ * has no per-type code.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const DEFAULT_REGISTRY_DIR = path.resolve(__dirname, '..', 'registry');
17
+
18
+ const APPEND_TYPES = new Set(['conversation', 'run']);
19
+
20
+ /**
21
+ * Load the core registry and merge extension registries.
22
+ * @param {string} [registryDir] - directory containing core.json + extensions/
23
+ * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object }}
24
+ */
25
+ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
26
+ const corePath = path.join(registryDir, 'core.json');
27
+ const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
28
+
29
+ const types = new Map();
30
+ for (const [name, def] of Object.entries(core.document_primitives || {})) {
31
+ types.set(name, { ...def, type: name, registry: 'core' });
32
+ }
33
+
34
+ const extDir = path.join(registryDir, 'extensions');
35
+ if (fs.existsSync(extDir)) {
36
+ for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json'))) {
37
+ const ext = JSON.parse(fs.readFileSync(path.join(extDir, file), 'utf8'));
38
+ for (const [name, def] of Object.entries(ext.document_primitives || {})) {
39
+ if (types.has(name) && types.get(name).registry === 'core') continue; // core wins; extensions MUST NOT redefine
40
+ types.set(name, { ...def, type: name, registry: ext.registry || file });
41
+ }
42
+ }
43
+ }
44
+
45
+ const contractTypes = new Set(Object.keys(core.contract_primitives || {}));
46
+ const portabilityClasses = Object.keys(core.portability?.classes || {});
47
+ return { types, contractTypes, portabilityClasses, core };
48
+ }
49
+
50
+ /** Is this document type append-only (events appended, never rewritten)? */
51
+ export function isAppendType(typeDef) {
52
+ if (!typeDef) return false;
53
+ if (typeof typeDef.append_only === 'boolean') return typeDef.append_only;
54
+ return APPEND_TYPES.has(typeDef.type);
55
+ }
56
+
57
+ /**
58
+ * Resolve the effective portability class of a file: the most restrictive of
59
+ * the type default and an `x_portability` instance override (§5.5).
60
+ */
61
+ export function resolvePortability(typeDef, frontmatter) {
62
+ const order = { structural: 0, resource_bound: 1, tenant_private: 2 };
63
+ const base = typeDef?.portability || 'structural';
64
+ const override = frontmatter?.x_portability;
65
+ if (override && order[override] != null && order[override] > (order[base] ?? 0)) return override;
66
+ return base;
67
+ }