capdag 0.178.444 → 0.181.455

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/capdag.js +363 -94
  2. package/capdag.test.js +364 -116
  3. package/package.json +2 -2
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}
@@ -2118,9 +2375,9 @@ class CapArg {
2118
2375
  this.required = required;
2119
2376
  this.is_sequence = options.is_sequence || false;
2120
2377
  this.sources = sources; // Array of ArgSource
2121
- this.arg_description = options.arg_description || null;
2378
+ this.arg_description = options.arg_description !== undefined ? options.arg_description : null;
2122
2379
  this.default_value = options.default_value !== undefined ? options.default_value : null;
2123
- this.metadata = options.metadata || null;
2380
+ this.metadata = options.metadata !== undefined ? options.metadata : null;
2124
2381
  }
2125
2382
 
2126
2383
  /**
@@ -2154,11 +2411,15 @@ class CapArg {
2154
2411
  sources: this.sources.map(s => s.toJSON())
2155
2412
  };
2156
2413
  if (this.is_sequence) result.is_sequence = true;
2157
- if (this.arg_description) result.arg_description = this.arg_description;
2414
+ if (this.arg_description !== null && this.arg_description !== undefined) {
2415
+ result.arg_description = this.arg_description;
2416
+ }
2158
2417
  if (this.default_value !== null && this.default_value !== undefined) {
2159
2418
  result.default_value = this.default_value;
2160
2419
  }
2161
- if (this.metadata) result.metadata = this.metadata;
2420
+ if (this.metadata !== null && this.metadata !== undefined) {
2421
+ result.metadata = this.metadata;
2422
+ }
2162
2423
  return result;
2163
2424
  }
2164
2425
 
@@ -2618,10 +2879,10 @@ class CapManifest {
2618
2879
  * @param {CapGroup[]} capGroups - Cap groups (all caps must be in a group)
2619
2880
  */
2620
2881
  constructor(name, version, channel, registryUrl, description, capGroups = []) {
2621
- if (!name || typeof name !== 'string') {
2882
+ if (typeof name !== 'string') {
2622
2883
  throw new Error('CapManifest name is required and must be a string');
2623
2884
  }
2624
- if (!version || typeof version !== 'string') {
2885
+ if (typeof version !== 'string') {
2625
2886
  throw new Error('CapManifest version is required and must be a string');
2626
2887
  }
2627
2888
  if (channel !== 'release' && channel !== 'nightly') {
@@ -2630,7 +2891,7 @@ class CapManifest {
2630
2891
  if (registryUrl !== null && registryUrl !== undefined && typeof registryUrl !== 'string') {
2631
2892
  throw new Error("CapManifest registry_url must be null (dev build) or a string");
2632
2893
  }
2633
- if (!description || typeof description !== 'string') {
2894
+ if (typeof description !== 'string') {
2634
2895
  throw new Error('CapManifest description is required and must be a string');
2635
2896
  }
2636
2897
 
@@ -2664,10 +2925,10 @@ class CapManifest {
2664
2925
  * @throws {Error} If required fields are missing or invalid
2665
2926
  */
2666
2927
  static fromJSON(json) {
2667
- if (!json.name) throw new Error('CapManifest missing required field: name');
2668
- if (!json.version) throw new Error('CapManifest missing required field: version');
2669
- if (!json.channel) throw new Error('CapManifest missing required field: channel');
2670
- if (!json.description) throw new Error('CapManifest missing required field: description');
2928
+ if (!Object.prototype.hasOwnProperty.call(json, 'name')) throw new Error('CapManifest missing required field: name');
2929
+ if (!Object.prototype.hasOwnProperty.call(json, 'version')) throw new Error('CapManifest missing required field: version');
2930
+ if (!Object.prototype.hasOwnProperty.call(json, 'channel')) throw new Error('CapManifest missing required field: channel');
2931
+ if (!Object.prototype.hasOwnProperty.call(json, 'description')) throw new Error('CapManifest missing required field: description');
2671
2932
  if (!Array.isArray(json.cap_groups)) throw new Error('CapManifest missing required field: cap_groups');
2672
2933
 
2673
2934
  // registry_url must be present as a key (may be null for dev builds)
@@ -2700,10 +2961,16 @@ class CapManifest {
2700
2961
  capGroups
2701
2962
  );
2702
2963
 
2703
- if (json.author && typeof json.author === 'string') {
2964
+ if (Object.prototype.hasOwnProperty.call(json, 'author')) {
2965
+ if (typeof json.author !== 'string') {
2966
+ throw new Error('CapManifest author must be a string when present');
2967
+ }
2704
2968
  manifest.author = json.author;
2705
2969
  }
2706
- if (json.page_url && typeof json.page_url === 'string') {
2970
+ if (Object.prototype.hasOwnProperty.call(json, 'page_url')) {
2971
+ if (typeof json.page_url !== 'string') {
2972
+ throw new Error('CapManifest page_url must be a string when present');
2973
+ }
2707
2974
  manifest.page_url = json.page_url;
2708
2975
  }
2709
2976
 
@@ -2723,8 +2990,8 @@ class CapManifest {
2723
2990
  description: this.description,
2724
2991
  cap_groups: this.cap_groups.map(g => g.toJSON())
2725
2992
  };
2726
- if (this.author) result.author = this.author;
2727
- if (this.page_url) result.page_url = this.page_url;
2993
+ if (this.author !== null && this.author !== undefined) result.author = this.author;
2994
+ if (this.page_url !== null && this.page_url !== undefined) result.page_url = this.page_url;
2728
2995
  return result;
2729
2996
  }
2730
2997
  }
@@ -6086,6 +6353,7 @@ class FabricRegistryClient {
6086
6353
  module.exports = {
6087
6354
  CapUrn,
6088
6355
  CapKind,
6356
+ CapEffect,
6089
6357
  CapUrnBuilder,
6090
6358
  CapMatcher,
6091
6359
  CapUrnError,
@@ -6220,6 +6488,7 @@ module.exports = {
6220
6488
  MEDIA_CAP_DEFINITION,
6221
6489
  MEDIA_MEDIA_SPEC_DEFINITION,
6222
6490
  // Standard cap URN constants
6491
+ CAP_IDENTITY,
6223
6492
  CAP_ADAPTER_SELECTION,
6224
6493
  CAP_LOOKUP_CAP_FABRIC,
6225
6494
  CAP_LOOKUP_MEDIA_SPEC_FABRIC,