capdag 0.180.452 → 0.182.459

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.
@@ -555,6 +555,26 @@ function buildStylesheet() {
555
555
  selector: 'edge.active',
556
556
  style: { 'width': 3, 'z-index': 1000 },
557
557
  },
558
+ {
559
+ selector: 'edge.strand-shape-edge',
560
+ style: {
561
+ 'line-style': 'dashed',
562
+ 'width': 2,
563
+ 'text-background-opacity': 0.92,
564
+ },
565
+ },
566
+ {
567
+ selector: 'edge.strand-foreach-edge',
568
+ style: {
569
+ 'target-arrow-shape': 'triangle',
570
+ },
571
+ },
572
+ {
573
+ selector: 'edge.strand-collect-edge',
574
+ style: {
575
+ 'target-arrow-shape': 'tee',
576
+ },
577
+ },
558
578
  {
559
579
  selector: 'edge.faded',
560
580
  style: { 'opacity': fadedEdgeOpacity },
@@ -1374,7 +1394,7 @@ function collapseStrandShapeTransitions(built) {
1374
1394
  label: '',
1375
1395
  title: '',
1376
1396
  fullUrn: '',
1377
- edgeClass: 'strand-cap-edge',
1397
+ edgeClass: 'strand-cap-edge strand-shape-edge strand-collect-edge',
1378
1398
  color: bodyExitCapEdge ? bodyExitCapEdge.color : inEdge.color,
1379
1399
  foreachEntry: false,
1380
1400
  });
@@ -1399,15 +1419,16 @@ function collapseStrandShapeTransitions(built) {
1399
1419
  e.edgeClass !== 'strand-collection');
1400
1420
  edges = edges.concat(synthesizedExitEdges);
1401
1421
 
1402
- // Step 2: foreach-entry cap edges keep whatever label the
1403
- // strand builder emitted the cap's own cardinality marker
1404
- // (from input_is_sequence/output_is_sequence) is the single
1405
- // source of truth for which edge carries a (1→n) / (n→1) /
1406
- // (n→n) annotation. The ForEach/Collect shape transitions
1407
- // themselves are invisible in the render; the cap preceding a
1408
- // ForEach (with output_is_sequence=true) already produces the
1409
- // (1→n) marker, and the cap following a Collect (with
1410
- // input_is_sequence=true) produces (n→1). No relabeling.
1422
+ // Step 2: surface shape transitions as distinct edge semantics.
1423
+ // The cap edge that enters a foreach body keeps its original cap
1424
+ // label; only the edge styling changes. Collect bridges are
1425
+ // synthesized above as dedicated dashed edges.
1426
+ edges = edges.map(edge => {
1427
+ if (!edge.foreachEntry) return edge;
1428
+ return Object.assign({}, edge, {
1429
+ edgeClass: `${edge.edgeClass} strand-shape-edge strand-foreach-edge`,
1430
+ });
1431
+ });
1411
1432
 
1412
1433
  // Step 3: merge the trailing `step_N → output` edge when step_N
1413
1434
  // and output represent the same media URN. The strand builder
package/capdag.js CHANGED
@@ -2,7 +2,11 @@
2
2
  // Follows the exact same rules as Rust, Go, and Objective-C implementations
3
3
 
4
4
  // Import TaggedUrn from the tagged-urn package
5
- const { TaggedUrn, valuesMatch: taggedUrnValuesMatch, scoreTagValue } = require('tagged-urn');
5
+ const {
6
+ TaggedUrn,
7
+ valuesMatch: taggedUrnValuesMatch,
8
+ scoreTagValue
9
+ } = require('tagged-urn');
6
10
 
7
11
  /**
8
12
  * Error types for Cap URN operations
@@ -30,7 +34,10 @@ const ErrorCodes = {
30
34
  MISSING_OUT_SPEC: 11,
31
35
  EMPTY_VALUE: 12,
32
36
  INVALID_IN_SPEC: 13,
33
- INVALID_OUT_SPEC: 14
37
+ INVALID_OUT_SPEC: 14,
38
+ INVALID_EFFECT: 15,
39
+ INVALID_EFFECT_APPLICATION: 16,
40
+ ILLEGAL_DECLARATION: 17
34
41
  };
35
42
 
36
43
  // Note: All parsing is delegated to TaggedUrn from tagged-urn-js
@@ -114,8 +121,8 @@ function validatePreservedDirectionSpec(spec, tagName) {
114
121
  }
115
122
 
116
123
  /**
117
- * Functional category of a cap, derived from all three axes (`in`,
118
- * `out`, and the remaining tags). The classification is **logical** —
124
+ * Functional category of a cap, derived from all four structural axes
125
+ * (`in`, `out`, `effect`, and the remaining tags). The classification is **logical** —
119
126
  * the dispatch protocol does not branch on CapKind. Exposed so tools,
120
127
  * UIs, planners, and tests can reason about a cap's role without
121
128
  * re-deriving the rules.
@@ -124,15 +131,13 @@ function validatePreservedDirectionSpec(spec, tagName) {
124
131
  * is the **top type** (universal wildcard). With those anchors the
125
132
  * five kinds fall out:
126
133
  *
127
- * IDENTITY in=media:, out=media:, no other tags → A → A
134
+ * IDENTITY in=media:, out=media:, effect=none, no other tags → A → A
128
135
  * SOURCE in=media:void, out!=void → () → B
129
136
  * SINK in!=void, out=media:void → A → ()
130
137
  * EFFECT in=media:void, out=media:void → () → ()
131
138
  * TRANSFORM anything else
132
139
  *
133
- * IDENTITY is the **fully generic** cap on every axis. Adding any
134
- * tag specifies something on the third axis and demotes the morphism
135
- * to a TRANSFORM whose in/out happen to be the wildcards.
140
+ * `cap:effect=none` is the categorical identity morphism.
136
141
  *
137
142
  * String values are snake_case to match other capdag enum
138
143
  * serializations on the wire.
@@ -145,6 +150,50 @@ const CapKind = Object.freeze({
145
150
  TRANSFORM: 'transform',
146
151
  });
147
152
 
153
+ const CapEffect = Object.freeze({
154
+ DECLARED: 'declared',
155
+ NONE: 'none',
156
+ PATCH: 'patch',
157
+ ANY: '?',
158
+ });
159
+
160
+ function normalizeEffectValue(rawValue) {
161
+ if (rawValue === undefined || rawValue === null) return CapEffect.DECLARED;
162
+ if (rawValue === '?' || rawValue === '*') return CapEffect.ANY;
163
+ if (rawValue === CapEffect.DECLARED) return CapEffect.DECLARED;
164
+ if (rawValue === CapEffect.NONE) return CapEffect.NONE;
165
+ if (rawValue === CapEffect.PATCH) return CapEffect.PATCH;
166
+ if (rawValue === '') {
167
+ throw new CapUrnError(ErrorCodes.INVALID_EFFECT, "Empty value for 'effect' tag is not allowed");
168
+ }
169
+ throw new CapUrnError(
170
+ ErrorCodes.INVALID_EFFECT,
171
+ `Unsupported effect '${rawValue}'. Supported values are declared, none, patch, or explicit unconstrained ?effect/effect=*`
172
+ );
173
+ }
174
+
175
+ function validateNonStructuralTags(tags) {
176
+ try {
177
+ new TaggedUrn('cap', tags, true);
178
+ } catch (error) {
179
+ const msg = error && error.message ? error.message : String(error);
180
+ const msgLower = msg.toLowerCase();
181
+ if (msgLower.includes('duplicate')) {
182
+ throw new CapUrnError(ErrorCodes.DUPLICATE_KEY, msg);
183
+ }
184
+ if (msgLower.includes('numeric') || msgLower.includes('purely numeric')) {
185
+ throw new CapUrnError(ErrorCodes.NUMERIC_KEY, msg);
186
+ }
187
+ if (msgLower.includes('invalid character')) {
188
+ throw new CapUrnError(ErrorCodes.INVALID_CHARACTER, msg);
189
+ }
190
+ if (msgLower.includes('escape')) {
191
+ throw new CapUrnError(ErrorCodes.INVALID_ESCAPE_SEQUENCE, msg);
192
+ }
193
+ throw new CapUrnError(ErrorCodes.INVALID_TAG_FORMAT, msg);
194
+ }
195
+ }
196
+
148
197
  class CapUrn {
149
198
  // Per-axis weights for cap-URN specificity. Two orders of
150
199
  // magnitude separate each axis to keep them in distinct digit
@@ -157,19 +206,23 @@ class CapUrn {
157
206
  * Create a new CapUrn with direction specs.
158
207
  * @param {string} inSpec - Input media URN (e.g., "media:void")
159
208
  * @param {string} outSpec - Output media URN (e.g., "media:object")
160
- * @param {Object} tags - Other tags (must NOT contain 'in' or 'out')
209
+ * @param {string} effect - Runtime media identity effect
210
+ * @param {Object} tags - Other tags (must NOT contain 'in', 'out', or 'effect')
161
211
  */
