@aikdna/kdna-core 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +227 -64
  2. package/README.md +114 -77
  3. package/package.json +8 -2
  4. package/schema/agent-host-capabilities.schema.json +62 -0
  5. package/schema/agent-host-receipt.schema.json +371 -0
  6. package/schema/agent-host-request.schema.json +223 -0
  7. package/schema/{bundle-profile-v1.schema.json → bundle-profile.schema.json} +9 -5
  8. package/schema/checksums.schema.json +42 -3
  9. package/schema/consumption-plan.schema.json +428 -0
  10. package/schema/digest-evidence.schema.json +212 -0
  11. package/schema/{kdna-envelope-external-grant-v1.schema.json → external-grant-envelope.schema.json} +17 -4
  12. package/schema/{kdna-key-grant-v1.schema.json → external-key-grant.schema.json} +39 -7
  13. package/schema/judgment-trace.schema.json +1251 -0
  14. package/schema/load-plan.schema.json +7 -5
  15. package/schema/manifest.schema.json +70 -10
  16. package/schema/{payload-profile-v1.schema.json → payload-profile.schema.json} +30 -6
  17. package/schema/runtime-capsule.schema.json +116 -0
  18. package/src/asset-reader.js +106 -120
  19. package/src/{v1 → container}/index.js +566 -209
  20. package/src/container/index.mjs +20 -0
  21. package/src/container-dispatcher.js +10 -7
  22. package/src/crypto-profile.js +37 -107
  23. package/src/external-key-grant.js +41 -15
  24. package/src/index.js +11 -4
  25. package/src/index.mjs +143 -24
  26. package/src/loader-compatibility.js +59 -0
  27. package/src/loader.js +1 -1
  28. package/src/public-api.js +59 -163
  29. package/src/remote-runtime.d.ts +13 -0
  30. package/src/remote-runtime.js +27 -0
  31. package/src/remote-runtime.mjs +3 -0
  32. package/src/runtime-api.js +9 -10
  33. package/src/runtime-capsule.js +529 -0
  34. package/src/runtime-contract.js +1882 -0
  35. package/src/types.d.ts +1127 -136
  36. package/src/workpack-pure.js +1 -1
  37. package/src/lint-pure.js +0 -430
  38. package/src/v1/index.mjs +0 -20
@@ -20,7 +20,10 @@
20
20
  * - the source directory must contain mimetype, kdna.json, payload.kdnab
21
21
  * - checksums.json and signatures/ are optional
22
22
  * - lineage must be a single object (not an array)
23
- * - pack output must be deterministic: same input → same SHA-256
23
+ * - pack output is reproducible within one pinned toolchain/compressor;
24
+ * DEFLATE bytes may differ across compressors, zlib versions, or systems
25
+ * - entry_set_digest/source_fingerprint identify logical entries; A binds
26
+ * the exact immutable package bytes
24
27
  *
25
28
  * Output language must stay content-neutral. We never say "trusted",
26
29
  * "recommended", "high_quality", or "officially_approved". We say
@@ -37,6 +40,11 @@ const path = require('node:path');
37
40
  const zlib = require('node:zlib');
38
41
  const crypto = require('node:crypto');
39
42
  const cbor = require('cbor-x');
