@aikdna/kdna-core 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+
6
+ const Ajv2020 = require('ajv/dist/2020');
7
+ const addFormats = require('ajv-formats');
8
+
9
+ const { createKdnaAssetReader } = require('./asset-reader');
10
+ const v1 = require('./v1');
11
+ const digestEvidenceSchema = require('../schema/digest-evidence.schema.json');
12
+ const capsule1Schema = require('../schema/runtime-capsule-1.schema.json');
13
+ const capsule2Schema = require('../schema/runtime-capsule-2.schema.json');
14
+
15
+ const SHA256_RE = /^sha256:[0-9a-f]{64}$/;
16
+ const DIGEST_PROFILE = 'kdna-capsule-digests-v1';
17
+ const CAPSULE_DIGEST_PROFILE = 'kdna-capsule-jcs-v1';
18
+ const COMPARISON_SOURCES = new Set([
19
+ 'caller',
20
+ 'registry',
21
+ 'install_receipt',
22
+ 'lockfile',
23
+ 'kdna.json.content_digest',
24
+ 'kdna.json.authoring.content_digest',
25
+ 'checksums.json.entry_set_digest',
26
+ 'checksums.json.asset_digest',
27
+ ]);
28
+ const EXTERNAL_COMPARISON_SOURCES = new Set([
29
+ 'caller',
30
+ 'registry',
31
+ 'install_receipt',
32
+ 'lockfile',
33
+ ]);
34
+ const COMPARISON_TARGETS = new Set([
35
+ 'external_expected',
36
+ 'manifest_declaration',
37
+ 'checksum_declaration',
38
+ ]);
39
+ const BASIS = Object.freeze({
40
+ asset: 'kdna-container-bytes-v1',
41
+ content: 'kdna-content-tree-v1',
42
+ runtime_entry_set: 'kdna-runtime-entry-set-v1',
43
+ });
44
+ const CAPSULE_1_EXTENSION_KEYS = Object.freeze([
45
+ 'extends_chain',
46
+ 'inheritance_applied',
47
+ 'resolved_dependencies',
48
+ 'rag_isolation_policy',
49
+ ]);
50
+ const CAPSULE_1_ACCESS_MAP = Object.freeze({
51
+ open: 'public',
52
+ protected: 'licensed',
53
+ runtime: 'remote',
54
+ });
55
+
56
+ let validators;
57
+
58
+ function schemaValidators() {
59
+ if (validators) return validators;
60
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
61
+ addFormats(ajv);
62
+ ajv.addSchema(digestEvidenceSchema);
63
+ validators = {
64
+ capsule1: ajv.compile(capsule1Schema),
65
+ capsule2: ajv.compile(capsule2Schema),
66
+ };
67
+ return validators;
68
+ }
69
+
70
+ function fail(code, message, details) {
71
+ const error = new Error(message);
72
+ error.code = code;
73
+ if (details !== undefined) error.details = details;
74
+ throw error;
75
+ }
76
+
77
+ function sha256Digest(bytes) {
78
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
79
+ return `sha256:${crypto.createHash('sha256').update(buffer).digest('hex')}`;
80
+ }
81
+
82
+ function snapshotPackagedBytes(input) {
83
+ if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
84
+ return { bytes: Buffer.from(input), inputKind: 'packaged_bytes' };
85
+ }
86
+ if (typeof input !== 'string') {
87
+ fail(
88
+ 'KDNA_CAPSULE_2_INPUT_INVALID',
89
+ 'Capsule 2 requires a packaged .kdna path, Buffer, or Uint8Array.',
90
+ );
91
+ }
92
+
93
+ let stat;
94
+ try {
95
+ stat = fs.statSync(input);
96
+ } catch (error) {
97
+ fail('KDNA_CAPSULE_2_INPUT_INVALID', `Cannot read packaged KDNA asset: ${error.message}`);
98
+ }
99
+ if (stat.isDirectory()) {
100
+ fail(
101
+ 'KDNA_ASSET_FILE_REQUIRED',
102
+ 'Runtime Capsule 2 requires final packaged .kdna bytes; authoring directories are not runtime assets.',
103
+ );
104
+ }
105
+ if (!stat.isFile()) {
106
+ fail('KDNA_CAPSULE_2_INPUT_INVALID', 'Capsule 2 input is not a regular file.');
107
+ }
108
+ return { bytes: fs.readFileSync(input), inputKind: 'packaged_file' };
109
+ }
110
+
111
+ function computeAssetDigest(assetBytes) {
112
+ if (!Buffer.isBuffer(assetBytes) && !(assetBytes instanceof Uint8Array)) {
113
+ fail('KDNA_DIGEST_INPUT_INVALID', 'A requires final packaged asset bytes.');
114
+ }
115
+ return sha256Digest(assetBytes);
116
+ }
117
+
118
+ function readJsonEntry(asset, entryName) {
119
+ if (!asset.entries.has(entryName)) return null;
120
+ try {
121
+ return JSON.parse(asset.readEntry(entryName).toString('utf8'));
122
+ } catch (error) {
123
+ fail('KDNA_DIGEST_DECLARATION_INVALID', `${entryName}: invalid JSON: ${error.message}`);
124
+ }
125
+ }
126
+
127
+ function normalizeExpectedDigest(
128
+ expected,
129
+ defaultSource,
130
+ allowedSources = EXTERNAL_COMPARISON_SOURCES,
131
+ ) {
132
+ if (expected == null) return null;
133
+ const normalized =
134
+ typeof expected === 'string' ? { value: expected, source: defaultSource } : expected;
135
+ if (
136
+ !normalized ||
137
+ typeof normalized !== 'object' ||
138
+ !SHA256_RE.test(normalized.value || '') ||
139
+ typeof normalized.source !== 'string' ||
140
+ !allowedSources.has(normalized.source)
141
+ ) {
142
+ fail(
143
+ 'KDNA_DIGEST_EXPECTATION_INVALID',
144
+ 'Expected digest must provide a lowercase sha256 value and a factual source.',
145
+ );
146
+ }
147
+ return { value: normalized.value, source: normalized.source };
148
+ }
149
+
150
+ function normalizeDeclaredDigest(value, source) {
151
+ return normalizeExpectedDigest(
152
+ { value, source },
153
+ source,
154
+ new Set([source]),
155
+ );
156
+ }
157
+
158
+ function comparison(observed, expected, against) {
159
+ if (!expected) {
160
+ return { state: 'not_compared', against: null, expected: null, source: null };
161
+ }
162
+ return {
163
+ state: observed === expected.value ? 'matched' : 'mismatched',
164
+ against,
165
+ expected: expected.value,
166
+ source: expected.source,
167
+ };
168
+ }
169
+
170
+ function declaredContentDigest(manifest) {
171
+ const topLevel = manifest && manifest.content_digest !== undefined
172
+ ? normalizeDeclaredDigest(manifest.content_digest, 'kdna.json.content_digest')
173
+ : null;
174
+ const authoring = manifest?.authoring?.content_digest !== undefined
175
+ ? normalizeDeclaredDigest(
176
+ manifest.authoring.content_digest,
177
+ 'kdna.json.authoring.content_digest',
178
+ )
179
+ : null;
180
+ if (topLevel && authoring && topLevel.value !== authoring.value) {
181
+ fail(
182
+ 'KDNA_CONTENT_DIGEST_DECLARATION_CONFLICT',
183
+ 'kdna.json content_digest conflicts with authoring.content_digest.',
184
+ { top_level: topLevel.value, authoring: authoring.value },
185
+ );
186
+ }
187
+ return topLevel || authoring;
188
+ }
189
+
190
+ function declaredEntrySetDigest(checksums) {
191
+ if (!checksums) return null;
192
+ const canonical = checksums.entry_set_digest !== undefined
193
+ ? normalizeDeclaredDigest(
194
+ checksums.entry_set_digest,
195
+ 'checksums.json.entry_set_digest',
196
+ )
197
+ : null;
198
+ const legacy = checksums.asset_digest !== undefined
199
+ ? normalizeDeclaredDigest(checksums.asset_digest, 'checksums.json.asset_digest')
200
+ : null;
201
+ if (canonical && legacy && canonical.value !== legacy.value) {
202
+ fail(
203
+ 'KDNA_RUNTIME_ENTRY_SET_DIGEST_DECLARATION_CONFLICT',
204
+ 'checksums.json entry_set_digest conflicts with deprecated asset_digest.',
205
+ { entry_set_digest: canonical.value, asset_digest: legacy.value },
206
+ );
207
+ }
208
+ return canonical || legacy;
209
+ }
210
+
211
+ function comparisonWithDeclarationPriority(observed, declared, external, declarationTarget) {
212
+ const declarationComparison = comparison(observed, declared, declarationTarget);
213
+ if (declarationComparison.state === 'mismatched') return declarationComparison;
214
+ if (external) return comparison(observed, external, 'external_expected');
215
+ return declarationComparison;
216
+ }
217
+
218
+ function computeDigestEvidenceFromBytes(assetBytes, options = {}) {
219
+ const bytes = Buffer.isBuffer(assetBytes)
220
+ ? Buffer.from(assetBytes)
221
+ : Buffer.from(assetBytes || []);
222
+ const reader = createKdnaAssetReader();
223
+ const asset = reader.openSync(bytes);
224
+ if (!asset.entries.has('kdna.json') || !asset.entries.has('payload.kdnab')) {
225
+ fail(
226
+ 'KDNA_DIGEST_INPUT_INVALID',
227
+ 'Digest evidence requires kdna.json and payload.kdnab entries.',
228
+ );
229
+ }
230
+
231
+ const manifest = readJsonEntry(asset, 'kdna.json');
232
+ const checksums = readJsonEntry(asset, 'checksums.json');
233
+ const assetDigest = computeAssetDigest(bytes);
234
+ const contentDigest = reader.contentDigestSync(asset);
235
+ const entrySetDigest = v1.computeRuntimeEntrySetDigest(
236
+ asset.readEntry('kdna.json'),
237
+ asset.readEntry('payload.kdnab'),
238
+ );
239
+ const externalExpected = options.expectedDigests || {};
240
+ const expectedAsset = normalizeExpectedDigest(
241
+ externalExpected.asset ?? options.expectedAssetDigest,
242
+ 'caller',
243
+ );
244
+ const externalContent = normalizeExpectedDigest(externalExpected.content, 'caller');
245
+ const externalEntrySet = normalizeExpectedDigest(externalExpected.runtime_entry_set, 'caller');
246
+ const declaredContent = declaredContentDigest(manifest);
247
+ const declaredEntrySet = declaredEntrySetDigest(checksums);
248
+
249
+ return {
250
+ profile: DIGEST_PROFILE,
251
+ asset: {
252
+ value: assetDigest,
253
+ basis: BASIS.asset,
254
+ comparison: comparison(assetDigest, expectedAsset, 'external_expected'),
255
+ },
256
+ content: {
257
+ value: contentDigest,
258
+ basis: BASIS.content,
259
+ comparison: comparisonWithDeclarationPriority(
260
+ contentDigest,
261
+ declaredContent,
262
+ externalContent,
263
+ 'manifest_declaration',
264
+ ),
265
+ },
266
+ runtime_entry_set: {
267
+ value: entrySetDigest,
268
+ basis: BASIS.runtime_entry_set,
269
+ comparison: comparisonWithDeclarationPriority(
270
+ entrySetDigest,
271
+ declaredEntrySet,
272
+ externalEntrySet,
273
+ 'checksum_declaration',
274
+ ),
275
+ },
276
+ };
277
+ }
278
+
279
+ function computeDigestEvidence(input, options = {}) {
280
+ const { bytes } = snapshotPackagedBytes(input);
281
+ return computeDigestEvidenceFromBytes(bytes, options);
282
+ }
283
+
284
+ function assertUnicodeScalarString(value, label) {
285
+ for (let index = 0; index < value.length; index += 1) {
286
+ const code = value.charCodeAt(index);
287
+ if (code >= 0xd800 && code <= 0xdbff) {
288
+ const next = value.charCodeAt(index + 1);
289
+ if (!(next >= 0xdc00 && next <= 0xdfff)) {
290
+ fail('KDNA_JCS_INVALID_UNICODE', `${label} contains an unpaired high surrogate.`);
291
+ }
292
+ index += 1;
293
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
294
+ fail('KDNA_JCS_INVALID_UNICODE', `${label} contains an unpaired low surrogate.`);
295
+ }
296
+ }
297
+ }
298
+
299
+ function canonicalizeJcs(value) {
300
+ const active = new Set();
301
+
302
+ function serialize(current, label) {
303
+ if (current === null) return 'null';
304
+ if (typeof current === 'boolean') return current ? 'true' : 'false';
305
+ if (typeof current === 'string') {
306
+ assertUnicodeScalarString(current, label);
307
+ return JSON.stringify(current);
308
+ }
309
+ if (typeof current === 'number') {
310
+ if (!Number.isFinite(current)) {
311
+ fail('KDNA_JCS_NON_FINITE_NUMBER', `${label} contains a non-finite number.`);
312
+ }
313
+ return JSON.stringify(current);
314
+ }
315
+ if (typeof current !== 'object') {
316
+ fail('KDNA_JCS_UNSUPPORTED_VALUE', `${label} is not an RFC 8785 JSON value.`);
317
+ }
318
+ if (active.has(current)) {
319
+ fail('KDNA_JCS_CYCLIC_VALUE', `${label} contains a cyclic reference.`);
320
+ }
321
+
322
+ active.add(current);
323
+ try {
324
+ if (Array.isArray(current)) {
325
+ const items = [];
326
+ for (let index = 0; index < current.length; index += 1) {
327
+ if (!Object.prototype.hasOwnProperty.call(current, index)) {
328
+ fail('KDNA_JCS_UNSUPPORTED_VALUE', `${label} contains a sparse array.`);
329
+ }
330
+ items.push(serialize(current[index], `${label}[${index}]`));
331
+ }
332
+ return `[${items.join(',')}]`;
333
+ }
334
+
335
+ const prototype = Object.getPrototypeOf(current);
336
+ if (prototype !== Object.prototype && prototype !== null) {
337
+ fail('KDNA_JCS_UNSUPPORTED_VALUE', `${label} must be a plain JSON object.`);
338
+ }
339
+ const names = Object.getOwnPropertyNames(current);
340
+ const keys = Object.keys(current);
341
+ if (names.length !== keys.length || Object.getOwnPropertySymbols(current).length !== 0) {
342
+ fail('KDNA_JCS_UNSUPPORTED_VALUE', `${label} contains non-JSON object members.`);
343
+ }
344
+ keys.sort();
345
+ const members = keys.map((key) => {
346
+ assertUnicodeScalarString(key, `${label} key`);
347
+ const descriptor = Object.getOwnPropertyDescriptor(current, key);
348
+ if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
349
+ fail('KDNA_JCS_UNSUPPORTED_VALUE', `${label}.${key} must be a data property.`);
350
+ }
351
+ return `${JSON.stringify(key)}:${serialize(descriptor.value, `${label}.${key}`)}`;
352
+ });
353
+ return `{${members.join(',')}}`;
354
+ } finally {
355
+ active.delete(current);
356
+ }
357
+ }
358
+
359
+ return serialize(value, '$');
360
+ }
361
+
362
+ function computeCapsuleDeliveryDigest(capsule) {
363
+ return sha256Digest(Buffer.from(canonicalizeJcs(capsule), 'utf8'));
364
+ }
365
+
366
+ function schemaErrorDetails(validator) {
367
+ return JSON.parse(JSON.stringify(validator.errors || []));
368
+ }
369
+
370
+ function assertJsonValue(value, code, label) {
371
+ try {
372
+ canonicalizeJcs(value);
373
+ } catch (error) {
374
+ fail(code, `${label} is not an RFC 8785 JSON value: ${error.message}`, {
375
+ cause_code: error.code || null,
376
+ });
377
+ }
378
+ }
379
+
380
+ function assertCapsule1Success(capsule, code = 'KDNA_CAPSULE_2_BUILD_INVALID') {
381
+ assertJsonValue(capsule, code, 'Capsule 1');
382
+ const validator = schemaValidators().capsule1;
383
+ if (!validator(capsule)) {
384
+ fail(code, 'Capsule 1 does not satisfy the frozen schema.', schemaErrorDetails(validator));
385
+ }
386
+ if (capsule.trace.schema_valid !== true) {
387
+ fail(code, 'Capsule 1 schema_valid must be true before building Capsule 2.');
388
+ }
389
+ if (capsule.signature.state !== capsule.trace.signature_state) {
390
+ fail(code, 'Capsule 1 signature state conflicts with its trace.');
391
+ }
392
+ if (capsule.profile !== capsule.trace.profile) {
393
+ fail(code, 'Capsule 1 profile conflicts with its trace.');
394
+ }
395
+ if (!['public', 'licensed', 'remote'].includes(capsule2Access(capsule.access))) {
396
+ fail(code, `Capsule 1 access is not a supported Runtime value: ${capsule.access}.`);
397
+ }
398
+ }
399
+
400
+ function assertCapsule2Success(capsule, code = 'KDNA_CAPSULE_ADAPTER_INPUT_INVALID') {
401
+ assertJsonValue(capsule, code, 'Capsule 2');
402
+ const validator = schemaValidators().capsule2;
403
+ if (!validator(capsule)) {
404
+ fail(code, 'Capsule 2 does not satisfy the successful Runtime schema.', schemaErrorDetails(validator));
405
+ }
406
+ assertSuccessfulDigestEvidence(capsule.digests);
407
+ if (capsule.signature.state !== capsule.trace.signature_state) {
408
+ fail(code, 'Capsule 2 signature state conflicts with its trace.');
409
+ }
410
+ if (capsule.profile !== capsule.trace.profile) {
411
+ fail(code, 'Capsule 2 profile conflicts with its trace.');
412
+ }
413
+ const capsule1Access = capsule.compatibility?.capsule_1_access;
414
+ if (capsule1Access && CAPSULE_1_ACCESS_MAP[capsule1Access] !== capsule.access) {
415
+ fail(
416
+ code,
417
+ 'Capsule 1 compatibility access does not map to Capsule 2 access.',
418
+ {
419
+ capsule_1_access: capsule1Access,
420
+ expected_capsule_2_access: CAPSULE_1_ACCESS_MAP[capsule1Access],
421
+ capsule_2_access: capsule.access,
422
+ },
423
+ );
424
+ }
425
+ }
426
+
427
+ function assertSuccessfulDigestEvidence(digests) {
428
+ if (!digests || digests.profile !== DIGEST_PROFILE) {
429
+ fail('KDNA_CAPSULE_2_DIGEST_EVIDENCE_INVALID', 'Capsule 2 digest evidence is missing.');
430
+ }
431
+ for (const name of ['asset', 'content', 'runtime_entry_set']) {
432
+ const item = digests[name];
433
+ if (!item || !SHA256_RE.test(item.value || '') || item.basis !== BASIS[name]) {
434
+ fail(
435
+ 'KDNA_CAPSULE_2_DIGEST_EVIDENCE_INVALID',
436
+ `Capsule 2 ${name} digest evidence is invalid.`,
437
+ );
438
+ }
439
+ const comparisonState = item.comparison && item.comparison.state;
440
+ if (!['matched', 'not_compared'].includes(comparisonState)) {
441
+ const mismatchCodes = {
442
+ asset: 'KDNA_ASSET_DIGEST_MISMATCH',
443
+ content: 'KDNA_CONTENT_DIGEST_MISMATCH',
444
+ runtime_entry_set: 'KDNA_RUNTIME_ENTRY_SET_DIGEST_MISMATCH',
445
+ };
446
+ fail(
447
+ comparisonState === 'mismatched'
448
+ ? mismatchCodes[name]
449
+ : 'KDNA_CAPSULE_2_DIGEST_EVIDENCE_INVALID',
450
+ `Capsule 2 cannot be emitted with ${name} comparison ${comparisonState || 'missing'}.`,
451
+ { digest: name, evidence: item },
452
+ );
453
+ }
454
+ if (
455
+ comparisonState === 'matched' &&
456
+ (item.comparison.expected !== item.value ||
457
+ !COMPARISON_TARGETS.has(item.comparison.against) ||
458
+ !COMPARISON_SOURCES.has(item.comparison.source))
459
+ ) {
460
+ fail(
461
+ 'KDNA_CAPSULE_2_DIGEST_EVIDENCE_INVALID',
462
+ `Capsule 2 ${name} matched comparison is internally inconsistent.`,
463
+ );
464
+ }
465
+ if (
466
+ comparisonState === 'not_compared' &&
467
+ (item.comparison.expected !== null ||
468
+ item.comparison.against !== null ||
469
+ item.comparison.source !== null)
470
+ ) {
471
+ fail(
472
+ 'KDNA_CAPSULE_2_DIGEST_EVIDENCE_INVALID',
473
+ `Capsule 2 ${name} not_compared evidence must not claim an expectation.`,
474
+ );
475
+ }
476
+ }
477
+ }
478
+
479
+ function capsule2Access(access) {
480
+ if (!access || access === 'open') return 'public';
481
+ if (access === 'protected') return 'licensed';
482
+ if (access === 'runtime') return 'remote';
483
+ return access;
484
+ }
485
+
486
+ function buildCapsuleV2({ capsule1, manifest, digests, inputKind, loadedAt } = {}) {
487
+ if (!capsule1 || capsule1.type !== 'kdna.context.capsule' || capsule1.version !== '1.0') {
488
+ fail(
489
+ 'KDNA_CAPSULE_2_BUILD_INVALID',
490
+ 'Capsule 2 builder requires a frozen Capsule 1 projection.',
491
+ );
492
+ }
493
+ assertCapsule1Success(capsule1);
494
+ if (!manifest || typeof manifest !== 'object') {
495
+ fail('KDNA_CAPSULE_2_BUILD_INVALID', 'Capsule 2 builder requires the Runtime manifest.');
496
+ }
497
+ for (const field of ['asset_id', 'asset_uid', 'version', 'judgment_version']) {
498
+ if (typeof manifest[field] !== 'string' || manifest[field].length === 0) {
499
+ fail('KDNA_CAPSULE_2_BUILD_INVALID', `Runtime manifest ${field} is required.`);
500
+ }
501
+ }
502
+ if (!['packaged_file', 'packaged_bytes'].includes(inputKind)) {
503
+ fail('KDNA_CAPSULE_2_BUILD_INVALID', 'Capsule 2 input_kind must identify packaged bytes.');
504
+ }
505
+ assertSuccessfulDigestEvidence(digests);
506
+
507
+ const expectedDomain = manifest.name || manifest.asset_id;
508
+ if (capsule1.domain !== expectedDomain) {
509
+ fail(
510
+ 'KDNA_CAPSULE_2_BUILD_INVALID',
511
+ 'Capsule 1 domain does not match the Runtime manifest identity.',
512
+ { capsule_1: capsule1.domain, manifest: expectedDomain },
513
+ );
514
+ }
515
+ if (capsule1.judgment_version !== manifest.judgment_version) {
516
+ fail(
517
+ 'KDNA_CAPSULE_2_BUILD_INVALID',
518
+ 'Capsule 1 judgment_version does not match the Runtime manifest.',
519
+ { capsule_1: capsule1.judgment_version, manifest: manifest.judgment_version },
520
+ );
521
+ }
522
+ const expectedAccess = manifest.access || 'public';
523
+ if (capsule1.access !== expectedAccess) {
524
+ fail(
525
+ 'KDNA_CAPSULE_2_BUILD_INVALID',
526
+ 'Capsule 1 access does not match the Runtime manifest.',
527
+ { capsule_1: capsule1.access, manifest: expectedAccess },
528
+ );
529
+ }
530
+ if (capsule1.asset_digest !== digests.runtime_entry_set.value) {
531
+ fail(
532
+ 'KDNA_CAPSULE_2_BUILD_INVALID',
533
+ 'Capsule 1 asset_digest does not match Runtime entry-set digest E.',
534
+ { capsule_1: capsule1.asset_digest, runtime_entry_set: digests.runtime_entry_set.value },
535
+ );
536
+ }
537
+
538
+ const timestamp = loadedAt || capsule1.trace?.loaded_at;
539
+ if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp))) {
540
+ fail('KDNA_CAPSULE_2_BUILD_INVALID', 'Capsule 2 loaded_at must be an ISO date-time string.');
541
+ }
542
+
543
+ const capsule = {
544
+ type: 'kdna.context.capsule',
545
+ version: '2.0',
546
+ asset: {
547
+ asset_id: manifest.asset_id,
548
+ asset_uid: manifest.asset_uid,
549
+ version: manifest.version,
550
+ judgment_version: manifest.judgment_version,
551
+ },
552
+ digests,
553
+ signature: capsule1.signature,
554
+ access: capsule2Access(capsule1.access),
555
+ risk_level: capsule1.risk_level,
556
+ profile: capsule1.profile,
557
+ context: capsule1.context,
558
+ trace: {
559
+ payload_encoding: capsule1.trace?.payload_encoding || 'cbor',
560
+ loaded_by: 'kdna-core',
561
+ loaded_at: timestamp,
562
+ input_kind: inputKind,
563
+ runtime_eligible: true,
564
+ schema_valid: capsule1.trace?.schema_valid === true,
565
+ signature_state: capsule1.trace?.signature_state || capsule1.signature?.state,
566
+ profile: capsule1.profile,
567
+ },
568
+ };
569
+
570
+ // Capsule 2 identity is always manifest.asset_id. A distinct legacy name is
571
+ // carried only so the one-way v2 -> v1 adapter can reproduce the frozen v1
572
+ // `domain` value. It has no routing or identity authority in Capsule 2.
573
+ const compatibility = {};
574
+ if (capsule1.domain && capsule1.domain !== manifest.asset_id) {
575
+ compatibility.capsule_1_domain = capsule1.domain;
576
+ }
577
+ if (CAPSULE_1_ACCESS_MAP[capsule1.access]) {
578
+ compatibility.capsule_1_access = capsule1.access;
579
+ }
580
+ const capsule1Extensions = {};
581
+ for (const key of CAPSULE_1_EXTENSION_KEYS) {
582
+ if (Object.prototype.hasOwnProperty.call(capsule1, key)) {
583
+ capsule1Extensions[key] = capsule1[key];
584
+ }
585
+ }
586
+ if (Object.keys(capsule1Extensions).length > 0) {
587
+ compatibility.capsule_1_extensions = capsule1Extensions;
588
+ }
589
+ if (Object.keys(compatibility).length > 0) {
590
+ capsule.compatibility = compatibility;
591
+ }
592
+
593
+ assertCapsule2Success(capsule, 'KDNA_CAPSULE_2_BUILD_INVALID');
594
+ return capsule;
595
+ }
596
+
597
+ function adaptCapsuleV2ToV1(capsule) {
598
+ if (!capsule || capsule.type !== 'kdna.context.capsule' || capsule.version !== '2.0') {
599
+ fail('KDNA_CAPSULE_ADAPTER_INPUT_INVALID', 'The adapter requires a Runtime Capsule 2 value.');
600
+ }
601
+ assertCapsule2Success(capsule);
602
+ const domain = capsule.compatibility?.capsule_1_domain || capsule.asset?.asset_id;
603
+ if (typeof domain !== 'string' || domain.length === 0) {
604
+ fail('KDNA_CAPSULE_ADAPTER_INPUT_INVALID', 'Capsule 2 has no Capsule 1 domain mapping.');
605
+ }
606
+ const capsule1 = {
607
+ type: 'kdna.context.capsule',
608
+ version: '1.0',
609
+ domain,
610
+ judgment_version: capsule.asset.judgment_version,
611
+ asset_digest: capsule.digests.runtime_entry_set.value,
612
+ signature: capsule.signature,
613
+ access: capsule.compatibility?.capsule_1_access || capsule.access,
614
+ risk_level: capsule.risk_level,
615
+ profile: capsule.profile,
616
+ context: capsule.context,
617
+ trace: {
618
+ payload_encoding: capsule.trace.payload_encoding,
619
+ loaded_by: capsule.trace.loaded_by,
620
+ loaded_at: capsule.trace.loaded_at,
621
+ schema_valid: capsule.trace.schema_valid,
622
+ signature_state: capsule.trace.signature_state,
623
+ profile: capsule.trace.profile,
624
+ },
625
+ };
626
+ const capsule1Extensions = capsule.compatibility?.capsule_1_extensions;
627
+ if (capsule1Extensions) {
628
+ for (const key of CAPSULE_1_EXTENSION_KEYS) {
629
+ if (Object.prototype.hasOwnProperty.call(capsule1Extensions, key)) {
630
+ capsule1[key] = capsule1Extensions[key];
631
+ }
632
+ }
633
+ }
634
+ assertCapsule1Success(capsule1, 'KDNA_CAPSULE_ADAPTER_OUTPUT_INVALID');
635
+ return capsule1;
636
+ }
637
+
638
+ function loadCapsuleV2(input, options = {}) {
639
+ const { bytes, inputKind } = snapshotPackagedBytes(input);
640
+ let validation;
641
+ try {
642
+ validation = v1.validate(bytes);
643
+ } catch (error) {
644
+ fail('KDNA_CAPSULE_2_ASSET_INVALID', error.message);
645
+ }
646
+ if (validation.overall_valid !== true) {
647
+ fail(
648
+ 'KDNA_CAPSULE_2_ASSET_INVALID',
649
+ `Cannot emit Capsule 2 from an invalid asset: ${validation.problems.join('; ')}`,
650
+ validation,
651
+ );
652
+ }
653
+
654
+ const digests = computeDigestEvidenceFromBytes(bytes, {
655
+ expectedDigests: options.expectedDigests,
656
+ expectedAssetDigest: options.expectedAssetDigest,
657
+ });
658
+ assertSuccessfulDigestEvidence(digests);
659
+ const reader = createKdnaAssetReader();
660
+ const asset = reader.openSync(bytes);
661
+ const manifest = reader.readManifestSync(asset);
662
+ const capsule1 = v1.loadAuthorized(bytes, {
663
+ ...options,
664
+ as: 'json',
665
+ });
666
+ return buildCapsuleV2({
667
+ capsule1,
668
+ manifest,
669
+ digests,
670
+ inputKind,
671
+ loadedAt: options.loadedAt,
672
+ });
673
+ }
674
+
675
+ module.exports = {
676
+ BASIS,
677
+ CAPSULE_DIGEST_PROFILE,
678
+ DIGEST_PROFILE,
679
+ adaptCapsuleV2ToV1,
680
+ buildCapsuleV2,
681
+ canonicalizeJcs,
682
+ computeAssetDigest,
683
+ computeCapsuleDeliveryDigest,
684
+ computeDigestEvidence,
685
+ computeRuntimeEntrySetDigest: v1.computeRuntimeEntrySetDigest,
686
+ loadCapsuleV2,
687
+ };
@@ -18,7 +18,8 @@ const MIMETYPE = 'application/vnd.kdna.asset';
18
18
  * @property {object} manifest — parsed kdna.json
19
19
  * @property {Buffer} payloadRaw — raw payload.kdnab bytes
20
20
  * @property {object|null} checksums
21
- * @property {Buffer|null} assetDigest
21
+ * @property {Buffer|null} assetDigest — legacy internal name for final container bytes
22
+ * @property {string|null} containerDigest — tagged SHA-256 of final container bytes
22
23
  * @property {Buffer|null} containerBuffer
23
24
  * @property {Map<string, Buffer>|null} entries
24
25
  */
@@ -177,6 +178,7 @@ function readSourceDirectory(absPath, _opts) {
177
178
  payloadRaw,
178
179
  checksums,
179
180
  assetDigest: null,
181
+ containerDigest: null,
180
182
  containerBuffer: null,
181
183
  entries,
182
184
  };
@@ -198,6 +200,7 @@ function readKdnaContainer(absPath, _opts) {
198
200
  ? (() => { try { return JSON.parse(v1Layout.map['checksums.json'].toString('utf8')); } catch { return null; } })()
199
201
  : null,
200
202
  assetDigest,
203
+ containerDigest: `sha256:${assetDigest.toString('hex')}`,
201
204
  containerBuffer: containerBuf,
202
205
  entries: new Map(Object.entries(v1Layout.map)),
203
206
  };