162
- constructor(inSpec, outSpec, tags = {}) {
212
+ constructor(inSpec, outSpec, effect = CapEffect.DECLARED, tags = {}) {
163
213
  this.inSpec = canonicalizeDirectionSpec(inSpec, 'in');
164
214
  this.outSpec = canonicalizeDirectionSpec(outSpec, 'out');
215
+ this.effectValue = normalizeEffectValue(effect);
165
216
  this.tags = {};
166
- // Copy tags, filtering out any 'in' or 'out' that might have slipped through
217
+ // Copy tags, filtering out any structural coordinates that might have slipped through
167
218
  for (const [key, value] of Object.entries(tags)) {
168
219
  const keyLower = key.toLowerCase();
169
- if (keyLower !== 'in' && keyLower !== 'out') {
220
+ if (keyLower !== 'in' && keyLower !== 'out' && keyLower !== 'effect') {
170
221
  this.tags[keyLower] = value;
171
222
  }
172
223
  }
224
+ validateNonStructuralTags(this.tags);
225
+ this._validateAdmissible();
173
226
  }
174
227
 
175
228
  /**
@@ -188,6 +241,14 @@ class CapUrn {
188
241
  return this.outSpec;
189
242
  }
190
243
 
244
+ /**
245
+ * Get the canonical effect coordinate.
246
+ * @returns {string}
247
+ */
248
+ getEffect() {
249
+ return this.effectValue;
250
+ }
251
+
191
252
  /**
192
253
  * Parse the in= spec into a MediaUrn.
193
254
  * @returns {MediaUrn} The input media URN
@@ -207,15 +268,11 @@ class CapUrn {
207
268
  }
208
269
 
209
270
  /**
210
- * Functional category of this cap, derived from all three axes:
211
- * `in` (parsed MediaUrn), `out` (parsed MediaUrn), and the rest of
212
- * the tags (the operation/metadata axis — `this.tags` does NOT
213
- * include in/out, those live in this.inSpec/this.outSpec).
271
+ * Functional category of this cap, derived from all four axes:
272
+ * `in`, `out`, `effect`, and the remaining y-axis tags.
214
273
  *
215
- * Identity requires every axis to be in its most generic form: in
216
- * is the top media URN (`media:`), out is the top media URN, and
217
- * there are no other tags. Source/Sink/Effect are decided by void
218
- * on either directional axis. Anything else is Transform.
274
+ * Identity requires top/top, no other tags, and explicit
275
+ * `effect=none`.
219
276
  *
220
277
  * @returns {string} A {@link CapKind} value (snake_case string).
221
278
  * @throws {MediaUrnError} If either side is not a valid media URN
@@ -232,7 +289,9 @@ class CapUrn {
232
289
  const outTop = outMedia.isTop();
233
290
  const noExtraTags = Object.keys(this.tags).length === 0;
234
291
 
235
- if (inTop && outTop && noExtraTags) return CapKind.IDENTITY;
292
+ if (inTop && outTop && noExtraTags) {
293
+ if (this.effectValue === CapEffect.NONE) return CapKind.IDENTITY;
294
+ }
236
295
  if (inVoid && outVoid) return CapKind.EFFECT;
237
296
  if (inVoid) return CapKind.SOURCE;
238
297
  if (outVoid) return CapKind.SINK;
@@ -293,20 +352,23 @@ class CapUrn {
293
352
  const inSpec = processDirectionTag(taggedUrn, 'in');
294
353
  const outSpec = processDirectionTag(taggedUrn, 'out');
295
354
 
296
- // Build remaining tags (excluding in/out)
355
+ const effect = normalizeEffectValue(taggedUrn.getTag('effect'));
356
+
357
+ // Build remaining tags (excluding in/out/effect)
297
358
  const remainingTags = {};
298
359
  for (const [key, value] of Object.entries(taggedUrn.tags)) {
299
- if (key !== 'in' && key !== 'out') {
360
+ if (key !== 'in' && key !== 'out' && key !== 'effect') {
300
361
  remainingTags[key] = value;
301
362
  }
302
363
  }
303
364
 
304
- return new CapUrn(inSpec, outSpec, remainingTags);
365
+ return new CapUrn(inSpec, outSpec, effect, remainingTags);
305
366
  }
306
367
 
307
368
  /**
308
369
  * Create a Cap URN from a tags object.
309
- * Unlike string parsing, this path requires explicit `in` and `out` tags.
370
+ * Missing structural coordinates default exactly as they do in string
371
+ * parsing (`in=media:`, `out=media:`, `effect=declared`).
310
372
  *
311
373
  * @param {Object} tags - Object containing all tags including 'in' and 'out'
312
374
  * @returns {CapUrn} The parsed Cap URN
@@ -330,16 +392,18 @@ class CapUrn {
330
392
  throw new CapUrnError(ErrorCodes.INVALID_OUT_SPEC, "Empty value for 'out' tag is not allowed");
331
393
  }
332
394
 
333
- // Build remaining tags (excluding in/out)
395
+ const effect = normalizeEffectValue(tags['effect'] || tags['EFFECT']);
396
+
397
+ // Build remaining tags (excluding in/out/effect)
334
398
  const remainingTags = {};
335
399
  for (const [key, value] of Object.entries(tags)) {
336
400
  const keyLower = key.toLowerCase();
337
- if (keyLower !== 'in' && keyLower !== 'out') {
401
+ if (keyLower !== 'in' && keyLower !== 'out' && keyLower !== 'effect') {
338
402
  remainingTags[keyLower] = value;
339
403
  }
340
404
  }
341
405
 
342
- return new CapUrn(inSpec, outSpec, remainingTags);
406
+ return new CapUrn(inSpec, outSpec, effect, remainingTags);
343
407
  }
344
408
 
345
409
  /**
@@ -352,11 +416,8 @@ class CapUrn {
352
416
  */
353
417
  toString() {
354
418
  // `in` and `out` segments are emitted only when they refine beyond
355
- // the trivial wildcard `media:`. A cap whose in/out are both
356
- // `media:` and which has no other tags has the canonical form
357
- // `cap:` — the bare identity URN. The canonicalizer collapses both
358
- // written forms (`cap:` and `cap:in=media:;out=media:`) to one
359
- // representative so byte-equality matches semantic identity.
419
+ // the trivial wildcard `media:`. Missing `effect` means the default
420
+ // `declared`; `effect=none` is preserved.
360
421
  const allTags = { ...this.tags };
361
422
  if (this.inSpec !== 'media:') {
362
423
  allTags['in'] = this.inSpec;
@@ -364,6 +425,9 @@ class CapUrn {
364
425
  if (this.outSpec !== 'media:') {
365
426
  allTags['out'] = this.outSpec;
366
427
  }
428
+ if (this.effectValue !== CapEffect.DECLARED) {
429
+ allTags['effect'] = this.effectValue;
430
+ }
367
431
 
368
432
  const taggedUrn = new TaggedUrn('cap', allTags, true);
369
433
  return taggedUrn.toString();
@@ -372,7 +436,8 @@ class CapUrn {
372
436
  /**
373
437
  * Get the value of a specific tag
374
438
  * Key is normalized to lowercase for lookup
375
- * Returns inSpec for "in" key, outSpec for "out" key
439
+ * Returns inSpec for "in" key, outSpec for "out" key, and the effect
440
+ * coordinate for "effect".
376
441
  *
377
442
  * @param {string} key - The tag key
378
443
  * @returns {string|undefined} The tag value or undefined if not found
@@ -385,13 +450,16 @@ class CapUrn {
385
450
  if (keyLower === 'out') {
386
451
  return this.outSpec;
387
452
  }
453
+ if (keyLower === 'effect') {
454
+ return this.effectValue;
455
+ }
388
456
  return this.tags[keyLower];
389
457
  }
390
458
 
391
459
  /**
392
460
  * Check if this cap has a specific tag with a specific value
393
461
  * Key is normalized to lowercase; value comparison is case-sensitive
394
- * Checks inSpec for "in" key, outSpec for "out" key
462
+ * Checks inSpec for "in" key, outSpec for "out" key, and effect for "effect"
395
463
  *
396
464
  * @param {string} key - The tag key
397
465
  * @param {string} value - The tag value to check
@@ -405,6 +473,9 @@ class CapUrn {
405
473
  if (keyLower === 'out') {
406
474
  return this.outSpec === value;
407
475
  }
476
+ if (keyLower === 'effect') {
477
+ return this.effectValue === value;
478
+ }
408
479
  const tagValue = this.tags[keyLower];
409
480
  return tagValue !== undefined && tagValue === value;
410
481
  }
@@ -422,7 +493,7 @@ class CapUrn {
422
493
  */
423
494
  hasMarkerTag(tagName) {
424
495
  const keyLower = tagName.toLowerCase();
425
- if (keyLower === 'in' || keyLower === 'out') {
496
+ if (keyLower === 'in' || keyLower === 'out' || keyLower === 'effect') {
426
497
  return false;
427
498
  }
428
499
  return this.tags[keyLower] === '*';
@@ -430,8 +501,8 @@ class CapUrn {
430
501
 
431
502
  /**
432
503
  * Create a new cap URN with an added or updated tag.
433
- * Attempts to set `in` / `out` through `withTag` are ignored; use
434
- * `withInSpec` / `withOutSpec` instead.
504
+ * Reserved structural coordinates must be changed through dedicated
505
+ * accessors.
435
506
  *
436
507
  * @param {string} key - The tag key
437
508
  * @param {string} value - The tag value
@@ -442,13 +513,15 @@ class CapUrn {
442
513
  throw new CapUrnError(ErrorCodes.EMPTY_VALUE, `Empty value for key '${key}' (use '*' for wildcard)`);
443
514
  }
444
515
  const keyLower = key.toLowerCase();
445
- // Silently ignore attempts to set in/out via withTag
446
- if (keyLower === 'in' || keyLower === 'out') {
447
- return this;
516
+ if (keyLower === 'in' || keyLower === 'out' || keyLower === 'effect') {
517
+ throw new CapUrnError(
518
+ ErrorCodes.INVALID_TAG_FORMAT,
519
+ `Reserved structural key '${keyLower}' must be changed via withInSpec(), withOutSpec(), or withEffect()`
520
+ );
448
521
  }
449
522
  const newTags = { ...this.tags };
450
523
  newTags[keyLower] = value;
451
- return new CapUrn(this.inSpec, this.outSpec, newTags);
524
+ return new CapUrn(this.inSpec, this.outSpec, this.effectValue, newTags);
452
525
  }
453
526
 
454
527
  /**
@@ -458,9 +531,12 @@ class CapUrn {
458
531
  * @returns {CapUrn} A new CapUrn instance with the updated inSpec
459
532
  */
460
533
  withInSpec(inSpec) {
461
- const updated = new CapUrn(this.inSpec, this.outSpec, this.tags);
462
- updated.inSpec = validatePreservedDirectionSpec(inSpec, 'in');
463
- return updated;
534
+ return new CapUrn(
535
+ validatePreservedDirectionSpec(inSpec, 'in'),
536
+ this.outSpec,
537
+ this.effectValue,
538
+ this.tags
539
+ );
464
540
  }
465
541
 
466
542
  /**
@@ -470,28 +546,43 @@ class CapUrn {
470
546
  * @returns {CapUrn} A new CapUrn instance with the updated outSpec
471
547
  */
472
548
  withOutSpec(outSpec) {
473
- const updated = new CapUrn(this.inSpec, this.outSpec, this.tags);
474
- updated.outSpec = validatePreservedDirectionSpec(outSpec, 'out');
475
- return updated;
549
+ return new CapUrn(
550
+ this.inSpec,
551
+ validatePreservedDirectionSpec(outSpec, 'out'),
552
+ this.effectValue,
553
+ this.tags
554
+ );
555
+ }
556
+
557
+ /**
558
+ * Create a new cap URN with a different effect coordinate.
559
+ *
560
+ * @param {string} effect
561
+ * @returns {CapUrn}
562
+ */
563
+ withEffect(effect) {
564
+ return new CapUrn(this.inSpec, this.outSpec, normalizeEffectValue(effect), this.tags);
476
565
  }
477
566
 
478
567
  /**
479
568
  * Create a new cap URN with a tag removed
480
569
  * Key is normalized to lowercase for case-insensitive removal
481
- * SILENTLY IGNORES attempts to remove "in" or "out" - they are required
570
+ * Reserved structural coordinates must be changed through dedicated accessors.
482
571
  *
483
572
  * @param {string} key - The tag key to remove
484
573
  * @returns {CapUrn} A new CapUrn instance with the tag removed
485
574
  */
486
575
  withoutTag(key) {
487
576
  const keyLower = key.toLowerCase();
488
- // Silently ignore attempts to remove in/out - they are required
489
- if (keyLower === 'in' || keyLower === 'out') {
490
- return this;
577
+ if (keyLower === 'in' || keyLower === 'out' || keyLower === 'effect') {
578
+ throw new CapUrnError(
579
+ ErrorCodes.INVALID_TAG_FORMAT,
580
+ `Reserved structural key '${keyLower}' cannot be removed via withoutTag()`
581
+ );
491
582
  }
492
583
  const newTags = { ...this.tags };
493
584
  delete newTags[keyLower];
494
- return new CapUrn(this.inSpec, this.outSpec, newTags);
585
+ return new CapUrn(this.inSpec, this.outSpec, this.effectValue, newTags);
495
586
  }
496
587
 
497
588
  /**
@@ -516,8 +607,8 @@ class CapUrn {
516
607
  // Input direction: pattern accepts instance. `media:` on the pattern side is
517
608
  // the wildcard top and skips the check.
518
609
  if (this.inSpec !== 'media:' && this.inSpec !== '*') {
519
- const capIn = TaggedUrn.fromString(this.inSpec);
520
- const requestIn = TaggedUrn.fromString(request.inSpec);
610
+ const capIn = MediaUrn.fromString(this.inSpec);
611
+ const requestIn = MediaUrn.fromString(request.inSpec);
521
612
  if (!capIn.accepts(requestIn)) {
522
613
  return false;
523
614
  }
@@ -526,13 +617,17 @@ class CapUrn {
526
617
  // Output direction: provider output must conform to requested output.
527
618
  // `media:` on the pattern side is wildcard top and skips the check.
528
619
  if (this.outSpec !== 'media:' && this.outSpec !== '*') {
529
- const capOut = TaggedUrn.fromString(this.outSpec);
530
- const requestOut = TaggedUrn.fromString(request.outSpec);
620
+ const capOut = MediaUrn.fromString(this.outSpec);
621
+ const requestOut = MediaUrn.fromString(request.outSpec);
531
622
  if (!capOut.conformsTo(requestOut)) {
532
623
  return false;
533
624
  }
534
625
  }
535
626
 
627
+ if (this.effectValue !== CapEffect.ANY && this.effectValue !== request.effectValue) {
628
+ return false;
629
+ }
630
+
536
631
  // Y-axis: every tag's per-key match runs through the six-form
537
632
  // truth table (taggedUrnValuesMatch). Walk the union of all keys
538
633
  // appearing on either side so missing-on-pattern and
@@ -593,8 +688,8 @@ class CapUrn {
593
688
  * @returns {number} The specificity score
594
689
  */
595
690
  specificity() {
596
- const inUrn = TaggedUrn.fromString(this.inSpec);
597
- const outUrn = TaggedUrn.fromString(this.outSpec);
691
+ const inUrn = MediaUrn.fromString(this.inSpec);
692
+ const outUrn = MediaUrn.fromString(this.outSpec);
598
693
 
599
694
  let yScore = 0;
600
695
  for (const value of Object.values(this.tags)) {
@@ -629,10 +724,13 @@ class CapUrn {
629
724
  withWildcardTag(key) {
630
725
  const keyLower = key.toLowerCase();
631
726
  if (keyLower === 'in') {
632
- return this.withInSpec('*');
727
+ return this.withInSpec('media:');
633
728
  }
634
729
  if (keyLower === 'out') {
635
- return this.withOutSpec('*');
730
+ return this.withOutSpec('media:');
731
+ }
732
+ if (keyLower === 'effect') {
733
+ return this.withEffect(CapEffect.ANY);
636
734
  }
637
735
  if (this.tags.hasOwnProperty(keyLower)) {
638
736
  return this.withTag(key, '*');
@@ -642,7 +740,7 @@ class CapUrn {
642
740
 
643
741
  /**
644
742
  * Create a new cap with only specified tags
645
- * Always preserves inSpec and outSpec (they are required)
743
+ * Always preserves inSpec, outSpec, and effect.
646
744
  *
647
745
  * @param {string[]} keys - Array of tag keys to include
648
746
  * @returns {CapUrn} A new CapUrn instance with only the specified tags (plus in/out)
@@ -651,29 +749,28 @@ class CapUrn {
651
749
  const newTags = {};
652
750
  for (const key of keys) {
653
751
  const normalizedKey = key.toLowerCase();
654
- // Skip in/out - they are always preserved via constructor
655
- if (normalizedKey !== 'in' && normalizedKey !== 'out') {
752
+ if (normalizedKey !== 'in' && normalizedKey !== 'out' && normalizedKey !== 'effect') {
656
753
  if (this.tags.hasOwnProperty(normalizedKey)) {
657
754
  newTags[normalizedKey] = this.tags[normalizedKey];
658
755
  }
659
756
  }
660
757
  }
661
- return new CapUrn(this.inSpec, this.outSpec, newTags);
758
+ return new CapUrn(this.inSpec, this.outSpec, this.effectValue, newTags);
662
759
  }
663
760
 
664
761
  /**
665
762
  * Merge with another cap (other takes precedence for conflicts)
666
- * Direction specs (in/out) are taken from other
763
+ * Structural coordinates are taken from other.
667
764
  *
668
765
  * @param {CapUrn} other - The cap to merge with
669
766
  * @returns {CapUrn} A new CapUrn instance with merged tags
670
767
  */
671
768
  merge(other) {
672
769
  if (!other) {
673
- return new CapUrn(this.inSpec, this.outSpec, this.tags);
770
+ return new CapUrn(this.inSpec, this.outSpec, this.effectValue, this.tags);
674
771
  }
675
772
  const newTags = { ...this.tags, ...other.tags };
676
- return new CapUrn(other.inSpec, other.outSpec, newTags);
773
+ return new CapUrn(other.inSpec, other.outSpec, other.effectValue, newTags);
677
774
  }
678
775
 
679
776
  /**
@@ -710,7 +807,7 @@ class CapUrn {
710
807
  }
711
808
 
712
809
  // Compare direction specs
713
- if (this.inSpec !== other.inSpec || this.outSpec !== other.outSpec) {
810
+ if (this.inSpec !== other.inSpec || this.outSpec !== other.outSpec || this.effectValue !== other.effectValue) {
714
811
  return false;
715
812
  }
716
813
 
@@ -751,6 +848,139 @@ class CapUrn {
751
848
  }
752
849
  return hash.toString(16);
753
850
  }
851
+
852
+ /**
853
+ * Check if this provider can dispatch the given request.
854
+ *
855
+ * @param {CapUrn} request
856
+ * @returns {boolean}
857
+ */
858
+ isDispatchable(request) {
859
+ return this._inputDispatchable(request)
860
+ && this._outputDispatchable(request)
861
+ && this._effectDispatchable(request)
862
+ && this._capTagsDispatchable(request);
863
+ }
864
+
865
+ _inputDispatchable(request) {
866
+ if (request.inSpec === 'media:') return true;
867
+ if (this.inSpec === 'media:') return true;
868
+ return MediaUrn.fromString(request.inSpec).conformsTo(MediaUrn.fromString(this.inSpec));
869
+ }
870
+
871
+ _outputDispatchable(request) {
872
+ if (request.outSpec === 'media:') return true;
873
+ if (this.outSpec === 'media:') return false;
874
+ return MediaUrn.fromString(this.outSpec).conformsTo(MediaUrn.fromString(request.outSpec));
875
+ }
876
+
877
+ _effectDispatchable(request) {
878
+ return request.effectValue === CapEffect.ANY || this.effectValue === request.effectValue;
879
+ }
880
+
881
+ _capTagsDispatchable(request) {
882
+ const allKeys = new Set([
883
+ ...Object.keys(this.tags),
884
+ ...Object.keys(request.tags),
885
+ ]);
886
+ for (const key of allKeys) {
887
+ const patt = Object.prototype.hasOwnProperty.call(request.tags, key)
888
+ ? request.tags[key]
889
+ : undefined;
890
+ const inst = Object.prototype.hasOwnProperty.call(this.tags, key)
891
+ ? this.tags[key]
892
+ : undefined;
893
+ if (!taggedUrnValuesMatch(inst, patt)) {
894
+ return false;
895
+ }
896
+ }
897
+ return true;
898
+ }
899
+
900
+ /**
901
+ * Infer the runtime output media for a concrete runtime input.
902
+ *
903
+ * @param {MediaUrn} runtimeInput
904
+ * @returns {MediaUrn}
905
+ */
906
+ inferRuntimeOutputMedia(runtimeInput) {
907
+ const declaredIn = this.inMediaUrn();
908
+ const declaredOut = this.outMediaUrn();
909
+
910
+ if (!runtimeInput.conformsTo(declaredIn)) {
911
+ throw new CapUrnError(
912
+ ErrorCodes.INVALID_EFFECT_APPLICATION,
913
+ `Runtime input '${runtimeInput}' does not conform to declared input '${declaredIn}'`
914
+ );
915
+ }
916
+
917
+ let runtimeOut;
918
+ switch (this.effectValue) {
919
+ case CapEffect.DECLARED:
920
+ runtimeOut = declaredOut;
921
+ break;
922
+ case CapEffect.NONE:
923
+ runtimeOut = runtimeInput;
924
+ break;
925
+ case CapEffect.PATCH: {
926
+ const delta = declaredOut.deltaFrom(declaredIn);
927
+ runtimeOut = runtimeInput.applyDelta(delta);
928
+ break;
929
+ }
930
+ case CapEffect.ANY:
931
+ throw new CapUrnError(
932
+ ErrorCodes.INVALID_EFFECT_APPLICATION,
933
+ 'Cannot infer runtime output for an unconstrained effect request'
934
+ );
935
+ default:
936
+ throw new CapUrnError(
937
+ ErrorCodes.INVALID_EFFECT_APPLICATION,
938
+ `Unexpected effect '${this.effectValue}' during runtime output inference`
939
+ );
940
+ }
941
+
942
+ if (!runtimeOut.conformsTo(declaredOut)) {
943
+ throw new CapUrnError(
944
+ ErrorCodes.INVALID_EFFECT_APPLICATION,
945
+ `Inferred runtime output '${runtimeOut}' does not conform to declared output '${declaredOut}'`
946
+ );
947
+ }
948
+ return runtimeOut;
949
+ }
950
+
951
+ _validateAdmissible() {
952
+ const inMedia = this.inMediaUrn();
953
+ const outMedia = this.outMediaUrn();
954
+ const noExtraTags = Object.keys(this.tags).length === 0;
955
+
956
+ if (inMedia.isTop() && outMedia.isTop() && noExtraTags && this.effectValue === CapEffect.DECLARED) {
957
+ throw new CapUrnError(
958
+ ErrorCodes.ILLEGAL_DECLARATION,
959
+ 'illegal bare top cap; use cap:effect=none for identity, or declare a non-vacuous input/output/effect/tag'
960
+ );
961
+ }
962
+
963
+ if (this.effectValue === CapEffect.NONE) {
964
+ if (!inMedia.conformsTo(outMedia)) {
965
+ throw new CapUrnError(
966
+ ErrorCodes.ILLEGAL_DECLARATION,
967
+ `effect=none requires declared input '${inMedia}' to conform to declared output '${outMedia}'`
968
+ );
969
+ }
970
+ return;
971
+ }
972
+
973
+ if (this.effectValue === CapEffect.PATCH) {
974
+ const delta = outMedia.deltaFrom(inMedia);
975
+ const witness = inMedia.applyDelta(delta);
976
+ if (!witness.conformsTo(outMedia)) {
977
+ throw new CapUrnError(
978
+ ErrorCodes.ILLEGAL_DECLARATION,
979
+ `effect=patch witness '${witness}' does not conform to declared output '${outMedia}'`
980
+ );
981
+ }
982
+ }
983
+ }
754
984
  }
755
985
 
756
986
  /**
@@ -760,6 +990,7 @@ class CapUrnBuilder {
760
990
  constructor() {
761
991
  this._inSpec = null;
762
992
  this._outSpec = null;
993
+ this._effect = CapEffect.DECLARED;
763
994
  this._tags = {};
764
995
  }
765
996
 
@@ -785,10 +1016,15 @@ class CapUrnBuilder {
785
1016
  return this;
786
1017
  }
787
1018
 
1019
+ effect(effect) {
1020
+ this._effect = normalizeEffectValue(effect);
1021
+ return this;
1022
+ }
1023
+
788
1024
  /**
789
1025
  * Add or update a tag
790
1026
  * Key is normalized to lowercase; value is preserved as-is
791
- * SILENTLY IGNORES attempts to set "in" or "out" - use inSpec/outSpec methods
1027
+ * Structural coordinates are reserved; use dedicated methods instead.
792
1028
  *
793
1029
  * @param {string} key - The tag key
794
1030
  * @param {string} value - The tag value
@@ -796,10 +1032,13 @@ class CapUrnBuilder {
796
1032
  */
797
1033
  tag(key, value) {
798
1034
  const keyLower = key.toLowerCase();
799
- // Silently ignore in/out - use inSpec/outSpec methods
800
- if (keyLower !== 'in' && keyLower !== 'out') {
801
- this._tags[keyLower] = value;
1035
+ if (keyLower === 'in' || keyLower === 'out' || keyLower === 'effect') {
1036
+ throw new CapUrnError(
1037
+ ErrorCodes.INVALID_TAG_FORMAT,
1038
+ `Reserved structural key '${keyLower}' must be set via inSpec(), outSpec(), or effect()`
1039
+ );
802
1040
  }
1041
+ this._tags[keyLower] = value;
803
1042
  return this;
804
1043
  }
805
1044
 
@@ -813,9 +1052,13 @@ class CapUrnBuilder {
813
1052
  */
814
1053
  marker(key) {
815
1054
  const keyLower = key.toLowerCase();
816
- if (keyLower !== 'in' && keyLower !== 'out') {
817
- this._tags[keyLower] = '*';
1055
+ if (keyLower === 'in' || keyLower === 'out' || keyLower === 'effect') {
1056
+ throw new CapUrnError(
1057
+ ErrorCodes.INVALID_TAG_FORMAT,
1058
+ `Reserved structural key '${keyLower}' cannot be used as a marker`
1059
+ );
818
1060
  }
1061
+ this._tags[keyLower] = '*';
819
1062
  return this;
820
1063
  }
821
1064
 
@@ -832,7 +1075,7 @@ class CapUrnBuilder {
832
1075
  if (!this._outSpec) {
833
1076
  throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' spec - call outSpec() before build()");
834
1077
  }
835
- return new CapUrn(this._inSpec, this._outSpec, this._tags);
1078
+ return new CapUrn(this._inSpec, this._outSpec, this._effect, this._tags);
836
1079
  }
837
1080
  }
838
1081
 
@@ -1128,9 +1371,9 @@ const MEDIA_MEDIA_SPEC_DEFINITION = 'media:media-spec-definition;json;record;tex
1128
1371
  // STANDARD CAP URN CONSTANTS
1129
1372
  // =============================================================================
1130
1373
 
1131
- // Standard echo capability URN
1132
- // Accepts any media type as input and outputs any media type
1133
- const CAP_IDENTITY = 'cap:in=media:;out=media:';
1374
+ // Standard identity capability URN.
1375
+ // Accepts any media type as input and preserves the runtime media identity.
1376
+ const CAP_IDENTITY = 'cap:effect=none';
1134
1377
 
1135
1378
  // Adapter-selection capability. Default implementation returns empty END (no match).
1136
1379
  // Cartridges that inspect file content override this with a handler that returns {"media_urns": [...]}.
@@ -1390,6 +1633,20 @@ class MediaUrn {
1390
1633
  */
1391
1634
  isComparable(other) { return this._urn.isComparable(other._urn); }
1392
1635
 
1636
+ /**
1637
+ * Compute the coordinate-space delta from `base` to this media URN.
1638
+ * @param {MediaUrn} base
1639
+ * @returns {TaggedUrnCoordinateDelta}
1640
+ */
1641
+ deltaFrom(base) { return this._urn.deltaFrom(base._urn); }
1642
+
1643
+ /**
1644
+ * Apply a coordinate-space delta to this media URN.
1645
+ * @param {TaggedUrnCoordinateDelta} delta
1646
+ * @returns {MediaUrn}
1647
+ */
1648
+ applyDelta(delta) { return new MediaUrn(this._urn.applyDelta(delta)); }
1649
+
1393
1650
  /**
1394
1651
  * @param {MediaUrn} other
1395
1652
  * @returns {boolean}
@@ -6096,6 +6353,7 @@ class FabricRegistryClient {
6096
6353
  module.exports = {
6097
6354
  CapUrn,
6098
6355
  CapKind,
6356
+ CapEffect,
6099
6357
  CapUrnBuilder,
6100
6358
  CapMatcher,
6101
6359
  CapUrnError,
@@ -6230,6 +6488,7 @@ module.exports = {
6230
6488
  MEDIA_CAP_DEFINITION,
6231
6489
  MEDIA_MEDIA_SPEC_DEFINITION,
6232
6490
  // Standard cap URN constants
6491
+ CAP_IDENTITY,
6233
6492
  CAP_ADAPTER_SELECTION,
6234
6493
  CAP_LOOKUP_CAP_FABRIC,
6235
6494
  CAP_LOOKUP_MEDIA_SPEC_FABRIC,
package/capdag.test.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // All implementations (Rust, Go, JS, ObjC, Python) must pass these identically.
4
4
 
5
5
  const {
6
- CapUrn, CapKind, CapUrnBuilder, CapMatcher, CapUrnError, ErrorCodes,
6
+ CapUrn, CapKind, CapEffect, CapUrnBuilder, CapMatcher, CapUrnError, ErrorCodes,
7
7
  MediaUrn, MediaUrnError, MediaUrnErrorCodes,
8
8
  Cap, CapGroup, CapManifest, MediaSpec, MediaSpecError, MediaSpecErrorCodes,
9
9
  resolveMediaUrn, buildExtensionIndex, mediaUrnsForExtension, getExtensionMappings,
@@ -26,7 +26,8 @@ const {
26
26
  MEDIA_FILE_PATH,
27
27
  MEDIA_COLLECTION, MEDIA_COLLECTION_LIST,
28
28
  MEDIA_DECISION,
29
- MEDIA_AUDIO_SPEECH
29
+ MEDIA_AUDIO_SPEECH,
30
+ CAP_IDENTITY
30
31
  } = require('./capdag.js');
31
32
 
32
33
  // ============================================================================
@@ -309,9 +310,15 @@ function test939_capUrnCanonicalFormDropsWildcardInOut() {
309
310
  `input ${JSON.stringify(v)} canonicalized to ${JSON.stringify(parsed.toString())}, expected ${JSON.stringify(canonical)} — wildcard in/out segments must be elided so the registry SHA-256 key is stable across input spellings`
310
311
  );
311
312
  }
312
- // Bare-identity round-trip.
313
- const identity = CapUrn.fromString('cap:in=media:;out=media:');
314
- assertEqual(identity.toString(), 'cap:', 'cap with wildcard in/out and no other tags must canonicalize to bare "cap:"');
313
+ assertThrows(
314
+ () => CapUrn.fromString('cap:in=media:;out=media:'),
315
+ ErrorCodes.ILLEGAL_DECLARATION,
316
+ 'declared top-to-top cap must be rejected as inadmissible'
317
+ );
318
+
319
+ const identity = CapUrn.fromString('cap:effect=none');
320
+ assertEqual(identity.toString(), 'cap:effect=none', 'true identity must preserve explicit effect=none');
321
+ assert(identity.toString() !== generic.toString(), 'cap: and cap:effect=none must not collapse');
315
322
  }
316
323
 
317
324
  // TEST017: Test tag matching: exact match, subset match, wildcard match, value mismatch
@@ -494,17 +501,19 @@ function test027_wildcardTag() {
494
501
  assertEqual(wildcardExt.getTag('ext'), '*', 'Should set ext to wildcard');
495
502
 
496
503
  const wildcardIn = cap.withWildcardTag('in');
497
- assertEqual(wildcardIn.getInSpec(), '*', 'Should set in to wildcard');
504
+ assertEqual(wildcardIn.getInSpec(), 'media:', 'Should set in to canonical top media:');
498
505
 
499
506
  const wildcardOut = cap.withWildcardTag('out');
500
- assertEqual(wildcardOut.getOutSpec(), '*', 'Should set out to wildcard');
507
+ assertEqual(wildcardOut.getOutSpec(), 'media:', 'Should set out to canonical top media:');
501
508
  }
502
509
 
503
- // TEST028: Test empty cap URN defaults to media: wildcard
510
+ // TEST028: Test empty cap URN is illegal
504
511
  function test028_emptyCapUrnNotAllowed() {
505
- const empty = CapUrn.fromString('cap:');
506
- assertEqual(empty.getInSpec(), MEDIA_IDENTITY, 'Empty cap should default in to media:');
507
- assertEqual(empty.getOutSpec(), MEDIA_IDENTITY, 'Empty cap should default out to media:');
512
+ assertThrows(
513
+ () => CapUrn.fromString('cap:'),
514
+ ErrorCodes.ILLEGAL_DECLARATION,
515
+ 'Empty cap must be rejected as inadmissible'
516
+ );
508
517
  }
509
518
 
510
519
  // TEST029: Test minimal valid cap URN has just in and out, empty tags
@@ -683,7 +692,7 @@ function test047_matchingSemanticsThumbnailVoidInput() {
683
692
 
684
693
  // TEST048: Matching semantics - wildcard direction matches anything
685
694
  function test048_matchingSemanticsWildcardDirection() {
686
- const cap = CapUrn.fromString('cap:in=*;out=*');
695
+ const cap = CapUrn.fromString('cap:in=*;out=*;op');
687
696
  const request = CapUrn.fromString(testUrn('generate;ext=pdf'));
688
697
  assert(cap.accepts(request), 'Wildcard cap should accept any request');
689
698
  }
@@ -2542,7 +2551,7 @@ function test1302_predicateConstantConsistency() {
2542
2551
  // cap_urn.rs: TEST1303-TEST1307 (CapUrn tier tests)
2543
2552
  // ============================================================================
2544
2553
 
2545
- // TEST1303: without_tag removes tag, ignores in/out, case-insensitive for keys
2554
+ // TEST1303: without_tag removes tag, rejects structural keys, case-insensitive for keys
2546
2555
  function test1303_withoutTag() {
2547
2556
  const cap = CapUrn.fromString('cap:in="media:void";test;ext=pdf;out="media:void"');
2548
2557
  const removed = cap.withoutTag('ext');
@@ -2553,11 +2562,9 @@ function test1303_withoutTag() {
2553
2562
  const removed2 = cap.withoutTag('EXT');
2554
2563
  assertEqual(removed2.getTag('ext'), undefined, 'withoutTag should be case-insensitive');
2555
2564
 
2556
- // Removing in/out is silently ignored
2557
- const same = cap.withoutTag('in');
2558
- assertEqual(same.getInSpec(), 'media:void', 'withoutTag must not remove in');
2559
- const same2 = cap.withoutTag('out');
2560
- assertEqual(same2.getOutSpec(), 'media:void', 'withoutTag must not remove out');
2565
+ assertThrows(() => cap.withoutTag('in'), 'withoutTag must reject in');
2566
+ assertThrows(() => cap.withoutTag('out'), 'withoutTag must reject out');
2567
+ assertThrows(() => cap.withoutTag('effect'), 'withoutTag must reject effect');
2561
2568
 
2562
2569
  // Removing non-existent tag is no-op
2563
2570
  const same3 = cap.withoutTag('nonexistent');
@@ -2581,6 +2588,12 @@ function test1304_withInOutSpec() {
2581
2588
  const changedBoth = cap.withInSpec('media:pdf').withOutSpec(MEDIA_TXT);
2582
2589
  assertEqual(changedBoth.getInSpec(), 'media:pdf', 'Chain should set inSpec');
2583
2590
  assertEqual(changedBoth.getOutSpec(), MEDIA_TXT, 'Chain should set outSpec');
2591
+
2592
+ const identity = CapUrn.fromString('cap:effect=none');
2593
+ assertThrows(
2594
+ () => identity.withOutSpec('media:pdf'),
2595
+ 'withOutSpec must revalidate admissibility'
2596
+ );
2584
2597
  }
2585
2598
 
2586
2599
  // TEST561: N/A for JS (in_media_urn/out_media_urn not in JS CapUrn)
@@ -2630,15 +2643,28 @@ function test1306_areCompatible() {
2630
2643
 
2631
2644
  // TEST565: N/A for JS (tags_to_string not in JS CapUrn)
2632
2645
 
2633
- // TEST1307: with_tag silently ignores in/out keys
2634
- function test1307_withTagIgnoresInOut() {
2646
+ // TEST1307: with_tag rejects structural keys
2647
+ function test1307_withTagRejectsStructuralKeys() {
2635
2648
  const cap = CapUrn.fromString('cap:in="media:void";test;out="media:void"');
2636
- // Attempting to set in/out via withTag is silently ignored
2637
- const same = cap.withTag('in', 'media:');
2638
- assertEqual(same.getInSpec(), 'media:void', 'withTag must not change in_spec');
2649
+ assertThrows(() => cap.withTag('in', 'media:'), 'withTag must reject in');
2650
+ assertThrows(() => cap.withTag('out', 'media:'), 'withTag must reject out');
2651
+ assertThrows(() => cap.withTag('effect', 'none'), 'withTag must reject effect');
2652
+ }
2639
2653
 
2640
- const same2 = cap.withTag('out', 'media:');
2641
- assertEqual(same2.getOutSpec(), 'media:void', 'withTag must not change out_spec');
2654
+ // TEST1308: builder rejects structural keys on tag/marker
2655
+ function test1308_builderRejectsStructuralKeys() {
2656
+ assertThrows(
2657
+ () => new CapUrnBuilder().tag('in', 'media:void'),
2658
+ 'builder.tag must reject structural in'
2659
+ );
2660
+ assertThrows(
2661
+ () => new CapUrnBuilder().marker('effect'),
2662
+ 'builder.marker must reject structural effect'
2663
+ );
2664
+ assertThrows(
2665
+ () => new CapUrnBuilder().inSpec('media:void').outSpec('media:record').tag('123', 'value').build(),
2666
+ 'builder.build must reject invalid non-structural tags'
2667
+ );
2642
2668
  }
2643
2669
 
2644
2670
  // TEST1294: RULE11 - void-input cap with stdin source rejected
@@ -2697,47 +2723,58 @@ function test1297_rule11NonVoidInputWithStdin() {
2697
2723
  // cap_urn.rs: TEST639-TEST653 (Cap URN wildcard tests)
2698
2724
  // ============================================================================
2699
2725
 
2700
- // TEST639: cap: (empty) defaults to in=media:;out=media:
2701
- function test639_emptyCapDefaultsToMediaWildcard() {
2702
- const cap = CapUrn.fromString('cap:');
2703
- assertEqual(cap.getInSpec(), MEDIA_IDENTITY, 'Empty cap should default in to media:');
2704
- assertEqual(cap.getOutSpec(), MEDIA_IDENTITY, 'Empty cap should default out to media:');
2705
- assertEqual(Object.keys(cap.tags).length, 0, 'Empty cap should have no extra tags');
2726
+ // TEST639: cap: (empty) is the illegal bare top form
2727
+ function test639_emptyCapIsIllegal() {
2728
+ assertThrows(
2729
+ () => CapUrn.fromString('cap:'),
2730
+ ErrorCodes.ILLEGAL_DECLARATION,
2731
+ 'Empty cap must be rejected as inadmissible'
2732
+ );
2706
2733
  }
2707
2734
 
2708
- // TEST640: cap:in defaults out to media:
2709
- function test640_inOnlyDefaultsOutToMedia() {
2710
- const cap = CapUrn.fromString('cap:in');
2711
- assertEqual(cap.getInSpec(), MEDIA_IDENTITY, 'Bare in should normalize to media:');
2712
- assertEqual(cap.getOutSpec(), MEDIA_IDENTITY, 'Missing out should default to media:');
2735
+ // TEST640: cap:in collapses to the same illegal bare top form
2736
+ function test640_inOnlyIsIllegal() {
2737
+ assertThrows(
2738
+ () => CapUrn.fromString('cap:in'),
2739
+ ErrorCodes.ILLEGAL_DECLARATION,
2740
+ 'Bare in must be rejected as inadmissible'
2741
+ );
2713
2742
  }
2714
2743
 
2715
- // TEST641: cap:out defaults in to media:
2716
- function test641_outOnlyDefaultsInToMedia() {
2717
- const cap = CapUrn.fromString('cap:out');
2718
- assertEqual(cap.getInSpec(), MEDIA_IDENTITY, 'Missing in should default to media:');
2719
- assertEqual(cap.getOutSpec(), MEDIA_IDENTITY, 'Bare out should normalize to media:');
2744
+ // TEST641: cap:out collapses to the same illegal bare top form
2745
+ function test641_outOnlyIsIllegal() {
2746
+ assertThrows(
2747
+ () => CapUrn.fromString('cap:out'),
2748
+ ErrorCodes.ILLEGAL_DECLARATION,
2749
+ 'Bare out must be rejected as inadmissible'
2750
+ );
2720
2751
  }
2721
2752
 
2722
- // TEST642: cap:in;out both become media:
2723
- function test642_inOutWithoutValuesBecomeMedia() {
2724
- const cap = CapUrn.fromString('cap:in;out');
2725
- assertEqual(cap.getInSpec(), MEDIA_IDENTITY, 'Bare in should normalize to media:');
2726
- assertEqual(cap.getOutSpec(), MEDIA_IDENTITY, 'Bare out should normalize to media:');
2753
+ // TEST642: cap:in;out collapses to the same illegal bare top form
2754
+ function test642_inOutWithoutValuesAreIllegal() {
2755
+ assertThrows(
2756
+ () => CapUrn.fromString('cap:in;out'),
2757
+ ErrorCodes.ILLEGAL_DECLARATION,
2758
+ 'Bare in/out must be rejected as inadmissible'
2759
+ );
2727
2760
  }
2728
2761
 
2729
- // TEST643: cap:in=*;out=* becomes media:
2730
- function test643_explicitAsteriskIsWildcard() {
2731
- const cap = CapUrn.fromString('cap:in=*;out=*');
2732
- assertEqual(cap.getInSpec(), MEDIA_IDENTITY, 'in=* should normalize to media:');
2733
- assertEqual(cap.getOutSpec(), MEDIA_IDENTITY, 'out=* should normalize to media:');
2762
+ // TEST643: cap:in=*;out=* is the same illegal bare top form
2763
+ function test643_explicitAsteriskIsIllegal() {
2764
+ assertThrows(
2765
+ () => CapUrn.fromString('cap:in=*;out=*'),
2766
+ ErrorCodes.ILLEGAL_DECLARATION,
2767
+ 'Explicit wildcard top-to-top must be rejected as inadmissible'
2768
+ );
2734
2769
  }
2735
2770
 
2736
- // TEST644: cap:in=media:;out=* has specific in, wildcard out
2737
- function test644_specificInWildcardOut() {
2738
- const cap = CapUrn.fromString('cap:in=media:;out=*');
2739
- assertEqual(cap.getInSpec(), 'media:', 'Should have specific in');
2740
- assertEqual(cap.getOutSpec(), 'media:', 'Wildcard out should normalize to media:');
2771
+ // TEST644: cap:in=media:;out=* is the same illegal bare top form
2772
+ function test644_specificInWildcardOutIsIllegal() {
2773
+ assertThrows(
2774
+ () => CapUrn.fromString('cap:in=media:;out=*'),
2775
+ ErrorCodes.ILLEGAL_DECLARATION,
2776
+ 'Top-to-top declared form must be rejected as inadmissible'
2777
+ );
2741
2778
  }
2742
2779
 
2743
2780
  // TEST645: cap:in=*;out=media:text has wildcard in, specific out
@@ -2767,8 +2804,8 @@ function test647_invalidOutSpecFails() {
2767
2804
 
2768
2805
  // TEST648: Wildcard in/out match specific caps
2769
2806
  function test648_wildcardAcceptsSpecific() {
2770
- const wildcard = CapUrn.fromString('cap:in=*;out=*');
2771
- const specific = CapUrn.fromString('cap:in="media:";out="media:text"');
2807
+ const wildcard = CapUrn.fromString('cap:in=*;out=*;raw');
2808
+ const specific = CapUrn.fromString('cap:in="media:";out="media:text";raw');
2772
2809
 
2773
2810
  assert(wildcard.accepts(specific), 'Wildcard should accept specific');
2774
2811
  assert(specific.conformsTo(wildcard), 'Specific should conform to wildcard');
@@ -2776,52 +2813,105 @@ function test648_wildcardAcceptsSpecific() {
2776
2813
 
2777
2814
  // TEST649: Specificity - wildcard has 0, specific has tag count
2778
2815
  function test649_specificityScoring() {
2779
- const wildcard = CapUrn.fromString('cap:in=*;out=*');
2780
- const specific = CapUrn.fromString('cap:in="media:";out="media:text"');
2816
+ const wildcard = CapUrn.fromString('cap:in=*;out=*;raw');
2817
+ const specific = CapUrn.fromString('cap:in="media:";out="media:text";raw');
2781
2818
 
2782
- assertEqual(wildcard.specificity(), 0, 'Wildcard cap should have 0 specificity');
2819
+ assertEqual(wildcard.specificity(), 2, 'Marker-only wildcard cap should have y-axis specificity only');
2783
2820
  assert(specific.specificity() > 0, 'Specific cap should have non-zero specificity');
2784
2821
  }
2785
2822
 
2786
- // TEST650: N/A for JS (JS requires in/out, cap:in=media:;out=media:;test would fail parsing)
2823
+ // TEST650: cap:in=media:;out=media:;test preserves other tags
2824
+ function test650_wildcardPreserveOtherTags() {
2825
+ const cap = CapUrn.fromString('cap:in=media:;out=media:;test');
2826
+ assertEqual(cap.getInSpec(), 'media:', 'in spec should remain media:');
2827
+ assertEqual(cap.getOutSpec(), 'media:', 'out spec should remain media:');
2828
+ assertEqual(cap.getEffect(), CapEffect.DECLARED, 'missing effect should default to declared');
2829
+ assert(cap.hasMarkerTag('test'), 'marker tag should be preserved');
2830
+ }
2787
2831
 
2788
- // TEST651: All identity forms produce the same CapUrn
2789
- function test651_identityFormsEquivalent() {
2832
+ // TEST651: Generic top-to-top spellings are all rejected.
2833
+ function test651_wildcardGenericFormsRejected() {
2790
2834
  const forms = [
2835
+ 'cap:',
2836
+ 'cap:in;out',
2791
2837
  'cap:in=*;out=*',
2792
- 'cap:in="media:";out="media:"',
2838
+ 'cap:in=media:;out=media:',
2839
+ 'cap:in;out=media:',
2840
+ 'cap:in=*;out=media:',
2841
+ 'cap:in=media:;out',
2842
+ 'cap:in=media:;out=*',
2793
2843
  ];
2794
-
2795
- const first = CapUrn.fromString(forms[0]);
2796
- // All forms should produce equivalent caps (wildcard behavior)
2797
- for (let i = 1; i < forms.length; i++) {
2798
- const cap = CapUrn.fromString(forms[i]);
2799
- // Both should accept specific caps
2800
- const specific = CapUrn.fromString('cap:in="media:";out="media:text"');
2801
- assert(first.accepts(specific), `Form 0 should accept specific`);
2802
- assert(cap.accepts(specific), `Form ${i} should accept specific`);
2844
+ for (const form of forms) {
2845
+ assertThrows(
2846
+ () => CapUrn.fromString(form),
2847
+ ErrorCodes.ILLEGAL_DECLARATION,
2848
+ `${form} must be rejected as inadmissible`
2849
+ );
2803
2850
  }
2804
2851
  }
2805
2852
 
2806
- // TEST652: N/A for JS (CAP_IDENTITY constant not in JS)
2853
+ // TEST652: CAP_IDENTITY constant names the true identity cap, not bare cap:
2854
+ function test652_capIdentityConstantWorks() {
2855
+ const identity = CapUrn.fromString(CAP_IDENTITY);
2856
+ assertEqual(identity.toString(), 'cap:effect=none', 'CAP_IDENTITY must be explicit effect=none');
2857
+ assertEqual(identity.kind(), CapKind.IDENTITY, 'CAP_IDENTITY must classify as identity');
2807
2858
 
2808
- // TEST653: Identity (no tags) does not match specific requests via routing
2809
- function test653_identityRoutingIsolation() {
2810
- const identity = CapUrn.fromString('cap:in=*;out=*');
2811
- const specificRequest = CapUrn.fromString('cap:in="media:void";test;out="media:void"');
2859
+ const longForm = CapUrn.fromString('cap:in=media:;out=media:;effect=none');
2860
+ assert(identity.accepts(longForm), 'identity should accept its long form');
2861
+ assert(longForm.accepts(identity), 'long form should accept canonical identity');
2812
2862
 
2813
- // Identity has specificity 0 (no tags, wildcard directions)
2814
- assertEqual(identity.specificity(), 0, 'Identity specificity should be 0');
2863
+ assertThrows(
2864
+ () => CapUrn.fromString('cap:'),
2865
+ ErrorCodes.ILLEGAL_DECLARATION,
2866
+ 'bare cap must be rejected as inadmissible'
2867
+ );
2868
+ }
2815
2869
 
2816
- // Specific request has higher specificity
2817
- assert(specificRequest.specificity() > identity.specificity(),
2818
- 'Specific request should have higher specificity than identity');
2870
+ // TEST653: invalid effect=none declarations fail at construction.
2871
+ function test653_invalidEffectNoneDeclarationRejected() {
2872
+ assertThrows(
2873
+ () => CapUrn.fromString('cap:in=media:pdf;out=media:textable;effect=none'),
2874
+ ErrorCodes.ILLEGAL_DECLARATION,
2875
+ 'invalid effect=none declaration must fail at construction'
2876
+ );
2877
+ }
2819
2878
 
2820
- // CapMatcher should prefer specific over identity
2821
- const specificCap = CapUrn.fromString('cap:in="media:void";test;out="media:void"');
2822
- const best = CapMatcher.findBestMatch([identity, specificCap], specificRequest);
2823
- assert(best !== null, 'Should find a match');
2824
- assert(best.hasMarkerTag('test'), 'CapMatcher should prefer specific cap over identity');
2879
+ // TEST654: effect=none preserves runtime media identity.
2880
+ function test654_effectNonePreservesRuntimeMedia() {
2881
+ const decimate = CapUrn.fromString('cap:decimate-sequence;effect=none');
2882
+ const png = MediaUrn.fromString('media:image;png');
2883
+ const pdf = MediaUrn.fromString('media:pdf');
2884
+ assertEqual(decimate.inferRuntimeOutputMedia(png).toString(), png.toString(), 'effect=none should preserve png');
2885
+ assertEqual(decimate.inferRuntimeOutputMedia(pdf).toString(), pdf.toString(), 'effect=none should preserve pdf');
2886
+ }
2887
+
2888
+ // TEST655: default effect=declared does not preserve runtime refinements.
2889
+ function test655_effectDeclaredUsesDeclaredOutput() {
2890
+ const resize = CapUrn.fromString('cap:in=media:image;out=media:image;resize');
2891
+ const png = MediaUrn.fromString('media:image;png;width=4000');
2892
+ assertEqual(
2893
+ resize.inferRuntimeOutputMedia(png).toString(),
2894
+ 'media:image',
2895
+ 'default declared effect should collapse to declared output'
2896
+ );
2897
+ }
2898
+
2899
+ // TEST656: invalid effect=none declarations fail hard at construction.
2900
+ function test656_invalidEffectNoneFailsHard() {
2901
+ assertThrows(
2902
+ () => CapUrn.fromString('cap:in=media:pdf;out=media:textable;effect=none'),
2903
+ ErrorCodes.ILLEGAL_DECLARATION,
2904
+ 'invalid effect=none declaration must fail at construction'
2905
+ );
2906
+ }
2907
+
2908
+ // TEST657: omitted effect means declared; unconstrained effect must be explicit.
2909
+ function test657_effectDispatchRequiresExplicitWildcard() {
2910
+ const noneProvider = CapUrn.fromString('cap:effect=none');
2911
+ const declaredRequest = CapUrn.fromString('cap:raw');
2912
+ const anyRequest = CapUrn.fromString('cap:?effect');
2913
+ assert(!noneProvider.isDispatchable(declaredRequest), 'effect=none should not silently satisfy declared request');
2914
+ assert(noneProvider.isDispatchable(anyRequest), 'explicit ?effect should accept any provider effect');
2825
2915
  }
2826
2916
 
2827
2917
  // ============================================================================
@@ -5481,25 +5571,28 @@ function testRenderer_validateResolvedMachinePayload_rejectsMissingFields() {
5481
5571
  // surface, not a per-port detail.
5482
5572
  // ============================================================================
5483
5573
 
5484
- // TEST1800: Identity classifier — only the bare cap: form qualifies.
5485
- // Adding any tag (even one that doesn't constrain in/out) demotes
5486
- // the cap to Transform because the operation/metadata axis is no
5487
- // longer fully generic.
5574
+ // TEST1800: Identity classifier — only explicit effect=none qualifies.
5488
5575
  function test1800_kindIdentityOnlyForBareCap() {
5489
- const identity = CapUrn.fromString('cap:');
5490
- assertEqual(identity.kind(), CapKind.IDENTITY, 'cap: should be Identity');
5576
+ const identity = CapUrn.fromString('cap:effect=none');
5577
+ assertEqual(identity.kind(), CapKind.IDENTITY, 'cap:effect=none should be Identity');
5491
5578
 
5492
5579
  for (const spelling of [
5493
- 'cap:in=media:;out=media:',
5494
- 'cap:in=*;out=*',
5495
- 'cap:in=media:',
5496
- 'cap:out=media:',
5580
+ 'cap:in=media:;out=media:;effect=none',
5581
+ 'cap:effect=none;in=*;out=*',
5582
+ 'cap:effect=none;in=media:',
5583
+ 'cap:effect=none;out=media:',
5497
5584
  ]) {
5498
5585
  const cap = CapUrn.fromString(spelling);
5499
5586
  assertEqual(cap.kind(), CapKind.IDENTITY,
5500
- `${spelling} should classify as Identity (canonical form is cap:)`);
5587
+ `${spelling} should classify as Identity`);
5501
5588
  }
5502
5589
 
5590
+ assertThrows(
5591
+ () => CapUrn.fromString('cap:'),
5592
+ ErrorCodes.ILLEGAL_DECLARATION,
5593
+ 'bare cap must be rejected as inadmissible'
5594
+ );
5595
+
5503
5596
  const withOp = CapUrn.fromString('cap:passthrough');
5504
5597
  assertEqual(withOp.kind(), CapKind.TRANSFORM,
5505
5598
  'cap:passthrough specifies the operation axis — not Identity');
@@ -5587,7 +5680,7 @@ function test1810_mediaVoidIsAtomic() {
5587
5680
  // once parsed.
5588
5681
  function test1805_kindInvariantUnderCanonicalSpellings() {
5589
5682
  const cases = [
5590
- { a: 'cap:', b: 'cap:in=media:;out=media:', expected: CapKind.IDENTITY },
5683
+ { a: 'cap:effect=none', b: 'cap:in=media:;out=media:;effect=none', expected: CapKind.IDENTITY },
5591
5684
  {
5592
5685
  a: 'cap:extract;in=media:pdf;out=media:textable',
5593
5686
  b: 'cap:extract;in="media:pdf";out="media:textable"',
@@ -5627,8 +5720,8 @@ function test1805_kindInvariantUnderCanonicalSpellings() {
5627
5720
 
5628
5721
  // TEST1820: A `?`-valued cap-tag scores 0. Same as missing.
5629
5722
  function test1820_specificityQuestionIsZero() {
5630
- const bare = CapUrn.fromString('cap:');
5631
- assertEqual(bare.specificity(), 0, 'cap: must score 0 (top of order)');
5723
+ const bare = CapUrn.fromString('cap:?effect');
5724
+ assertEqual(bare.specificity(), 0, 'cap:?effect must score 0 (fully unconstrained request)');
5632
5725
 
5633
5726
  const withQ = CapUrn.fromString('cap:?target');
5634
5727
  assertEqual(withQ.specificity(), 0,
@@ -6023,7 +6116,8 @@ async function runTests() {
6023
6116
  runTest('TEST1304: with_in_out_spec', test1304_withInOutSpec);
6024
6117
  runTest('TEST1305: find_all_matches', test1305_findAllMatches);
6025
6118
  runTest('TEST1306: are_compatible', test1306_areCompatible);
6026
- runTest('TEST1307: with_tag_ignores_in_out', test1307_withTagIgnoresInOut);
6119
+ runTest('TEST1307: with_tag_rejects_structural_keys', test1307_withTagRejectsStructuralKeys);
6120
+ runTest('TEST1308: builder_rejects_structural_keys', test1308_builderRejectsStructuralKeys);
6027
6121
  runTest('TEST1294: rule11_void_input_with_stdin_rejected', test1294_rule11VoidInputWithStdinRejected);
6028
6122
  runTest('TEST1295: rule11_non_void_input_without_stdin_rejected', test1295_rule11NonVoidInputWithoutStdinRejected);
6029
6123
  runTest('TEST1296: rule11_void_input_cli_flag_only', test1296_rule11VoidInputCliFlagOnly);
@@ -6031,21 +6125,25 @@ async function runTests() {
6031
6125
 
6032
6126
  // cap_urn.rs: TEST639-TEST653 (Cap URN wildcard tests)
6033
6127
  console.log('\n--- cap_urn.rs (wildcard tests) ---');
6034
- runTest('TEST639: empty_cap_defaults_to_media_wildcard', test639_emptyCapDefaultsToMediaWildcard);
6035
- runTest('TEST640: in_only_defaults_out_to_media', test640_inOnlyDefaultsOutToMedia);
6036
- runTest('TEST641: out_only_defaults_in_to_media', test641_outOnlyDefaultsInToMedia);
6037
- runTest('TEST642: in_out_without_values_become_media', test642_inOutWithoutValuesBecomeMedia);
6038
- runTest('TEST643: explicit_asterisk_is_wildcard', test643_explicitAsteriskIsWildcard);
6039
- runTest('TEST644: specific_in_wildcard_out', test644_specificInWildcardOut);
6128
+ runTest('TEST639: empty_cap_is_illegal', test639_emptyCapIsIllegal);
6129
+ runTest('TEST640: in_only_is_illegal', test640_inOnlyIsIllegal);
6130
+ runTest('TEST641: out_only_is_illegal', test641_outOnlyIsIllegal);
6131
+ runTest('TEST642: in_out_without_values_are_illegal', test642_inOutWithoutValuesAreIllegal);
6132
+ runTest('TEST643: explicit_asterisk_is_illegal', test643_explicitAsteriskIsIllegal);
6133
+ runTest('TEST644: specific_in_wildcard_out_is_illegal', test644_specificInWildcardOutIsIllegal);
6040
6134
  runTest('TEST645: wildcard_in_specific_out', test645_wildcardInSpecificOut);
6041
6135
  runTest('TEST646: invalid_in_spec_fails', test646_invalidInSpecFails);
6042
6136
  runTest('TEST647: invalid_out_spec_fails', test647_invalidOutSpecFails);
6043
6137
  runTest('TEST648: wildcard_accepts_specific', test648_wildcardAcceptsSpecific);
6044
6138
  runTest('TEST649: specificity_scoring', test649_specificityScoring);
6045
- console.log(' SKIP TEST650: N/A for JS (requires in/out)');
6046
- runTest('TEST651: identity_forms_equivalent', test651_identityFormsEquivalent);
6047
- console.log(' SKIP TEST652: N/A for JS (CAP_IDENTITY constant)');
6048
- runTest('TEST653: identity_routing_isolation', test653_identityRoutingIsolation);
6139
+ runTest('TEST650: wildcard_preserve_other_tags', test650_wildcardPreserveOtherTags);
6140
+ runTest('TEST651: wildcard_generic_forms_rejected', test651_wildcardGenericFormsRejected);
6141
+ runTest('TEST652: cap_identity_constant_works', test652_capIdentityConstantWorks);
6142
+ runTest('TEST653: invalid_effect_none_declaration_rejected', test653_invalidEffectNoneDeclarationRejected);
6143
+ runTest('TEST654: effect_none_preserves_runtime_media', test654_effectNonePreservesRuntimeMedia);
6144
+ runTest('TEST655: effect_declared_uses_declared_output', test655_effectDeclaredUsesDeclaredOutput);
6145
+ runTest('TEST656: invalid_effect_none_fails_hard', test656_invalidEffectNoneFailsHard);
6146
+ runTest('TEST657: effect_dispatch_requires_explicit_wildcard', test657_effectDispatchRequiresExplicitWildcard);
6049
6147
 
6050
6148
  // machine module: parser tests (mirrors parser.rs)
6051
6149
  console.log('\n--- machine/parser.rs ---');
@@ -6219,7 +6317,7 @@ async function runTests() {
6219
6317
  runTest('RENDERER: validateResolvedMachine_rejectsMissingFields', testRenderer_validateResolvedMachinePayload_rejectsMissingFields);
6220
6318
 
6221
6319
  console.log('\n--- CapKind classifier (test1800–test1805) ---');
6222
- runTest('TEST1800: kind_identity_only_for_bare_cap', test1800_kindIdentityOnlyForBareCap);
6320
+ runTest('TEST1800: kind_identity_requires_effect_none', test1800_kindIdentityOnlyForBareCap);
6223
6321
  runTest('TEST1801: kind_source_when_input_is_void', test1801_kindSourceWhenInputIsVoid);
6224
6322
  runTest('TEST1802: kind_sink_when_output_is_void', test1802_kindSinkWhenOutputIsVoid);
6225
6323
  runTest('TEST1803: kind_effect_when_both_sides_void', test1803_kindEffectWhenBothSidesVoid);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "author": "Bahram Joharshamshiri",
3
3
  "dependencies": {
4
4
  "peggy": "^5.1.0",
5
- "tagged-urn": "^0.40.107"
5
+ "tagged-urn": "^0.42.114"
6
6
  },
7
7
  "description": "JavaScript implementation of Cap URN (Capability Uniform Resource Names) with strict validation and matching",
8
8
  "engines": {
@@ -40,5 +40,5 @@
40
40
  "pretest": "npm run build:parser",
41
41
  "test": "node capdag.test.js"
42
42
  },
43
- "version": "0.180.452"
43
+ "version": "0.182.459"
44
44
  }