@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.
@@ -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
@@ -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.');