@gregiteen/ssss-cli 0.13.0 → 0.14.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/CHANGELOG.md +28 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/ssss-spec.md +112 -7
- package/package.json +1 -1
- package/scripts/cmd-adapter.mjs +2 -0
- package/scripts/cmd-bridge.mjs +113 -0
- package/scripts/cmd-import.mjs +21 -1
- package/scripts/cmd-provision.mjs +33 -2
- package/scripts/cmd-serve.mjs +155 -0
- package/scripts/cmd-upgrade.mjs +110 -0
- package/scripts/conformance-09.mjs +20 -0
- package/scripts/conformance.mjs +473 -1
- package/scripts/ssss.mjs +7 -1
- package/src/authorization.mjs +28 -5
- package/src/bundle.mjs +469 -27
- package/src/kernel.mjs +28 -3
- package/src/projections.mjs +93 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ssss upgrade` — move an installed capability bundle to a new version via
|
|
3
|
+
* structural-only patch envelopes (spec §17.4). Refuses any tenant_private
|
|
4
|
+
* modification.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { exportBundle, upgradeBundle, importBundle } from '../src/bundle.mjs';
|
|
9
|
+
import { createEngine } from '../src/engine.mjs';
|
|
10
|
+
import { parseArgs, usage, die, wantsHelp } from './lib/cli.mjs';
|
|
11
|
+
|
|
12
|
+
const HELP = usage({
|
|
13
|
+
summary: 'Upgrade an installed bundle to a new version via structural-only patches (§17.4).',
|
|
14
|
+
usage: 'ssss upgrade <bundle.ucw.json> --vault <dir> [options]',
|
|
15
|
+
options: [
|
|
16
|
+
['--vault <dir>', 'Target vault directory. Required.'],
|
|
17
|
+
['--from <file>', 'Baseline bundle or plan (if omitted, exported from vault).'],
|
|
18
|
+
['--workspace <id>', 'Target workspace id (default: ws-upgrade).'],
|
|
19
|
+
['--prefix <path>', 'Path prefix for the bundle within the vault.'],
|
|
20
|
+
['--dry-run', 'Validate every patch envelope without committing.'],
|
|
21
|
+
['--json', 'Emit machine-readable JSON upgrade summary.'],
|
|
22
|
+
['--registry <dir>', 'Registry directory (default: core registry).'],
|
|
23
|
+
],
|
|
24
|
+
examples: [
|
|
25
|
+
'ssss upgrade festival-v2.ucw.json --vault ./tenant',
|
|
26
|
+
'ssss upgrade festival-v2.ucw.json --vault ./tenant --dry-run',
|
|
27
|
+
'ssss upgrade festival-v2.ucw.json --vault ./tenant --from festival-v1.ucw.json',
|
|
28
|
+
],
|
|
29
|
+
seeAlso: ['ssss import', 'ssss provision', 'ssss export'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export async function run(argv) {
|
|
33
|
+
if (wantsHelp(argv)) { console.log(HELP); return; }
|
|
34
|
+
const { positionals, flags } = parseArgs(argv, { booleans: ['dry-run', 'json'] });
|
|
35
|
+
const file = positionals[0];
|
|
36
|
+
if (!file) die('upgrade needs a new bundle file.\n\n' + HELP);
|
|
37
|
+
if (!fs.existsSync(file)) die(`no such file: ${file}`);
|
|
38
|
+
if (!flags.vault) die('upgrade needs --vault <dir>.\n\n' + HELP);
|
|
39
|
+
|
|
40
|
+
const vault = path.resolve(flags.vault);
|
|
41
|
+
if (!fs.existsSync(vault)) die(`no such vault directory: ${vault}`);
|
|
42
|
+
|
|
43
|
+
let nextBundle;
|
|
44
|
+
try { nextBundle = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
45
|
+
catch (err) { die(`bundle is not valid JSON: ${err.message}`); }
|
|
46
|
+
|
|
47
|
+
let installedBundle;
|
|
48
|
+
if (flags.from) {
|
|
49
|
+
if (!fs.existsSync(flags.from)) die(`no such baseline file: ${flags.from}`);
|
|
50
|
+
try { installedBundle = JSON.parse(fs.readFileSync(flags.from, 'utf8')); }
|
|
51
|
+
catch (err) { die(`baseline file is not valid JSON: ${err.message}`); }
|
|
52
|
+
} else {
|
|
53
|
+
try {
|
|
54
|
+
installedBundle = exportBundle(vault, {
|
|
55
|
+
profile: 'backup',
|
|
56
|
+
registryDir: flags.registry,
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
die(`failed to inspect current vault state: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const upgrade = upgradeBundle(installedBundle, nextBundle, {
|
|
64
|
+
workspaceId: flags.workspace || 'ws-upgrade',
|
|
65
|
+
pathPrefix: flags.prefix || '',
|
|
66
|
+
dryRun: !!flags['dry-run'],
|
|
67
|
+
registryDir: flags.registry,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!upgrade.ok) {
|
|
71
|
+
for (const e of upgrade.errors) console.error(`✗ upgrade error: ${e}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const declared = nextBundle?.manifest?.required_extensions;
|
|
76
|
+
const registryNames = Array.isArray(declared) && declared.length ? declared : null;
|
|
77
|
+
const engine = createEngine({ registryDir: flags.registry, ...(registryNames ? { registryNames } : {}) });
|
|
78
|
+
|
|
79
|
+
const result = await importBundle(upgrade.plan, vault, engine);
|
|
80
|
+
const failed = result.results.filter((r) => !r.success);
|
|
81
|
+
|
|
82
|
+
if (flags.json) {
|
|
83
|
+
const summary = {
|
|
84
|
+
ok: result.ok,
|
|
85
|
+
from_version: upgrade.fromVersion,
|
|
86
|
+
to_version: upgrade.toVersion,
|
|
87
|
+
migration: upgrade.migration,
|
|
88
|
+
patches_count: upgrade.patchesCount,
|
|
89
|
+
operations_count: upgrade.operationsCount,
|
|
90
|
+
committed: result.committed,
|
|
91
|
+
would_commit: result.wouldCommit,
|
|
92
|
+
failed_count: failed.length,
|
|
93
|
+
dry_run: !!flags['dry-run'],
|
|
94
|
+
};
|
|
95
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
96
|
+
if (!result.ok) process.exit(1);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (flags['dry-run']) {
|
|
101
|
+
console.log(`${result.ok ? '✓' : '✗'} upgrade dry-run ${upgrade.fromVersion} → ${upgrade.toVersion}: ${result.wouldCommit || 0} would apply, ${upgrade.plan.length - (result.wouldCommit || 0)} unchanged, ${failed.length} failed → ${vault}`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(`${result.ok ? '✓' : '✗'} upgrade ${upgrade.fromVersion} → ${upgrade.toVersion}: ${result.committed} committed, ${upgrade.plan.length - result.committed} unchanged, ${failed.length} failed → ${vault}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (failed.length) {
|
|
107
|
+
for (const r of failed) console.error(` ✗ ${r.path}: ${(r.validation?.errors || []).join('; ')}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -303,6 +303,12 @@ async function checkCapabilities(root, check) {
|
|
|
303
303
|
capabilityCovers('ssss:assistant:*', 'ssss:assistant:create') && !capabilityCovers('ssss:assistant:*', 'ssss:rule:create'));
|
|
304
304
|
check('capabilities: a namespace wildcard covers its namespace only',
|
|
305
305
|
capabilityCovers('ssss:*', 'ssss:rule:patch') && !capabilityCovers('acme:*', 'ssss:rule:patch') && !capabilityCovers('ss:*', 'ssss:rule:patch'));
|
|
306
|
+
check('capabilities: path selector @<glob> grants matching path',
|
|
307
|
+
capabilityCovers('ssss:rule:*@rules/marketing/**', 'ssss:rule:create', 'rules/marketing/promo.md')
|
|
308
|
+
&& !capabilityCovers('ssss:rule:*@rules/marketing/**', 'ssss:rule:create', 'rules/finance/audit.md'));
|
|
309
|
+
check('capabilities: path selector @<glob> with *:* grants only matching paths',
|
|
310
|
+
capabilityCovers('*:*@capabilities/messaging/**', 'ssss:rule:create', 'capabilities/messaging/rules.md')
|
|
311
|
+
&& !capabilityCovers('*:*@capabilities/messaging/**', 'ssss:rule:create', 'capabilities/other/rules.md'));
|
|
306
312
|
|
|
307
313
|
const engine = createEngine();
|
|
308
314
|
const vault = path.join(root, 'legacy-roles');
|
|
@@ -318,6 +324,20 @@ async function checkCapabilities(root, check) {
|
|
|
318
324
|
check('legacy roles: *:<type> grants writes to that type', (await write('lr-6', 'assistants/c/ASSISTANT.md', doc('assistant', 'C'), 'typed')).success);
|
|
319
325
|
check('legacy roles: write:* grants writes to every type', (await write('lr-7', 'workflows/d/WORKFLOW.md', doc('workflow', 'D'), 'owner')).success);
|
|
320
326
|
check('legacy roles: an unknown role is forbidden, not promoted', (await write('lr-8', 'rules/e.md', doc('rule', 'E'), 'ghost')).error?.code === 'forbidden');
|
|
327
|
+
|
|
328
|
+
const pathPrincipal = {
|
|
329
|
+
id: 'scoped-user',
|
|
330
|
+
kind: 'agent',
|
|
331
|
+
workspaceIds: ['w'],
|
|
332
|
+
capabilities: ['ssss:rule:*@rules/marketing/**'],
|
|
333
|
+
authentication: { provider: 'token', assurance: 'verified' },
|
|
334
|
+
};
|
|
335
|
+
const writeScoped = (key, pathName, content) => engine.processOperation(
|
|
336
|
+
{ type: 'operation', workspace_id: 'w', idempotency_key: key, path: pathName, content }, vault, { principal: pathPrincipal });
|
|
337
|
+
check('kernel authorization: path-scoped grant allows matching path (G5)',
|
|
338
|
+
(await writeScoped('ps-1', 'rules/marketing/banner.md', doc('rule', 'Banner'))).success);
|
|
339
|
+
check('kernel authorization: path-scoped grant forbids non-matching path (G5)',
|
|
340
|
+
(await writeScoped('ps-2', 'rules/engineering/lint.md', doc('rule', 'Lint'))).error?.code === 'forbidden');
|
|
321
341
|
}
|
|
322
342
|
|
|
323
343
|
// Resource hooks (spec §6.3): every prepare ends in exactly one finalize or
|
package/scripts/conformance.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
validateBundle,
|
|
27
27
|
provisionBundle,
|
|
28
28
|
importBundle,
|
|
29
|
+
upgradeBundle,
|
|
29
30
|
} from '../src/bundle.mjs';
|
|
30
31
|
import { parseDocument } from '../src/frontmatter.mjs';
|
|
31
32
|
import { loadRegistries } from '../src/registry.mjs';
|
|
@@ -41,6 +42,7 @@ import { auditRegistryFieldUsage } from './audit-registry-field-usage.mjs';
|
|
|
41
42
|
import { validateSkills } from './validate-skills.mjs';
|
|
42
43
|
import { auditSpecExamples } from './audit-spec-examples.mjs';
|
|
43
44
|
import { runKernel09Conformance } from './conformance-09.mjs';
|
|
45
|
+
import { createServer } from './cmd-serve.mjs';
|
|
44
46
|
|
|
45
47
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
46
48
|
const FIXTURES = path.resolve(__dirname, '..', 'conformance', 'fixtures.json');
|
|
@@ -375,6 +377,335 @@ async function runBundleConformance() {
|
|
|
375
377
|
fs.rmSync(missingVault, { recursive: true, force: true });
|
|
376
378
|
}
|
|
377
379
|
|
|
380
|
+
// Phase 3 Deliverables: S2, S3, S4, S11, D4 bindings, legacy bundle
|
|
381
|
+
const multiVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-multi-'));
|
|
382
|
+
try {
|
|
383
|
+
const engine = createEngine({ registryDir: REGISTRY_DIR });
|
|
384
|
+
// S2: Two-workspace install into different workspaces with prefixes
|
|
385
|
+
const provAlpha = provisionBundle(bundle, {
|
|
386
|
+
workspaceId: 'ws-alpha',
|
|
387
|
+
pathPrefix: 'apps/alpha',
|
|
388
|
+
parameters: { business_name: 'Alpha Fest', domain: 'alpha.example' },
|
|
389
|
+
});
|
|
390
|
+
const provBeta = provisionBundle(bundle, {
|
|
391
|
+
workspaceId: 'ws-beta',
|
|
392
|
+
pathPrefix: 'apps/beta',
|
|
393
|
+
parameters: { business_name: 'Beta Fest', domain: 'beta.example' },
|
|
394
|
+
});
|
|
395
|
+
check('two-workspace provisioning succeeds for both instances', provAlpha.ok && provBeta.ok,
|
|
396
|
+
`alpha=${provAlpha.ok} beta=${provBeta.ok}`);
|
|
397
|
+
|
|
398
|
+
// Check slug remapping and prefixing
|
|
399
|
+
const alphaSlugs = provAlpha.plan.map((e) => parseDocument(e.content).data.slug).filter(Boolean);
|
|
400
|
+
const betaSlugs = provBeta.plan.map((e) => parseDocument(e.content).data.slug).filter(Boolean);
|
|
401
|
+
const distinctSlugs = alphaSlugs.every((s) => !betaSlugs.includes(s) && s.startsWith('apps-alpha-'));
|
|
402
|
+
check('two-workspace install isolates slugs and prefixes paths without alias collision (S2)',
|
|
403
|
+
distinctSlugs && provAlpha.plan.every((e) => e.path.startsWith('apps/alpha/')) &&
|
|
404
|
+
provBeta.plan.every((e) => e.path.startsWith('apps/beta/')));
|
|
405
|
+
|
|
406
|
+
// Check link integrity in both targets (no dangling links)
|
|
407
|
+
check('two-workspace install rewrites internal links and resolves all targets (S2)',
|
|
408
|
+
provAlpha.danglingLinks.length === 0 && provBeta.danglingLinks.length === 0);
|
|
409
|
+
|
|
410
|
+
// Import both into the same vault
|
|
411
|
+
const importAlpha = await importBundle(provAlpha.plan, multiVault, engine);
|
|
412
|
+
const importBeta = await importBundle(provBeta.plan, multiVault, engine);
|
|
413
|
+
check('two-workspace import commits both instances into the same vault without conflict (S2)',
|
|
414
|
+
importAlpha.ok && importBeta.ok &&
|
|
415
|
+
importAlpha.committed === bundle.files.length && importBeta.committed === bundle.files.length);
|
|
416
|
+
|
|
417
|
+
// S3: Determinism
|
|
418
|
+
const provBeta2 = provisionBundle(bundle, {
|
|
419
|
+
workspaceId: 'ws-beta',
|
|
420
|
+
pathPrefix: 'apps/beta',
|
|
421
|
+
parameters: { business_name: 'Beta Fest', domain: 'beta.example' },
|
|
422
|
+
});
|
|
423
|
+
check('provisioning is deterministic: equal inputs yield byte-identical plan (S3)',
|
|
424
|
+
JSON.stringify(provBeta.plan) === JSON.stringify(provBeta2.plan));
|
|
425
|
+
|
|
426
|
+
const reimportBeta = await importBundle(provBeta2.plan, multiVault, engine);
|
|
427
|
+
check('re-importing provisioned plan commits 0 new documents (S3)',
|
|
428
|
+
reimportBeta.ok && reimportBeta.committed === 0);
|
|
429
|
+
|
|
430
|
+
// D4: Resource bindings emitted in result
|
|
431
|
+
check('provisionBundle emits resource bindings with relations and modes (D4)',
|
|
432
|
+
provAlpha.bindings.length > 0 &&
|
|
433
|
+
provAlpha.bindings.some((b) => b.type === 'domain' && b.value === 'alpha.example' && b.relation === 'owns_domain'));
|
|
434
|
+
|
|
435
|
+
// D4: Unbound required resource fails when not dryRun
|
|
436
|
+
const unboundBundle = JSON.parse(JSON.stringify(bundle));
|
|
437
|
+
const provUnbound = provisionBundle(unboundBundle, {
|
|
438
|
+
workspaceId: 'ws-unbound',
|
|
439
|
+
parameters: { business_name: 'Unbound Fest' }, // domain omitted!
|
|
440
|
+
dryRun: false,
|
|
441
|
+
});
|
|
442
|
+
check('unbound required resource fails provisioning when dryRun is false (D4)',
|
|
443
|
+
!provUnbound.ok && provUnbound.errors.some((e) => e.includes('unbound required resource')));
|
|
444
|
+
|
|
445
|
+
const provUnboundDry = provisionBundle(unboundBundle, {
|
|
446
|
+
workspaceId: 'ws-unbound',
|
|
447
|
+
parameters: { business_name: 'Unbound Fest' },
|
|
448
|
+
dryRun: true,
|
|
449
|
+
});
|
|
450
|
+
check('unbound required resource error is suppressed when dryRun is true (D4)',
|
|
451
|
+
!provUnboundDry.errors.some((e) => e.includes('unbound required resource')));
|
|
452
|
+
|
|
453
|
+
// S4: Dependencies
|
|
454
|
+
const depFiles = [
|
|
455
|
+
{ path: 'rules/dep.md', content: '---\ntype: rule\ntitle: Dep\ndescription: Dep.\ntimestamp: 2026-07-02T00:00:00Z\nname: Dep\n---\nDep body.\n' },
|
|
456
|
+
];
|
|
457
|
+
const depBundle = {
|
|
458
|
+
manifest: {
|
|
459
|
+
name: 'core-dep',
|
|
460
|
+
description: 'Core dependency bundle',
|
|
461
|
+
version: '1.0.0',
|
|
462
|
+
exported_at: '2026-07-02T00:00:00Z',
|
|
463
|
+
ssss_core_version: '0.9',
|
|
464
|
+
required_extensions: [],
|
|
465
|
+
export_profile: 'template',
|
|
466
|
+
primitive_inventory: { rule: 1 },
|
|
467
|
+
provisioning: [],
|
|
468
|
+
parameters: [],
|
|
469
|
+
file_count: 1,
|
|
470
|
+
provenance: {
|
|
471
|
+
content_hash: contentHash(depFiles),
|
|
472
|
+
exporter: DEFAULT_EXPORTER,
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
files: depFiles,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const bundleWithDep = JSON.parse(JSON.stringify(bundle));
|
|
479
|
+
bundleWithDep.manifest.dependencies = {
|
|
480
|
+
primitives: bundle.manifest.dependencies?.primitives || [],
|
|
481
|
+
extensions: bundle.manifest.dependencies?.extensions || [],
|
|
482
|
+
migrations: [],
|
|
483
|
+
integrity: bundle.manifest.dependencies?.integrity,
|
|
484
|
+
bundles: [
|
|
485
|
+
{ id: 'core-dep', installMode: 'required' },
|
|
486
|
+
{ id: 'opt-pack', installMode: 'optional', description: 'Optional telemetry pack' },
|
|
487
|
+
],
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// Missing required dependency fails
|
|
491
|
+
const provMissingDep = provisionBundle(bundleWithDep, {
|
|
492
|
+
workspaceId: 'ws-dep-test',
|
|
493
|
+
parameters: { business_name: 'Test', domain: 'test.example' },
|
|
494
|
+
});
|
|
495
|
+
check('bundle with missing required dependency fails clearly (S4)',
|
|
496
|
+
!provMissingDep.ok && provMissingDep.errors.some((e) => e.includes("missing required bundle dependency 'core-dep'")));
|
|
497
|
+
|
|
498
|
+
// Supplied dependency installs first and suggested dependencies are returned
|
|
499
|
+
const provWithDep = provisionBundle(bundleWithDep, {
|
|
500
|
+
workspaceId: 'ws-dep-test',
|
|
501
|
+
parameters: { business_name: 'Test', domain: 'test.example' },
|
|
502
|
+
dependencies: { 'core-dep': depBundle },
|
|
503
|
+
});
|
|
504
|
+
check('bundle with supplied dependency provisions dependency first (S4)',
|
|
505
|
+
provWithDep.ok &&
|
|
506
|
+
provWithDep.dependencies.installed.includes('core-dep') &&
|
|
507
|
+
provWithDep.plan[0].path === 'rules/dep.md' &&
|
|
508
|
+
provWithDep.dependencies.suggested.some((s) => s.id === 'opt-pack' && s.installMode === 'optional'));
|
|
509
|
+
|
|
510
|
+
// Dependency cycle detection
|
|
511
|
+
const cyclicBundleA = JSON.parse(JSON.stringify(depBundle));
|
|
512
|
+
cyclicBundleA.manifest.name = 'cycle-a';
|
|
513
|
+
cyclicBundleA.manifest.dependencies = {
|
|
514
|
+
primitives: ['rule'], extensions: [], migrations: [],
|
|
515
|
+
integrity: { content_hash: cyclicBundleA.manifest.provenance.content_hash, ssss_core_version: '0.9' },
|
|
516
|
+
bundles: [{ id: 'cycle-b', installMode: 'required' }],
|
|
517
|
+
};
|
|
518
|
+
const cyclicBundleB = JSON.parse(JSON.stringify(depBundle));
|
|
519
|
+
cyclicBundleB.manifest.name = 'cycle-b';
|
|
520
|
+
cyclicBundleB.manifest.dependencies = {
|
|
521
|
+
primitives: ['rule'], extensions: [], migrations: [],
|
|
522
|
+
integrity: { content_hash: cyclicBundleB.manifest.provenance.content_hash, ssss_core_version: '0.9' },
|
|
523
|
+
bundles: [{ id: 'cycle-a', installMode: 'required' }],
|
|
524
|
+
};
|
|
525
|
+
const provCycle = provisionBundle(cyclicBundleA, {
|
|
526
|
+
dependencies: { 'cycle-b': cyclicBundleB },
|
|
527
|
+
});
|
|
528
|
+
check('dependency cycle is rejected with clear cycle error (D5)',
|
|
529
|
+
!provCycle.ok && provCycle.errors.some((e) => e.includes('dependency cycle detected')));
|
|
530
|
+
|
|
531
|
+
// S11 / D8: Base-version conflict protection
|
|
532
|
+
const durVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-dur-'));
|
|
533
|
+
try {
|
|
534
|
+
const v1Envelope = {
|
|
535
|
+
type: 'operation', workspace_id: 'ws-dur', idempotency_key: 'v1-init',
|
|
536
|
+
path: 'rules/managed.md', actor: { role: 'system' },
|
|
537
|
+
content: '---\ntype: rule\ntitle: Base Rule\ndescription: Base version.\ntimestamp: 2026-07-02T00:00:00Z\nname: Base Rule\n---\nBase content.\n',
|
|
538
|
+
};
|
|
539
|
+
const v1Res = await engine.processOperation(v1Envelope, durVault);
|
|
540
|
+
check('v1 initial document commits cleanly', v1Res.success);
|
|
541
|
+
const v1Hash = 'sha256:' + crypto.createHash('sha256').update(Buffer.from(v1Envelope.content, 'utf8')).digest('hex');
|
|
542
|
+
|
|
543
|
+
// Local edit: someone modifies the document locally
|
|
544
|
+
const v2LocalEnvelope = {
|
|
545
|
+
type: 'operation', workspace_id: 'ws-dur', idempotency_key: 'v2-local',
|
|
546
|
+
path: 'rules/managed.md', actor: { role: 'admin' },
|
|
547
|
+
content: '---\ntype: rule\ntitle: Base Rule\ndescription: Modified locally.\ntimestamp: 2026-07-03T00:00:00Z\nname: Base Rule\n---\nModified locally.\n',
|
|
548
|
+
};
|
|
549
|
+
const v2Res = await engine.processOperation(v2LocalEnvelope, durVault);
|
|
550
|
+
check('local edit v2 commits cleanly', v2Res.success);
|
|
551
|
+
|
|
552
|
+
// Attempt to deliver an older/divergent content with expected_before_hash === v1Hash
|
|
553
|
+
const staleDeliveryEnvelope = {
|
|
554
|
+
type: 'operation', workspace_id: 'ws-dur', idempotency_key: 'stale-delivery',
|
|
555
|
+
path: 'rules/managed.md', actor: { role: 'system' },
|
|
556
|
+
expected_before_hash: v1Hash,
|
|
557
|
+
content: '---\ntype: rule\ntitle: Base Rule\ndescription: Stale update.\ntimestamp: 2026-07-04T00:00:00Z\nname: Base Rule\n---\nStale delivery.\n',
|
|
558
|
+
};
|
|
559
|
+
const staleRes = await engine.processOperation(staleDeliveryEnvelope, durVault);
|
|
560
|
+
check('stale delivery with outdated expected_before_hash fails with version_conflict (S11/D8)',
|
|
561
|
+
staleRes.success === false && staleRes.error?.code === 'version_conflict');
|
|
562
|
+
|
|
563
|
+
// Verify that local edit survived
|
|
564
|
+
const diskContent = fs.readFileSync(path.join(durVault, 'rules/managed.md'), 'utf8');
|
|
565
|
+
check('local edit survives intact after rejected stale delivery (S11/D8)',
|
|
566
|
+
diskContent.includes('Modified locally.'));
|
|
567
|
+
} finally {
|
|
568
|
+
fs.rmSync(durVault, { recursive: true, force: true });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Phase 4 Deliverables: S5 upgradeBundle, migration record, tenant_private refusal
|
|
572
|
+
const upgradeVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-upg-'));
|
|
573
|
+
try {
|
|
574
|
+
// Baseline bundle v1.0.0
|
|
575
|
+
const v1Files = [
|
|
576
|
+
{
|
|
577
|
+
path: 'rules/terms.md',
|
|
578
|
+
content: '---\ntype: rule\ntitle: Terms v1\ndescription: Baseline terms.\ntimestamp: 2026-07-02T00:00:00Z\nname: Terms v1\n---\nTerms v1 content.\n',
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
path: 'workflows/support.md',
|
|
582
|
+
content: '---\ntype: workflow\ntitle: Support Flow\ndescription: Support.\ntimestamp: 2026-07-02T00:00:00Z\nname: Support Flow\n---\nSupport body.\n',
|
|
583
|
+
},
|
|
584
|
+
];
|
|
585
|
+
const v1Bundle = {
|
|
586
|
+
manifest: {
|
|
587
|
+
name: 'test-app',
|
|
588
|
+
description: 'Test application bundle',
|
|
589
|
+
version: '1.0.0',
|
|
590
|
+
exported_at: '2026-07-02T00:00:00Z',
|
|
591
|
+
ssss_core_version: '0.9',
|
|
592
|
+
required_extensions: [],
|
|
593
|
+
export_profile: 'template',
|
|
594
|
+
primitive_inventory: { rule: 1, workflow: 1 },
|
|
595
|
+
provisioning: [],
|
|
596
|
+
parameters: [],
|
|
597
|
+
file_count: 2,
|
|
598
|
+
provenance: { content_hash: contentHash(v1Files), exporter: DEFAULT_EXPORTER },
|
|
599
|
+
},
|
|
600
|
+
files: v1Files,
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// Provision and import baseline v1
|
|
604
|
+
const provV1 = provisionBundle(v1Bundle, { workspaceId: 'ws-upg-test' });
|
|
605
|
+
const importV1 = await importBundle(provV1.plan, upgradeVault, engine);
|
|
606
|
+
check('upgrade fixture baseline v1 imports cleanly', importV1.ok && importV1.committed === 2);
|
|
607
|
+
|
|
608
|
+
// Operator creates a tenant_private task document in the vault
|
|
609
|
+
const tenantTaskEnvelope = {
|
|
610
|
+
type: 'operation',
|
|
611
|
+
workspace_id: 'ws-upg-test',
|
|
612
|
+
idempotency_key: 'tenant-ticket-1',
|
|
613
|
+
path: 'tasks/ticket-101.md',
|
|
614
|
+
actor: { role: 'admin' },
|
|
615
|
+
content: '---\ntype: task\ntitle: Customer Ticket 101\ndescription: Urgent assistance.\ntimestamp: 2026-07-03T00:00:00Z\nname: Ticket 101\nstatus: pending\npriority: urgent\ncategory: support\n---\nTenant private customer message.\n',
|
|
616
|
+
};
|
|
617
|
+
const taskRes = await engine.processOperation(tenantTaskEnvelope, upgradeVault);
|
|
618
|
+
check('tenant_private task commits into upgrade vault', taskRes.success);
|
|
619
|
+
|
|
620
|
+
// Build next bundle v1.1.0 with structural updates
|
|
621
|
+
const v2Files = [
|
|
622
|
+
{
|
|
623
|
+
path: 'rules/terms.md',
|
|
624
|
+
content: '---\ntype: rule\ntitle: Terms v2\ndescription: Updated terms for v2.\ntimestamp: 2026-07-04T00:00:00Z\nname: Terms v2\n---\nUpdated Terms v2 content.\n',
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
path: 'workflows/support.md',
|
|
628
|
+
content: '---\ntype: workflow\ntitle: Support Flow\ndescription: Support.\ntimestamp: 2026-07-02T00:00:00Z\nname: Support Flow\n---\nSupport body.\n', // unchanged
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
path: 'rules/refunds.md',
|
|
632
|
+
content: '---\ntype: rule\ntitle: Refund Policy\ndescription: New refund rules.\ntimestamp: 2026-07-04T00:00:00Z\nname: Refund Policy\n---\nNew refund policy.\n', // newly added structural file
|
|
633
|
+
},
|
|
634
|
+
];
|
|
635
|
+
const v2Bundle = {
|
|
636
|
+
manifest: {
|
|
637
|
+
name: 'test-app',
|
|
638
|
+
description: 'Test application bundle',
|
|
639
|
+
version: '1.1.0',
|
|
640
|
+
exported_at: '2026-07-04T00:00:00Z',
|
|
641
|
+
ssss_core_version: '0.9',
|
|
642
|
+
required_extensions: [],
|
|
643
|
+
export_profile: 'template',
|
|
644
|
+
primitive_inventory: { rule: 2, workflow: 1 },
|
|
645
|
+
provisioning: [],
|
|
646
|
+
parameters: [],
|
|
647
|
+
file_count: 3,
|
|
648
|
+
provenance: { content_hash: contentHash(v2Files), exporter: DEFAULT_EXPORTER },
|
|
649
|
+
},
|
|
650
|
+
files: v2Files,
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// Plan upgrade
|
|
654
|
+
const upgPlan = upgradeBundle(v1Bundle, v2Bundle, {
|
|
655
|
+
workspaceId: 'ws-upg-test',
|
|
656
|
+
registryDir: REGISTRY_DIR,
|
|
657
|
+
});
|
|
658
|
+
check('upgradeBundle generates plan with patch and migration (D6)',
|
|
659
|
+
upgPlan.ok && upgPlan.patchesCount === 1 && upgPlan.operationsCount === 2);
|
|
660
|
+
|
|
661
|
+
// Apply upgrade to vault
|
|
662
|
+
const upgImport = await importBundle(upgPlan.plan, upgradeVault, engine);
|
|
663
|
+
check('upgrade import applies structural changes and records migration (S5)',
|
|
664
|
+
upgImport.ok && upgImport.committed === 3);
|
|
665
|
+
|
|
666
|
+
// Verify structural document is updated
|
|
667
|
+
const updatedTerms = fs.readFileSync(path.join(upgradeVault, 'rules/terms.md'), 'utf8');
|
|
668
|
+
check('structural document was updated by upgrade patch (S5)',
|
|
669
|
+
updatedTerms.includes('Terms v2') && updatedTerms.includes('Updated Terms v2 content.'));
|
|
670
|
+
|
|
671
|
+
// Verify newly added structural file was created
|
|
672
|
+
check('new structural document was created during upgrade (S5)',
|
|
673
|
+
fs.existsSync(path.join(upgradeVault, 'rules/refunds.md')));
|
|
674
|
+
|
|
675
|
+
// Verify migration primitive record exists on disk
|
|
676
|
+
check('migration primitive record was committed during upgrade (D6)',
|
|
677
|
+
fs.existsSync(path.join(upgradeVault, upgPlan.migration.path)));
|
|
678
|
+
|
|
679
|
+
// Verify tenant_private document remains completely untouched
|
|
680
|
+
const tenantTaskDisk = fs.readFileSync(path.join(upgradeVault, 'tasks/ticket-101.md'), 'utf8');
|
|
681
|
+
check('tenant_private task document is completely untouched after upgrade (S5)',
|
|
682
|
+
tenantTaskDisk.includes('Tenant private customer message.'));
|
|
683
|
+
|
|
684
|
+
// Violating patch rejected: attempt to patch tenant_private file with intent: 'upgrade'
|
|
685
|
+
const badUpgradeEnvelope = {
|
|
686
|
+
type: 'patch',
|
|
687
|
+
workspace_id: 'ws-upg-test',
|
|
688
|
+
idempotency_key: 'bad-upg-patch',
|
|
689
|
+
path: 'tasks/ticket-101.md',
|
|
690
|
+
intent: 'upgrade',
|
|
691
|
+
actor: { role: 'system' },
|
|
692
|
+
patches: { title: 'Overwritten Ticket' },
|
|
693
|
+
};
|
|
694
|
+
const badRes = await engine.processOperation(badUpgradeEnvelope, upgradeVault);
|
|
695
|
+
check('engine refuses upgrade envelope targeting tenant_private document (S5/D6)',
|
|
696
|
+
badRes.success === false && badRes.error?.code === 'forbidden');
|
|
697
|
+
|
|
698
|
+
// Verify tenant task is still untouched after rejected attempt
|
|
699
|
+
const tenantTaskAfterBad = fs.readFileSync(path.join(upgradeVault, 'tasks/ticket-101.md'), 'utf8');
|
|
700
|
+
check('tenant_private document remains intact after engine rejected violating upgrade patch (S5/D6)',
|
|
701
|
+
tenantTaskAfterBad.includes('Customer Ticket 101') && !tenantTaskAfterBad.includes('Overwritten Ticket'));
|
|
702
|
+
} finally {
|
|
703
|
+
fs.rmSync(upgradeVault, { recursive: true, force: true });
|
|
704
|
+
}
|
|
705
|
+
} finally {
|
|
706
|
+
fs.rmSync(multiVault, { recursive: true, force: true });
|
|
707
|
+
}
|
|
708
|
+
|
|
378
709
|
let pass = 0;
|
|
379
710
|
for (const c of checks) {
|
|
380
711
|
if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
|
|
@@ -924,6 +1255,78 @@ async function runCliSmokeConformance() {
|
|
|
924
1255
|
check('ssss migrate 0.8-to-0.9 dry-run emits backup manifest without writes',
|
|
925
1256
|
migrateOut.dry_run === true && migrateOut.writes_performed === 0 && Array.isArray(migrateOut.backup_manifest),
|
|
926
1257
|
JSON.stringify(migrateOut));
|
|
1258
|
+
|
|
1259
|
+
const v1Path = path.join(tmp, 'v1.ucw.json');
|
|
1260
|
+
execFileSync('node', [
|
|
1261
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
1262
|
+
'export', vault,
|
|
1263
|
+
'--out', v1Path,
|
|
1264
|
+
'--profile', 'template',
|
|
1265
|
+
]);
|
|
1266
|
+
const upgDryOut = JSON.parse(execFileSync('node', [
|
|
1267
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
1268
|
+
'upgrade', v1Path,
|
|
1269
|
+
'--vault', vault,
|
|
1270
|
+
'--from', v1Path,
|
|
1271
|
+
'--dry-run',
|
|
1272
|
+
'--json',
|
|
1273
|
+
], { encoding: 'utf8' }));
|
|
1274
|
+
check('ssss upgrade --dry-run reports 0 needed patches for identical version',
|
|
1275
|
+
upgDryOut.ok === true && upgDryOut.patches_count === 0,
|
|
1276
|
+
JSON.stringify(upgDryOut));
|
|
1277
|
+
|
|
1278
|
+
// ssss serve smoke
|
|
1279
|
+
const serveVault = path.join(tmp, 'serve-vault');
|
|
1280
|
+
fs.mkdirSync(serveVault, { recursive: true });
|
|
1281
|
+
const serveInstance = createServer({ vault: serveVault, token: 'smoke-token', registryDir: REGISTRY_DIR });
|
|
1282
|
+
await new Promise((resolve) => serveInstance.listen(0, '127.0.0.1', resolve));
|
|
1283
|
+
const servePort = serveInstance.address().port;
|
|
1284
|
+
try {
|
|
1285
|
+
const healthRes = await fetch(`http://127.0.0.1:${servePort}/health`);
|
|
1286
|
+
const healthData = await healthRes.json();
|
|
1287
|
+
check('ssss serve health endpoint returns 200 with ok and version',
|
|
1288
|
+
healthRes.status === 200 && healthData.ok === true && typeof healthData.version === 'string',
|
|
1289
|
+
JSON.stringify(healthData));
|
|
1290
|
+
|
|
1291
|
+
const serveOpRes = await fetch(`http://127.0.0.1:${servePort}/v1/operations`, {
|
|
1292
|
+
method: 'POST',
|
|
1293
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer smoke-token' },
|
|
1294
|
+
body: JSON.stringify({
|
|
1295
|
+
type: 'operation',
|
|
1296
|
+
workspace_id: 'ws-smoke',
|
|
1297
|
+
path: 'rules/smoke.md',
|
|
1298
|
+
idempotency_key: 'smoke-serve-1',
|
|
1299
|
+
actor: { role: 'admin', agent_id: 'smoke-agent' },
|
|
1300
|
+
content: '---\ntype: rule\nname: "Smoke Rule"\ntitle: "Smoke Rule"\ndescription: "Smoke test."\ntimestamp: 2026-07-02T00:00:00Z\n---\nRule.\n',
|
|
1301
|
+
}),
|
|
1302
|
+
});
|
|
1303
|
+
const serveOpData = await serveOpRes.json();
|
|
1304
|
+
check('ssss serve commits valid operation envelope with 201 Created',
|
|
1305
|
+
serveOpRes.status === 201 && serveOpData.success === true,
|
|
1306
|
+
JSON.stringify(serveOpData));
|
|
1307
|
+
} finally {
|
|
1308
|
+
await new Promise((resolve) => serveInstance.close(resolve));
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// ssss bridge smoke
|
|
1312
|
+
const bridgeVault = path.join(tmp, 'bridge-vault');
|
|
1313
|
+
fs.mkdirSync(bridgeVault, { recursive: true });
|
|
1314
|
+
const bridgeInput = JSON.stringify({
|
|
1315
|
+
type: 'operation',
|
|
1316
|
+
workspace_id: 'ws-smoke',
|
|
1317
|
+
path: 'rules/bridge-rule.md',
|
|
1318
|
+
idempotency_key: 'smoke-bridge-1',
|
|
1319
|
+
actor: { role: 'admin', agent_id: 'smoke-agent' },
|
|
1320
|
+
content: '---\ntype: rule\nname: "Bridge Rule"\ntitle: "Bridge Rule"\ndescription: "Smoke test."\ntimestamp: 2026-07-02T00:00:00Z\n---\nRule.\n',
|
|
1321
|
+
});
|
|
1322
|
+
const bridgeOut = JSON.parse(execFileSync('node', [
|
|
1323
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
1324
|
+
'bridge',
|
|
1325
|
+
'--vault', bridgeVault,
|
|
1326
|
+
], { input: bridgeInput, encoding: 'utf8' }));
|
|
1327
|
+
check('ssss bridge executes operation over stdin/stdout JSON',
|
|
1328
|
+
bridgeOut.success === true && bridgeOut.path === 'rules/bridge-rule.md',
|
|
1329
|
+
JSON.stringify(bridgeOut));
|
|
927
1330
|
} finally {
|
|
928
1331
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
929
1332
|
}
|
|
@@ -937,6 +1340,73 @@ async function runCliSmokeConformance() {
|
|
|
937
1340
|
return pass === checks.length;
|
|
938
1341
|
}
|
|
939
1342
|
|
|
1343
|
+
/**
|
|
1344
|
+
* Cross-language wire protocol and bridge conformance (spec §6.8).
|
|
1345
|
+
* Proves that:
|
|
1346
|
+
* 1. The reference HTTP server (ssss serve) passes 100% of canonical fixtures via POST /v1/operations.
|
|
1347
|
+
* 2. The subprocess bridge (ssss bridge) passes 100% of canonical fixtures via stdin/stdout JSON.
|
|
1348
|
+
*/
|
|
1349
|
+
async function runWireAndBridgeConformance(doc) {
|
|
1350
|
+
const checks = [];
|
|
1351
|
+
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
1352
|
+
|
|
1353
|
+
// 1. HTTP Wire Contract Conformance (ssss serve, spec §6.8)
|
|
1354
|
+
const serveVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-wire-conformance-'));
|
|
1355
|
+
try {
|
|
1356
|
+
const server = createServer({ vault: serveVault, token: 'wire-test-token', registryDir: REGISTRY_DIR });
|
|
1357
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
1358
|
+
const port = server.address().port;
|
|
1359
|
+
try {
|
|
1360
|
+
const endpoint = `http://127.0.0.1:${port}/v1/operations`;
|
|
1361
|
+
let pass = 0;
|
|
1362
|
+
for (const f of doc.fixtures) {
|
|
1363
|
+
const headers = { 'content-type': 'application/json', authorization: 'Bearer wire-test-token' };
|
|
1364
|
+
const res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(f.request) });
|
|
1365
|
+
const body = await res.json().catch(() => ({}));
|
|
1366
|
+
const want = f.expected_response || {};
|
|
1367
|
+
const okSuccess = want.success == null || body.success === want.success;
|
|
1368
|
+
const okStatus = f.expected_http_status == null || res.status === f.expected_http_status;
|
|
1369
|
+
if (okSuccess && okStatus) pass++;
|
|
1370
|
+
}
|
|
1371
|
+
check(`reference HTTP server passes canonical fixtures (${pass}/${doc.fixtures.length}, spec §6.8)`,
|
|
1372
|
+
pass === doc.fixtures.length,
|
|
1373
|
+
`${pass}/${doc.fixtures.length} passed`);
|
|
1374
|
+
} finally {
|
|
1375
|
+
await new Promise((resolve) => server.close(resolve));
|
|
1376
|
+
}
|
|
1377
|
+
} finally {
|
|
1378
|
+
fs.rmSync(serveVault, { recursive: true, force: true });
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// 2. Subprocess Stdin-JSON Bridge Conformance (ssss bridge, spec §6.8)
|
|
1382
|
+
const bridgeVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bridge-conformance-'));
|
|
1383
|
+
try {
|
|
1384
|
+
let pass = 0;
|
|
1385
|
+
for (const f of doc.fixtures) {
|
|
1386
|
+
const stdout = execFileSync('node', [path.join(__dirname, 'ssss.mjs'), 'bridge', '--vault', bridgeVault], {
|
|
1387
|
+
input: JSON.stringify(f.request),
|
|
1388
|
+
encoding: 'utf8',
|
|
1389
|
+
});
|
|
1390
|
+
const body = JSON.parse(stdout.trim());
|
|
1391
|
+
const want = f.expected_response || {};
|
|
1392
|
+
const okSuccess = want.success == null || body.success === want.success;
|
|
1393
|
+
if (okSuccess) pass++;
|
|
1394
|
+
}
|
|
1395
|
+
check(`subprocess bridge passes canonical fixtures (${pass}/${doc.fixtures.length}, spec §6.8)`,
|
|
1396
|
+
pass === doc.fixtures.length,
|
|
1397
|
+
`${pass}/${doc.fixtures.length} passed`);
|
|
1398
|
+
} finally {
|
|
1399
|
+
fs.rmSync(bridgeVault, { recursive: true, force: true });
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
let passCount = 0;
|
|
1403
|
+
for (const c of checks) {
|
|
1404
|
+
if (c.cond) { passCount++; console.log(` ✅ ${c.name}`); }
|
|
1405
|
+
else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
1406
|
+
}
|
|
1407
|
+
return passCount === checks.length;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
940
1410
|
/**
|
|
941
1411
|
* Runtime conformance (spec §11.8). Proves that a conformant daemon can treat
|
|
942
1412
|
* workflow frontmatter as the canonical schedule source, then derive trigger,
|
|
@@ -1155,9 +1625,11 @@ async function main() {
|
|
|
1155
1625
|
const bundleOk = await runBundleConformance();
|
|
1156
1626
|
console.log('\nRunning CLI smoke conformance (scripts/ssss.mjs) ...');
|
|
1157
1627
|
const cliSmokeOk = await runCliSmokeConformance();
|
|
1628
|
+
console.log('\nRunning cross-language wire & bridge conformance (spec §6.8) ...');
|
|
1629
|
+
const wireBridgeOk = await runWireAndBridgeConformance(doc);
|
|
1158
1630
|
process.exit(
|
|
1159
1631
|
engineOk && runtimeOk && operationRegressionOk && assetPointerOk && extensionRegistryOk &&
|
|
1160
|
-
semanticOk && kernel09Ok && bundleOk && cliSmokeOk ? 0 : 1
|
|
1632
|
+
semanticOk && kernel09Ok && bundleOk && cliSmokeOk && wireBridgeOk ? 0 : 1
|
|
1161
1633
|
);
|
|
1162
1634
|
} else {
|
|
1163
1635
|
console.log('\nℹ No --endpoint/--engine given; ran structural + registry validation only.');
|