43
+ const {
44
+ KDNA_LOADER_VERSION,
45
+ assessLoaderCompatibility,
46
+ loaderVersionUnsupportedMessage,
47
+ } = require('../loader-compatibility');
40
48
  const {
41
49
  EXTERNAL_ENVELOPE_PROFILE,
42
50
  validateExternalEnvelope,
@@ -67,12 +75,6 @@ const FORBIDDEN_LEGACY_TOP_LEVEL = new Set([
67
75
  'KDNA_Evolution.json',
68
76
  ]);
69
77
 
70
- // Legacy aliases — prefer the unversioned names above
71
- const V1_REQUIRED_DIR_ENTRIES = REQUIRED_DIR_ENTRIES;
72
- const V1_OPTIONAL_DIR_ENTRIES = OPTIONAL_DIR_ENTRIES;
73
- const V1_ALLOWED_TOP_LEVEL_ENTRIES = ALLOWED_TOP_LEVEL_ENTRIES;
74
- const V1_FORBIDDEN_LEGACY_TOP_LEVEL = FORBIDDEN_LEGACY_TOP_LEVEL;
75
-
76
78
  const DEFAULT_CONTAINER_LIMITS = Object.freeze({
77
79
  maxContainerBytes: 25 * 1024 * 1024,
78
80
  maxEntries: 128,
@@ -84,7 +86,7 @@ const DEFAULT_CONTAINER_LIMITS = Object.freeze({
84
86
  maxJsonStringLength: 1024 * 1024,
85
87
  });
86
88
 
87
- // Words that must never appear in v1 CLI output as positive claims.
89
+ // Words that must never appear in layout CLI output as positive claims.
88
90
  // Schema-valid, signature-valid, compatible — those are fine.
89
91
  // "trusted", "recommended", "high_quality", "officially_approved" — never.
90
92
  const FORBIDDEN_OUTPUT_TERMS = Object.freeze([
@@ -103,8 +105,8 @@ let _validators = null;
103
105
  function loadPackagedSchemas() {
104
106
  return {
105
107
  manifestSchema: require('../../schema/manifest.schema.json'),
106
- payloadSchema: require('../../schema/payload-profile-v1.schema.json'),
107
- bundlePayloadSchema: require('../../schema/bundle-profile-v1.schema.json'),
108
+ payloadSchema: require('../../schema/payload-profile.schema.json'),
109
+ bundlePayloadSchema: require('../../schema/bundle-profile.schema.json'),
108
110
  checksumsSchema: require('../../schema/checksums.schema.json'),
109
111
  loadContractSchema: require('../../schema/load-contract.schema.json'),
110
112
  };
@@ -131,10 +133,10 @@ function loadSchemasFromDisk() {
131
133
  return {
132
134
  manifestSchema: JSON.parse(fs.readFileSync(path.join(schemaDir, 'manifest.schema.json'), 'utf8')),
133
135
  payloadSchema: JSON.parse(
134
- fs.readFileSync(path.join(schemaDir, 'payload-profile-v1.schema.json'), 'utf8'),
136
+ fs.readFileSync(path.join(schemaDir, 'payload-profile.schema.json'), 'utf8'),
135
137
  ),
136
138
  bundlePayloadSchema: JSON.parse(
137
- fs.readFileSync(path.join(schemaDir, 'bundle-profile-v1.schema.json'), 'utf8'),
139
+ fs.readFileSync(path.join(schemaDir, 'bundle-profile.schema.json'), 'utf8'),
138
140
  ),
139
141
  checksumsSchema: JSON.parse(
140
142
  fs.readFileSync(path.join(schemaDir, 'checksums.schema.json'), 'utf8'),
@@ -187,7 +189,7 @@ function loadSchemas() {
187
189
  */
188
190
  function isKdnaSourceDir(absPath) {
189
191
  if (!fs.existsSync(absPath) || !fs.statSync(absPath).isDirectory()) return false;
190
- for (const f of V1_REQUIRED_DIR_ENTRIES) {
192
+ for (const f of REQUIRED_DIR_ENTRIES) {
191
193
  if (!fs.existsSync(path.join(absPath, f))) return false;
192
194
  }
193
195
  const mime = fs.readFileSync(path.join(absPath, 'mimetype'), 'utf8');
@@ -228,9 +230,13 @@ function detectContainerFormat(absPath) {
228
230
  * truncated input.
229
231
  */
230
232
  function listZipEntries(absPath, opts = {}) {
233
+ return listZipEntriesFromBuffer(fs.readFileSync(absPath), opts);
234
+ }
235
+
236
+ function listZipEntriesFromBuffer(input, opts = {}) {
231
237
  const secure = opts.secure !== false;
232
238
  const limits = { ...DEFAULT_CONTAINER_LIMITS, ...(opts.limits || {}) };
233
- const buf = fs.readFileSync(absPath);
239
+ const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
234
240
  if (secure && buf.length > limits.maxContainerBytes) {
235
241
  throw new Error(`container exceeds maximum size (${limits.maxContainerBytes} bytes)`);
236
242
  }
@@ -393,8 +399,8 @@ function validateContainerEntryMetadata(entry) {
393
399
  throw new Error(`container has duplicate entry: ${normalizedName}`);
394
400
  }
395
401
  const topLevel = normalizedName.split('/')[0];
396
- if (!V1_ALLOWED_TOP_LEVEL_ENTRIES.has(topLevel)) {
397
- if (V1_FORBIDDEN_LEGACY_TOP_LEVEL.has(topLevel)) {
402
+ if (!ALLOWED_TOP_LEVEL_ENTRIES.has(topLevel)) {
403
+ if (FORBIDDEN_LEGACY_TOP_LEVEL.has(topLevel)) {
398
404
  throw new Error(`container includes forbidden top-level source entry: ${topLevel}`);
399
405
  }
400
406
  throw new Error(`container includes unsupported top-level entry: ${topLevel}`);
@@ -512,17 +518,17 @@ function listSourceDir(dir, opts = {}) {
512
518
  return out;
513
519
  }
514
520
 
515
- // ─── Read v1 from either source dir or container ───────────────────────
521
+ // ─── Read layout from either source dir or container ───────────────────────
516
522
 
517
523
  /**
518
- * Read a v1 layout (source dir or .kdna container) and return a single
524
+ * Read a layout layout (source dir or .kdna container) and return a single
519
525
  * normalized map of { mimetype, kdna.json, payload.kdnab, checksums.json? }.
520
526
  * `where` describes the origin for error messages.
521
527
  *
522
528
  * Throws an Error with a clear, content-neutral message if the layout
523
529
  * is malformed (missing entry, wrong mimetype, etc.).
524
530
  */
525
- function readV1Layout(absPath) {
531
+ function readLayout(absPath) {
526
532
  let stat;
527
533
  try {
528
534
  stat = fs.statSync(absPath);
@@ -533,10 +539,11 @@ function readV1Layout(absPath) {
533
539
  const map = {};
534
540
  let entries = null; // ZIP entries if container
535
541
  let kind; // 'dir' | 'file'
542
+ let containerBytes = null;
536
543
 
537
544
  if (stat.isDirectory()) {
538
545
  kind = 'dir';
539
- for (const f of V1_REQUIRED_DIR_ENTRIES) {
546
+ for (const f of REQUIRED_DIR_ENTRIES) {
540
547
  const full = path.join(absPath, f);
541
548
  if (!fs.existsSync(full)) {
542
549
  throw new Error(`not a KDNA authoring source directory: missing ${f}`);
@@ -545,7 +552,7 @@ function readV1Layout(absPath) {
545
552
  throw new Error(`not a KDNA authoring source directory: ${f} must not be a symlink`);
546
553
  }
547
554
  }
548
- for (const f of [...V1_REQUIRED_DIR_ENTRIES, ...V1_OPTIONAL_DIR_ENTRIES]) {
555
+ for (const f of [...REQUIRED_DIR_ENTRIES, ...OPTIONAL_DIR_ENTRIES]) {
549
556
  const full = path.join(absPath, f);
550
557
  if (fs.existsSync(full)) {
551
558
  if (fs.statSync(full).isFile()) {
@@ -558,13 +565,10 @@ function readV1Layout(absPath) {
558
565
  }
559
566
  } else if (stat.isFile()) {
560
567
  kind = 'file';
561
- entries = listZipEntries(absPath);
562
- if (entries.length === 0 || entries[0].name !== 'mimetype') {
563
- throw new Error('not a KDNA Asset Container: first entry is not mimetype');
564
- }
565
- if (entries[0].method !== 0) {
566
- throw new Error('not a KDNA Asset Container: mimetype must be uncompressed');
567
- }
568
+ // Open once so parsing and the whole-file digest come from the same
569
+ // immutable snapshot, without a path-level TOCTOU window.
570
+ containerBytes = fs.readFileSync(absPath);
571
+ entries = listZipEntriesFromBuffer(containerBytes);
568
572
  for (const e of entries) {
569
573
  // We only need the well-known entries; signatures/ attachments/ etc.
570
574
  // are passed through unchanged by the loader but not parsed here.
@@ -577,25 +581,36 @@ function readV1Layout(absPath) {
577
581
  map[e.name] = e.data;
578
582
  }
579
583
  }
580
- for (const f of V1_REQUIRED_DIR_ENTRIES) {
581
- if (!map[f]) {
582
- throw new Error(`not a KDNA Asset Container: missing ${f}`);
583
- }
584
- }
585
584
  } else {
586
585
  throw new Error(`not a file or directory: ${absPath}`);
587
586
  }
588
587
 
589
- // mimetype content must equal the literal v1 media type.
588
+ const containerDigest = kind === 'file'
589
+ ? `sha256:${crypto.createHash('sha256').update(containerBytes).digest('hex')}`
590
+ : null;
591
+
592
+ return finalizeContainerLayout({ kind, map, entries, containerDigest });
593
+ }
594
+
595
+ function finalizeContainerLayout({ kind, map, entries, containerDigest }) {
596
+ if (kind !== 'dir' && (!entries || entries.length === 0 || entries[0].name !== 'mimetype')) {
597
+ throw new Error('not a KDNA Asset Container: first entry is not mimetype');
598
+ }
599
+ if (kind !== 'dir' && entries[0].method !== 0) {
600
+ throw new Error('not a KDNA Asset Container: mimetype must be uncompressed');
601
+ }
602
+ for (const required of REQUIRED_DIR_ENTRIES) {
603
+ if (!map[required]) {
604
+ const source = kind === 'dir' ? 'KDNA authoring source directory' : 'KDNA Asset Container';
605
+ throw new Error(`not a ${source}: missing ${required}`);
606
+ }
607
+ }
608
+
590
609
  const mime = map.mimetype.toString('utf8');
591
610
  if (mime !== MIMETYPE) {
592
- throw new Error(
593
- `not a KDNA layout: mimetype is "${mime}", expected "${MIMETYPE}"`,
594
- );
611
+ throw new Error(`not a KDNA layout: mimetype is "${mime}", expected "${MIMETYPE}"`);
595
612
  }
596
613
 
597
- // Lineage must be a single object, not an array. (Format rule from
598
- // docs/core/manifest.md / schema/manifest.schema.json.)
599
614
  let manifest;
600
615
  try {
601
616
  manifest = parseJsonEntry('kdna.json', map['kdna.json']);
@@ -606,7 +621,60 @@ function readV1Layout(absPath) {
606
621
  throw new Error('kdna.json.lineage must be an object, not an array');
607
622
  }
608
623
 
609
- return { kind, map, manifest, entries };
624
+ return { kind, map, manifest, entries, containerDigest };
625
+ }
626
+
627
+ function readLayoutBytes(input) {
628
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
629
+ const entries = listZipEntriesFromBuffer(bytes);
630
+ const map = {};
631
+ for (const entry of entries) {
632
+ if (
633
+ entry.name === 'mimetype' ||
634
+ entry.name === 'kdna.json' ||
635
+ entry.name === 'payload.kdnab' ||
636
+ entry.name === 'checksums.json'
637
+ ) {
638
+ map[entry.name] = entry.data;
639
+ }
640
+ }
641
+ return finalizeContainerLayout({
642
+ kind: 'memory',
643
+ map,
644
+ entries,
645
+ containerDigest: `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`,
646
+ });
647
+ }
648
+
649
+ function readInputLayout(input) {
650
+ if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
651
+ return readLayoutBytes(input);
652
+ }
653
+ return readLayout(path.resolve(input));
654
+ }
655
+
656
+ function snapshotPackagedInput(input) {
657
+ if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
658
+ return Buffer.from(input);
659
+ }
660
+ if (typeof input !== 'string') return input;
661
+ const resolved = path.resolve(input);
662
+ let stat;
663
+ try {
664
+ stat = fs.statSync(resolved);
665
+ } catch {
666
+ return input;
667
+ }
668
+ // Authoring directories remain available to inspect/validate. Runtime
669
+ // wrappers reject them; this helper only freezes packaged-file loads.
670
+ return stat.isFile() ? fs.readFileSync(resolved) : input;
671
+ }
672
+
673
+ function inputSource(input, kind) {
674
+ return {
675
+ kind,
676
+ path: typeof input === 'string' ? path.resolve(input) : null,
677
+ };
610
678
  }
611
679
 
612
680
  function parseJsonEntry(name, bytes, opts = {}) {
@@ -658,7 +726,7 @@ function parsePayloadEntry(name, bytes, opts = {}) {
658
726
  try {
659
727
  parsed = cbor.decode(bytes);
660
728
  } catch {
661
- throw new Error(`${name}: not valid CBOR. payload.kdnab must be CBOR-encoded (kdna_version 1.0).`);
729
+ throw new Error(`${name}: not valid CBOR. payload.kdnab must be CBOR-encoded (format 0.1.0).`);
662
730
  }
663
731
  assertJsonWithinLimits(parsed, name, limits);
664
732
  return parsed;
@@ -670,10 +738,10 @@ function parsePayloadEntry(name, bytes, opts = {}) {
670
738
  * Print a content-neutral manifest summary. Always JSON. Never emits
671
739
  * the words trusted / recommended / high_quality / officially_approved.
672
740
  */
673
- function buildInspectOutput(v1) {
674
- const m = v1.manifest;
741
+ function buildInspectOutput(container) {
742
+ const m = container.manifest;
675
743
  const out = {
676
- kdna_version: m.kdna_version ?? null,
744
+ format_version: m.format_version ?? null,
677
745
  asset_id: m.asset_id ?? null,
678
746
  asset_uid: m.asset_uid ?? null,
679
747
  asset_type: m.asset_type ?? null,
@@ -683,10 +751,11 @@ function buildInspectOutput(v1) {
683
751
  payload: m.payload ? m.payload.path : null,
684
752
  payload_encrypted: m.payload ? m.payload.encrypted : null,
685
753
  profile: m.compatibility ? m.compatibility.profile : null,
754
+ ...assessLoaderCompatibility(m),
686
755
  load_contract_default_profile: m.load_contract ? m.load_contract.default_profile : null,
687
756
  };
688
757
  if (m.signatures !== undefined) out.signature_count = Array.isArray(m.signatures) ? m.signatures.length : 0;
689
- if (v1.map['checksums.json']) out.checksums_present = true;
758
+ if (container.map['checksums.json']) out.checksums_present = true;
690
759
  return out;
691
760
  }
692
761
 
@@ -697,26 +766,27 @@ function buildInspectOutput(v1) {
697
766
  * reports each gate independently. Never includes trust / recommended
698
767
  * / high_quality / officially_approved as a positive claim.
699
768
  */
700
- function runValidate(v1) {
769
+ function runValidate(layout) {
701
770
  const result = {
702
771
  format_valid: true,
703
772
  schema_valid: true,
704
773
  payload_valid: true,
705
774
  checksums_valid: true,
706
775
  load_contract_valid: true,
776
+ ...assessLoaderCompatibility(layout.manifest),
707
777
  };
708
778
  const problems = [];
709
779
 
710
- // format gate — already proven by readV1Layout, but we re-state the gates
780
+ // format gate — already proven by readLayout, but we re-state the gates
711
781
  // so the report matches the spec.
712
- for (const f of V1_REQUIRED_DIR_ENTRIES) {
713
- if (!v1.map[f]) {
782
+ for (const f of REQUIRED_DIR_ENTRIES) {
783
+ if (!layout.map[f]) {
714
784
  result.format_valid = false;
715
785
  problems.push(`format: missing required entry ${f}`);
716
786
  }
717
787
  }
718
- if (v1.map.mimetype) {
719
- const mimeStr = v1.map.mimetype.toString('utf8');
788
+ if (layout.map.mimetype) {
789
+ const mimeStr = layout.map.mimetype.toString('utf8');
720
790
  if (mimeStr !== MIMETYPE) {
721
791
  result.format_valid = false;
722
792
  problems.push(`format: mimetype is not ${MIMETYPE}`);
@@ -732,35 +802,82 @@ function runValidate(v1) {
732
802
  );
733
803
  return finalizeValidate(result, problems);
734
804
  }
735
- if (!validators.manifest(v1.manifest)) {
805
+ if (!validators.manifest(layout.manifest)) {
736
806
  result.schema_valid = false;
737
807
  for (const err of validators.manifest.errors) {
738
808
  problems.push(`manifest: ${err.instancePath || '<root>'} ${err.message}`);
739
809
  }
740
810
  }
741
811
 
742
- // payload gate — payload.kdnab against payload-profile-v1.schema.json
812
+ // Known pre-1.0 aliases are not extension fields. Rejecting them here keeps
813
+ // the current layout validator as the single manifest authority while allowing
814
+ // genuinely namespaced/forward-compatible manifest extensions.
815
+ const removedAliases = {
816
+ kdna_spec: 'format_version',
817
+ kdna_version: 'format_version',
818
+ spec_version: 'format_version',
819
+ };
820
+ for (const [field, replacement] of Object.entries(removedAliases)) {
821
+ if (Object.prototype.hasOwnProperty.call(layout.manifest, field)) {
822
+ result.schema_valid = false;
823
+ problems.push(`kdna.json: ${field} is not allowed. Use ${replacement}.`);
824
+ }
825
+ }
826
+
827
+ // payload gate — payload.kdnab against payload-profile.schema.json
743
828
  // For actually encrypted payloads (envelopes), skip schema validation.
744
829
  let payload;
745
830
  try {
746
- payload = parsePayloadEntry("payload.kdnab", v1.map['payload.kdnab']);
831
+ payload = parsePayloadEntry("payload.kdnab", layout.map['payload.kdnab']);
747
832
  } catch (e) {
748
833
  result.payload_valid = false;
749
834
  problems.push(`payload: not valid KDNA payload (${e.message})`);
750
835
  return finalizeValidate(result, problems);
751
836
  }
752
837
 
753
- const isEncryptedPayload = payload.profile
754
- && payload.ciphertext
755
- && (v1.manifest.payload?.encrypted || v1.manifest.encryption?.encrypted_entries?.includes('payload.kdnab'));
838
+ const payloadIsEncryptedEnvelope = Boolean(payload.profile && payload.ciphertext);
839
+ const encryptedEntries = layout.manifest.encryption?.encrypted_entries;
840
+ const manifestDeclaresEncryptedPayload = Boolean(
841
+ layout.manifest.payload?.encrypted
842
+ || (Array.isArray(encryptedEntries) && encryptedEntries.includes('payload.kdnab')),
843
+ );
844
+ if (manifestDeclaresEncryptedPayload && !payloadIsEncryptedEnvelope) {
845
+ result.payload_valid = false;
846
+ problems.push('payload: manifest declares encryption but payload.kdnab is not an encrypted envelope');
847
+ }
848
+ if (payloadIsEncryptedEnvelope && !manifestDeclaresEncryptedPayload) {
849
+ result.payload_valid = false;
850
+ problems.push('payload: encrypted envelope is missing its manifest encryption declaration');
851
+ }
852
+ const isEncryptedPayload = payloadIsEncryptedEnvelope && manifestDeclaresEncryptedPayload;
756
853
 
757
854
  if (isEncryptedPayload) {
758
855
  // Encrypted payload — verify it's a proper encryption envelope, not plaintext.
759
- const encProfile = v1.manifest.encryption?.profile;
856
+ const encProfile = layout.manifest.encryption?.profile;
857
+ const encProfileVersion = layout.manifest.encryption?.profile_version;
858
+ if (!layout.manifest.encryption) {
859
+ result.payload_valid = false;
860
+ problems.push('payload: encrypted payload requires a manifest encryption declaration');
861
+ }
862
+ if (
863
+ !Array.isArray(encryptedEntries)
864
+ || encryptedEntries.length !== 1
865
+ || encryptedEntries[0] !== 'payload.kdnab'
866
+ ) {
867
+ result.payload_valid = false;
868
+ problems.push('payload: manifest encrypted_entries must declare only payload.kdnab');
869
+ }
760
870
  if (encProfile && payload.profile !== encProfile) {
761
871
  result.payload_valid = false;
762
872
  problems.push(`payload: encrypted envelope profile ${payload.profile || 'unknown'} does not match manifest encryption profile ${encProfile}`);
763
873
  }
874
+ const payloadProfileVersion = payload.profile === EXTERNAL_ENVELOPE_PROFILE
875
+ ? payload.contract_version
876
+ : payload.profile_version;
877
+ if (encProfileVersion && payloadProfileVersion !== encProfileVersion) {
878
+ result.payload_valid = false;
879
+ problems.push(`payload: encrypted envelope compatibility coordinate ${payloadProfileVersion || 'unknown'} does not match manifest encryption profile_version ${encProfileVersion}`);
880
+ }
764
881
  let hasKeyMaterial = !!payload.wrapped_key || (Array.isArray(payload.key_slots) && payload.key_slots.length > 0);
765
882
  if (payload.profile === EXTERNAL_ENVELOPE_PROFILE) {
766
883
  try {
@@ -778,7 +895,7 @@ function runValidate(v1) {
778
895
  // Encrypted payload passes payload gate if envelope is structurally valid.
779
896
  } else {
780
897
  // Plaintext payload — full schema validation.
781
- const isBundleProfile = v1.manifest.compatibility?.profile === 'bundle-profile-v1' || v1.manifest.asset_type === 'bundle';
898
+ const isBundleProfile = layout.manifest.compatibility?.profile === 'kdna.payload.bundle' || layout.manifest.asset_type === 'bundle';
782
899
  const payloadValidator = isBundleProfile ? validators.bundlePayload : validators.payload;
783
900
  if (!payloadValidator(payload)) {
784
901
  result.payload_valid = false;
@@ -789,10 +906,10 @@ function runValidate(v1) {
789
906
  }
790
907
 
791
908
  // checksums gate — checksums.json against checksums.schema.json
792
- if (v1.map['checksums.json']) {
909
+ if (layout.map['checksums.json']) {
793
910
  let checks;
794
911
  try {
795
- checks = parseJsonEntry('checksums.json', v1.map['checksums.json']);
912
+ checks = parseJsonEntry('checksums.json', layout.map['checksums.json']);
796
913
  } catch (e) {
797
914
  result.checksums_valid = false;
798
915
  problems.push(`checksums: not valid JSON (${e.message})`);
@@ -805,13 +922,13 @@ function runValidate(v1) {
805
922
  }
806
923
  // Digest matching verification — compute actual hashes and compare.
807
924
  if (checks) {
808
- result.checksums_valid = verifyDigests(checks, v1.map, problems, result);
925
+ result.checksums_valid = verifyDigests(checks, layout.map, problems, result);
809
926
  }
810
927
  }
811
928
 
812
929
  // load_contract gate — only if manifest references a load_contract block
813
- if (v1.manifest.load_contract) {
814
- const lc = v1.manifest.load_contract;
930
+ if (layout.manifest.load_contract) {
931
+ const lc = layout.manifest.load_contract;
815
932
  const validLc = _ajv.getSchema('load-contract.schema.json');
816
933
  if (validLc && !validLc(lc)) {
817
934
  result.load_contract_valid = false;
@@ -828,6 +945,51 @@ function runValidate(v1) {
828
945
  }
829
946
 
830
947
  function verifyDigests(checksums, map, problems, result) {
948
+ const digestMetadataPresent =
949
+ checksums.digest_profile !== undefined ||
950
+ checksums.digest_profile_version !== undefined ||
951
+ checksums.covered_entries !== undefined;
952
+ if (digestMetadataPresent) {
953
+ const requiredDigestFields = [
954
+ 'digest_profile',
955
+ 'digest_profile_version',
956
+ 'covered_entries',
957
+ 'manifest_digest',
958
+ 'payload_digest',
959
+ 'entry_set_digest',
960
+ ];
961
+ const missingDigestFields = requiredDigestFields.filter(
962
+ (field) => checksums[field] === undefined,
963
+ );
964
+ if (missingDigestFields.length) {
965
+ problems.push(
966
+ `checksums: kdna.digest-basis.runtime-entry-set metadata requires ${missingDigestFields.join(', ')}`,
967
+ );
968
+ result.checksums_valid = false;
969
+ return false;
970
+ }
971
+ if (checksums.digest_profile !== 'kdna.digest-basis.runtime-entry-set') {
972
+ problems.push('checksums: unsupported digest_profile (expected kdna.digest-basis.runtime-entry-set)');
973
+ result.checksums_valid = false;
974
+ return false;
975
+ }
976
+ if (checksums.digest_profile_version !== '0.1.0') {
977
+ problems.push('checksums: unsupported digest_profile_version (expected 0.1.0)');
978
+ result.checksums_valid = false;
979
+ return false;
980
+ }
981
+ const coveredEntries = checksums.covered_entries;
982
+ if (
983
+ !Array.isArray(coveredEntries)
984
+ || coveredEntries.length !== 2
985
+ || coveredEntries[0] !== 'kdna.json'
986
+ || coveredEntries[1] !== 'payload.kdnab'
987
+ ) {
988
+ problems.push('checksums: covered_entries does not match kdna.digest-basis.runtime-entry-set');
989
+ result.checksums_valid = false;
990
+ return false;
991
+ }
992
+ }
831
993
  const algo = checksums.algorithm || 'sha256';
832
994
  if (algo !== 'sha256') {
833
995
  problems.push(`checksums: unsupported digest algorithm ${algo} (supported: sha256)`);
@@ -857,21 +1019,21 @@ function verifyDigests(checksums, map, problems, result) {
857
1019
  stillValid = false;
858
1020
  }
859
1021
  }
860
- if (checksums.asset_digest) {
861
- const expectedAssetDigest = checksums.asset_digest.replace(/^sha256:/, '');
1022
+ const declaredEntrySetDigest = checksums.entry_set_digest;
1023
+ if (declaredEntrySetDigest) {
1024
+ const expectedEntrySetDigest = declaredEntrySetDigest.replace(/^sha256:/, '');
862
1025
  const missingAssetInputs = ['kdna.json', 'payload.kdnab'].filter((entryName) => !entryDigests[entryName]);
863
1026
  if (missingAssetInputs.length) {
864
- problems.push(`checksums: asset_digest cannot be verified without ${missingAssetInputs.join(', ')}`);
1027
+ problems.push(`checksums: entry_set_digest cannot be verified without ${missingAssetInputs.join(', ')}`);
865
1028
  stillValid = false;
866
1029
  } else {
867
- const combined = Object.keys(entryDigests)
868
- .sort()
869
- .map((name) => `${name}:${entryDigests[name]}`)
870
- .join('\n');
871
- const actualAssetDigest = crypto.createHash('sha256').update(combined).digest('hex');
872
- if (actualAssetDigest !== expectedAssetDigest) {
1030
+ const actualEntrySetDigest = computeRuntimeEntrySetDigest(
1031
+ map['kdna.json'],
1032
+ map['payload.kdnab'],
1033
+ ).slice('sha256:'.length);
1034
+ if (actualEntrySetDigest !== expectedEntrySetDigest) {
873
1035
  problems.push(
874
- `checksums: asset_digest mismatch (declared ${expectedAssetDigest.slice(0, 8)}..., actual ${actualAssetDigest.slice(0, 8)}...)`,
1036
+ `checksums: entry_set_digest mismatch (declared ${expectedEntrySetDigest.slice(0, 8)}..., actual ${actualEntrySetDigest.slice(0, 8)}...)`,
875
1037
  );
876
1038
  stillValid = false;
877
1039
  }
@@ -904,7 +1066,29 @@ function digestEntry(sourceDir, entry) {
904
1066
  };
905
1067
  }
906
1068
 
907
- function buildChecksumsV1(sourceDir) {
1069
+ function computeRuntimeEntrySetDigest(manifestBytes, payloadBytes) {
1070
+ if (
1071
+ (!Buffer.isBuffer(manifestBytes) && !(manifestBytes instanceof Uint8Array))
1072
+ || (!Buffer.isBuffer(payloadBytes) && !(payloadBytes instanceof Uint8Array))
1073
+ ) {
1074
+ const error = new Error(
1075
+ 'E requires raw uncompressed kdna.json and payload.kdnab bytes.',
1076
+ );
1077
+ error.code = 'KDNA_DIGEST_INPUT_INVALID';
1078
+ throw error;
1079
+ }
1080
+ const entries = {
1081
+ 'kdna.json': crypto.createHash('sha256').update(manifestBytes).digest('hex'),
1082
+ 'payload.kdnab': crypto.createHash('sha256').update(payloadBytes).digest('hex'),
1083
+ };
1084
+ const combined = Object.keys(entries)
1085
+ .sort()
1086
+ .map((name) => `${name}:${entries[name]}`)
1087
+ .join('\n');
1088
+ return `sha256:${crypto.createHash('sha256').update(combined).digest('hex')}`;
1089
+ }
1090
+
1091
+ function buildChecksums(sourceDir) {
908
1092
  const absSrc = path.resolve(sourceDir);
909
1093
  if (!fs.existsSync(absSrc) || !fs.statSync(absSrc).isDirectory()) {
910
1094
  throw new Error(`not a directory: ${absSrc}`);
@@ -914,16 +1098,18 @@ function buildChecksumsV1(sourceDir) {
914
1098
  'kdna.json': digestEntry(absSrc, 'kdna.json'),
915
1099
  'payload.kdnab': digestEntry(absSrc, 'payload.kdnab'),
916
1100
  };
917
- const combined = Object.keys(entries)
918
- .sort()
919
- .map((name) => `${name}:${entries[name].value}`)
920
- .join('\n');
921
-
1101
+ const entrySetDigest = computeRuntimeEntrySetDigest(
1102
+ fs.readFileSync(path.join(absSrc, 'kdna.json')),
1103
+ fs.readFileSync(path.join(absSrc, 'payload.kdnab')),
1104
+ );
922
1105
  return {
1106
+ digest_profile: 'kdna.digest-basis.runtime-entry-set',
1107
+ digest_profile_version: '0.1.0',
1108
+ covered_entries: ['kdna.json', 'payload.kdnab'],
923
1109
  algorithm: 'sha256',
924
1110
  manifest_digest: `sha256:${entries['kdna.json'].value}`,
925
1111
  payload_digest: `sha256:${entries['payload.kdnab'].value}`,
926
- asset_digest: `sha256:${crypto.createHash('sha256').update(combined).digest('hex')}`,
1112
+ entry_set_digest: entrySetDigest,
927
1113
  entries,
928
1114
  };
929
1115
  }
@@ -931,17 +1117,19 @@ function buildChecksumsV1(sourceDir) {
931
1117
  // ─── pack ──────────────────────────────────────────────────────────────
932
1118
 
933
1119
  /**
934
- * Pack an authoring source directory into a .kdna asset. Output is
935
- * deterministic: the same source directory packed twice produces
936
- * byte-identical output (fixed DOS timestamps, fixed entry order,
937
- * mimetype first).
1120
+ * Pack an authoring source directory into a .kdna asset. With one pinned
1121
+ * packer toolchain and compressor, output is byte-reproducible (fixed DOS
1122
+ * timestamps, fixed entry order, mimetype first). DEFLATE output may differ
1123
+ * across compressors, zlib versions, or systems. entry_set_digest and
1124
+ * source_fingerprint identify the logical entries; A binds the exact immutable
1125
+ * package bytes.
938
1126
  */
939
1127
  function pack(sourceDir, outputPath) {
940
1128
  const absSrc = path.resolve(sourceDir);
941
1129
  if (!fs.existsSync(absSrc) || !fs.statSync(absSrc).isDirectory()) {
942
1130
  throw new Error(`not a directory: ${absSrc}`);
943
1131
  }
944
- for (const f of V1_REQUIRED_DIR_ENTRIES) {
1132
+ for (const f of REQUIRED_DIR_ENTRIES) {
945
1133
  if (!fs.existsSync(path.join(absSrc, f))) {
946
1134
  throw new Error(`cannot pack: missing required entry ${f}`);
947
1135
  }
@@ -1020,7 +1208,7 @@ function pack(sourceDir, outputPath) {
1020
1208
  // ─── unpack ────────────────────────────────────────────────────────────
1021
1209
 
1022
1210
  /**
1023
- * Unpack a v1 .kdna container to a directory. Refuses path traversal.
1211
+ * Unpack a layout .kdna container to a directory. Refuses path traversal.
1024
1212
  * Does not auto-execute any entry.
1025
1213
  */
1026
1214
  function unpack(inputPath, outputDir) {
@@ -1061,23 +1249,20 @@ function unpack(inputPath, outputDir) {
1061
1249
  // ─── Public router entry points ────────────────────────────────────────
1062
1250
 
1063
1251
  function inspect(inputPath, _opts = {}) {
1064
- const v1 = readV1Layout(path.resolve(inputPath));
1065
- const out = buildInspectOutput(v1);
1252
+ const layout = readInputLayout(inputPath);
1253
+ const out = buildInspectOutput(layout);
1066
1254
  // Guard against accidental forbidden wording in any future field additions.
1067
1255
  assertNoForbiddenTerms(out);
1068
1256
  return out;
1069
1257
  }
1070
1258
 
1071
1259
  function validate(inputPath, _opts = {}) {
1072
- const v1 = readV1Layout(path.resolve(inputPath));
1073
- return runValidate(v1);
1260
+ const layout = readInputLayout(inputPath);
1261
+ return runValidate(layout);
1074
1262
  }
1075
1263
 
1076
1264
  function normalizeAccess(access) {
1077
1265
  const value = access || 'public';
1078
- if (value === 'open') return { access: 'public', alias: value };
1079
- if (value === 'protected') return { access: 'licensed', alias: value };
1080
- if (value === 'runtime') return { access: 'remote', alias: value };
1081
1266
  return { access: value, alias: null };
1082
1267
  }
1083
1268
 
@@ -1085,13 +1270,12 @@ function inferEntitlementProfile(manifest) {
1085
1270
  if (manifest.entitlement && typeof manifest.entitlement.profile === 'string') {
1086
1271
  return manifest.entitlement.profile;
1087
1272
  }
1088
- if (manifest.encryption && manifest.encryption.profile === 'kdna-password-protected-v1') {
1273
+ if (manifest.encryption && manifest.encryption.profile === 'kdna.encryption.password') {
1089
1274
  return 'password';
1090
1275
  }
1091
- if (manifest.encryption && manifest.encryption.profile === 'kdna-password-protected-v1-scrypt') {
1276
+ if (manifest.encryption && manifest.encryption.profile === 'kdna.encryption.password.scrypt') {
1092
1277
  return 'password';
1093
1278
  }
1094
- if (manifest.access === 'protected') return 'password';
1095
1279
  return null;
1096
1280
  }
1097
1281
 
@@ -1113,14 +1297,14 @@ function finalizeLoadPlan(plan) {
1113
1297
  return plan;
1114
1298
  }
1115
1299
 
1116
- function computeSourceFingerprint(_inputPath, v1) {
1300
+ function computeSourceFingerprint(_inputPath, layout) {
1117
1301
  // Fingerprint the logical entries, not the transport ZIP bytes. DEFLATE
1118
1302
  // output can differ between zlib versions and operating systems even when
1119
1303
  // every uncompressed KDNA entry is identical. LoadPlan conformance must be
1120
1304
  // stable across JS, Swift, CI runners, and repackaging transports.
1121
1305
  const hash = crypto.createHash('sha256');
1122
- for (const name of Object.keys(v1.map).sort()) {
1123
- const bytes = v1.map[name];
1306
+ for (const name of Object.keys(layout.map).sort()) {
1307
+ const bytes = layout.map[name];
1124
1308
  hash.update(name);
1125
1309
  hash.update('\0');
1126
1310
  hash.update(String(bytes.length));
@@ -1131,7 +1315,7 @@ function computeSourceFingerprint(_inputPath, v1) {
1131
1315
  return `sha256:${hash.digest('hex')}`;
1132
1316
  }
1133
1317
 
1134
- function inputFingerprint(inputPath, v1, opts = {}) {
1318
+ function inputFingerprint(inputPath, layout, opts = {}) {
1135
1319
  const allowedEntitlement = new Set(['active', 'expired', 'revoked', 'offline_grace']);
1136
1320
  const entitlementInput = (
1137
1321
  opts.entitlement && allowedEntitlement.has(opts.entitlement.status)
@@ -1140,12 +1324,12 @@ function inputFingerprint(inputPath, v1, opts = {}) {
1140
1324
  return {
1141
1325
  has_password_input: opts.hasPassword === true,
1142
1326
  entitlement_input: entitlementInput,
1143
- source_fingerprint: computeSourceFingerprint(inputPath, v1),
1327
+ source_fingerprint: computeSourceFingerprint(inputPath, layout),
1144
1328
  };
1145
1329
  }
1146
1330
 
1147
- function baseLoadPlan(inputPath, v1, validation, opts = {}) {
1148
- const manifest = v1.manifest;
1331
+ function baseLoadPlan(inputPath, layout, validation, opts = {}) {
1332
+ const manifest = layout.manifest;
1149
1333
  const accessInfo = normalizeAccess(manifest.access);
1150
1334
  const entitlementProfile = inferEntitlementProfile(manifest);
1151
1335
  const asset = {
@@ -1157,7 +1341,7 @@ function baseLoadPlan(inputPath, v1, validation, opts = {}) {
1157
1341
  };
1158
1342
 
1159
1343
  const plan = {
1160
- kdna_version: manifest.kdna_version || null,
1344
+ format_version: manifest.format_version || null,
1161
1345
  asset,
1162
1346
  access: accessInfo.access,
1163
1347
  access_alias: accessInfo.alias,
@@ -1166,7 +1350,7 @@ function baseLoadPlan(inputPath, v1, validation, opts = {}) {
1166
1350
  required_action: 'block',
1167
1351
  can_load_now: false,
1168
1352
  projection_policy: 'none',
1169
- input_fingerprint: inputFingerprint(inputPath, v1, opts),
1353
+ input_fingerprint: inputFingerprint(inputPath, layout, opts),
1170
1354
  checks: {
1171
1355
  format_valid: validation.format_valid,
1172
1356
  schema_valid: validation.schema_valid,
@@ -1177,8 +1361,8 @@ function baseLoadPlan(inputPath, v1, validation, opts = {}) {
1177
1361
  },
1178
1362
  issues: [],
1179
1363
  source: {
1180
- kind: v1.kind,
1181
- path: path.resolve(inputPath),
1364
+ kind: layout.kind,
1365
+ path: typeof inputPath === 'string' ? path.resolve(inputPath) : null,
1182
1366
  },
1183
1367
  };
1184
1368
 
@@ -1193,9 +1377,9 @@ function baseLoadPlan(inputPath, v1, validation, opts = {}) {
1193
1377
  return plan;
1194
1378
  }
1195
1379
 
1196
- // ─── B1: Unified container model → legacy v1 layout ─────────────────────
1380
+ // ─── B1: Unified container model → legacy layout layout ─────────────────────
1197
1381
 
1198
- function canonicalToV1Layout(asset) {
1382
+ function canonicalToContainerLayout(asset) {
1199
1383
  const map = {};
1200
1384
  if (asset.entries) {
1201
1385
  for (const [name, buf] of asset.entries) {
@@ -1218,6 +1402,7 @@ function canonicalToV1Layout(asset) {
1218
1402
  map,
1219
1403
  manifest: asset.manifest,
1220
1404
  entries: Object.keys(map),
1405
+ containerDigest: asset.containerDigest || null,
1221
1406
  };
1222
1407
  }
1223
1408
 
@@ -1227,21 +1412,38 @@ function canonicalToV1Layout(asset) {
1227
1412
  * result instead of parsing manifest fields directly.
1228
1413
  */
1229
1414
  function planLoad(inputPath, opts = {}) {
1230
- let v1;
1415
+ let layout;
1416
+
1417
+ if (Buffer.isBuffer(inputPath) || inputPath instanceof Uint8Array) {
1418
+ try {
1419
+ layout = readInputLayout(inputPath);
1420
+ } catch (e) {
1421
+ return finalizeLoadPlan({
1422
+ format_version: null,
1423
+ asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
1424
+ access: null, access_alias: null, entitlement_profile: null,
1425
+ state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
1426
+ input_fingerprint: null,
1427
+ checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
1428
+ issues: [buildLoadPlanIssue('KDNA_FORMAT_INVALID', 'blocking', e.message)],
1429
+ source: inputSource(inputPath, 'memory'),
1430
+ });
1431
+ }
1432
+ }
1231
1433
 
1232
1434
  // Try the unified container dispatcher first; the internal compatibility
1233
1435
  // reader below remains available only for authoring and migration tooling.
1234
- if (readAsset !== null) {
1436
+ if (!layout && readAsset !== null) {
1235
1437
  try {
1236
1438
  const asset = readAsset(path.resolve(inputPath));
1237
- v1 = canonicalToV1Layout(asset);
1439
+ layout = canonicalToContainerLayout(asset);
1238
1440
  } catch (e) {
1239
- // If dispatcher fails, fall back to legacy readV1Layout for backward compat
1441
+ // If dispatcher fails, fall back to legacy readLayout for backward compat
1240
1442
  if (e.code === 'KDNA_FORMAT_UNKNOWN' || e.code === 'KDNA_PATH_NOT_FOUND') {
1241
1443
  // let the legacy path handle it
1242
1444
  } else {
1243
1445
  return finalizeLoadPlan({
1244
- kdna_version: null,
1446
+ format_version: null,
1245
1447
  asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
1246
1448
  access: null, access_alias: null, entitlement_profile: null,
1247
1449
  state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
@@ -1251,33 +1453,37 @@ function planLoad(inputPath, opts = {}) {
1251
1453
  e.code === 'KDNA_FORMAT_UNKNOWN' ? 'KDNA_FORMAT_UNKNOWN' : 'KDNA_FORMAT_INVALID',
1252
1454
  'blocking', e.message,
1253
1455
  )],
1254
- source: { kind: null, path: path.resolve(inputPath) },
1456
+ source: inputSource(inputPath, null),
1255
1457
  });
1256
1458
  }
1257
1459
  }
1258
1460
  }
1259
1461
 
1260
- if (!v1) {
1462
+ if (!layout) {
1261
1463
  try {
1262
- v1 = readV1Layout(path.resolve(inputPath));
1464
+ layout = readInputLayout(inputPath);
1263
1465
  } catch (e) {
1264
1466
  return finalizeLoadPlan({
1265
- kdna_version: null,
1467
+ format_version: null,
1266
1468
  asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
1267
1469
  access: null, access_alias: null, entitlement_profile: null,
1268
1470
  state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
1269
1471
  input_fingerprint: null,
1270
1472
  checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
1271
1473
  issues: [buildLoadPlanIssue('KDNA_FORMAT_INVALID', 'blocking', e.message)],
1272
- source: { kind: null, path: path.resolve(inputPath) },
1474
+ source: inputSource(inputPath, null),
1273
1475
  });
1274
1476
  }
1275
1477
  }
1276
1478
 
1277
- const validation = runValidate(v1);
1278
- const plan = baseLoadPlan(inputPath, v1, validation, opts);
1479
+ const validation = runValidate(layout);
1480
+ const plan = baseLoadPlan(inputPath, layout, validation, opts);
1279
1481
 
1280
1482
  if (!validation.overall_valid) {
1483
+ if (!['public', 'licensed', 'remote'].includes(plan.access)) {
1484
+ plan.access = null;
1485
+ plan.access_alias = null;
1486
+ }
1281
1487
  plan.state = 'invalid';
1282
1488
  plan.required_action = 'block';
1283
1489
  plan.can_load_now = false;
@@ -1288,22 +1494,27 @@ function planLoad(inputPath, opts = {}) {
1288
1494
  return finalizeLoadPlan(plan);
1289
1495
  }
1290
1496
 
1291
- const manifest = v1.manifest;
1497
+ const manifest = layout.manifest;
1498
+
1499
+ if (validation.loader_compatible === false) {
1500
+ plan.state = 'invalid';
1501
+ plan.required_action = 'block';
1502
+ plan.can_load_now = false;
1503
+ plan.projection_policy = 'none';
1504
+ plan.issues.push(buildLoadPlanIssue(
1505
+ 'KDNA_LOADER_VERSION_UNSUPPORTED',
1506
+ 'blocking',
1507
+ loaderVersionUnsupportedMessage(validation.min_loader_version),
1508
+ ));
1509
+ return finalizeLoadPlan(plan);
1510
+ }
1292
1511
 
1293
1512
  function externalEntitlementMatchesCurrentAsset(entitlement) {
1294
1513
  if (!isVerifiedExternalEntitlement(entitlement) || !entitlement.asset) return false;
1295
- let checksums;
1296
- try {
1297
- checksums = v1.map['checksums.json']
1298
- ? parseJsonEntry('checksums.json', v1.map['checksums.json'])
1299
- : null;
1300
- } catch {
1301
- return false;
1302
- }
1303
- return entitlement.asset.asset_id === (manifest.asset_id || manifest.name)
1514
+ return entitlement.asset.asset_id === manifest.asset_id
1304
1515
  && entitlement.asset.asset_uid === manifest.asset_uid
1305
1516
  && entitlement.asset.version === manifest.version
1306
- && entitlement.asset.digest === checksums?.asset_digest
1517
+ && entitlement.asset.digest === layout.containerDigest
1307
1518
  && entitlement.asset.entry_path === manifest.payload?.path;
1308
1519
  }
1309
1520
 
@@ -1698,34 +1909,46 @@ function assertNoForbiddenTerms(obj) {
1698
1909
  walk(obj);
1699
1910
  if (seen.size > 0) {
1700
1911
  throw new Error(
1701
- `internal: v1 inspect output contains forbidden terms: ${[...seen].join(', ')}`,
1912
+ `internal: layout inspect output contains forbidden terms: ${[...seen].join(', ')}`,
1702
1913
  );
1703
1914
  }
1704
1915
  }
1705
1916
 
1706
1917
  module.exports = {
1707
1918
  MIMETYPE,
1919
+ KDNA_LOADER_VERSION,
1708
1920
  REQUIRED_DIR_ENTRIES,
1709
1921
  isKdnaSourceDir,
1710
1922
  detectContainerFormat,
1711
- readLayout: readV1Layout,
1923
+ readLayout: readLayout,
1712
1924
  inspect,
1713
1925
  validate,
1714
1926
  planLoad,
1715
1927
  loadAuthorized,
1716
- buildChecksums: buildChecksumsV1,
1928
+ buildChecksums: buildChecksums,
1929
+ computeRuntimeEntrySetDigest,
1717
1930
  pack,
1718
1931
  unpack,
1719
- load: loadV1,
1720
- loadAsset: loadV1,
1721
- buildCapsule,
1932
+ load: loadAsset,
1933
+ loadAsset: loadAsset,
1722
1934
  FORBIDDEN_OUTPUT_TERMS,
1723
1935
  parseSemver,
1724
1936
  compareSemver,
1725
1937
  satisfies,
1726
1938
  };
1727
1939
 
1728
- // ─── loadV1 — v1 runtime loading / load contract ──────────────────────
1940
+ // The remote Runtime package subpath uses this non-enumerable bridge. Keeping
1941
+ // it non-enumerable prevents the package-root object spread from turning the
1942
+ // deployer-only contract into an ordinary consumer API. Package exports also
1943
+ // block direct access to this internal container module after installation.
1944
+ Object.defineProperty(module.exports, 'loadRemoteRuntimeAssetForServer', {
1945
+ value: loadRemoteRuntimeAssetForServer,
1946
+ enumerable: false,
1947
+ configurable: false,
1948
+ writable: false,
1949
+ });
1950
+
1951
+ // ─── loadAsset — layout runtime loading / load contract ──────────────────────
1729
1952
 
1730
1953
  function renderPromptItem(item) {
1731
1954
  if (item === undefined || item === null) return '';
@@ -1746,7 +1969,7 @@ function renderPromptItem(item) {
1746
1969
  // Boundary card: kdna-studio writes { id, scope, out_of_scope,
1747
1970
  // acceptable_exceptions }. Render the scope/out-of-scope rule
1748
1971
  // instead of falling through to the UUID `id` (which was the
1749
- // v0.15.0 behavior and made boundaries unreadable in
1972
+ // 0.15.0 behavior and made boundaries unreadable in
1750
1973
  // `kdna load --as=prompt`).
1751
1974
  if (
1752
1975
  item.type === 'boundary' ||
@@ -1806,7 +2029,12 @@ function sanitizePrompt(s) {
1806
2029
  }
1807
2030
 
1808
2031
  function loadAuthorized(inputPath, opts = {}) {
1809
- const plan = planLoad(inputPath, opts);
2032
+ // A packaged path is read exactly once. Authorization and projection must
2033
+ // operate on the same immutable byte snapshot even if the path changes
2034
+ // while this synchronous call is in progress.
2035
+ const inputKind = typeof inputPath === 'string' ? 'packaged_file' : 'packaged_bytes';
2036
+ const inputSnapshot = snapshotPackagedInput(inputPath);
2037
+ const plan = planLoad(inputSnapshot, opts);
1810
2038
  if (plan.can_load_now !== true) {
1811
2039
  const issueCodes = Array.isArray(plan.issues)
1812
2040
  ? plan.issues.map((issue) => issue.code).filter(Boolean)
@@ -1826,11 +2054,155 @@ function loadAuthorized(inputPath, opts = {}) {
1826
2054
  schema_valid: plan.checks && plan.checks.overall_valid === true,
1827
2055
  signature_valid: plan.signature_valid === true,
1828
2056
  },
2057
+ _runtimeAssetBytes: inputSnapshot,
2058
+ _runtimeInputKind: inputKind,
1829
2059
  };
1830
- return loadV1Unsafe(inputPath, mergedOpts);
2060
+ return loadAssetUnsafe(inputSnapshot, mergedOpts);
2061
+ }
2062
+
2063
+ function remoteRuntimeDeniedPlan(plan, code, message) {
2064
+ return finalizeLoadPlan({
2065
+ ...plan,
2066
+ state: 'invalid',
2067
+ required_action: 'block',
2068
+ can_load_now: false,
2069
+ projection_policy: 'none',
2070
+ issues: [buildLoadPlanIssue(code, 'blocking', message)],
2071
+ });
2072
+ }
2073
+
2074
+ function packagedAssetRequiredPlan(inputPath) {
2075
+ return finalizeLoadPlan({
2076
+ format_version: null,
2077
+ asset: {
2078
+ asset_id: null,
2079
+ asset_uid: null,
2080
+ title: null,
2081
+ version: null,
2082
+ judgment_version: null,
2083
+ },
2084
+ access: null,
2085
+ access_alias: null,
2086
+ entitlement_profile: null,
2087
+ state: 'invalid',
2088
+ required_action: 'block',
2089
+ can_load_now: false,
2090
+ projection_policy: 'none',
2091
+ input_fingerprint: null,
2092
+ checks: {
2093
+ format_valid: false,
2094
+ schema_valid: false,
2095
+ payload_valid: false,
2096
+ checksums_valid: false,
2097
+ load_contract_valid: false,
2098
+ overall_valid: false,
2099
+ },
2100
+ issues: [buildLoadPlanIssue(
2101
+ 'KDNA_ASSET_FILE_REQUIRED',
2102
+ 'blocking',
2103
+ 'Remote Runtime loading requires a packaged .kdna asset file. Source directories are authoring inputs only.',
2104
+ )],
2105
+ source: {
2106
+ kind: 'dir',
2107
+ path: typeof inputPath === 'string' ? path.resolve(inputPath) : null,
2108
+ },
2109
+ });
1831
2110
  }
1832
2111
 
1833
- function loadV1(inputPath, opts = {}) {
2112
+ function loadPlanDeniedError(plan, fallbackCode = 'KDNA_LOAD_NOT_AUTHORIZED') {
2113
+ const issueCodes = Array.isArray(plan && plan.issues)
2114
+ ? plan.issues.map((issue) => issue.code).filter(Boolean)
2115
+ : [];
2116
+ const error = new Error(
2117
+ `LoadPlan denied loading: state=${plan?.state || 'invalid'} required_action=${plan?.required_action || 'block'}`,
2118
+ );
2119
+ error.code = issueCodes[0] || fallbackCode;
2120
+ error.plan = plan;
2121
+ return error;
2122
+ }
2123
+
2124
+ /**
2125
+ * Deployer-only remote Runtime loading contract.
2126
+ *
2127
+ * The caller must already control the packaged remote asset. This function
2128
+ * snapshots a packaged path exactly once, validates and plans that immutable
2129
+ * byte snapshot, accepts only the remote access mode and the frozen
2130
+ * single-asset shape, then emits the full JSON Runtime Capsule required by a
2131
+ * server-side projection engine.
2132
+ */
2133
+ function loadRemoteRuntimeAssetForServer(inputPath) {
2134
+ const inputKind = typeof inputPath === 'string' ? 'packaged_file' : 'packaged_bytes';
2135
+ const inputSnapshot = snapshotPackagedInput(inputPath);
2136
+
2137
+ if (!Buffer.isBuffer(inputSnapshot)) {
2138
+ if (typeof inputPath === 'string') {
2139
+ try {
2140
+ if (fs.statSync(path.resolve(inputPath)).isDirectory()) {
2141
+ throw loadPlanDeniedError(packagedAssetRequiredPlan(inputPath));
2142
+ }
2143
+ } catch (error) {
2144
+ if (error && error.code === 'KDNA_ASSET_FILE_REQUIRED') throw error;
2145
+ }
2146
+ throw loadPlanDeniedError(planLoad(inputPath));
2147
+ }
2148
+ const plan = remoteRuntimeDeniedPlan(
2149
+ packagedAssetRequiredPlan(null),
2150
+ 'KDNA_FORMAT_INVALID',
2151
+ 'Remote Runtime input must be a packaged .kdna file path or packaged bytes.',
2152
+ );
2153
+ throw loadPlanDeniedError(plan);
2154
+ }
2155
+
2156
+ const plan = planLoad(inputSnapshot);
2157
+ if (!plan.checks || plan.checks.overall_valid !== true) {
2158
+ throw loadPlanDeniedError(plan);
2159
+ }
2160
+
2161
+ if (plan.access !== 'remote') {
2162
+ throw loadPlanDeniedError(remoteRuntimeDeniedPlan(
2163
+ plan,
2164
+ 'KDNA_REMOTE_RUNTIME_ACCESS_REQUIRED',
2165
+ 'The Remote Runtime loader accepts only packaged assets whose access mode is remote.',
2166
+ ));
2167
+ }
2168
+
2169
+ const layout = readInputLayout(inputSnapshot);
2170
+ const manifest = layout.manifest;
2171
+ const hasDependencies = manifest.dependencies
2172
+ && typeof manifest.dependencies === 'object'
2173
+ && Object.keys(manifest.dependencies).length > 0;
2174
+ if (hasDependencies || manifest.extends !== undefined) {
2175
+ throw loadPlanDeniedError(remoteRuntimeDeniedPlan(
2176
+ plan,
2177
+ 'KDNA_REMOTE_RUNTIME_COMPOSITION_UNSUPPORTED',
2178
+ 'Remote Runtime loading currently accepts one asset only; dependencies and extends declarations are not authorized.',
2179
+ ));
2180
+ }
2181
+
2182
+ if (
2183
+ plan.state !== 'needs_runtime'
2184
+ || plan.required_action !== 'connect_runtime'
2185
+ || plan.can_load_now !== false
2186
+ || plan.projection_policy !== 'remote'
2187
+ ) {
2188
+ throw loadPlanDeniedError(plan);
2189
+ }
2190
+
2191
+ return loadAssetUnsafe(inputSnapshot, {
2192
+ profile: 'full',
2193
+ as: 'json',
2194
+ resolvedDependencies: [],
2195
+ extendsChain: [],
2196
+ _validation: {
2197
+ schema_valid: plan.checks.overall_valid === true,
2198
+ signature_valid: plan.signature_valid === true,
2199
+ },
2200
+ _runtimeAssetBytes: inputSnapshot,
2201
+ _runtimeInputKind: inputKind,
2202
+ });
2203
+ }
2204
+
2205
+ function loadAsset(inputPath, opts = {}) {
1834
2206
  return loadAuthorized(inputPath, opts);
1835
2207
  }
1836
2208
 
@@ -1894,15 +2266,15 @@ function hasCompactPromptContent(content) {
1894
2266
  );
1895
2267
  }
1896
2268
 
1897
- function loadV1Unsafe(inputPath, opts = {}) {
1898
- const v1 = readV1Layout(path.resolve(inputPath));
1899
- const m = v1.manifest;
2269
+ function loadAssetUnsafe(inputPath, opts = {}) {
2270
+ const layout = readInputLayout(inputPath);
2271
+ const m = layout.manifest;
1900
2272
  const profile = opts.profile || (m.load_contract ? m.load_contract.default_profile : 'compact') || 'compact';
1901
2273
  const as = opts.as || 'json';
1902
2274
 
1903
2275
  let payload;
1904
2276
  try {
1905
- const rawPayload = v1.map['payload.kdnab'];
2277
+ const rawPayload = layout.map['payload.kdnab'];
1906
2278
  payload = parsePayloadEntry("payload.kdnab", rawPayload);
1907
2279
  } catch (e) {
1908
2280
  throw new Error(`payload.kdnab is not valid KDNA payload: ${e.message}`, { cause: e });
@@ -1914,10 +2286,10 @@ function loadV1Unsafe(inputPath, opts = {}) {
1914
2286
  // Consumer-provided decrypt hook (e.g., licensed entry key)
1915
2287
  try {
1916
2288
  const decrypted = opts.decryptEntry({
1917
- asset: { entries: v1.map, manifest: m },
2289
+ asset: { entries: layout.map, manifest: m },
1918
2290
  manifest: m,
1919
2291
  entryName: 'payload.kdnab',
1920
- ciphertext: v1.map['payload.kdnab'],
2292
+ ciphertext: layout.map['payload.kdnab'],
1921
2293
  });
1922
2294
  const decryptedBuf = typeof decrypted === 'string'
1923
2295
  ? Buffer.from(decrypted, 'utf8')
@@ -1929,7 +2301,7 @@ function loadV1Unsafe(inputPath, opts = {}) {
1929
2301
  throw err;
1930
2302
  }
1931
2303
  } else if (opts.password) {
1932
- // Password-based decryption via kdna-password-protected-v1*
2304
+ // Password-based decryption via kdna.encryption.password*
1933
2305
  // profiles. Detects Argon2id vs scrypt from the envelope profile.
1934
2306
  try {
1935
2307
  const {
@@ -1937,10 +2309,10 @@ function loadV1Unsafe(inputPath, opts = {}) {
1937
2309
  decryptProtectedEntryScrypt,
1938
2310
  PASSWORD_PROTECTED_SCRYPT_PROFILE,
1939
2311
  } = require('../crypto-profile.js');
1940
- const encryptedEnvelope = v1.map['payload.kdnab'];
2312
+ const encryptedEnvelope = layout.map['payload.kdnab'];
1941
2313
  let envelope;
1942
2314
  try { envelope = cbor.decode(Buffer.isBuffer(encryptedEnvelope) ? encryptedEnvelope : Buffer.from(encryptedEnvelope)); }
1943
- catch { throw new Error('Cannot decode encrypted envelope: payload.kdnab must be CBOR-encoded (kdna_version 1.0)'); }
2315
+ catch { throw new Error('Cannot decode encrypted envelope: payload.kdnab must be CBOR-encoded (format 0.1.0)'); }
1944
2316
  const decryptFn = (envelope.profile === PASSWORD_PROTECTED_SCRYPT_PROFILE)
1945
2317
  ? decryptProtectedEntryScrypt
1946
2318
  : decryptProtectedEntry;
@@ -1968,11 +2340,11 @@ function loadV1Unsafe(inputPath, opts = {}) {
1968
2340
  }
1969
2341
 
1970
2342
  // Digest verification — refuse to load if checksums.json is present and digests mismatch.
1971
- if (v1.map['checksums.json']) {
2343
+ if (layout.map['checksums.json']) {
1972
2344
  try {
1973
- const checks = parseJsonEntry('checksums.json', v1.map['checksums.json']);
2345
+ const checks = parseJsonEntry('checksums.json', layout.map['checksums.json']);
1974
2346
  const problems = [];
1975
- const ok = verifyDigests(checks, v1.map, problems, {});
2347
+ const ok = verifyDigests(checks, layout.map, problems, {});
1976
2348
  if (!ok) {
1977
2349
  const err = new Error(`checksum verification failed: ${problems.join('; ')}`);
1978
2350
  err.code = 'checksum_mismatch';
@@ -2020,7 +2392,8 @@ function loadV1Unsafe(inputPath, opts = {}) {
2020
2392
  if (m.load_contract && m.load_contract.profiles && m.load_contract.profiles.compact && m.load_contract.profiles.compact.max_tokens_hint) {
2021
2393
  maxTokensHint = m.load_contract.profiles.compact.max_tokens_hint;
2022
2394
  }
2023
- result.content = { asset_id: m.asset_id, asset_uid: m.asset_uid, title: m.title, version: m.version, judgment_version: m.judgment_version, asset_type: m.asset_type, summary: m.summary || null, language: m.language || null, keywords: m.keywords || [], profiles_available: m.load_contract ? Object.keys(m.load_contract.profiles || {}) : [], max_tokens_hint: maxTokensHint };
2395
+ result.content = { asset_id: m.asset_id, asset_uid: m.asset_uid, title: m.title, version: m.version, judgment_version: m.judgment_version, asset_type: m.asset_type, summary: m.summary || null, language: m.language || null, keywords: m.keywords || [], profiles_available: m.load_contract ? Object.keys(m.load_contract.profiles || {}) : [] };
2396
+ if (maxTokensHint !== undefined) result.content.max_tokens_hint = maxTokensHint;
2024
2397
  } else if (profile === 'compact') {
2025
2398
  const core = payload.core || {};
2026
2399
  const normalizeList = (items) => (items || []).map((item) => {
@@ -2028,6 +2401,13 @@ function loadV1Unsafe(inputPath, opts = {}) {
2028
2401
  if (item && typeof item === 'object') return item;
2029
2402
  return null;
2030
2403
  }).filter(Boolean);
2404
+ const preserveDeclaredList = (items) => (
2405
+ Array.isArray(items)
2406
+ ? items.map((item) => (
2407
+ typeof item === 'string' ? item : globalThis.structuredClone(item)
2408
+ ))
2409
+ : []
2410
+ );
2031
2411
  result.content = {
2032
2412
  highest_question: core.highest_question || null,
2033
2413
  // These scoped domain-level values are already validated by the payload
@@ -2043,11 +2423,10 @@ function loadV1Unsafe(inputPath, opts = {}) {
2043
2423
  : null,
2044
2424
  axioms: (core.axioms || []).map(normalizeCompactAxiom).filter(Boolean),
2045
2425
  boundaries: normalizeList(core.boundaries),
2046
- // The canonical payload field is singular (`reasoning.self_check`).
2047
- // Runtime Capsule uses plural (`context.self_checks`) as its projection
2048
- // field. Reading the plural name from the payload silently discarded
2049
- // every Studio-authored self-check.
2050
- self_checks: normalizeList(payload.reasoning && payload.reasoning.self_check),
2426
+ // Payload uses the singular field name while Runtime Capsule uses the
2427
+ // plural collection name. Preserve every validated item and its shape;
2428
+ // strings remain strings and structured questions remain objects.
2429
+ self_checks: preserveDeclaredList(payload.reasoning && payload.reasoning.self_check),
2051
2430
  failure_modes: normalizeList(payload.reasoning && payload.reasoning.failure_modes),
2052
2431
  patterns: normalizeList(payload.patterns).slice(0, 3),
2053
2432
  };
@@ -2069,7 +2448,7 @@ function loadV1Unsafe(inputPath, opts = {}) {
2069
2448
  if (opts.extendsChain && opts.extendsChain.length > 0 && result.content) {
2070
2449
  try {
2071
2450
  const base = opts.extendsChain[0];
2072
- const baseLoaded = loadV1Unsafe(base.path, {
2451
+ const baseLoaded = loadAssetUnsafe(base.path, {
2073
2452
  ...opts,
2074
2453
  resolvedDependencies: [],
2075
2454
  extendsChain: [],
@@ -2113,7 +2492,7 @@ function loadV1Unsafe(inputPath, opts = {}) {
2113
2492
 
2114
2493
  if (opts.resolvedDependencies && opts.resolvedDependencies.length > 0) {
2115
2494
  result.resolved_dependencies = opts.resolvedDependencies.map(dep => {
2116
- const depLoaded = loadV1Unsafe(dep.path, { ...opts, resolvedDependencies: [] });
2495
+ const depLoaded = loadAssetUnsafe(dep.path, { ...opts, resolvedDependencies: [] });
2117
2496
  // RAG namespace (Story 11): scoped identifier for namespace isolation.
2118
2497
  // Format: name@version (or just name when version is absent).
2119
2498
  const ragNamespace = dep.name
@@ -2199,7 +2578,7 @@ function loadV1Unsafe(inputPath, opts = {}) {
2199
2578
  let textOut = text.trim();
2200
2579
  if (opts.resolvedDependencies && opts.resolvedDependencies.length > 0) {
2201
2580
  for (const dep of opts.resolvedDependencies) {
2202
- const depLoaded = loadV1Unsafe(dep.path, { ...opts, resolvedDependencies: [] });
2581
+ const depLoaded = loadAssetUnsafe(dep.path, { ...opts, resolvedDependencies: [] });
2203
2582
  if (depLoaded.text) {
2204
2583
  // Namespace header (Story 11): each component section is prefixed
2205
2584
  // with [NAMESPACE: id] so consumers can isolate RAG content per source.
@@ -2218,59 +2597,37 @@ function loadV1Unsafe(inputPath, opts = {}) {
2218
2597
  return result;
2219
2598
  }
2220
2599
 
2221
- return wrapAsCapsule(result, v1, profile, opts);
2600
+ return wrapAsCapsule(result, layout, profile, { ...opts, as });
2222
2601
  }
2223
2602
 
2224
- function wrapAsCapsule(result, v1, profile, opts) {
2603
+ function wrapAsCapsule(result, layout, profile, opts) {
2225
2604
  if (opts.as === 'json') {
2226
- return buildCapsule(result, v1, profile, opts);
2605
+ return buildRuntimeCapsuleProjection(result, layout, profile, opts);
2227
2606
  }
2228
2607
  return result;
2229
2608
  }
2230
2609
 
2231
- function buildCapsule(loadResult, v1, profile, opts = {}) {
2232
- const m = v1.manifest;
2610
+ function buildRuntimeCapsuleProjection(loadResult, layout, profile, opts = {}) {
2611
+ const m = layout.manifest;
2233
2612
  const val = opts._validation || {};
2234
- const assetDigest = v1.assetDigest || (v1.map && v1.map['checksums.json']
2235
- ? (() => {
2236
- try {
2237
- const checks = JSON.parse(v1.map['checksums.json'].toString('utf8'));
2238
- return checks.asset_digest || null;
2239
- } catch { return null; }
2240
- })()
2241
- : null);
2242
-
2243
- const pubkey = (m.creator && m.creator.pubkey) || (m.author && m.author.pubkey) || null;
2613
+ const pubkey = (m.creator && m.creator.pubkey) || null;
2244
2614
  const sigVerified = val.signature_valid === true;
2245
- const capsule = {
2246
- type: 'kdna.context.capsule',
2247
- version: '1.0',
2248
- domain: m.name || m.asset_id || null,
2249
- judgment_version: m.judgment_version || null,
2250
- asset_digest: assetDigest,
2615
+ const runtimeCapsule = require('../runtime-capsule');
2616
+ const digests = runtimeCapsule.computeDigestEvidence(opts._runtimeAssetBytes, {
2617
+ expectedDigests: opts.expectedDigests,
2618
+ });
2619
+ return runtimeCapsule.buildRuntimeCapsule({
2620
+ projection: loadResult,
2621
+ manifest: m,
2622
+ digests,
2251
2623
  signature: pubkey
2252
2624
  ? { state: sigVerified ? 'verified' : 'not_checked', issuer: pubkey }
2253
2625
  : { state: 'absent' },
2254
- access: m.access || 'public',
2255
- risk_level: m.risk_level || null,
2256
- profile: loadResult.profile,
2257
- context: loadResult.content || {},
2258
- trace: {
2259
- payload_encoding: (m.payload && m.payload.encoding) || 'cbor',
2260
- loaded_by: 'kdna-core',
2261
- loaded_at: new Date().toISOString(),
2262
- schema_valid: val.schema_valid === true,
2263
- signature_state: sigVerified ? 'verified' : (pubkey ? 'not_checked' : 'absent'),
2264
- profile: profile,
2265
- },
2266
- };
2267
-
2268
- if (loadResult.extends_chain) capsule.extends_chain = loadResult.extends_chain;
2269
- if (loadResult.inheritance_applied !== undefined) capsule.inheritance_applied = loadResult.inheritance_applied;
2270
- if (loadResult.resolved_dependencies) capsule.resolved_dependencies = loadResult.resolved_dependencies;
2271
- if (loadResult.rag_isolation_policy) capsule.rag_isolation_policy = loadResult.rag_isolation_policy;
2272
-
2273
- return capsule;
2626
+ inputKind: opts._runtimeInputKind,
2627
+ loadedAt: opts.loadedAt,
2628
+ schemaValid: val.schema_valid === true,
2629
+ profile,
2630
+ });
2274
2631
  }
2275
2632
 
2276
2633
  // ─── Semver & Dependency Resolution (Story 6) ─────────────────────────
@@ -2286,9 +2643,9 @@ function parseSemver(v) {
2286
2643
  };
2287
2644
  }
2288
2645
 
2289
- function compareSemver(v1, v2) {
2290
- const p1 = parseSemver(v1);
2291
- const p2 = parseSemver(v2);
2646
+ function compareSemver(leftVersion, rightVersion) {
2647
+ const p1 = parseSemver(leftVersion);
2648
+ const p2 = parseSemver(rightVersion);
2292
2649
  if (!p1 || !p2) return 0;
2293
2650
  if (p1.major !== p2.major) return p1.major - p2.major;
2294
2651
  if (p1.minor !== p2.minor) return p1.minor - p2.minor;