capdag 1.226.626 → 1.240.679

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/capdag.test.js CHANGED
@@ -89,6 +89,19 @@ function assertThrowsMediaUrn(fn, expectedErrorCode, message) {
89
89
  }
90
90
  }
91
91
 
92
+ function assertThrowsMessage(fn, expectedMessage, message) {
93
+ try {
94
+ fn();
95
+ } catch (error) {
96
+ assert(
97
+ typeof error.message === 'string' && error.message.includes(expectedMessage),
98
+ `${message}: expected error containing ${JSON.stringify(expectedMessage)}, got ${JSON.stringify(error.message)}`
99
+ );
100
+ return;
101
+ }
102
+ throw new Error(`Assertion failed: ${message}: expected function to throw`);
103
+ }
104
+
92
105
  function runTest(name, fn) {
93
106
  try {
94
107
  const result = fn();
@@ -126,14 +139,14 @@ function test6204_Urn(tags) {
126
139
  // Helper to create a Cap for testing
127
140
  function makeCap(urnString, title) {
128
141
  const capUrn = CapUrn.fromString(urnString);
129
- return new Cap(capUrn, title, 'test', title);
142
+ return new Cap(capUrn, title, ['test'], title);
130
143
  }
131
144
 
132
145
  // Helper to create caps with specific in/out media URNs for graph testing
133
146
  function makeGraphCap(inUrn, outUrn, title) {
134
147
  const urnString = `cap:in="${inUrn}";convert;out="${outUrn}"`;
135
148
  const capUrn = CapUrn.fromString(urnString);
136
- return new Cap(capUrn, title, 'convert', title);
149
+ return new Cap(capUrn, title, ['convert'], title);
137
150
  }
138
151
 
139
152
  // ============================================================================
@@ -715,7 +728,7 @@ function test050_matchingSemanticsDirectionMismatch() {
715
728
  assert(!cap.accepts(request), 'Incompatible direction types should not match');
716
729
  }
717
730
 
718
- // TEST890: Semantic direction matching - generic provider matches specific request
731
+ // TEST890: Semantic direction matching - generic candidate matches specific request
719
732
  function test890_directionSemanticMatching() {
720
733
  // Generic wildcard cap accepts specific pdf request
721
734
  const genericCap = CapUrn.fromString(
@@ -1271,7 +1284,7 @@ function test116_capArgConstructors() {
1271
1284
  // TEST150: JSON roundtrip
1272
1285
  function test150_capManifestJsonSerialization() {
1273
1286
  const capUrn = CapUrn.fromString(test6204_Urn('extract;target=metadata'));
1274
- const cap = new Cap(capUrn, 'Extract Metadata', 'extract-metadata');
1287
+ const cap = new Cap(capUrn, 'Extract Metadata', ['extract-metadata']);
1275
1288
  cap.addArg(new CapArg('media:ext=pdf', true, [new ArgSource({ stdin: 'media:ext=pdf' })]));
1276
1289
  cap.addArg(new CapArg(
1277
1290
  'media:chunk-size;enc=utf-8;numeric',
@@ -1409,14 +1422,14 @@ function test6208_CapFabGetOutgoingConformsToMatching() {
1409
1422
  // the renderer colours/groups edges by provenance in browse mode.
1410
1423
  function test6224_CapFabDistinctRegistryNames() {
1411
1424
  const graph = new CapFab();
1412
- graph.addCap(makeGraphCap('media:ext=pdf', 'media:enc=utf-8', 'PDF to Text'), 'providers');
1425
+ graph.addCap(makeGraphCap('media:ext=pdf', 'media:enc=utf-8', 'PDF to Text'), 'bundled-cartridges');
1413
1426
  graph.addCap(makeGraphCap('media:enc=utf-8', 'media:embedding-vector', 'Embed'), 'cartridges');
1414
1427
 
1415
1428
  const edges = graph.getEdges();
1416
1429
  assertEqual(edges.length, 2, 'Two caps must produce two edges');
1417
1430
 
1418
1431
  const names = new Set(edges.map(e => e.registryName));
1419
- assert(names.has('providers'), 'providers registry name must be preserved');
1432
+ assert(names.has('bundled-cartridges'), 'bundled-cartridges registry name must be preserved');
1420
1433
  assert(names.has('cartridges'), 'cartridges registry name must be preserved');
1421
1434
  }
1422
1435
 
@@ -1719,7 +1732,7 @@ function test6242_JS_resolveMediaUrnFromSpecs() {
1719
1732
  // TEST6246: J s cap j s o n serialization
1720
1733
  function test6246_JS_capJSONSerialization() {
1721
1734
  const urn = CapUrn.fromString(test6204_Urn('test'));
1722
- const cap = new Cap(urn, 'Test Cap', 'test_command');
1735
+ const cap = new Cap(urn, 'Test Cap', ['test_command']);
1723
1736
  cap.arguments = {
1724
1737
  required: [{ name: 'input', media_urn: MEDIA_STRING }],
1725
1738
  optional: []
@@ -1742,7 +1755,7 @@ function test6246_JS_capJSONSerialization() {
1742
1755
  // surface as failures here.
1743
1756
  function test6249_JS_capDocumentationRoundTrip() {
1744
1757
  const urn = CapUrn.fromString(test6204_Urn('documented'));
1745
- const cap = new Cap(urn, 'Documented Cap', 'documented');
1758
+ const cap = new Cap(urn, 'Documented Cap', ['documented']);
1746
1759
  const body = '# Documented Cap\r\n\nDoes the thing.\n\n```bash\necho "hi"\n```\n\nSee also: \u2605\n';
1747
1760
  cap.setDocumentation(body);
1748
1761
  assertEqual(cap.getDocumentation(), body, 'Setter must store the body verbatim');
@@ -1764,7 +1777,7 @@ function test6249_JS_capDocumentationRoundTrip() {
1764
1777
  // (which has no null sentinel) and pollute generated JSON.
1765
1778
  function test6253_JS_capDocumentationOmittedWhenNull() {
1766
1779
  const urn = CapUrn.fromString(test6204_Urn('undocumented'));
1767
- const cap = new Cap(urn, 'Undocumented Cap', 'undocumented');
1780
+ const cap = new Cap(urn, 'Undocumented Cap', ['undocumented']);
1768
1781
  assertEqual(cap.getDocumentation(), null, 'Default documentation must be null');
1769
1782
 
1770
1783
  const json = cap.toJSON();
@@ -1854,8 +1867,10 @@ function test6269_JS_mediaDefConstruction() {
1854
1867
  // to assert on. Tests that need an empty channel use literals inline.
1855
1868
  const sampleRegistry = {
1856
1869
  schemaVersion: '5.0',
1870
+ registryVersion: 1,
1857
1871
  lastUpdated: '2026-02-07T16:48:28Z',
1858
1872
  registryUrl: 'https://test.example/manifest',
1873
+ fabricRegistryUrl: 'https://fabric.test',
1859
1874
  channels: {
1860
1875
  release: { cartridges: {
1861
1876
  pdfcartridge: {
@@ -2089,7 +2104,7 @@ function test323_cartridgeRepoServerValidateRegistry() {
2089
2104
  // Missing channels object
2090
2105
  threw = false;
2091
2106
  try {
2092
- new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest'});
2107
+ new CartridgeRepoServer({schemaVersion: '5.0', registryVersion: 1, fabricRegistryUrl: 'https://fabric.test', registryUrl: 'https://test.example/manifest'});
2093
2108
  } catch (e) {
2094
2109
  threw = true;
2095
2110
  assert(e.message.includes('channels'), 'Should reject missing channels');
@@ -2099,7 +2114,7 @@ function test323_cartridgeRepoServerValidateRegistry() {
2099
2114
  // Missing one of the two required channels
2100
2115
  threw = false;
2101
2116
  try {
2102
- new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest', channels: {release: {cartridges: {}}}});
2117
+ new CartridgeRepoServer({schemaVersion: '5.0', registryVersion: 1, fabricRegistryUrl: 'https://fabric.test', registryUrl: 'https://test.example/manifest', channels: {release: {cartridges: {}}}});
2103
2118
  } catch (e) {
2104
2119
  threw = true;
2105
2120
  assert(e.message.includes('nightly'), 'Should require nightly channel');
@@ -2581,7 +2596,9 @@ function test1874_registryUrlFromBuildEnvRejectsEmptyString() {
2581
2596
  function discoveryInstallFixture(root, slug, channelFolder, name, version, cartridgeJson, entry) {
2582
2597
  const fs = require('fs');
2583
2598
  const path = require('path');
2584
- const dir = path.join(root, slug, channelFolder, name, version);
2599
+ // {slug}/v1/{channel}/{name}/{version} — the version level pins to the host
2600
+ // registry version (the tests construct identities at cartridgeRegistryVersion 1).
2601
+ const dir = path.join(root, slug, 'v1', channelFolder, name, version);
2585
2602
  fs.mkdirSync(dir, { recursive: true });
2586
2603
  if (cartridgeJson !== null && cartridgeJson !== undefined) {
2587
2604
  fs.writeFileSync(path.join(dir, 'cartridge.json'), cartridgeJson);
@@ -2634,7 +2651,7 @@ async function test1875_scanAllReachesBothDevAndRegistrySlugs() {
2634
2651
  const url = 'https://cartridges.example.com/manifest';
2635
2652
  const rslug = slugForSync(url);
2636
2653
  // Host baked for a DIFFERENT registry than the on-disk registry cartridge.
2637
- const host = new DiscoveryIdentity({ channel: 'nightly', registryUrl: 'https://other.example.com/manifest', fabricManifestVersion: 1 });
2654
+ const host = new DiscoveryIdentity({ channel: 'nightly', registryUrl: 'https://other.example.com/manifest', fabricManifestVersion: 1, cartridgeRegistryVersion: 1 });
2638
2655
  discoveryInstallFixture(root, 'dev', 'nightly', 'devcart', '1.0.0', discoveryDevCartridgeJson('nightly', 1), 'cart');
2639
2656
  discoveryInstallFixture(root, rslug, 'nightly', 'regcart', '1.0.0', discoveryRegistryCartridgeJson(url, 'nightly', 1), 'cart');
2640
2657
  const out = await discoverCartridges(root, host);
@@ -2658,7 +2675,7 @@ async function test1876_otherChannelSubtreeIsSkipped() {
2658
2675
  const url = 'https://cartridges.example.com/manifest';
2659
2676
  const rslug = slugForSync(url);
2660
2677
  discoveryInstallFixture(root, rslug, 'release', 'regcart', '1.0.0', discoveryRegistryCartridgeJson(url, 'release', 1), 'cart');
2661
- const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
2678
+ const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
2662
2679
  assertEqual(out.length, 0, 'a release-only slug must be invisible to a nightly host');
2663
2680
  } finally {
2664
2681
  discoveryRemoveRoot(root);
@@ -2675,7 +2692,7 @@ async function test1877_registryCartridgeUnderWrongSlugIsBadInstall() {
2675
2692
  const wrongSlug = slugForSync('https://somewhere-else.example.com/manifest');
2676
2693
  const json = discoveryRegistryCartridgeJson(url, 'nightly', 1);
2677
2694
  discoveryInstallFixture(root, wrongSlug, 'nightly', 'cart', '1.0.0', json, 'cart');
2678
- const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
2695
+ const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
2679
2696
  discoveryExpectIncompatible(out, CartridgeAttachmentErrorKind.BAD_INSTALLATION);
2680
2697
  } finally {
2681
2698
  discoveryRemoveRoot(root);
@@ -2683,11 +2700,11 @@ async function test1877_registryCartridgeUnderWrongSlugIsBadInstall() {
2683
2700
  }
2684
2701
 
2685
2702
  // TEST1878: a cartridge marked `installed_from: bundle` with no baked hash in
2686
- // BUNDLED_PROVIDER_HASHES (empty in this mirror) is rejected as BadInstallation
2703
+ // BUNDLED_CARTRIDGE_HASHES (empty in this mirror) is rejected as BadInstallation
2687
2704
  // — the bundled-integrity gate fires before the probe. Non-macOS only: on macOS
2688
2705
  // the baked-hash path is intentionally absent (OS code-signature is the guard),
2689
- // so a bundled provider is accepted there and would instead end at the probe.
2690
- async function test1878_bundledProviderWithoutBakedHashIsRejected() {
2706
+ // so a bundled cartridge is accepted there and would instead end at the probe.
2707
+ async function test1878_bundledCartridgeWithoutBakedHashIsRejected() {
2691
2708
  if (process.platform === 'darwin') {
2692
2709
  // Mirrors the Rust #[cfg(not(target_os = "macos"))] gate: on macOS the
2693
2710
  // baked-hash path does not exist, so this scenario is not exercised.
@@ -2703,9 +2720,9 @@ async function test1878_bundledProviderWithoutBakedHashIsRejected() {
2703
2720
  installed_at: '2024-01-01T00:00:00Z', installed_from: 'bundle', fabric_manifest_version: 1,
2704
2721
  });
2705
2722
  discoveryInstallFixture(root, 'dev', 'nightly', 'cart', '1.0.0', json, 'cart');
2706
- const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
2723
+ const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
2707
2724
  discoveryExpectIncompatible(out, CartridgeAttachmentErrorKind.BAD_INSTALLATION);
2708
- assert(out[0].error.message.includes('bundled provider integrity'),
2725
+ assert(out[0].error.message.includes('bundled cartridge integrity'),
2709
2726
  `message should name the bundled-integrity failure: ${out[0].error.message}`);
2710
2727
  } finally {
2711
2728
  discoveryRemoveRoot(root);
@@ -2954,7 +2971,7 @@ function test6544_builderRejectsStructuralKeys() {
2954
2971
  // TEST1294: RULE11 - void-input cap with stdin source rejected
2955
2972
  function test1294_rule11VoidInputWithStdinRejected() {
2956
2973
  const urn = CapUrn.fromString('cap:in="media:void";test;out="media:string"');
2957
- const cap = new Cap(urn, 'Test', 'test-cmd');
2974
+ const cap = new Cap(urn, 'Test', ['test-cmd']);
2958
2975
  const stdinSource = ArgSource.fromJSON({ stdin: 'media:string' });
2959
2976
  cap.args = [new CapArg('media:string', true, [stdinSource])];
2960
2977
  try {
@@ -2969,7 +2986,7 @@ function test1294_rule11VoidInputWithStdinRejected() {
2969
2986
  // TEST1295: RULE11 - non-void-input cap without stdin source rejected
2970
2987
  function test1295_rule11NonVoidInputWithoutStdinRejected() {
2971
2988
  const urn = CapUrn.fromString('cap:in="media:string";test;out="media:string"');
2972
- const cap = new Cap(urn, 'Test', 'test-cmd');
2989
+ const cap = new Cap(urn, 'Test', ['test-cmd']);
2973
2990
  const posSource = ArgSource.fromJSON({ cli_flag: '--name' });
2974
2991
  cap.args = [new CapArg('media:string', true, [posSource])];
2975
2992
  try {
@@ -2984,7 +3001,7 @@ function test1295_rule11NonVoidInputWithoutStdinRejected() {
2984
3001
  // TEST1296: RULE11 - void-input cap with only cli_flag sources passes
2985
3002
  function test1296_rule11VoidInputCliFlagOnly() {
2986
3003
  const urn = CapUrn.fromString('cap:in="media:void";test;out="media:string"');
2987
- const cap = new Cap(urn, 'Test', 'test-cmd');
3004
+ const cap = new Cap(urn, 'Test', ['test-cmd']);
2988
3005
  const flagSource = ArgSource.fromJSON({ cli_flag: '--name' });
2989
3006
  cap.args = [new CapArg('media:string', true, [flagSource])];
2990
3007
  // Should not throw
@@ -2994,7 +3011,7 @@ function test1296_rule11VoidInputCliFlagOnly() {
2994
3011
  // TEST1297: RULE11 - non-void-input cap with stdin source passes
2995
3012
  function test1297_rule11NonVoidInputWithStdin() {
2996
3013
  const urn = CapUrn.fromString('cap:in="media:string";test;out="media:string"');
2997
- const cap = new Cap(urn, 'Test', 'test-cmd');
3014
+ const cap = new Cap(urn, 'Test', ['test-cmd']);
2998
3015
  const stdinSource = ArgSource.fromJSON({ stdin: 'media:string' });
2999
3016
  cap.args = [new CapArg('media:string', true, [stdinSource])];
3000
3017
  // Should not throw
@@ -3191,11 +3208,11 @@ function test127_invalidEffectNoneFailsHard() {
3191
3208
 
3192
3209
  // TEST128: omitted effect means declared; unconstrained effect must be explicit
3193
3210
  function test128_effectDispatchRequiresExplicitWildcard() {
3194
- const noneProvider = CapUrn.fromString('cap:effect=none');
3211
+ const noneCandidate = CapUrn.fromString('cap:effect=none');
3195
3212
  const declaredRequest = CapUrn.fromString('cap:raw');
3196
3213
  const anyRequest = CapUrn.fromString('cap:?effect');
3197
- assert(!noneProvider.isDispatchable(declaredRequest), 'effect=none should not silently satisfy declared request');
3198
- assert(noneProvider.isDispatchable(anyRequest), 'explicit ?effect should accept any provider effect');
3214
+ assert(!noneCandidate.isDispatchable(declaredRequest), 'effect=none should not silently satisfy declared request');
3215
+ assert(noneCandidate.isDispatchable(anyRequest), 'explicit ?effect should accept any candidate effect');
3199
3216
  }
3200
3217
 
3201
3218
  // ============================================================================
@@ -4292,7 +4309,7 @@ function test6467_Machine_capRegistryEntry_construction() {
4292
4309
  command: 'extract',
4293
4310
  cap_description: 'Extracts text from PDF',
4294
4311
  args: [{ media_urn: 'media:ext=pdf', required: true }],
4295
- output: { media_urn: 'media:enc=utf-8;ext=txt', output_description: 'Extracted text' },
4312
+ output: { media_urn: 'media:enc=utf-8;ext=txt', output_description: 'Digitized text' },
4296
4313
  media_defs: [],
4297
4314
  urn_tags: { op: 'extract' },
4298
4315
  in_spec: 'media:ext=pdf',
@@ -4337,7 +4354,7 @@ function test6470_Machine_capRegistryEntry_defaults() {
4337
4354
  assertEqual(entry.urn, 'cap:in=media:;test;out=media:', 'URN should match');
4338
4355
  assertEqual(entry.title, '', 'Title should default to empty');
4339
4356
  assertEqual(entry.description, '', 'Description should default to empty');
4340
- assertEqual(entry.command, '', 'Command should default to empty');
4357
+ assert(Array.isArray(entry.aliases) && entry.aliases.length === 0, 'Aliases should default to an empty array');
4341
4358
  assert(Array.isArray(entry.args), 'Args should default to array');
4342
4359
  assertEqual(entry.args.length, 0, 'Args should be empty');
4343
4360
  }
@@ -4563,8 +4580,16 @@ function test6483_Renderer_buildBrowseGraphData_rejectsMissingMediaTitles() {
4563
4580
 
4564
4581
  // ---------------- strand builder ----------------
4565
4582
 
4583
+ let fixtureStepToken = 0;
4584
+
4585
+ function nextFixtureStepToken() {
4586
+ fixtureStepToken += 1;
4587
+ return `fixture-step-${fixtureStepToken}`;
4588
+ }
4589
+
4566
4590
  function makeCapStep(capUrn, title, fromSpec, toSpec, inSeq, outSeq) {
4567
4591
  return {
4592
+ token_id: nextFixtureStepToken(),
4568
4593
  step_type: {
4569
4594
  Cap: {
4570
4595
  cap_urn: capUrn,
@@ -4572,6 +4597,7 @@ function makeCapStep(capUrn, title, fromSpec, toSpec, inSeq, outSeq) {
4572
4597
  specificity: 0,
4573
4598
  input_is_sequence: inSeq,
4574
4599
  output_is_sequence: outSeq,
4600
+ inputs: [{ arg_urn: fromSpec, source: 'StrandInput' }],
4575
4601
  },
4576
4602
  },
4577
4603
  from_spec: fromSpec,
@@ -4581,6 +4607,7 @@ function makeCapStep(capUrn, title, fromSpec, toSpec, inSeq, outSeq) {
4581
4607
 
4582
4608
  function makeForEachStep(mediaDef) {
4583
4609
  return {
4610
+ token_id: nextFixtureStepToken(),
4584
4611
  step_type: { ForEach: { media_def: mediaDef } },
4585
4612
  from_spec: mediaDef,
4586
4613
  to_spec: mediaDef,
@@ -4589,12 +4616,34 @@ function makeForEachStep(mediaDef) {
4589
4616
 
4590
4617
  function makeCollectStep(mediaDef) {
4591
4618
  return {
4619
+ token_id: nextFixtureStepToken(),
4592
4620
  step_type: { Collect: { media_def: mediaDef } },
4593
4621
  from_spec: mediaDef,
4594
4622
  to_spec: mediaDef,
4595
4623
  };
4596
4624
  }
4597
4625
 
4626
+ // Build an intentionally linear fixture while still spelling the canonical
4627
+ // stable-token producer graph that production payloads carry. ForEach changes
4628
+ // cardinality but does not replace the current value producer; Collect does.
4629
+ function wireLinearStepInputs(steps) {
4630
+ let producerToken = null;
4631
+ for (const step of steps) {
4632
+ if (step.step_type.Cap) {
4633
+ step.step_type.Cap.inputs = [{
4634
+ arg_urn: step.from_spec,
4635
+ source: producerToken === null
4636
+ ? 'StrandInput'
4637
+ : { Step: { token_id: producerToken } },
4638
+ }];
4639
+ producerToken = step.token_id;
4640
+ } else if (step.step_type.Collect) {
4641
+ producerToken = step.token_id;
4642
+ }
4643
+ }
4644
+ return steps;
4645
+ }
4646
+
4598
4647
  // TEST6484: Renderer validate strand step rejects unknown variant
4599
4648
  function test6484_Renderer_validateStrandStep_rejectsUnknownVariant() {
4600
4649
  // A step with an unknown variant must fail hard at validation; no
@@ -4602,6 +4651,7 @@ function test6484_Renderer_validateStrandStep_rejectsUnknownVariant() {
4602
4651
  let threw = false;
4603
4652
  try {
4604
4653
  rendererValidateStrandStep({
4654
+ token_id: nextFixtureStepToken(),
4605
4655
  step_type: { WrongVariant: {} },
4606
4656
  from_spec: 'media:a',
4607
4657
  to_spec: 'media:a',
@@ -4620,6 +4670,7 @@ function test6486_Renderer_validateStrandStep_requiresBooleanIsSequence() {
4620
4670
  let threw = false;
4621
4671
  try {
4622
4672
  rendererValidateStrandStep({
4673
+ token_id: nextFixtureStepToken(),
4623
4674
  step_type: { Cap: {
4624
4675
  cap_urn: 'cap:in="media:a";x;out="media:b"',
4625
4676
  title: 't',
@@ -4641,13 +4692,13 @@ function test6487_Renderer_classifyStrandCapSteps_capFlags() {
4641
4692
  // Strand: ForEach → cap1 → cap2 → cap3 → Collect. cap1 should have
4642
4693
  // prevForEach=true; cap3 should have nextCollect=true; cap2 should
4643
4694
  // have neither.
4644
- const steps = [
4695
+ const steps = wireLinearStepInputs([
4645
4696
  makeForEachStep('media:ext=pdf;list'),
4646
4697
  makeCapStep('cap:in="media:ext=pdf";a;out="media:ext=png;image"', 'a', 'media:ext=pdf', 'media:ext=png;image', false, false),
4647
4698
  makeCapStep('cap:in="media:ext=png;image";b;out="media:ext=jpg"', 'b', 'media:ext=png;image', 'media:ext=jpg', false, false),
4648
4699
  makeCapStep('cap:in="media:ext=jpg";c;out="media:ext=txt"', 'c', 'media:ext=jpg', 'media:ext=txt', false, false),
4649
4700
  makeCollectStep('media:ext=txt'),
4650
- ];
4701
+ ]);
4651
4702
  const { capStepIndices, capFlags } = rendererClassifyStrandCapSteps(steps);
4652
4703
  assertEqual(capStepIndices.length, 3, 'three cap steps');
4653
4704
  assert(capFlags.get(1).prevForEach, 'cap1 has prevForEach');
@@ -4663,7 +4714,7 @@ function test6488_Renderer_classifyStrandCapSteps_nestedForks() {
4663
4714
  // Nested strand: ForEach → cap1 → ForEach → cap2 → Collect → cap3 → Collect.
4664
4715
  // cap1 has prevForEach (outer), cap2 has prevForEach (inner) and
4665
4716
  // nextCollect (inner), cap3 has nextCollect (outer).
4666
- const steps = [
4717
+ const steps = wireLinearStepInputs([
4667
4718
  makeForEachStep('media:a;list'),
4668
4719
  makeCapStep('cap:in="media:a";a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
4669
4720
  makeForEachStep('media:b;list'),
@@ -4671,7 +4722,7 @@ function test6488_Renderer_classifyStrandCapSteps_nestedForks() {
4671
4722
  makeCollectStep('media:c'),
4672
4723
  makeCapStep('cap:in="media:c";c;out="media:d"', 'c', 'media:c', 'media:d', false, false),
4673
4724
  makeCollectStep('media:d'),
4674
- ];
4725
+ ]);
4675
4726
  const { capFlags } = rendererClassifyStrandCapSteps(steps);
4676
4727
  assert(capFlags.get(1).prevForEach && !capFlags.get(1).nextCollect, 'cap1 outer entry');
4677
4728
  assert(capFlags.get(3).prevForEach && capFlags.get(3).nextCollect, 'cap2 inner both');
@@ -4698,9 +4749,9 @@ function test6489_Renderer_buildStrandGraphData_singleCapPlain() {
4698
4749
  const payload = withMediaDisplayNames({
4699
4750
  source_media_urn: 'media:a',
4700
4751
  target_media_urn: 'media:b',
4701
- steps: [
4752
+ steps: wireLinearStepInputs([
4702
4753
  makeCapStep('cap:in="media:a";x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4703
- ],
4754
+ ]),
4704
4755
  }, {
4705
4756
  'media:a': 'Source A',
4706
4757
  'media:b': 'Target B',
@@ -4724,9 +4775,9 @@ function test6491_Renderer_buildStrandGraphData_sequenceShowsCardinality() {
4724
4775
  const payload = withMediaDisplayNames({
4725
4776
  source_media_urn: 'media:a;list',
4726
4777
  target_media_urn: 'media:b',
4727
- steps: [
4778
+ steps: wireLinearStepInputs([
4728
4779
  makeCapStep('cap:in="media:a;list";x;out="media:b"', 'x', 'media:a;list', 'media:b', true, false),
4729
- ],
4780
+ ]),
4730
4781
  }, {
4731
4782
  'media:a;list': 'Source A List',
4732
4783
  'media:b': 'Target B',
@@ -4755,11 +4806,11 @@ function test6492_Renderer_buildStrandGraphData_foreachCollectSpan() {
4755
4806
  const payload = withMediaDisplayNames({
4756
4807
  source_media_urn: 'media:ext=pdf;list',
4757
4808
  target_media_urn: 'media:txt;list',
4758
- steps: [
4809
+ steps: wireLinearStepInputs([
4759
4810
  makeForEachStep('media:ext=pdf;list'),
4760
4811
  makeCapStep('cap:in="media:ext=pdf";extract;out="media:ext=txt"', 'extract', 'media:ext=pdf', 'media:ext=txt', false, false),
4761
4812
  makeCollectStep('media:ext=txt'),
4762
- ],
4813
+ ]),
4763
4814
  }, {
4764
4815
  'media:ext=pdf;list': 'PDF List',
4765
4816
  'media:ext=txt': 'Plain Text',
@@ -4798,10 +4849,10 @@ function test6493_Renderer_buildStrandGraphData_standaloneCollect() {
4798
4849
  const payload = withMediaDisplayNames({
4799
4850
  source_media_urn: 'media:a',
4800
4851
  target_media_urn: 'media:b;list',
4801
- steps: [
4852
+ steps: wireLinearStepInputs([
4802
4853
  makeCapStep('cap:in="media:a";x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4803
4854
  makeCollectStep('media:b'),
4804
- ],
4855
+ ]),
4805
4856
  }, {
4806
4857
  'media:a': 'Source A',
4807
4858
  'media:b': 'Target B',
@@ -4827,11 +4878,11 @@ function test6494_Renderer_buildStrandGraphData_unclosedForEachBody() {
4827
4878
  const payload = withMediaDisplayNames({
4828
4879
  source_media_urn: 'media:a',
4829
4880
  target_media_urn: 'media:c',
4830
- steps: [
4881
+ steps: wireLinearStepInputs([
4831
4882
  makeCapStep('cap:in="media:a";a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
4832
4883
  makeForEachStep('media:b'),
4833
4884
  makeCapStep('cap:in="media:b";b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
4834
- ],
4885
+ ]),
4835
4886
  }, {
4836
4887
  'media:a': 'Source A',
4837
4888
  'media:b': 'Intermediate B',
@@ -4865,11 +4916,11 @@ function test6495_Renderer_buildStrandGraphData_nestedForEachThrows() {
4865
4916
  const payload = withMediaDisplayNames({
4866
4917
  source_media_urn: 'media:a;list',
4867
4918
  target_media_urn: 'media:a',
4868
- steps: [
4919
+ steps: wireLinearStepInputs([
4869
4920
  makeForEachStep('media:a;list'),
4870
4921
  makeForEachStep('media:a'),
4871
4922
  makeCapStep('cap:in="media:a";x;out="media:a"', 'x', 'media:a', 'media:a', false, false),
4872
- ],
4923
+ ]),
4873
4924
  }, {
4874
4925
  'media:a;list': 'Source A List',
4875
4926
  'media:a': 'Source A',
@@ -4903,11 +4954,11 @@ function test6496_Renderer_collapseStrand_singleCapBodyKeepsCapOwnLabel() {
4903
4954
  const payload = withMediaDisplayNames({
4904
4955
  source_media_urn: 'media:ext=pdf;list',
4905
4956
  target_media_urn: 'media:txt;list',
4906
- steps: [
4957
+ steps: wireLinearStepInputs([
4907
4958
  makeForEachStep('media:ext=pdf;list'),
4908
4959
  makeCapStep('cap:in="media:ext=pdf";extract;out="media:ext=txt"', 'extract', 'media:ext=pdf', 'media:ext=txt', false, false),
4909
4960
  makeCollectStep('media:ext=txt'),
4910
- ],
4961
+ ]),
4911
4962
  }, {
4912
4963
  'media:ext=pdf;list': 'PDF List',
4913
4964
  'media:ext=txt': 'Plain Text',
@@ -4953,11 +5004,11 @@ function test6497_Renderer_collapseStrand_unclosedForEachBodyCollapses() {
4953
5004
  const payload = withMediaDisplayNames({
4954
5005
  source_media_urn: 'media:a',
4955
5006
  target_media_urn: 'media:c',
4956
- steps: [
5007
+ steps: wireLinearStepInputs([
4957
5008
  makeCapStep('cap:in="media:a";a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
4958
5009
  makeForEachStep('media:b'),
4959
5010
  makeCapStep('cap:in="media:b";b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
4960
- ],
5011
+ ]),
4961
5012
  }, {
4962
5013
  'media:a': 'Source A',
4963
5014
  'media:b': 'Intermediate B',
@@ -5011,10 +5062,10 @@ function test6498_Renderer_collapseStrand_standaloneCollectCollapses() {
5011
5062
  const payload = withMediaDisplayNames({
5012
5063
  source_media_urn: 'media:a',
5013
5064
  target_media_urn: 'media:b;list',
5014
- steps: [
5065
+ steps: wireLinearStepInputs([
5015
5066
  makeCapStep('cap:in="media:a";x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
5016
5067
  makeCollectStep('media:b'),
5017
- ],
5068
+ ]),
5018
5069
  }, {
5019
5070
  'media:a': 'Source A',
5020
5071
  'media:b': 'Target B',
@@ -5060,11 +5111,11 @@ function test6499_Renderer_collapseStrand_sequenceProducingCapBeforeForeach() {
5060
5111
  const payload = withMediaDisplayNames({
5061
5112
  source_media_urn: 'media:ext=pdf',
5062
5113
  target_media_urn: 'media:decision',
5063
- steps: [
5114
+ steps: wireLinearStepInputs([
5064
5115
  makeCapStep('cap:in="media:ext=pdf";disbind;out="media:page"', 'Disbind', 'media:ext=pdf', 'media:page', false, true),
5065
5116
  makeForEachStep('media:page'),
5066
5117
  makeCapStep('cap:in="media:page";decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
5067
- ],
5118
+ ]),
5068
5119
  }, {
5069
5120
  'media:ext=pdf': 'PDF',
5070
5121
  'media:page': 'Page',
@@ -5116,9 +5167,9 @@ function test6500_Renderer_collapseStrand_plainCapMergesTrailingOutput() {
5116
5167
  const payload = withMediaDisplayNames({
5117
5168
  source_media_urn: 'media:a',
5118
5169
  target_media_urn: 'media:b',
5119
- steps: [
5170
+ steps: wireLinearStepInputs([
5120
5171
  makeCapStep('cap:in="media:a";x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
5121
- ],
5172
+ ]),
5122
5173
  }, {
5123
5174
  'media:a': 'Source A',
5124
5175
  'media:b': 'Target B',
@@ -5149,9 +5200,9 @@ function test6501_Renderer_collapseStrand_plainCapDistinctTargetNoMerge() {
5149
5200
  const payload = withMediaDisplayNames({
5150
5201
  source_media_urn: 'media:a',
5151
5202
  target_media_urn: 'media:b;list',
5152
- steps: [
5203
+ steps: wireLinearStepInputs([
5153
5204
  makeCapStep('cap:in="media:a";x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
5154
- ],
5205
+ ]),
5155
5206
  }, {
5156
5207
  'media:a': 'Source A',
5157
5208
  'media:b': 'Target B',
@@ -5180,24 +5231,175 @@ function test6502_Renderer_validateStrandPayload_missingSourceMediaUrn() {
5180
5231
  assert(threw, 'missing source_media_urn must throw');
5181
5232
  }
5182
5233
 
5234
+ // TEST7120: Renderer requires the authoritative cap input graph
5235
+ function test7120_Renderer_validateStrandPayload_requiresAuthoritativeInputs() {
5236
+ const step = makeCapStep(
5237
+ 'cap:in="media:a";x;out="media:b"',
5238
+ 'x', 'media:a', 'media:b', false, false
5239
+ );
5240
+ const payload = { source_media_urn: 'media:a', target_media_urn: 'media:b', steps: [step] };
5241
+
5242
+ const missing = JSON.parse(JSON.stringify(payload));
5243
+ delete missing.steps[0].step_type.Cap.inputs;
5244
+ assertThrowsMessage(
5245
+ () => rendererValidateStrandPayload(missing),
5246
+ 'inputs must be an array',
5247
+ 'a cap cannot omit its data-flow graph'
5248
+ );
5249
+
5250
+ const empty = JSON.parse(JSON.stringify(payload));
5251
+ empty.steps[0].step_type.Cap.inputs = [];
5252
+ assertThrowsMessage(
5253
+ () => rendererValidateStrandPayload(empty),
5254
+ 'must include the primary input',
5255
+ 'a cap cannot omit its primary input'
5256
+ );
5257
+
5258
+ const duplicate = JSON.parse(JSON.stringify(payload));
5259
+ duplicate.steps[0].step_type.Cap.inputs.push(
5260
+ { arg_urn: 'media:a', source: 'StrandInput' }
5261
+ );
5262
+ assertThrowsMessage(
5263
+ () => rendererValidateStrandPayload(duplicate),
5264
+ "duplicates 'media:a'",
5265
+ 'one cap cannot bind the same argument slot twice'
5266
+ );
5267
+
5268
+ const unresolved = JSON.parse(JSON.stringify(payload));
5269
+ unresolved.steps[0].step_type.Cap.inputs[0].source = { Step: { token_id: 'missing-step' } };
5270
+ assertThrowsMessage(
5271
+ () => rendererValidateStrandPayload(unresolved),
5272
+ 'does not resolve to a strand step',
5273
+ 'a cap input cannot name an unknown producer'
5274
+ );
5275
+ }
5276
+
5183
5277
  // ---------------- run builder ----------------
5184
5278
 
5185
5279
  function test6503_Renderer_validateBodyOutcome_rejectsNegativeIndex() {
5186
5280
  let threw = false;
5187
5281
  try {
5188
- rendererValidateBodyOutcome({ body_index: -1, success: true, cap_urns: [] }, 'test');
5282
+ rendererValidateBodyOutcome({ body_index: -1, foreach_token_id: null, success: true, cap_urns: [], failed_token_id: null, failed_arg_urn: null }, 'test');
5189
5283
  } catch (e) {
5190
5284
  threw = true;
5191
5285
  }
5192
5286
  assert(threw, 'negative body_index must throw');
5193
5287
  }
5194
5288
 
5289
+ // TEST7121: Renderer body outcomes require an explicit stable failure coordinate
5290
+ function test7121_Renderer_validateBodyOutcome_requiresExplicitFailureCoordinate() {
5291
+ rendererValidateBodyOutcome(
5292
+ { body_index: 0, foreach_token_id: null, success: false, cap_urns: [], failed_token_id: null, failed_arg_urn: null },
5293
+ 'unattributed'
5294
+ );
5295
+ let threw = false;
5296
+ try {
5297
+ rendererValidateBodyOutcome({ body_index: 0, success: false, cap_urns: [] }, 'missing');
5298
+ } catch (error) {
5299
+ threw = true;
5300
+ assert(error.message.includes('foreach_token_id must be present as null or a stable ForEach step token'),
5301
+ 'missing execution-region coordinate error must state the required wire shape');
5302
+ }
5303
+ assert(threw, 'omitting foreach_token_id must fail hard');
5304
+
5305
+ threw = false;
5306
+ try {
5307
+ rendererValidateBodyOutcome(
5308
+ { body_index: 0, foreach_token_id: null, success: false, cap_urns: [], failed_arg_urn: null },
5309
+ 'missing-failure-coordinate'
5310
+ );
5311
+ } catch (error) {
5312
+ threw = true;
5313
+ assert(error.message.includes('must be present as null or a stable step token'),
5314
+ 'missing failure coordinate error must state the required wire shape');
5315
+ }
5316
+ assert(threw, 'omitting failed_token_id must fail hard');
5317
+
5318
+ threw = false;
5319
+ try {
5320
+ rendererValidateBodyOutcome(
5321
+ { body_index: 0, foreach_token_id: null, success: false, cap_urns: [], failed_token_id: null },
5322
+ 'missing-argument-coordinate'
5323
+ );
5324
+ } catch (error) {
5325
+ threw = true;
5326
+ assert(error.message.includes('must be present as null or an attributed argument URN'),
5327
+ 'missing argument coordinate error must state the required wire shape');
5328
+ }
5329
+ assert(threw, 'omitting failed_arg_urn must fail hard');
5330
+
5331
+ assertThrowsMessage(
5332
+ () => rendererValidateBodyOutcome(
5333
+ { body_index: 1, foreach_token_id: null, success: true, cap_urns: [], failed_token_id: null, failed_arg_urn: null },
5334
+ 'nonzero-linear'
5335
+ ),
5336
+ 'linear and must use body_index 0',
5337
+ 'a bare ordinal cannot address a second linear body'
5338
+ );
5339
+
5340
+ assertThrowsMessage(
5341
+ () => rendererValidateBodyOutcome(
5342
+ { body_index: Number.MAX_SAFE_INTEGER + 1, foreach_token_id: 'tok-foreach', success: true, cap_urns: [], failed_token_id: null, failed_arg_urn: null },
5343
+ 'unsafe-coordinate'
5344
+ ),
5345
+ 'non-negative safe integer',
5346
+ 'JSON transport must not round a body coordinate'
5347
+ );
5348
+ }
5349
+
5350
+ // TEST7122: Renderer body outcomes reject execution-region tokens for non-ForEach steps
5351
+ function test7122_Renderer_validateRunPayload_requiresForEachRegionCoordinate() {
5352
+ const strand = {
5353
+ source_media_urn: 'media:a',
5354
+ target_media_urn: 'media:b',
5355
+ steps: wireLinearStepInputs([
5356
+ makeCapStep('cap:in="media:a";convert;out="media:b"', 'convert', 'media:a', 'media:b', false, false),
5357
+ ]),
5358
+ };
5359
+ const payload = {
5360
+ resolved_strand: strand,
5361
+ body_outcomes: [{
5362
+ body_index: 0,
5363
+ foreach_token_id: strand.steps[0].token_id,
5364
+ success: true,
5365
+ cap_urns: [],
5366
+ failed_token_id: null,
5367
+ failed_arg_urn: null,
5368
+ }],
5369
+ visible_success_count: 1,
5370
+ visible_failure_count: 0,
5371
+ total_body_count: 1,
5372
+ };
5373
+ assertThrowsMessage(
5374
+ () => rendererValidateRunPayload(payload),
5375
+ 'does not resolve to a ForEach strand step',
5376
+ 'a body outcome cannot use a cap token as its execution-region coordinate'
5377
+ );
5378
+
5379
+ const duplicateCoordinate = {
5380
+ ...payload,
5381
+ body_outcomes: [0, 1].map(() => ({
5382
+ body_index: 0,
5383
+ foreach_token_id: null,
5384
+ success: true,
5385
+ cap_urns: [],
5386
+ failed_token_id: null,
5387
+ failed_arg_urn: null,
5388
+ })),
5389
+ };
5390
+ assertThrowsMessage(
5391
+ () => rendererValidateRunPayload(duplicateCoordinate),
5392
+ "duplicate body coordinate 'linear:0'",
5393
+ 'one execution coordinate cannot acquire two outcomes'
5394
+ );
5395
+ }
5396
+
5195
5397
  // TEST6504: Renderer build run graph data pages successes and failures
5196
5398
  function test6504_Renderer_buildRunGraphData_pagesSuccessesAndFailures() {
5197
5399
  // 6 successes, 4 failures. visible=3+2, total=10. Body has 2
5198
5400
  // caps (a, b). Each body replica is a chain of:
5199
5401
  // entry node + body_step_0 (cap a) + body_step_1 (cap b)
5200
- // = 3 nodes per body. Failed bodies truncate at failed_cap
5402
+ // = 3 nodes per body. Failed bodies truncate at failed_token_id
5201
5403
  // (cap b, idx=1) so `traceEnd=2` — same 3-node chain.
5202
5404
  //
5203
5405
  // Total replica nodes: (3 success × 3) + (2 failure × 3) = 15.
@@ -5207,26 +5409,28 @@ function test6504_Renderer_buildRunGraphData_pagesSuccessesAndFailures() {
5207
5409
  const strand = {
5208
5410
  source_media_urn: 'media:ext=pdf;list',
5209
5411
  target_media_urn: 'media:ext=txt',
5210
- steps: [
5412
+ steps: wireLinearStepInputs([
5211
5413
  makeForEachStep('media:ext=pdf;list'),
5212
5414
  makeCapStep('cap:in="media:ext=pdf";a;out="media:ext=png;image"', 'a', 'media:ext=pdf', 'media:ext=png;image', false, false),
5213
5415
  makeCapStep('cap:in="media:ext=png;image";b;out="media:ext=txt"', 'b', 'media:ext=png;image', 'media:ext=txt', false, false),
5214
5416
  makeCollectStep('media:ext=txt'),
5215
- ],
5417
+ ]),
5216
5418
  };
5217
5419
  const outcomes = [];
5218
5420
  for (let i = 0; i < 6; i++) {
5219
- outcomes.push({ body_index: i, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 });
5421
+ outcomes.push({ body_index: i, foreach_token_id: strand.steps[0].token_id, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_token_id: null, failed_arg_urn: null });
5220
5422
  }
5221
5423
  for (let i = 6; i < 10; i++) {
5222
5424
  outcomes.push({
5223
5425
  body_index: i,
5426
+ foreach_token_id: strand.steps[0].token_id,
5224
5427
  success: false,
5225
5428
  cap_urns: [],
5226
5429
  saved_paths: [],
5227
5430
  total_bytes: 0,
5228
5431
  duration_ms: 0,
5229
- failed_cap: 'cap:in="media:ext=png;image";b;out="media:ext=txt"',
5432
+ failed_token_id: strand.steps[2].token_id,
5433
+ failed_arg_urn: null,
5230
5434
  error: 'oom',
5231
5435
  });
5232
5436
  }
@@ -5263,22 +5467,21 @@ function test6504_Renderer_buildRunGraphData_pagesSuccessesAndFailures() {
5263
5467
  assertEqual(failureShowMore.data.hiddenCount, 2, 'failure hidden count = 2');
5264
5468
  }
5265
5469
 
5266
- // TEST6505: Renderer build run graph data failure without failed cap renders full trace
5267
- function test6505_Renderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace() {
5268
- // A failure without failed_cap (infrastructure failure) must
5269
- // still render the full body trace — the builder must not
5270
- // crash or produce zero replicas.
5470
+ // TEST6505: Renderer run graph keeps an unattributed body failure at its entry
5471
+ function test6505_Renderer_buildRunGraphData_unattributedFailureStopsAtEntry() {
5472
+ // A failure without failed_token_id is machine-scoped. The body remains
5473
+ // visible, but no cap is falsely presented as the failure location.
5271
5474
  //
5272
5475
  // Strand [ForEach, Cap, Collect] → body has 1 cap. Each body
5273
5476
  // replica emits 1 entry node + 1 body cap node = 2 nodes.
5274
5477
  const strand = {
5275
5478
  source_media_urn: 'media:ext=pdf;list',
5276
5479
  target_media_urn: 'media:ext=txt',
5277
- steps: [
5480
+ steps: wireLinearStepInputs([
5278
5481
  makeForEachStep('media:ext=pdf;list'),
5279
5482
  makeCapStep('cap:in="media:ext=pdf";a;out="media:ext=txt"', 'a', 'media:ext=pdf', 'media:ext=txt', false, false),
5280
5483
  makeCollectStep('media:ext=txt'),
5281
- ],
5484
+ ]),
5282
5485
  };
5283
5486
  const payload = {
5284
5487
  resolved_strand: strand,
@@ -5288,7 +5491,7 @@ function test6505_Renderer_buildRunGraphData_failureWithoutFailedCapRendersFullT
5288
5491
  'media:ext=txt': 'Text',
5289
5492
  },
5290
5493
  body_outcomes: [
5291
- { body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, error: 'unknown' },
5494
+ { body_index: 0, foreach_token_id: strand.steps[0].token_id, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_token_id: null, failed_arg_urn: null, error: 'unknown' },
5292
5495
  ],
5293
5496
  visible_success_count: 0,
5294
5497
  visible_failure_count: 5,
@@ -5299,19 +5502,15 @@ function test6505_Renderer_buildRunGraphData_failureWithoutFailedCapRendersFullT
5299
5502
  for (const n of built.replicaNodes) {
5300
5503
  if (n.classes === 'body-failure') failureNodes++;
5301
5504
  }
5302
- assertEqual(failureNodes, 2, 'entry + body cap = 2 failure replica nodes');
5505
+ assertEqual(failureNodes, 1, 'only the body entry is shown for an unattributed failure');
5303
5506
  }
5304
5507
 
5305
- // TEST6506: Renderer build run graph data uses cap urn is equivalent for failed cap
5306
- function test6506_Renderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
5307
- // The renderer matches failed_cap against step cap URNs via
5308
- // CapUrn.isEquivalent, NOT string equality. Feed a payload where
5309
- // failed_cap and the step's cap_urn differ only in tag order — they
5310
- // should still match, proving URNs are not treated as strings.
5508
+ // TEST6506: Renderer run graph resolves a failure by exact stable step token
5509
+ function test6506_Renderer_buildRunGraphData_resolvesExactFailedStepToken() {
5311
5510
  const strand = {
5312
5511
  source_media_urn: 'media:a',
5313
5512
  target_media_urn: 'media:c',
5314
- steps: [
5513
+ steps: wireLinearStepInputs([
5315
5514
  makeForEachStep('media:a;list'),
5316
5515
  // Canonical form places tags alphabetically: op after in/out.
5317
5516
  makeCapStep(
@@ -5323,11 +5522,8 @@ function test6506_Renderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap(
5323
5522
  'y', 'media:b', 'media:c', false, false
5324
5523
  ),
5325
5524
  makeCollectStep('media:c'),
5326
- ],
5525
+ ]),
5327
5526
  };
5328
- // The failed_cap URN is semantically the same as step 1 (cap y). If
5329
- // CapUrn.fromString canonicalizes (it should), any equivalent form
5330
- // will match. Feed a fully-specified form that is equivalent.
5331
5527
  const payload = {
5332
5528
  resolved_strand: strand,
5333
5529
  media_display_names: {
@@ -5339,12 +5535,14 @@ function test6506_Renderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap(
5339
5535
  body_outcomes: [
5340
5536
  {
5341
5537
  body_index: 0,
5538
+ foreach_token_id: strand.steps[0].token_id,
5342
5539
  success: false,
5343
5540
  cap_urns: [],
5344
5541
  saved_paths: [],
5345
5542
  total_bytes: 0,
5346
5543
  duration_ms: 0,
5347
- failed_cap: 'cap:in=media:b;out=media:c;y', // different tag order
5544
+ failed_token_id: strand.steps[2].token_id,
5545
+ failed_arg_urn: null,
5348
5546
  error: 'fail',
5349
5547
  },
5350
5548
  ],
@@ -5359,7 +5557,19 @@ function test6506_Renderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap(
5359
5557
  }
5360
5558
  // 1 entry + 2 body step nodes (cap x and cap y, truncated
5361
5559
  // at cap y) = 3 failure replica nodes.
5362
- assertEqual(failureNodes, 3, 'trace truncates at cap y via isEquivalent, yielding entry + 2 cap nodes');
5560
+ assertEqual(failureNodes, 3, 'trace truncates at cap y by stable token, yielding entry + 2 cap nodes');
5561
+
5562
+ const invalid = JSON.parse(JSON.stringify(payload));
5563
+ invalid.body_outcomes[0].failed_token_id = 'missing-step-token';
5564
+ let threw = false;
5565
+ try {
5566
+ rendererBuildRunGraphData(invalid);
5567
+ } catch (error) {
5568
+ threw = true;
5569
+ assert(error.message.includes('does not resolve to a strand step'),
5570
+ 'unknown token failure must identify the unresolved coordinate');
5571
+ }
5572
+ assert(threw, 'unknown failed_token_id must fail hard');
5363
5573
  }
5364
5574
 
5365
5575
  // TEST6507: Renderer build run graph data backbone has no foreach node
@@ -5378,11 +5588,11 @@ function test6507_Renderer_buildRunGraphData_backboneHasNoForeachNode() {
5378
5588
  const strand = {
5379
5589
  source_media_urn: 'media:ext=pdf',
5380
5590
  target_media_urn: 'media:decision',
5381
- steps: [
5591
+ steps: wireLinearStepInputs([
5382
5592
  makeCapStep('cap:in="media:ext=pdf";disbind;out="media:page"', 'Disbind', 'media:ext=pdf', 'media:page', false, true),
5383
5593
  makeForEachStep('media:page'),
5384
5594
  makeCapStep('cap:in="media:page";decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
5385
- ],
5595
+ ]),
5386
5596
  };
5387
5597
  const payload = {
5388
5598
  resolved_strand: strand,
@@ -5427,13 +5637,12 @@ function test6508_Renderer_buildRunGraphData_allFailedDropsTargetPlaceholder() {
5427
5637
  const strand = {
5428
5638
  source_media_urn: 'media:ext=pdf',
5429
5639
  target_media_urn: 'media:decision',
5430
- steps: [
5640
+ steps: wireLinearStepInputs([
5431
5641
  makeCapStep('cap:in="media:ext=pdf";disbind;out="media:page"', 'Disbind', 'media:ext=pdf', 'media:page', false, true),
5432
5642
  makeForEachStep('media:page'),
5433
5643
  makeCapStep('cap:in="media:page";decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
5434
- ],
5644
+ ]),
5435
5645
  };
5436
- const failedCapUrn = 'cap:in="media:page";decide;out="media:decision"';
5437
5646
  const payload = {
5438
5647
  resolved_strand: strand,
5439
5648
  media_display_names: {
@@ -5442,8 +5651,8 @@ function test6508_Renderer_buildRunGraphData_allFailedDropsTargetPlaceholder() {
5442
5651
  'media:decision': 'Decision',
5443
5652
  },
5444
5653
  body_outcomes: [
5445
- { body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
5446
- { body_index: 1, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
5654
+ { body_index: 0, foreach_token_id: strand.steps[1].token_id, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_token_id: strand.steps[2].token_id, failed_arg_urn: null, error: 'boom' },
5655
+ { body_index: 1, foreach_token_id: strand.steps[1].token_id, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_token_id: strand.steps[2].token_id, failed_arg_urn: null, error: 'boom' },
5447
5656
  ],
5448
5657
  visible_success_count: 3,
5449
5658
  visible_failure_count: 3,
@@ -5498,11 +5707,11 @@ function test6509_Renderer_buildRunGraphData_unclosedForeachSuccessNoMerge() {
5498
5707
  const strand = {
5499
5708
  source_media_urn: 'media:ext=pdf',
5500
5709
  target_media_urn: 'media:decision',
5501
- steps: [
5710
+ steps: wireLinearStepInputs([
5502
5711
  makeCapStep('cap:in="media:ext=pdf";disbind;out="media:page"', 'Disbind', 'media:ext=pdf', 'media:page', false, true),
5503
5712
  makeForEachStep('media:page'),
5504
5713
  makeCapStep('cap:in="media:page";decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
5505
- ],
5714
+ ]),
5506
5715
  };
5507
5716
  const payload = {
5508
5717
  resolved_strand: strand,
@@ -5512,7 +5721,7 @@ function test6509_Renderer_buildRunGraphData_unclosedForeachSuccessNoMerge() {
5512
5721
  'media:decision': 'Decision',
5513
5722
  },
5514
5723
  body_outcomes: [
5515
- { body_index: 0, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 },
5724
+ { body_index: 0, foreach_token_id: strand.steps[1].token_id, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_token_id: null, failed_arg_urn: null },
5516
5725
  ],
5517
5726
  visible_success_count: 3,
5518
5727
  visible_failure_count: 3,
@@ -5558,11 +5767,11 @@ function test6510_Renderer_buildRunGraphData_closedForeachSuccessMergesAtCollect
5558
5767
  const strand = {
5559
5768
  source_media_urn: 'media:ext=pdf;list',
5560
5769
  target_media_urn: 'media:txt;list',
5561
- steps: [
5770
+ steps: wireLinearStepInputs([
5562
5771
  makeForEachStep('media:ext=pdf;list'),
5563
5772
  makeCapStep('cap:in="media:ext=pdf";extract;out="media:ext=txt"', 'extract', 'media:ext=pdf', 'media:ext=txt', false, false),
5564
5773
  makeCollectStep('media:ext=txt'),
5565
- ],
5774
+ ]),
5566
5775
  };
5567
5776
  const payload = {
5568
5777
  resolved_strand: strand,
@@ -5573,7 +5782,7 @@ function test6510_Renderer_buildRunGraphData_closedForeachSuccessMergesAtCollect
5573
5782
  'media:txt;list': 'Text List',
5574
5783
  },
5575
5784
  body_outcomes: [
5576
- { body_index: 0, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 },
5785
+ { body_index: 0, foreach_token_id: strand.steps[0].token_id, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_token_id: null, failed_arg_urn: null },
5577
5786
  ],
5578
5787
  visible_success_count: 3,
5579
5788
  visible_failure_count: 3,
@@ -6337,7 +6546,7 @@ function test6736_axisWeightingDecodedLayout() {
6337
6546
  // TEST6737: Cap with version=0 round-trips with no `version` key on wire
6338
6547
  function test6737_capVersionZeroOmittedOnWire() {
6339
6548
  const urn = CapUrn.fromString('cap:in="media:void";test-op;out="media:enc=utf-8;record"');
6340
- const cap = new Cap(urn, 'Test Cap', 'test-op');
6549
+ const cap = new Cap(urn, 'Test Cap', ['test-op']);
6341
6550
  // version defaults to 0
6342
6551
  assertEqual(cap.version, 0, 'Default version should be 0');
6343
6552
  const json = cap.toJSON();
@@ -6349,7 +6558,7 @@ function test6737_capVersionZeroOmittedOnWire() {
6349
6558
  // TEST1848: Cap with version=N round-trips with `version: N` on wire
6350
6559
  function test1848_capVersionNonZeroOnWire() {
6351
6560
  const urn = CapUrn.fromString('cap:in="media:void";versioned-op;out="media:enc=utf-8;record"');
6352
- const cap = new Cap(urn, 'Versioned Cap', 'versioned-op');
6561
+ const cap = new Cap(urn, 'Versioned Cap', ['versioned-op']);
6353
6562
  cap.version = 42;
6354
6563
  const json = cap.toJSON();
6355
6564
  assert('version' in json, 'version!=0 must appear on wire');
@@ -6358,6 +6567,139 @@ function test1848_capVersionNonZeroOnWire() {
6358
6567
  assertEqual(restored.version, 42, 'Restored version must equal 42');
6359
6568
  }
6360
6569
 
6570
+ // ===========================================================================
6571
+ // Shared parity tests 7100-7104: CapArg.streamUrn / CapArg.isMainInput.
6572
+ // Same substantive assertions in every capdag mirror (rust, go, js, objc, py).
6573
+ // ===========================================================================
6574
+
6575
+ // TEST7100: streamUrn() returns the stdin source's URN when it differs from the declared slot media_urn — the stdin URN, not the slot URN, is what the runtime demuxes the arg's input stream by.
6576
+ function test7100_streamUrnReturnsStdinSourceUrnWhenItDiffersFromSlotUrn() {
6577
+ const arg = new CapArg(
6578
+ 'media:enc=utf-8;file-path',
6579
+ true,
6580
+ [new ArgSource({ stdin: 'media:ext=pdf;pdf-stream' })]
6581
+ );
6582
+ assertEqual(arg.streamUrn(), 'media:ext=pdf;pdf-stream',
6583
+ 'streamUrn must return the stdin source URN');
6584
+ assert(arg.streamUrn() !== arg.media_urn,
6585
+ 'stream URN must differ from the declared slot media_urn here');
6586
+ }
6587
+
6588
+ // TEST7101: streamUrn() falls back to the declared slot media_urn when the arg declares no stdin source — a producer-fed arg may be delivered by its declared URN without ever appearing on stdin.
6589
+ function test7101_streamUrnFallsBackToDeclaredMediaUrnWithoutStdinSource() {
6590
+ const arg = new CapArg(
6591
+ 'media:enc=utf-8;system-prompt',
6592
+ true,
6593
+ [new ArgSource({ cli_flag: '--system-prompt' })]
6594
+ );
6595
+ assertEqual(arg.streamUrn(), 'media:enc=utf-8;system-prompt',
6596
+ 'streamUrn must fall back to the declared media_urn');
6597
+ }
6598
+
6599
+ // TEST7102: isMainInput() is true when the stdin URN is order-theoretically EQUIVALENT to the cap's in= spec even when the two strings list their tags in a different order — the comparison is the MediaUrn equivalence predicate, never a string comparison.
6600
+ function test7102_isMainInputTrueOnTagOrderInsensitiveEquivalenceToInSpec() {
6601
+ const inSpec = MediaUrn.fromString('media:ext=pdf;pdf-stream');
6602
+ const arg = new CapArg(
6603
+ 'media:enc=utf-8;file-path',
6604
+ true,
6605
+ [new ArgSource({ stdin: 'media:pdf-stream;ext=pdf' })] // same tags, different order
6606
+ );
6607
+ assert(arg.isMainInput(inSpec),
6608
+ 'tag-order-insensitive equivalent stdin URN must be the main input');
6609
+ // The raw strings genuinely differ — proves the match is equivalence,
6610
+ // not string equality.
6611
+ assert(inSpec.toString() !== 'media:pdf-stream;ext=pdf',
6612
+ 'raw spec strings must differ for this test to be meaningful');
6613
+ }
6614
+
6615
+ // TEST7103: isMainInput() is false for cli_flag-only and position-only args (no stdin source means never the main input, whatever the declared slot URN says), and false when the stdin URN is not equivalent to in=.
6616
+ function test7103_isMainInputFalseWithoutEquivalentStdinSource() {
6617
+ const inSpec = MediaUrn.fromString('media:ext=pdf;pdf-stream');
6618
+
6619
+ const cliFlagOnly = new CapArg(
6620
+ 'media:ext=pdf;pdf-stream', // slot URN even matches in= — irrelevant
6621
+ true,
6622
+ [new ArgSource({ cli_flag: '--input' })]
6623
+ );
6624
+ assert(!cliFlagOnly.isMainInput(inSpec), 'cli_flag-only arg is never the main input');
6625
+
6626
+ const positionOnly = new CapArg(
6627
+ 'media:ext=pdf;pdf-stream',
6628
+ true,
6629
+ [new ArgSource({ position: 0 })]
6630
+ );
6631
+ assert(!positionOnly.isMainInput(inSpec), 'position-only arg is never the main input');
6632
+
6633
+ const nonEquivalentStdin = new CapArg(
6634
+ 'media:enc=utf-8;system-prompt',
6635
+ false,
6636
+ [new ArgSource({ stdin: 'media:enc=utf-8;system-prompt' })]
6637
+ );
6638
+ assert(!nonEquivalentStdin.isMainInput(inSpec),
6639
+ 'stdin URN not equivalent to in= must not be the main input');
6640
+ }
6641
+
6642
+ // TEST7104: A realistic multi-arg cap (one stdin main input; one required, defaultless cli_flag arg; several defaulted cli_flag args): exactly one arg is the main input, and partitioning the remaining args by required-without-default vs has-default yields the expected sets.
6643
+ function test7104_multiArgCapExactlyOneMainInputAndPartitionOfRest() {
6644
+ const inSpec = MediaUrn.fromString('media:ext=pdf;pdf-stream');
6645
+
6646
+ const args = [
6647
+ new CapArg(
6648
+ 'media:enc=utf-8;file-path',
6649
+ true,
6650
+ // Main input may ALSO be delivered by cli-flag; stdin is the
6651
+ // defining route.
6652
+ [new ArgSource({ stdin: 'media:pdf-stream;ext=pdf' }), new ArgSource({ cli_flag: '--input' })]
6653
+ ),
6654
+ new CapArg(
6655
+ 'media:enc=utf-8;question',
6656
+ true,
6657
+ [new ArgSource({ cli_flag: '--question' })]
6658
+ ),
6659
+ new CapArg(
6660
+ 'media:max-tokens;numeric',
6661
+ false,
6662
+ [new ArgSource({ cli_flag: '--max-tokens' })],
6663
+ { default_value: 1024 }
6664
+ ),
6665
+ new CapArg(
6666
+ 'media:numeric;temperature',
6667
+ false,
6668
+ [new ArgSource({ cli_flag: '--temperature' })],
6669
+ { default_value: 0.7 }
6670
+ ),
6671
+ new CapArg(
6672
+ 'media:enc=utf-8;system-prompt',
6673
+ false,
6674
+ [new ArgSource({ cli_flag: '--system-prompt' })],
6675
+ { default_value: 'You are a helpful assistant.' }
6676
+ ),
6677
+ ];
6678
+
6679
+ const mainInputs = args.filter(a => a.isMainInput(inSpec)).map(a => a.media_urn);
6680
+ const rest = args.filter(a => !a.isMainInput(inSpec));
6681
+ const requiredWithoutDefault = rest
6682
+ .filter(a => a.required && a.default_value === null)
6683
+ .map(a => a.media_urn);
6684
+ const withDefault = rest
6685
+ .filter(a => a.default_value !== null)
6686
+ .map(a => a.media_urn);
6687
+
6688
+ assertEqual(JSON.stringify(mainInputs),
6689
+ JSON.stringify(['media:enc=utf-8;file-path']),
6690
+ 'exactly one arg must be the main input');
6691
+ assertEqual(JSON.stringify(requiredWithoutDefault),
6692
+ JSON.stringify(['media:enc=utf-8;question']),
6693
+ 'required-without-default partition');
6694
+ assertEqual(JSON.stringify(withDefault),
6695
+ JSON.stringify([
6696
+ 'media:max-tokens;numeric',
6697
+ 'media:numeric;temperature',
6698
+ 'media:enc=utf-8;system-prompt',
6699
+ ]),
6700
+ 'has-default partition');
6701
+ }
6702
+
6361
6703
  // ===========================================================================
6362
6704
  // Fabric alias tests (shared numbers 1880-1882, 1887)
6363
6705
  //
@@ -6519,6 +6861,167 @@ function test1196_aliasedSerializationUsesAliasAndDropsHeader() {
6519
6861
  // Test runner
6520
6862
  // ============================================================================
6521
6863
 
6864
+
6865
+ // ============================================================================
6866
+ // Unified planner plan-space (planner.js) — TEST1450+
6867
+ // ============================================================================
6868
+
6869
+ const plannerNS = require('./planner.js');
6870
+
6871
+ /** Planner-specific throw assertion: any PlanStateError counts. */
6872
+ function assertThrowsPlanState(fn, message) {
6873
+ try {
6874
+ fn();
6875
+ } catch (error) {
6876
+ if (error instanceof plannerNS.PlanStateError) return;
6877
+ throw new Error(`Expected PlanStateError but got ${error.name}: ${error.message} (${message})`);
6878
+ }
6879
+ throw new Error(`Expected PlanStateError but function succeeded: ${message}`);
6880
+ }
6881
+
6882
+ // TEST1450: a defaults-only request is AUTO with every knob at its default,
6883
+ // and the proto JSON carries the documented wire shape.
6884
+ function test1450_planRequestDefaults() {
6885
+ const request = new plannerNS.PlanRequest({
6886
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6887
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6888
+ });
6889
+ assertEqual(request.isConfigured, false, 'defaults are not configured');
6890
+ const wire = request.toProtoJSON();
6891
+ assertEqual(wire.mode, 0, 'AUTO mode');
6892
+ assertEqual(wire.ranking, 0, 'intent ranking');
6893
+ assertEqual(wire.convergence.presence, 0, 'auto presence');
6894
+ assertEqual(wire.convergence.mechanism, 0, 'any mechanism');
6895
+ assertEqual(wire.sources[0].media_urn, 'media:ext=pdf', 'snake_case source field');
6896
+ assertEqual(wire.sources[0].is_sequence, false, 'snake_case sequence field');
6897
+ assertEqual(wire.max_depth, 0, '0 = engine default depth');
6898
+ }
6899
+
6900
+ // TEST1451: at-depth REQUIRES a positive depth; a depth anywhere else is
6901
+ // invalid; ranking never flips the request to CONFIGURED but any
6902
+ // space-constraining knob does (mode is forced to CONFIGURED on the wire).
6903
+ function test1451_planRequestKnobValidation() {
6904
+ assertThrowsPlanState(() => new plannerNS.PlanRequest({
6905
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6906
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6907
+ convergenceLocation: 'at-depth',
6908
+ }), 'at-depth without a depth must throw');
6909
+
6910
+ assertThrowsPlanState(() => new plannerNS.PlanRequest({
6911
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6912
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6913
+ convergenceAtDepth: 2,
6914
+ }), 'a depth without at-depth must throw');
6915
+
6916
+ const ranked = new plannerNS.PlanRequest({
6917
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6918
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6919
+ ranking: 'shortest',
6920
+ });
6921
+ assertEqual(ranked.isConfigured, false, 'ranking alone never configures');
6922
+ assertEqual(ranked.toProtoJSON().mode, 0, 'ranking alone stays AUTO');
6923
+
6924
+ const constrained = new plannerNS.PlanRequest({
6925
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6926
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6927
+ convergenceMechanism: 'collect',
6928
+ });
6929
+ assertEqual(constrained.isConfigured, true, 'a mechanism constrains');
6930
+ assertEqual(constrained.toProtoJSON().mode, 1, 'constrained ⇒ CONFIGURED on the wire');
6931
+
6932
+ assertThrowsPlanState(() => new plannerNS.PlanRequest({
6933
+ sources: [],
6934
+ exactTargets: ['media:enc=utf-8;ext=txt'],
6935
+ }), 'no sources must throw');
6936
+ assertThrowsPlanState(() => new plannerNS.PlanRequest({
6937
+ sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
6938
+ exactTargets: [],
6939
+ }), 'no targets must throw');
6940
+ }
6941
+
6942
+ // TEST1452: every knob round-trips value → proto number → value, and an
6943
+ // unknown value/number fails hard in both directions.
6944
+ function test1452_knobProtoRoundTrip() {
6945
+ const knobs = [
6946
+ plannerNS.ConvergencePresence, plannerNS.ConvergenceLocation,
6947
+ plannerNS.ConvergenceMechanism, plannerNS.ConvergenceArity,
6948
+ plannerNS.DivergencePresence, plannerNS.DivergenceLocation,
6949
+ plannerNS.RankPolicy, plannerNS.PlanMode,
6950
+ ];
6951
+ for (const knob of knobs) {
6952
+ for (const value of knob.values) {
6953
+ assertEqual(knob.fromProto(knob.toProto(value)), value, `${knob.name} round-trips ${value}`);
6954
+ }
6955
+ assertThrowsPlanState(() => knob.toProto('no-such-value'), `${knob.name} rejects unknown value`);
6956
+ assertThrowsPlanState(() => knob.fromProto(999), `${knob.name} rejects unknown number`);
6957
+ }
6958
+ }
6959
+
6960
+ // TEST1453: candidate/target parsers accept realistic proto-JSON and sort
6961
+ // candidates by rank; malformed payloads (missing field, wrong type) fail
6962
+ // hard with the offending path in the message.
6963
+ function test1453_planResponseParsers() {
6964
+ const candidates = plannerNS.parsePlanCandidates({
6965
+ candidates: [
6966
+ {
6967
+ notation: '[n0 -> edge_1 -> n1]',
6968
+ label: 'Convert each',
6969
+ rank: 1,
6970
+ profile: { source_media: ['media:ext=pdf'], target_media: ['media:enc=utf-8;ext=txt'], apexes: [], converged: false, diverged: false },
6971
+ cost: { cap_steps: 2, total_steps: 2, max_leg_depth: 0, intent_score: 0.5 },
6972
+ },
6973
+ {
6974
+ notation: '[(n0, n1) -> edge_0 -> n2]',
6975
+ label: 'Combine via text',
6976
+ rank: 0,
6977
+ profile: {
6978
+ source_media: ['media:ext=pdf', 'media:ext=md'],
6979
+ target_media: ['media:enc=utf-8;ext=txt'],
6980
+ apexes: [{ media_urn: 'media:enc=utf-8;page', mechanism: 2, depth: 1 }],
6981
+ converged: true,
6982
+ diverged: false,
6983
+ },
6984
+ cost: { cap_steps: 3, total_steps: 4, max_leg_depth: 1, intent_score: 0.8 },
6985
+ },
6986
+ ],
6987
+ });
6988
+ assertEqual(candidates.length, 2, 'two candidates');
6989
+ assertEqual(candidates[0].rank, 0, 'sorted by rank');
6990
+ assertEqual(candidates[0].profile.apexes[0].mechanism, 'collect', 'proto mechanism decoded to knob value');
6991
+ assertEqual(candidates[0].profile.converged, true, 'converged carried');
6992
+
6993
+ assertThrowsPlanState(
6994
+ () => plannerNS.parsePlanCandidates({ candidates: [{ label: 'missing notation', rank: 0 }] }),
6995
+ 'a candidate without notation must fail hard'
6996
+ );
6997
+
6998
+ const targets = plannerNS.parseConvergentTargets({
6999
+ targets: [
7000
+ {
7001
+ media_def: 'media:enc=utf-8;ext=txt',
7002
+ display_name: 'Plain Text',
7003
+ min_total_steps: 3,
7004
+ apex: { media_urn: 'media:enc=utf-8;page', mechanism: 2, depth: 1 },
7005
+ convergent: true,
7006
+ file_extension: 'txt',
7007
+ },
7008
+ {
7009
+ media_def: 'media:ext=png;image',
7010
+ display_name: 'PNG Image',
7011
+ min_total_steps: 2,
7012
+ convergent: false,
7013
+ file_extension: 'png',
7014
+ },
7015
+ ],
7016
+ });
7017
+ assertEqual(targets[0].apex.mediaUrn, 'media:enc=utf-8;page', 'apex carried');
7018
+ assertEqual(targets[1].apex, null, 'independent target has no apex');
7019
+ assertThrowsPlanState(
7020
+ () => plannerNS.parseConvergentTargets({ targets: [{ display_name: 'no media_def' }] }),
7021
+ 'a target without media_def must fail hard'
7022
+ );
7023
+ }
7024
+
6522
7025
  async function runTests() {
6523
7026
  console.log('Running capdag-js tests...\n');
6524
7027
 
@@ -6721,7 +7224,7 @@ async function runTests() {
6721
7224
  await runTest('TEST1875: scan_all_reaches_both_dev_and_registry_slugs', test1875_scanAllReachesBothDevAndRegistrySlugs);
6722
7225
  await runTest('TEST1876: other_channel_subtree_is_skipped', test1876_otherChannelSubtreeIsSkipped);
6723
7226
  await runTest('TEST1877: registry_cartridge_under_wrong_slug_is_bad_install', test1877_registryCartridgeUnderWrongSlugIsBadInstall);
6724
- await runTest('TEST1878: bundled_provider_without_baked_hash_is_rejected', test1878_bundledProviderWithoutBakedHashIsRejected);
7227
+ await runTest('TEST1878: bundled_cartridge_without_baked_hash_is_rejected', test1878_bundledCartridgeWithoutBakedHashIsRejected);
6725
7228
 
6726
7229
  // media_urn.rs: TEST1312-TEST1315, TEST1298-TEST1302 (MediaUrn predicates)
6727
7230
  console.log('\n--- media_urn.rs (predicates) ---');
@@ -6912,12 +7415,14 @@ async function runTests() {
6912
7415
  runTest('RENDERER: collapseStrand_plainCapMergesOutput', test6500_Renderer_collapseStrand_plainCapMergesTrailingOutput);
6913
7416
  runTest('RENDERER: collapseStrand_plainCapDistinctTarget', test6501_Renderer_collapseStrand_plainCapDistinctTargetNoMerge);
6914
7417
  runTest('RENDERER: validateStrand_missingSourceMediaUrn', test6502_Renderer_validateStrandPayload_missingSourceMediaUrn);
7418
+ runTest('RENDERER: validateStrand_requiresCapInputs', test7120_Renderer_validateStrandPayload_requiresAuthoritativeInputs);
6915
7419
 
6916
7420
  console.log('\n--- cap-fab-renderer run builder ---');
6917
7421
  runTest('RENDERER: validateBodyOutcome_negativeIndex', test6503_Renderer_validateBodyOutcome_rejectsNegativeIndex);
7422
+ runTest('RENDERER: validateBodyOutcome_failureCoordinate', test7121_Renderer_validateBodyOutcome_requiresExplicitFailureCoordinate);
6918
7423
  runTest('RENDERER: buildRun_pagesSuccessesAndFailures', test6504_Renderer_buildRunGraphData_pagesSuccessesAndFailures);
6919
- runTest('RENDERER: buildRun_failureWithoutFailedCap', test6505_Renderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace);
6920
- runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', test6506_Renderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
7424
+ runTest('RENDERER: buildRun_unattributedFailureAtEntry', test6505_Renderer_buildRunGraphData_unattributedFailureStopsAtEntry);
7425
+ runTest('RENDERER: buildRun_resolvesFailedStepToken', test6506_Renderer_buildRunGraphData_resolvesExactFailedStepToken);
6921
7426
  runTest('RENDERER: buildRun_backboneHasNoForeachNode', test6507_Renderer_buildRunGraphData_backboneHasNoForeachNode);
6922
7427
  runTest('RENDERER: buildRun_allFailedDropsPlaceholder', test6508_Renderer_buildRunGraphData_allFailedDropsTargetPlaceholder);
6923
7428
  runTest('RENDERER: buildRun_unclosedForeachNoMerge', test6509_Renderer_buildRunGraphData_unclosedForeachSuccessNoMerge);
@@ -6976,6 +7481,13 @@ async function runTests() {
6976
7481
  runTest('TEST1847: cap_version_zero_omitted_on_wire', test6737_capVersionZeroOmittedOnWire);
6977
7482
  runTest('TEST1848: cap_version_nonzero_on_wire', test1848_capVersionNonZeroOnWire);
6978
7483
 
7484
+ console.log('\n--- CapArg stream URN + main input (test7100–test7104) ---');
7485
+ runTest('TEST7100: stream_urn_prefers_stdin_source_urn', test7100_streamUrnReturnsStdinSourceUrnWhenItDiffersFromSlotUrn);
7486
+ runTest('TEST7101: stream_urn_falls_back_to_media_urn', test7101_streamUrnFallsBackToDeclaredMediaUrnWithoutStdinSource);
7487
+ runTest('TEST7102: is_main_input_by_urn_equivalence', test7102_isMainInputTrueOnTagOrderInsensitiveEquivalenceToInSpec);
7488
+ runTest('TEST7103: is_main_input_false_without_stdin', test7103_isMainInputFalseWithoutEquivalentStdinSource);
7489
+ runTest('TEST7104: multi_arg_main_input_partition', test7104_multiArgCapExactlyOneMainInputAndPartitionOfRest);
7490
+
6979
7491
  runTest('TEST1880: alias_name_normalization_rules', test1880_aliasNameNormalizationRules);
6980
7492
  runTest('TEST1881: token_urn_vs_alias_detection', test1881_tokenUrnVsAliasDetection);
6981
7493
  runTest('TEST1882: classify_alias_target_by_prefix', test1882_classifyAliasTargetByPrefix);
@@ -6987,6 +7499,13 @@ async function runTests() {
6987
7499
  runTest('TEST1896: cached_cap_aliases_filters_to_cap', test1896_cachedCapAliasesFiltersToCapTargets);
6988
7500
  runTest('TEST1196: aliased_serialization_uses_alias', test1196_aliasedSerializationUsesAliasAndDropsHeader);
6989
7501
 
7502
+ // planner.js: unified plan-space vocabulary
7503
+ console.log('--- planner.js ---');
7504
+ runTest('TEST1450: plan_request_defaults', test1450_planRequestDefaults);
7505
+ runTest('TEST1451: plan_request_knob_validation', test1451_planRequestKnobValidation);
7506
+ runTest('TEST1452: knob_proto_round_trip', test1452_knobProtoRoundTrip);
7507
+ runTest('TEST1453: plan_response_parsers', test1453_planResponseParsers);
7508
+
6990
7509
  // Summary
6991
7510
  console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
6992
7511
  if (failCount > 0) {