@aikdna/kdna-core 0.15.12 → 0.17.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,91 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const v1 = require('./v1');
6
+
7
+ function invalidDirectoryPlan(inputPath) {
8
+ const resolved = path.resolve(inputPath);
9
+ return {
10
+ kdna_version: null,
11
+ asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
12
+ access: null,
13
+ access_alias: null,
14
+ entitlement_profile: null,
15
+ state: 'invalid',
16
+ required_action: 'block',
17
+ can_load_now: false,
18
+ projection_policy: 'none',
19
+ input_fingerprint: null,
20
+ checks: {
21
+ format_valid: false,
22
+ schema_valid: false,
23
+ payload_valid: false,
24
+ checksums_valid: false,
25
+ load_contract_valid: false,
26
+ overall_valid: false,
27
+ },
28
+ issues: [{
29
+ code: 'KDNA_ASSET_FILE_REQUIRED',
30
+ severity: 'blocking',
31
+ message: 'Runtime loading requires a packaged .kdna asset file. Source directories are authoring inputs only.',
32
+ }],
33
+ source: { kind: 'dir', path: resolved },
34
+ };
35
+ }
36
+
37
+ function isDirectory(inputPath) {
38
+ if (typeof inputPath !== 'string') return false;
39
+ try {
40
+ return fs.statSync(path.resolve(inputPath)).isDirectory();
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function guardResolver(options = {}) {
47
+ if (typeof options.resolveAsset !== 'function') return options;
48
+ const resolveAsset = options.resolveAsset;
49
+ return {
50
+ ...options,
51
+ resolveAsset(name) {
52
+ const resolved = resolveAsset(name);
53
+ if (resolved?.path && isDirectory(resolved.path)) {
54
+ const error = new Error(
55
+ `Resolved asset "${name}" is a source directory. Runtime dependencies must be packaged .kdna files.`,
56
+ );
57
+ error.code = 'KDNA_ASSET_FILE_REQUIRED';
58
+ throw error;
59
+ }
60
+ return resolved;
61
+ },
62
+ };
63
+ }
64
+
65
+ function planLoad(inputPath, options = {}) {
66
+ if (isDirectory(inputPath)) return invalidDirectoryPlan(inputPath);
67
+ return v1.planLoad(inputPath, guardResolver(options));
68
+ }
69
+
70
+ function loadAuthorized(inputPath, options = {}) {
71
+ const plan = planLoad(inputPath, options);
72
+ if (plan.can_load_now !== true) {
73
+ const issueCodes = Array.isArray(plan.issues)
74
+ ? plan.issues.map((issue) => issue.code).filter(Boolean)
75
+ : [];
76
+ const error = new Error(
77
+ `LoadPlan denied loading: state=${plan.state || 'invalid'} required_action=${plan.required_action || 'block'}`,
78
+ );
79
+ error.code = issueCodes[0] || 'KDNA_LOAD_NOT_AUTHORIZED';
80
+ error.plan = plan;
81
+ throw error;
82
+ }
83
+ return v1.loadAuthorized(inputPath, guardResolver(options));
84
+ }
85
+
86
+ module.exports = {
87
+ planLoad,
88
+ loadAuthorized,
89
+ load: loadAuthorized,
90
+ loadAsset: loadAuthorized,
91
+ };
package/src/types.d.ts CHANGED
@@ -414,6 +414,165 @@ export function createLicensedDecryptEntry(options: {
414
414
  machineFingerprint: string;
415
415
  }): NonNullable<KdnaDecryptOptions['decryptEntry']>;
416
416
 
417
+ export const EXTERNAL_ENVELOPE_PROFILE: 'kdna-envelope-external-grant-v1';
418
+ export const EXTERNAL_GRANT_PROFILE: 'kdna-key-grant-v1';
419
+
420
+ export class KDNAExternalGrantError extends Error {
421
+ code: string;
422
+ }
423
+
424
+ export interface KDNAExternalGrantEnvelope {
425
+ profile: 'kdna-envelope-external-grant-v1';
426
+ alg: 'A256GCM';
427
+ cek_derivation: 'HKDF-SHA256';
428
+ key_ref: string;
429
+ issuer_key_id: string;
430
+ entry_path: string;
431
+ plaintext_digest: string;
432
+ iv: string;
433
+ tag: string;
434
+ ciphertext: string;
435
+ }
436
+
437
+ export interface KDNAExternalKeyGrant {
438
+ profile: 'kdna-key-grant-v1';
439
+ grant_id: string;
440
+ issuer: string;
441
+ signing_key_id: string;
442
+ entitlement_id: string;
443
+ account_id: string;
444
+ device_id: string;
445
+ device_public_key: string;
446
+ device_signing_public_key: string;
447
+ asset: {
448
+ asset_id: string;
449
+ asset_uid: string;
450
+ version: string;
451
+ digest: string;
452
+ entry_path: string;
453
+ ciphertext_digest: string;
454
+ key_ref: string;
455
+ issuer_key_id: string;
456
+ };
457
+ issued_at: string;
458
+ refresh_after: string;
459
+ offline_grace_until: string;
460
+ expires_at: string;
461
+ status: 'active' | 'revoked' | 'expired';
462
+ status_version: number;
463
+ wrap: {
464
+ alg: 'X25519-HKDF-SHA256+A256KW';
465
+ ephemeral_public_key: string;
466
+ salt: string;
467
+ wrapped_cek: string;
468
+ };
469
+ signature: string;
470
+ }
471
+
472
+ export interface KDNADeviceKeyPairs {
473
+ agreement: { public_key: string; private_key: string };
474
+ signing: { public_key: string; private_key: string };
475
+ }
476
+
477
+ /** Runtime-branded by authorizeExternalKeyGrant; a structurally similar object is not accepted. */
478
+ export interface KDNAVerifiedExternalEntitlement {
479
+ readonly status: 'active' | 'offline_grace';
480
+ readonly profile: 'kdna-key-grant-v1';
481
+ readonly grant_id: string;
482
+ readonly entitlement_id: string;
483
+ readonly account_id: string;
484
+ readonly device_id: string;
485
+ readonly refresh_after: string;
486
+ readonly offline_grace_until: string;
487
+ readonly expires_at: string;
488
+ readonly asset: {
489
+ readonly asset_id: string;
490
+ readonly asset_uid: string;
491
+ readonly version: string;
492
+ readonly digest: string;
493
+ readonly entry_path: string;
494
+ readonly ciphertext_digest: string;
495
+ readonly key_ref: string;
496
+ readonly issuer_key_id: string;
497
+ };
498
+ }
499
+
500
+ export interface KDNAExternalAuthorization {
501
+ entitlement: KDNAVerifiedExternalEntitlement;
502
+ decryptEntry: NonNullable<KdnaDecryptOptions['decryptEntry']>;
503
+ dispose(): void;
504
+ }
505
+
506
+ export function canonicalJson(value: unknown): string;
507
+ export function grantSigningPayload(grant: Omit<KDNAExternalKeyGrant, 'signature'> | KDNAExternalKeyGrant): Uint8Array;
508
+ export function validateExternalEnvelope(value: unknown): KDNAExternalGrantEnvelope;
509
+ export function validateExternalKeyGrant(value: unknown): KDNAExternalKeyGrant;
510
+ export function externalEnvelopeAad(options: {
511
+ manifest: KDNAManifest;
512
+ entryName: string;
513
+ plaintextDigest: string;
514
+ keyRef: string;
515
+ issuerKeyId: string;
516
+ }): Uint8Array;
517
+ export function deriveExternalAssetCek(options: {
518
+ issuerRootKey: string | Uint8Array;
519
+ manifest: KDNAManifest;
520
+ entryName: string;
521
+ plaintextDigest: string;
522
+ keyRef: string;
523
+ issuerKeyId: string;
524
+ }): Uint8Array;
525
+ export function encodeExternalEnvelope(value: KDNAExternalGrantEnvelope): Uint8Array;
526
+ export function decodeExternalEnvelope(value: KDNAExternalGrantEnvelope | Uint8Array): KDNAExternalGrantEnvelope;
527
+ export function encryptExternalGrantEntry(plaintext: string | Uint8Array, options: {
528
+ manifest: KDNAManifest;
529
+ entryName?: string;
530
+ issuerRootKey: string | Uint8Array;
531
+ keyRef: string;
532
+ issuerKeyId: string;
533
+ iv?: Uint8Array;
534
+ }): KDNAExternalGrantEnvelope;
535
+ export function generateDeviceKeyPairs(): KDNADeviceKeyPairs;
536
+ export function createExternalKeyGrant(options: {
537
+ issuerRootKey: string | Uint8Array;
538
+ issuerSigningPrivateKey: unknown;
539
+ signingKeyId: string;
540
+ issuer: string;
541
+ entitlementId: string;
542
+ accountId: string;
543
+ deviceId: string;
544
+ devicePublicKey: string;
545
+ deviceSigningPublicKey: string;
546
+ manifest: KDNAManifest;
547
+ envelope: KDNAExternalGrantEnvelope | Uint8Array;
548
+ assetDigest: string;
549
+ status?: 'active' | 'revoked' | 'expired';
550
+ statusVersion?: number;
551
+ issuedAt?: Date | string;
552
+ refreshAfter?: Date | string;
553
+ offlineGraceUntil?: Date | string;
554
+ expiresAt?: Date | string;
555
+ grantId?: string;
556
+ ephemeralKeyPair?: unknown;
557
+ wrapSalt?: Uint8Array;
558
+ }): KDNAExternalKeyGrant;
559
+ export function authorizeExternalKeyGrant(options: {
560
+ grant: KDNAExternalKeyGrant;
561
+ issuerPublicKeys: Map<string, unknown> | Record<string, unknown>;
562
+ manifest: KDNAManifest;
563
+ checksums: KDNAChecksums;
564
+ envelope: KDNAExternalGrantEnvelope | Uint8Array;
565
+ deviceAgreementKey: KDNADeviceKeyPairs['agreement'] | unknown;
566
+ expectedAccountId: string;
567
+ expectedDeviceId: string;
568
+ expectedDeviceSigningPublicKey: string;
569
+ minimumStatusVersion?: number;
570
+ now?: Date | string;
571
+ networkAvailable?: boolean;
572
+ allowOffline?: boolean;
573
+ }): KDNAExternalAuthorization;
574
+ export function isVerifiedExternalEntitlement(value: unknown): value is KDNAVerifiedExternalEntitlement;
575
+
417
576
  // Stable public API — preferred entry points for third-party integrations.
418
577
  export type KDNAAssetInput = string | Uint8Array | KdnaAsset;
419
578
 
@@ -482,7 +641,7 @@ export function matchDomain(input: string, candidates: Array<KDNAAssetInput | KD
482
641
  export function matchDomainSync(input: string, candidates: Array<KDNAAssetInput | KDNAInspectResult>, options?: KdnaDecryptOptions): KDNAMatchResult[];
483
642
  export function composeKDNA(inputs: KDNAAssetInput[], options?: { input?: string; profile?: 'compact' | 'scenario' | 'full' | string; separator?: string } & KdnaDecryptOptions): Promise<KDNAComposeResult>;
484
643
 
485
- // KDNA Core — source directory / container API
644
+ // KDNA Core — authoring source and packaged runtime API
486
645
  export const MIMETYPE: string;
487
646
  export const REQUIRED_DIR_ENTRIES: string[];
488
647
 
@@ -553,7 +712,7 @@ export interface KDNALoadPlan {
553
712
  path: string;
554
713
  };
555
714
  }
556
- export function planLoad(inputPath: string, options?: { password?: string; hasPassword?: boolean; entitlement?: Record<string, any> }): KDNALoadPlan;
715
+ export function planLoad(inputPath: string, options?: { password?: string; hasPassword?: boolean; entitlement?: KDNAVerifiedExternalEntitlement | Record<string, any> }): KDNALoadPlan;
557
716
  export function buildChecksums(sourceDir: string): KDNAChecksums;
558
717
  export function pack(sourceDir: string, outputPath: string): void;
559
718
  export function unpack(inputPath: string, outputDir: string): void;
package/src/v1/index.js CHANGED
@@ -1,17 +1,15 @@
1
1
  /**
2
- * v1-cli.js — KDNA Core v1 inspect / validate / pack / unpack for the
3
- * kdna monorepo CLI shim.
2
+ * KDNA Asset Container inspect / validate / pack / unpack implementation.
4
3
  *
5
4
  * KDNA Core is the official KDNA judgment-asset format and runtime
6
5
  * loading contract. .kdna assets are created, inspected, packed,
7
6
  * unpacked, and validated through the official KDNA toolchain. This
8
- * module is the v1 component of that toolchain.
9
7
  *
10
- * The KDNA Core v1 file format is documented in docs/core/file-format.md.
8
+ * The KDNA Asset Container is documented in docs/core/file-format.md.
11
9
  * This module is the shared implementation that:
12
10
  *
13
- * - packages/kdna/bin/kdna.js uses as a v1-aware router
14
- * - scripts/v1-*.mjs delegate to (via child_process) so the legacy
11
+ * - packages/kdna/bin/kdna.js uses as its container router
12
+ * - compatibility scripts delegate to it so historical tooling
15
13
  * scripts and the official CLI cannot drift
16
14
  *
17
15
  * Hard rules from the format spec:
@@ -39,6 +37,11 @@ const path = require('node:path');
39
37
  const zlib = require('node:zlib');
40
38
  const crypto = require('node:crypto');
41
39
  const cbor = require('cbor-x');
40
+ const {
41
+ EXTERNAL_ENVELOPE_PROFILE,
42
+ validateExternalEnvelope,
43
+ isVerifiedExternalEntitlement,
44
+ } = require('../external-key-grant');
42
45
 
43
46
  const { readAsset } = (() => {
44
47
  try {
@@ -178,7 +181,7 @@ function loadSchemas() {
178
181
  // ─── Format detection ──────────────────────────────────────────────────
179
182
 
180
183
  /**
181
- * Detect whether a directory is a v1 source layout.
184
+ * Detect whether a directory is a KDNA authoring source layout.
182
185
  * Required entries: mimetype, kdna.json, payload.kdnab.
183
186
  * mimetype content must equal "application/vnd.kdna.asset".
184
187
  */
@@ -536,10 +539,10 @@ function readV1Layout(absPath) {
536
539
  for (const f of V1_REQUIRED_DIR_ENTRIES) {
537
540
  const full = path.join(absPath, f);
538
541
  if (!fs.existsSync(full)) {
539
- throw new Error(`not a KDNA v1 source dir: missing ${f}`);
542
+ throw new Error(`not a KDNA authoring source directory: missing ${f}`);
540
543
  }
541
544
  if (fs.lstatSync(full).isSymbolicLink()) {
542
- throw new Error(`not a KDNA v1 source dir: ${f} must not be a symlink`);
545
+ throw new Error(`not a KDNA authoring source directory: ${f} must not be a symlink`);
543
546
  }
544
547
  }
545
548
  for (const f of [...V1_REQUIRED_DIR_ENTRIES, ...V1_OPTIONAL_DIR_ENTRIES]) {
@@ -557,10 +560,10 @@ function readV1Layout(absPath) {
557
560
  kind = 'file';
558
561
  entries = listZipEntries(absPath);
559
562
  if (entries.length === 0 || entries[0].name !== 'mimetype') {
560
- throw new Error('not a KDNA v1 container: first entry is not mimetype');
563
+ throw new Error('not a KDNA Asset Container: first entry is not mimetype');
561
564
  }
562
565
  if (entries[0].method !== 0) {
563
- throw new Error('not a KDNA v1 container: mimetype must be uncompressed');
566
+ throw new Error('not a KDNA Asset Container: mimetype must be uncompressed');
564
567
  }
565
568
  for (const e of entries) {
566
569
  // We only need the well-known entries; signatures/ attachments/ etc.
@@ -576,7 +579,7 @@ function readV1Layout(absPath) {
576
579
  }
577
580
  for (const f of V1_REQUIRED_DIR_ENTRIES) {
578
581
  if (!map[f]) {
579
- throw new Error(`not a KDNA v1 container: missing ${f}`);
582
+ throw new Error(`not a KDNA Asset Container: missing ${f}`);
580
583
  }
581
584
  }
582
585
  } else {
@@ -725,7 +728,7 @@ function runValidate(v1) {
725
728
  if (!validators) {
726
729
  result.schema_valid = false;
727
730
  problems.push(
728
- 'schema: ajv not available. KDNA Core v1 requires ajv and ajv-formats for JSON-Schema validation. Run: npm install ajv ajv-formats (or: npm install -g ajv ajv-formats for global CLI users)',
731
+ 'schema: KDNA Core requires ajv and ajv-formats for JSON-Schema validation. Run: npm install ajv ajv-formats (or: npm install -g ajv ajv-formats for global CLI users)',
729
732
  );
730
733
  return finalizeValidate(result, problems);
731
734
  }
@@ -758,7 +761,16 @@ function runValidate(v1) {
758
761
  result.payload_valid = false;
759
762
  problems.push(`payload: encrypted envelope profile ${payload.profile || 'unknown'} does not match manifest encryption profile ${encProfile}`);
760
763
  }
761
- const hasKeyMaterial = !!payload.wrapped_key || (Array.isArray(payload.key_slots) && payload.key_slots.length > 0);
764
+ let hasKeyMaterial = !!payload.wrapped_key || (Array.isArray(payload.key_slots) && payload.key_slots.length > 0);
765
+ if (payload.profile === EXTERNAL_ENVELOPE_PROFILE) {
766
+ try {
767
+ validateExternalEnvelope(payload);
768
+ hasKeyMaterial = true;
769
+ } catch (error) {
770
+ result.payload_valid = false;
771
+ problems.push(`payload: ${error.message}`);
772
+ }
773
+ }
762
774
  if (!payload.profile || !payload.ciphertext || !hasKeyMaterial) {
763
775
  result.payload_valid = false;
764
776
  problems.push('payload: encrypted envelope missing required fields (profile/ciphertext/key material)');
@@ -919,7 +931,7 @@ function buildChecksumsV1(sourceDir) {
919
931
  // ─── pack ──────────────────────────────────────────────────────────────
920
932
 
921
933
  /**
922
- * Pack a v1 source directory into a .kdna container. Output is
934
+ * Pack an authoring source directory into a .kdna asset. Output is
923
935
  * deterministic: the same source directory packed twice produces
924
936
  * byte-identical output (fixed DOS timestamps, fixed entry order,
925
937
  * mimetype first).
@@ -1017,7 +1029,7 @@ function unpack(inputPath, outputDir) {
1017
1029
  throw new Error(`not a file: ${absIn}`);
1018
1030
  }
1019
1031
  const entries = listZipEntries(absIn);
1020
- // Sanity: v1 container must have mimetype as first entry with the v1 media type.
1032
+ // Sanity: the asset must have mimetype as its first, uncompressed entry.
1021
1033
  if (entries.length === 0 || entries[0].name !== 'mimetype') {
1022
1034
  throw new Error('not a KDNA container: first entry is not mimetype');
1023
1035
  }
@@ -1101,12 +1113,11 @@ function finalizeLoadPlan(plan) {
1101
1113
  return plan;
1102
1114
  }
1103
1115
 
1104
- function computeSourceFingerprint(inputPath, v1) {
1105
- const absPath = path.resolve(inputPath);
1106
- if (v1.kind === 'file') {
1107
- return `sha256:${crypto.createHash('sha256').update(fs.readFileSync(absPath)).digest('hex')}`;
1108
- }
1109
-
1116
+ function computeSourceFingerprint(_inputPath, v1) {
1117
+ // Fingerprint the logical entries, not the transport ZIP bytes. DEFLATE
1118
+ // output can differ between zlib versions and operating systems even when
1119
+ // every uncompressed KDNA entry is identical. LoadPlan conformance must be
1120
+ // stable across JS, Swift, CI runners, and repackaging transports.
1110
1121
  const hash = crypto.createHash('sha256');
1111
1122
  for (const name of Object.keys(v1.map).sort()) {
1112
1123
  const bytes = v1.map[name];
@@ -1211,14 +1222,15 @@ function canonicalToV1Layout(asset) {
1211
1222
  }
1212
1223
 
1213
1224
  /**
1214
- * Plan a KDNA v1 runtime load without decrypting or emitting judgment content.
1225
+ * Plan a KDNA runtime load without decrypting or emitting judgment content.
1215
1226
  * Product consumers such as Chat should render authorization UI from this
1216
1227
  * result instead of parsing manifest fields directly.
1217
1228
  */
1218
1229
  function planLoad(inputPath, opts = {}) {
1219
1230
  let v1;
1220
1231
 
1221
- // Try the unified container dispatcher first (handles v1 dirs, v1 containers, v2 containers)
1232
+ // Try the unified container dispatcher first; the internal compatibility
1233
+ // reader below remains available only for authoring and migration tooling.
1222
1234
  if (readAsset !== null) {
1223
1235
  try {
1224
1236
  const asset = readAsset(path.resolve(inputPath));
@@ -1278,6 +1290,36 @@ function planLoad(inputPath, opts = {}) {
1278
1290
 
1279
1291
  const manifest = v1.manifest;
1280
1292
 
1293
+ function externalEntitlementMatchesCurrentAsset(entitlement) {
1294
+ 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)
1304
+ && entitlement.asset.asset_uid === manifest.asset_uid
1305
+ && entitlement.asset.version === manifest.version
1306
+ && entitlement.asset.digest === checksums?.asset_digest
1307
+ && entitlement.asset.entry_path === manifest.payload?.path;
1308
+ }
1309
+
1310
+ function rejectExternalEntitlementMismatch() {
1311
+ plan.state = 'invalid';
1312
+ plan.required_action = 'block';
1313
+ plan.can_load_now = false;
1314
+ plan.projection_policy = 'none';
1315
+ plan.issues.push(buildLoadPlanIssue(
1316
+ 'KDNA_GRANT_ASSET_MISMATCH',
1317
+ 'blocking',
1318
+ 'The verified account/device grant is bound to a different asset, version, digest, or encrypted entry.',
1319
+ ));
1320
+ return finalizeLoadPlan(plan);
1321
+ }
1322
+
1281
1323
  // Resolve dependencies if declared (Story 6)
1282
1324
  const resolvedDeps = [];
1283
1325
  if (manifest.dependencies && typeof manifest.dependencies === 'object' && Object.keys(manifest.dependencies).length > 0) {
@@ -1449,6 +1491,23 @@ function planLoad(inputPath, opts = {}) {
1449
1491
  }
1450
1492
 
1451
1493
  if (plan.entitlement_profile === 'account') {
1494
+ if (isVerifiedExternalEntitlement(opts.entitlement)) {
1495
+ if (!externalEntitlementMatchesCurrentAsset(opts.entitlement)) {
1496
+ return rejectExternalEntitlementMismatch();
1497
+ }
1498
+ plan.state = opts.entitlement.status === 'offline_grace' ? 'offline_grace' : 'ready';
1499
+ plan.required_action = opts.entitlement.status === 'offline_grace' ? 'sync' : 'load';
1500
+ plan.can_load_now = true;
1501
+ plan.projection_policy = 'minimal';
1502
+ if (opts.entitlement.status === 'offline_grace') {
1503
+ plan.issues.push(buildLoadPlanIssue(
1504
+ 'KDNA_AUTH_OFFLINE_GRACE_ACTIVE',
1505
+ 'warning',
1506
+ 'The verified device grant can load offline but must sync before grace expires.',
1507
+ ));
1508
+ }
1509
+ return finalizeLoadPlan(plan);
1510
+ }
1452
1511
  plan.state = 'needs_account';
1453
1512
  plan.required_action = 'sign_in_or_activate';
1454
1513
  plan.can_load_now = false;
@@ -1462,6 +1521,16 @@ function planLoad(inputPath, opts = {}) {
1462
1521
  }
1463
1522
 
1464
1523
  if (plan.entitlement_profile === 'org') {
1524
+ if (isVerifiedExternalEntitlement(opts.entitlement)) {
1525
+ if (!externalEntitlementMatchesCurrentAsset(opts.entitlement)) {
1526
+ return rejectExternalEntitlementMismatch();
1527
+ }
1528
+ plan.state = opts.entitlement.status === 'offline_grace' ? 'offline_grace' : 'ready';
1529
+ plan.required_action = opts.entitlement.status === 'offline_grace' ? 'sync' : 'load';
1530
+ plan.can_load_now = true;
1531
+ plan.projection_policy = 'minimal';
1532
+ return finalizeLoadPlan(plan);
1533
+ }
1465
1534
  plan.state = 'needs_org_auth';
1466
1535
  plan.required_action = 'sign_in_or_activate';
1467
1536
  plan.can_load_now = false;
@@ -1797,6 +1866,34 @@ function normalizeCompactAxiom(axiom) {
1797
1866
  };
1798
1867
  }
1799
1868
 
1869
+ function judgmentRoleExclusions(role) {
1870
+ if (!role || typeof role !== 'object' || Array.isArray(role)) return [];
1871
+ if (Array.isArray(role.does_not_act_as)) return role.does_not_act_as;
1872
+ return role.does_not_act_as ? [role.does_not_act_as] : [];
1873
+ }
1874
+
1875
+ function hasStandardJudgmentRoleContent(role) {
1876
+ if (!role || typeof role !== 'object' || Array.isArray(role)) return false;
1877
+ return Boolean(
1878
+ role.acts_as || judgmentRoleExclusions(role).length || role.responsibility,
1879
+ );
1880
+ }
1881
+
1882
+ function hasCompactPromptContent(content) {
1883
+ if (!content) return false;
1884
+ return Boolean(
1885
+ content.highest_question ||
1886
+ (Array.isArray(content.worldview) && content.worldview.length > 0) ||
1887
+ (Array.isArray(content.value_order) && content.value_order.length > 0) ||
1888
+ hasStandardJudgmentRoleContent(content.judgment_role) ||
1889
+ (Array.isArray(content.axioms) && content.axioms.length > 0) ||
1890
+ (Array.isArray(content.boundaries) && content.boundaries.length > 0) ||
1891
+ (Array.isArray(content.self_checks) && content.self_checks.length > 0) ||
1892
+ (Array.isArray(content.failure_modes) && content.failure_modes.length > 0) ||
1893
+ (Array.isArray(content.patterns) && content.patterns.length > 0)
1894
+ );
1895
+ }
1896
+
1800
1897
  function loadV1Unsafe(inputPath, opts = {}) {
1801
1898
  const v1 = readV1Layout(path.resolve(inputPath));
1802
1899
  const m = v1.manifest;
@@ -1828,7 +1925,7 @@ function loadV1Unsafe(inputPath, opts = {}) {
1828
1925
  payload = parsePayloadEntry('payload.kdnab', decryptedBuf);
1829
1926
  } catch (e) {
1830
1927
  const err = new Error(`Decryption failed: ${e.message}`);
1831
- err.code = 'KDNA_DECRYPT_FAILED';
1928
+ err.code = e.code || 'KDNA_DECRYPT_FAILED';
1832
1929
  throw err;
1833
1930
  }
1834
1931
  } else if (opts.password) {
@@ -1859,8 +1956,13 @@ function loadV1Unsafe(inputPath, opts = {}) {
1859
1956
  throw err;
1860
1957
  }
1861
1958
  } else {
1862
- const err = new Error('payload is encrypted and cannot be decrypted without a password or license key');
1863
- err.code = 'KDNA_AUTH_PASSWORD_REQUIRED';
1959
+ const isExternal = payload.profile === EXTERNAL_ENVELOPE_PROFILE;
1960
+ const err = new Error(
1961
+ isExternal
1962
+ ? 'payload requires a verified account/device external key grant'
1963
+ : 'payload is encrypted and cannot be decrypted without an authorized credential',
1964
+ );
1965
+ err.code = isExternal ? 'KDNA_AUTH_ACCOUNT_REQUIRED' : 'KDNA_AUTH_PASSWORD_REQUIRED';
1864
1966
  throw err;
1865
1967
  }
1866
1968
  }
@@ -1890,8 +1992,12 @@ function loadV1Unsafe(inputPath, opts = {}) {
1890
1992
  const core = payload.core || {};
1891
1993
  const hasJudgment = (core.axioms && core.axioms.length > 0)
1892
1994
  || (core.boundaries && core.boundaries.length > 0)
1995
+ || Boolean(core.highest_question)
1996
+ || (Array.isArray(core.worldview) && core.worldview.length > 0)
1997
+ || (Array.isArray(core.value_order) && core.value_order.length > 0)
1998
+ || hasStandardJudgmentRoleContent(core.judgment_role)
1893
1999
  || (payload.patterns && payload.patterns.length > 0)
1894
- || (payload.reasoning && ((payload.reasoning.self_checks && payload.reasoning.self_checks.length > 0) || (payload.reasoning.failure_modes && payload.reasoning.failure_modes.length > 0)));
2000
+ || (payload.reasoning && ((payload.reasoning.self_check && payload.reasoning.self_check.length > 0) || (payload.reasoning.failure_modes && payload.reasoning.failure_modes.length > 0)));
1895
2001
  profiles.push('index');
1896
2002
  if (hasJudgment) profiles.push('compact');
1897
2003
  if (payload.scenarios && payload.scenarios.length > 0) profiles.push('scenario');
@@ -1924,9 +2030,24 @@ function loadV1Unsafe(inputPath, opts = {}) {
1924
2030
  }).filter(Boolean);
1925
2031
  result.content = {
1926
2032
  highest_question: core.highest_question || null,
2033
+ // These scoped domain-level values are already validated by the payload
2034
+ // schema. Compact projection copies them without trimming, reordering,
2035
+ // filtering, or converting a declared string into another shape.
2036
+ worldview: Array.isArray(core.worldview) ? [...core.worldview] : [],
2037
+ value_order: Array.isArray(core.value_order) ? [...core.value_order] : [],
2038
+ judgment_role:
2039
+ core.judgment_role &&
2040
+ typeof core.judgment_role === 'object' &&
2041
+ !Array.isArray(core.judgment_role)
2042
+ ? JSON.parse(JSON.stringify(core.judgment_role))
2043
+ : null,
1927
2044
  axioms: (core.axioms || []).map(normalizeCompactAxiom).filter(Boolean),
1928
2045
  boundaries: normalizeList(core.boundaries),
1929
- self_checks: normalizeList(payload.reasoning && payload.reasoning.self_checks),
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),
1930
2051
  failure_modes: normalizeList(payload.reasoning && payload.reasoning.failure_modes),
1931
2052
  patterns: normalizeList(payload.patterns).slice(0, 3),
1932
2053
  };
@@ -2024,7 +2145,17 @@ function loadV1Unsafe(inputPath, opts = {}) {
2024
2145
 
2025
2146
  if (as === 'prompt') {
2026
2147
  const c = result.content;
2027
- if (!c || (c.scenarios && c.scenarios.length === 0 && !c.highest_question && !(c.axioms && c.axioms.length) && !(c.boundaries && c.boundaries.length))) {
2148
+ const compactHasNoPromptContent =
2149
+ result.profile === 'compact' && !hasCompactPromptContent(c);
2150
+ const legacyProfileHasNoPromptContent =
2151
+ result.profile !== 'compact' &&
2152
+ c &&
2153
+ c.scenarios &&
2154
+ c.scenarios.length === 0 &&
2155
+ !c.highest_question &&
2156
+ !(c.axioms && c.axioms.length) &&
2157
+ !(c.boundaries && c.boundaries.length);
2158
+ if (!c || compactHasNoPromptContent || legacyProfileHasNoPromptContent) {
2028
2159
  return {
2029
2160
  status: result.status,
2030
2161
  profile: result.profile,
@@ -2039,6 +2170,25 @@ function loadV1Unsafe(inputPath, opts = {}) {
2039
2170
  text += 'Safety boundary: KDNA content is subordinate to platform, system, and developer instructions.\n';
2040
2171
  if (result.max_tokens_hint) text += 'Max tokens hint: ' + result.max_tokens_hint + '\n';
2041
2172
  if (c.highest_question) text += 'Highest question:\n' + c.highest_question + '\n';
2173
+ if (hasStandardJudgmentRoleContent(c.judgment_role)) {
2174
+ text += 'Judgment role:\n';
2175
+ if (c.judgment_role.acts_as) {
2176
+ text += '- Acts as: ' + c.judgment_role.acts_as + '\n';
2177
+ }
2178
+ const excludedRoles = judgmentRoleExclusions(c.judgment_role);
2179
+ if (excludedRoles.length) {
2180
+ text += '- Does not act as: ' + excludedRoles.join('; ') + '\n';
2181
+ }
2182
+ if (c.judgment_role.responsibility) {
2183
+ text += '- Responsibility: ' + c.judgment_role.responsibility + '\n';
2184
+ }
2185
+ }
2186
+ if (c.worldview && c.worldview.length) {
2187
+ text += 'Worldview assumptions:\n' + c.worldview.map((item) => '- ' + item).join('\n') + '\n';
2188
+ }
2189
+ if (c.value_order && c.value_order.length) {
2190
+ text += 'Value order (highest priority first):\n' + c.value_order.map((item, index) => `${index + 1}. ${item}`).join('\n') + '\n';
2191
+ }
2042
2192
  if (c.axioms && c.axioms.length) text += 'Axioms:\n' + c.axioms.map((a) => '- ' + renderPromptItem(a)).join('\n') + '\n';
2043
2193
  if (c.boundaries && c.boundaries.length) text += 'Boundaries:\n' + c.boundaries.map((b) => '- ' + renderPromptItem(b)).join('\n') + '\n';
2044
2194
  if (c.self_checks && c.self_checks.length) text += 'Self-checks:\n' + c.self_checks.map((s) => '- ' + renderPromptItem(s)).join('\n') + '\n';