@aikdna/kdna-core 0.13.2 → 0.14.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.
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-core",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "KDNA Core library for validating, planning, and loading local .kdna judgment assets.",
5
+ "engines": {
6
+ "node": ">=18.0.0"
7
+ },
5
8
  "type": "commonjs",
6
9
  "main": "src/index.js",
7
10
  "module": "src/index.mjs",
@@ -22,7 +25,8 @@
22
25
  "types": "src/types.d.ts",
23
26
  "scripts": {
24
27
  "test": "node ../../scripts/run-node-tests.js test/*.test.js && node -e \"const m = require('./src/index.js'); console.log('kdna-core exports:', Object.keys(m).join(', '));\"",
25
- "prepublishOnly": "npm --prefix ../.. run ecosystem-gate"
28
+ "prepublishOnly": "npm run release:check && npm --prefix ../.. run ecosystem-gate",
29
+ "release:check": "node scripts/check-release-readiness.js"
26
30
  },
27
31
  "files": [
28
32
  "src/",
@@ -14,10 +14,10 @@
14
14
  "required_action",
15
15
  "can_load_now",
16
16
  "projection_policy",
17
- "input_fingerprint",
18
17
  "checks",
19
18
  "issues",
20
- "source"
19
+ "source",
20
+ "input_fingerprint"
21
21
  ],
22
22
  "additionalProperties": false,
23
23
  "properties": {
@@ -51,7 +51,9 @@
51
51
  "needs_runtime",
52
52
  "offline_grace",
53
53
  "expired",
54
+ "expired_grace",
54
55
  "revoked",
56
+ "denied",
55
57
  "invalid"
56
58
  ]
57
59
  },
@@ -66,6 +68,8 @@
66
68
  "sync",
67
69
  "connect_runtime",
68
70
  "migrate_legacy",
71
+ "renew_entitlement",
72
+ "contact_issuer",
69
73
  "block"
70
74
  ]
71
75
  },
@@ -74,10 +78,6 @@
74
78
  "type": "string",
75
79
  "enum": ["minimal", "remote", "none"]
76
80
  },
77
- "input_fingerprint": {
78
- "type": ["string", "null"],
79
- "pattern": "^sha256:[a-f0-9]{64}$"
80
- },
81
81
  "checks": {
82
82
  "type": "object",
83
83
  "required": [
@@ -111,6 +111,16 @@
111
111
  }
112
112
  }
113
113
  },
114
+ "input_fingerprint": {
115
+ "type": ["object", "null"],
116
+ "required": ["source_fingerprint", "has_password_input", "entitlement_input"],
117
+ "additionalProperties": false,
118
+ "properties": {
119
+ "source_fingerprint": { "type": ["string", "null"] },
120
+ "has_password_input": { "type": "boolean" },
121
+ "entitlement_input": { "type": ["string", "null"] }
122
+ }
123
+ },
114
124
  "source": {
115
125
  "type": "object",
116
126
  "required": ["kind", "path"],
@@ -0,0 +1,298 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+
7
+ const { createKdnaAssetReader } = require('../asset-reader.js');
8
+
9
+ const MIMETYPE_V1 = 'application/vnd.kdna.asset';
10
+ const MIMETYPE_V2 = 'application/vnd.aikdna.kdna+zip';
11
+
12
+ /**
13
+ * CanonicalAssetModel — the single internal representation of any KDNA asset,
14
+ * regardless of whether it came from a source directory, Container v1,
15
+ * or Canonical Distribution Container (Container v2 / SPEC 2.0).
16
+ *
17
+ * All downstream code (planLoad, loadAuthorized, decrypt, loadV1Unsafe)
18
+ * operates on this model. No consumer should check container format directly.
19
+ *
20
+ * @typedef {object} CanonicalAssetModel
21
+ * @property {'dir'|'file'} sourceKind — original input type
22
+ * @property {string} sourcePath — resolved absolute path
23
+ * @property {string} format — 'v1-dir' | 'v1-container' | 'v2-container'
24
+ * @property {string} mimetype — normalized mimetype
25
+ * @property {object} manifest — parsed kdna.json
26
+ * @property {Buffer} payloadRaw — raw payload.kdnab bytes (CBOR/JSON)
27
+ * @property {object|null} checksums — parsed checksums.json, or null
28
+ * @property {Buffer|null} assetDigest — SHA-256 of entire container (file only)
29
+ * @property {Buffer|null} containerBuffer — full container bytes (for ZIP inputs, may be null for dirs)
30
+ * @property {Map<string, Buffer>|null} entries — raw entry map (for v1 dir layout; null for v2/container)
31
+ */
32
+
33
+ /**
34
+ * Read any KDNA asset into a CanonicalAssetModel.
35
+ *
36
+ * Handles:
37
+ * - Source directories (mimetype + kdna.json + payload.kdnab on disk)
38
+ * - Container v1 (.kdna ZIP with application/vnd.kdna.asset mimetype)
39
+ * - Canonical containers (.kdna ZIP with application/vnd.aikdna.kdna+zip mimetype)
40
+ *
41
+ * @param {string} inputPath - absolute path to asset file or directory
42
+ * @param {object} [opts]
43
+ * @param {object} [opts.limits] - container size/entry limits
44
+ * @returns {CanonicalAssetModel}
45
+ */
46
+ function readAsset(inputPath, opts = {}) {
47
+ const absPath = path.resolve(inputPath);
48
+ const stat = fs.statSync(absPath);
49
+
50
+ if (stat.isDirectory()) {
51
+ return readSourceDirectory(absPath, opts);
52
+ }
53
+
54
+ if (!stat.isFile()) {
55
+ throw Object.assign(
56
+ new Error(`Not a file or directory: ${absPath}`),
57
+ { code: 'KDNA_PATH_NOT_FOUND' },
58
+ );
59
+ }
60
+
61
+ const format = detectFormat(absPath);
62
+ if (format === 'v1-container') {
63
+ return readV1Container(absPath, opts);
64
+ }
65
+ if (format === 'v2-container') {
66
+ return readV2Container(absPath, opts);
67
+ }
68
+
69
+ throw Object.assign(
70
+ new Error(`Unknown KDNA container format: ${absPath}`),
71
+ { code: 'KDNA_FORMAT_UNKNOWN' },
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Detect the container format of a .kdna file.
77
+ * Returns 'v1-container', 'v2-container', or throws.
78
+ */
79
+ function detectFormat(absPath) {
80
+ const buf = fs.readFileSync(absPath);
81
+ if (buf.length < 4) {
82
+ throw Object.assign(
83
+ new Error(`File too small to be a KDNA container: ${absPath}`),
84
+ { code: 'KDNA_FORMAT_INVALID' },
85
+ );
86
+ }
87
+
88
+ // Must be a ZIP (PK signature)
89
+ const sig = buf.readUInt32LE(0);
90
+ if (sig !== 0x04034b50) {
91
+ throw Object.assign(
92
+ new Error(`Not a valid ZIP/KDNA container: ${absPath}`),
93
+ { code: 'KDNA_FORMAT_INVALID' },
94
+ );
95
+ }
96
+
97
+ // Read the mimetype from the first entry (central directory first entry)
98
+ const eocdOffset = findEocd(buf);
99
+ let cdOffset = buf.readUInt32LE(eocdOffset + 16);
100
+ const cdEntryCount = buf.readUInt16LE(eocdOffset + 10);
101
+
102
+ for (let i = 0; i < cdEntryCount; i++) {
103
+ const entryOffset = cdOffset;
104
+ const nameLen = buf.readUInt16LE(entryOffset + 28);
105
+ const extraLen = buf.readUInt16LE(entryOffset + 30);
106
+ const commentLen = buf.readUInt16LE(entryOffset + 32);
107
+ const name = buf.toString('utf8', entryOffset + 46, entryOffset + 46 + nameLen);
108
+
109
+ if (name !== 'mimetype') {
110
+ cdOffset = entryOffset + 46 + nameLen + extraLen + commentLen;
111
+ continue;
112
+ }
113
+
114
+ const method = buf.readUInt16LE(entryOffset + 10);
115
+ if (method !== 0) {
116
+ throw Object.assign(
117
+ new Error('mimetype entry must be stored (no compression)'),
118
+ { code: 'KDNA_FORMAT_INVALID' },
119
+ );
120
+ }
121
+
122
+ const localOffset = buf.readUInt32LE(entryOffset + 42);
123
+ const localNameLen = buf.readUInt16LE(localOffset + 26);
124
+ const localExtraLen = buf.readUInt16LE(localOffset + 28);
125
+ const dataOffset = localOffset + 30 + localNameLen + localExtraLen;
126
+ const compressedSize = buf.readUInt32LE(entryOffset + 20);
127
+ const mime = buf.toString('utf8', dataOffset, dataOffset + compressedSize).trim();
128
+
129
+ if (mime === MIMETYPE_V1) return 'v1-container';
130
+ if (mime === MIMETYPE_V2) return 'v2-container';
131
+
132
+ throw Object.assign(
133
+ new Error(`Unknown KDNA mimetype: ${mime}`),
134
+ { code: 'KDNA_FORMAT_UNKNOWN' },
135
+ );
136
+ }
137
+
138
+ throw Object.assign(
139
+ new Error('No mimetype entry found in KDNA container'),
140
+ { code: 'KDNA_FORMAT_INVALID' },
141
+ );
142
+ }
143
+
144
+ function findEocd(buf) {
145
+ const maxSearch = Math.min(buf.length, 65557);
146
+ for (let i = buf.length - 22; i >= buf.length - maxSearch && i >= 0; i--) {
147
+ if (buf.readUInt32LE(i) === 0x06054b50) {
148
+ return i;
149
+ }
150
+ }
151
+ throw Object.assign(
152
+ new Error('EOCD record not found in ZIP container'),
153
+ { code: 'KDNA_FORMAT_INVALID' },
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Read a source directory into CanonicalAssetModel.
159
+ */
160
+ function readSourceDirectory(absPath, _opts) {
161
+ const mimetypePath = path.join(absPath, 'mimetype');
162
+ const manifestPath = path.join(absPath, 'kdna.json');
163
+ const payloadPath = path.join(absPath, 'payload.kdnab');
164
+ const checksumsPath = path.join(absPath, 'checksums.json');
165
+
166
+ if (!fs.existsSync(mimetypePath) || !fs.existsSync(manifestPath) || !fs.existsSync(payloadPath)) {
167
+ throw Object.assign(
168
+ new Error(`Source directory missing required entries: ${absPath}`),
169
+ { code: 'KDNA_FORMAT_INVALID' },
170
+ );
171
+ }
172
+
173
+ const mimetype = fs.readFileSync(mimetypePath, 'utf8').trim();
174
+ if (![MIMETYPE_V1, MIMETYPE_V2].includes(mimetype)) {
175
+ throw Object.assign(
176
+ new Error(`Unknown mimetype in source directory: ${mimetype}`),
177
+ { code: 'KDNA_FORMAT_UNKNOWN' },
178
+ );
179
+ }
180
+
181
+ let manifest;
182
+ try {
183
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
184
+ } catch (e) {
185
+ throw Object.assign(
186
+ new Error(`Invalid kdna.json in source directory: ${e.message}`),
187
+ { code: 'KDNA_MANIFEST_PARSE_ERROR' },
188
+ );
189
+ }
190
+
191
+ const payloadRaw = fs.readFileSync(payloadPath);
192
+ const checksums = fs.existsSync(checksumsPath)
193
+ ? JSON.parse(fs.readFileSync(checksumsPath, 'utf8'))
194
+ : null;
195
+
196
+ const entries = new Map();
197
+ for (const entryName of fs.readdirSync(absPath)) {
198
+ const entryPath = path.join(absPath, entryName);
199
+ if (fs.statSync(entryPath).isFile()) {
200
+ entries.set(entryName, fs.readFileSync(entryPath));
201
+ }
202
+ }
203
+
204
+ return {
205
+ sourceKind: 'dir',
206
+ sourcePath: absPath,
207
+ format: 'v1-dir',
208
+ mimetype,
209
+ manifest,
210
+ payloadRaw,
211
+ checksums,
212
+ assetDigest: null,
213
+ containerBuffer: null,
214
+ entries,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Read a Container v1 (.kdna with v1 mimetype) into CanonicalAssetModel.
220
+ */
221
+ function readV1Container(absPath, _opts) {
222
+ // Delegate to the existing v1 layout reader for validation
223
+ const v1Layout = require('../v1/index.js').readV1Layout(absPath);
224
+
225
+ const containerBuf = fs.readFileSync(absPath);
226
+ const assetDigest = crypto.createHash('sha256').update(containerBuf).digest();
227
+
228
+ return {
229
+ sourceKind: 'file',
230
+ sourcePath: absPath,
231
+ format: 'v1-container',
232
+ mimetype: MIMETYPE_V1,
233
+ manifest: v1Layout.manifest,
234
+ payloadRaw: v1Layout.map['payload.kdnab'] || null,
235
+ checksums: v1Layout.map['checksums.json']
236
+ ? (() => { try { return JSON.parse(v1Layout.map['checksums.json'].toString('utf8')); } catch { return null; } })()
237
+ : null,
238
+ assetDigest,
239
+ containerBuffer: containerBuf,
240
+ entries: new Map(Object.entries(v1Layout.map)),
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Read a Canonical Container (v2 mimetype) into CanonicalAssetModel.
246
+ * Uses the existing KdnaAssetReader for ZIP parsing.
247
+ */
248
+ function readV2Container(absPath, _opts) {
249
+ const reader = createKdnaAssetReader();
250
+ const asset = reader.openSync(absPath);
251
+
252
+ const manifest = reader.readManifestSync(asset);
253
+ const payloadRaw = reader.readEntrySync(asset, 'payload.kdnab');
254
+ const checksums = (() => {
255
+ try {
256
+ const raw = reader.readEntrySync(asset, 'checksums.json');
257
+ return raw ? JSON.parse(raw.toString('utf8')) : null;
258
+ } catch {
259
+ return null;
260
+ }
261
+ })();
262
+
263
+ return {
264
+ sourceKind: 'file',
265
+ sourcePath: absPath,
266
+ format: 'v2-container',
267
+ mimetype: MIMETYPE_V2,
268
+ manifest,
269
+ payloadRaw,
270
+ checksums,
271
+ assetDigest: Buffer.from(asset.asset_digest.replace(/^sha256:/, ''), 'hex'),
272
+ containerBuffer: fs.readFileSync(absPath),
273
+ entries: null, // v2 uses lazy reads, not pre-loaded map
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Extract the mimetype from a CanonicalAssetModel.
279
+ */
280
+ function getMimetype(asset) {
281
+ return asset.mimetype;
282
+ }
283
+
284
+ /**
285
+ * Determine if a CanonicalAssetModel represents a source directory.
286
+ */
287
+ function isSourceDirectory(asset) {
288
+ return asset.sourceKind === 'dir';
289
+ }
290
+
291
+ module.exports = {
292
+ MIMETYPE_V1,
293
+ MIMETYPE_V2,
294
+ readAsset,
295
+ detectFormat,
296
+ getMimetype,
297
+ isSourceDirectory,
298
+ };
package/src/v1/index.js CHANGED
@@ -39,6 +39,14 @@ const path = require('node:path');
39
39
  const zlib = require('node:zlib');
40
40
  const crypto = require('node:crypto');
41
41
 
42
+ const { readAsset } = (() => {
43
+ try {
44
+ return require('../container-dispatcher.js');
45
+ } catch {
46
+ return { readAsset: null, MIMETYPE_V1: null, MIMETYPE_V2: null };
47
+ }
48
+ })();
49
+
42
50
  const MIMETYPE_V1 = 'application/vnd.kdna.asset';
43
51
  const MIMETYPE_V2 = 'application/vnd.aikdna.kdna+zip';
44
52
  const V1_REQUIRED_DIR_ENTRIES = ['mimetype', 'kdna.json', 'payload.kdnab'];
@@ -682,6 +690,7 @@ function runValidate(v1) {
682
690
  }
683
691
 
684
692
  // payload gate — payload.kdnab against payload-profile-v1.schema.json
693
+ // For actually encrypted payloads (envelopes), skip schema validation.
685
694
  let payload;
686
695
  try {
687
696
  payload = parseJsonEntry('payload.kdnab', v1.map['payload.kdnab']);
@@ -690,7 +699,26 @@ function runValidate(v1) {
690
699
  problems.push(`payload: not valid JSON (${e.message})`);
691
700
  return finalizeValidate(result, problems);
692
701
  }
693
- if (!validators.payload(payload)) {
702
+
703
+ const isEncryptedPayload = payload.profile
704
+ && payload.ciphertext
705
+ && (v1.manifest.payload?.encrypted || v1.manifest.encryption?.encrypted_entries?.includes('payload.kdnab'));
706
+
707
+ if (isEncryptedPayload) {
708
+ // Encrypted payload — verify it's a proper encryption envelope, not plaintext.
709
+ const encProfile = v1.manifest.encryption?.profile;
710
+ if (encProfile && payload.profile !== encProfile) {
711
+ result.payload_valid = false;
712
+ problems.push(`payload: encrypted envelope profile ${payload.profile || 'unknown'} does not match manifest encryption profile ${encProfile}`);
713
+ }
714
+ const hasKeyMaterial = !!payload.wrapped_key || (Array.isArray(payload.key_slots) && payload.key_slots.length > 0);
715
+ if (!payload.profile || !payload.ciphertext || !hasKeyMaterial) {
716
+ result.payload_valid = false;
717
+ problems.push('payload: encrypted envelope missing required fields (profile/ciphertext/key material)');
718
+ }
719
+ // Encrypted payload passes payload gate if envelope is structurally valid.
720
+ } else if (!validators.payload(payload)) {
721
+ // Plaintext payload — full schema validation.
694
722
  result.payload_valid = false;
695
723
  for (const err of validators.payload.errors) {
696
724
  problems.push(`payload: ${err.instancePath || '<root>'} ${err.message}`);
@@ -1089,6 +1117,23 @@ function baseLoadPlan(inputPath, v1, validation, opts = {}) {
1089
1117
  return plan;
1090
1118
  }
1091
1119
 
1120
+ // ─── B1: Unified container model → legacy v1 layout ─────────────────────
1121
+
1122
+ function canonicalToV1Layout(asset) {
1123
+ const map = {};
1124
+ if (asset.entries) {
1125
+ for (const [name, buf] of asset.entries) {
1126
+ map[name] = buf;
1127
+ }
1128
+ }
1129
+ return {
1130
+ kind: asset.sourceKind,
1131
+ map,
1132
+ manifest: asset.manifest,
1133
+ entries: Object.keys(map),
1134
+ };
1135
+ }
1136
+
1092
1137
  /**
1093
1138
  * Plan a KDNA v1 runtime load without decrypting or emitting judgment content.
1094
1139
  * Product consumers such as Chat should render authorization UI from this
@@ -1096,42 +1141,49 @@ function baseLoadPlan(inputPath, v1, validation, opts = {}) {
1096
1141
  */
1097
1142
  function planLoad(inputPath, opts = {}) {
1098
1143
  let v1;
1099
- try {
1100
- v1 = readV1Layout(path.resolve(inputPath));
1101
- } catch (e) {
1102
- return finalizeLoadPlan({
1103
- kdna_version: null,
1104
- asset: {
1105
- asset_id: null,
1106
- asset_uid: null,
1107
- title: null,
1108
- version: null,
1109
- judgment_version: null,
1110
- },
1111
- access: null,
1112
- access_alias: null,
1113
- entitlement_profile: null,
1114
- state: 'invalid',
1115
- required_action: 'block',
1116
- can_load_now: false,
1117
- projection_policy: 'none',
1118
- input_fingerprint: null,
1119
- checks: {
1120
- format_valid: false,
1121
- schema_valid: false,
1122
- payload_valid: false,
1123
- checksums_valid: false,
1124
- load_contract_valid: false,
1125
- overall_valid: false,
1126
- },
1127
- issues: [
1128
- buildLoadPlanIssue('KDNA_FORMAT_INVALID', 'blocking', e.message),
1129
- ],
1130
- source: {
1131
- kind: null,
1132
- path: path.resolve(inputPath),
1133
- },
1134
- });
1144
+
1145
+ // Try the unified container dispatcher first (handles v1 dirs, v1 containers, v2 containers)
1146
+ if (readAsset !== null) {
1147
+ try {
1148
+ const asset = readAsset(path.resolve(inputPath));
1149
+ v1 = canonicalToV1Layout(asset);
1150
+ } catch (e) {
1151
+ // If dispatcher fails, fall back to legacy readV1Layout for backward compat
1152
+ if (e.code === 'KDNA_FORMAT_UNKNOWN' || e.code === 'KDNA_PATH_NOT_FOUND') {
1153
+ // let the legacy path handle it
1154
+ } else {
1155
+ return finalizeLoadPlan({
1156
+ kdna_version: null,
1157
+ asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
1158
+ access: null, access_alias: null, entitlement_profile: null,
1159
+ state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
1160
+ input_fingerprint: null,
1161
+ checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
1162
+ issues: [buildLoadPlanIssue(
1163
+ e.code === 'KDNA_FORMAT_UNKNOWN' ? 'KDNA_FORMAT_UNKNOWN' : 'KDNA_FORMAT_INVALID',
1164
+ 'blocking', e.message,
1165
+ )],
1166
+ source: { kind: null, path: path.resolve(inputPath) },
1167
+ });
1168
+ }
1169
+ }
1170
+ }
1171
+
1172
+ if (!v1) {
1173
+ try {
1174
+ v1 = readV1Layout(path.resolve(inputPath));
1175
+ } catch (e) {
1176
+ return finalizeLoadPlan({
1177
+ kdna_version: null,
1178
+ asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
1179
+ access: null, access_alias: null, entitlement_profile: null,
1180
+ state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
1181
+ input_fingerprint: null,
1182
+ checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
1183
+ issues: [buildLoadPlanIssue('KDNA_FORMAT_INVALID', 'blocking', e.message)],
1184
+ source: { kind: null, path: path.resolve(inputPath) },
1185
+ });
1186
+ }
1135
1187
  }
1136
1188
 
1137
1189
  const validation = runValidate(v1);
@@ -1510,11 +1562,12 @@ function normalizeCompactAxiom(axiom) {
1510
1562
  if (!axiom || typeof axiom !== 'object') return null;
1511
1563
  const statement = axiom.statement || axiom.one_sentence || axiom.full_statement || axiom.id || null;
1512
1564
  if (!statement) return null;
1565
+ const oneSentence = (axiom.one_sentence && !String(axiom.one_sentence).startsWith('<TBD')) ? axiom.one_sentence : (typeof axiom.full_statement === 'string' && axiom.full_statement.length > 0 ? axiom.full_statement.substring(0, 120) + (axiom.full_statement.length > 120 ? '…' : '') : statement);
1513
1566
  return {
1514
1567
  type: 'axiom_applicability',
1515
1568
  id: axiom.id || null,
1516
1569
  statement,
1517
- one_sentence: axiom.one_sentence || statement,
1570
+ one_sentence: oneSentence,
1518
1571
  applies_when: normalizeTextList(axiom.applies_when),
1519
1572
  does_not_apply_when: normalizeTextList(axiom.does_not_apply_when),
1520
1573
  failure_risk: axiom.failure_risk || null,
@@ -1529,15 +1582,53 @@ function loadV1Unsafe(inputPath, opts = {}) {
1529
1582
 
1530
1583
  let payload;
1531
1584
  try {
1532
- payload = parseJsonEntry('payload.kdnab', v1.map['payload.kdnab']);
1585
+ const rawPayload = v1.map['payload.kdnab'];
1586
+ payload = parseJsonEntry('payload.kdnab', rawPayload);
1533
1587
  } catch (e) {
1534
1588
  throw new Error(`payload.kdnab is not valid JSON: ${e.message}`);
1535
1589
  }
1536
1590
 
1537
- if (payload.encrypted === true || (v1.map.mimetype && v1.map.mimetype.includes('encrypted'))) {
1538
- const err = new Error('payload is encrypted and cannot be decrypted without a key');
1539
- err.code = 'requires_decryption';
1540
- throw err;
1591
+ if (payload.profile && payload.ciphertext && (m.payload?.encrypted || m.encryption?.encrypted_entries?.includes('payload.kdnab'))) {
1592
+ // B4: Attempt decryption when password or decrypt hook is available.
1593
+ if (opts.decryptEntry) {
1594
+ // Consumer-provided decrypt hook (e.g., licensed entry key)
1595
+ try {
1596
+ const decrypted = opts.decryptEntry({
1597
+ asset: { entries: v1.map, manifest: m },
1598
+ manifest: m,
1599
+ entryName: 'payload.kdnab',
1600
+ ciphertext: v1.map['payload.kdnab'],
1601
+ });
1602
+ const decryptedBuf = typeof decrypted === 'string'
1603
+ ? Buffer.from(decrypted, 'utf8')
1604
+ : Buffer.from(decrypted);
1605
+ payload = parseJsonEntry('payload.kdnab', decryptedBuf);
1606
+ } catch (e) {
1607
+ const err = new Error(`Decryption failed: ${e.message}`);
1608
+ err.code = 'KDNA_DECRYPT_FAILED';
1609
+ throw err;
1610
+ }
1611
+ } else if (opts.password) {
1612
+ // Password-based decryption via kdna-password-protected-v1 profile
1613
+ try {
1614
+ const { decryptProtectedEntry } = require('../crypto-profile.js');
1615
+ const encryptedEnvelope = v1.map['payload.kdnab'].toString('utf8');
1616
+ const decryptedBuf = decryptProtectedEntry(encryptedEnvelope, {
1617
+ entryName: 'payload.kdnab',
1618
+ manifest: m,
1619
+ password: opts.password,
1620
+ });
1621
+ payload = parseJsonEntry('payload.kdnab', decryptedBuf);
1622
+ } catch (e) {
1623
+ const err = new Error(`Decryption failed with provided password: ${e.message}`);
1624
+ err.code = 'KDNA_DECRYPT_FAILED';
1625
+ throw err;
1626
+ }
1627
+ } else {
1628
+ const err = new Error('payload is encrypted and cannot be decrypted without a password or license key');
1629
+ err.code = 'KDNA_AUTH_PASSWORD_REQUIRED';
1630
+ throw err;
1631
+ }
1541
1632
  }
1542
1633
 
1543
1634
  // Digest verification — refuse to load if checksums.json is present and digests mismatch.
@@ -1585,26 +1676,31 @@ function loadV1Unsafe(inputPath, opts = {}) {
1585
1676
  };
1586
1677
 
1587
1678
  if (profile === 'index') {
1588
- 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 || {}) : [] };
1679
+ let maxTokensHint = undefined;
1680
+ if (m.load_contract && m.load_contract.profiles && m.load_contract.profiles.compact && m.load_contract.profiles.compact.max_tokens_hint) {
1681
+ maxTokensHint = m.load_contract.profiles.compact.max_tokens_hint;
1682
+ }
1683
+ 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 };
1589
1684
  } else if (profile === 'compact') {
1590
1685
  const core = payload.core || {};
1686
+ const normalizeList = (items) => (items || []).map((item) => {
1687
+ if (typeof item === 'string') return { type: 'text', text: item };
1688
+ if (item && typeof item === 'object') return item;
1689
+ return null;
1690
+ }).filter(Boolean);
1591
1691
  result.content = {
1592
1692
  highest_question: core.highest_question || null,
1593
1693
  axioms: (core.axioms || []).map(normalizeCompactAxiom).filter(Boolean),
1594
- boundaries: core.boundaries || [],
1595
- self_checks: (payload.reasoning && payload.reasoning.self_checks) || [],
1596
- failure_modes: (payload.reasoning && payload.reasoning.failure_modes) || [],
1597
- patterns: (payload.patterns || []).slice(0, 3),
1694
+ boundaries: normalizeList(core.boundaries),
1695
+ self_checks: normalizeList(payload.reasoning && payload.reasoning.self_checks),
1696
+ failure_modes: normalizeList(payload.reasoning && payload.reasoning.failure_modes),
1697
+ patterns: normalizeList(payload.patterns).slice(0, 3),
1598
1698
  };
1599
1699
  if (m.load_contract && m.load_contract.profiles && m.load_contract.profiles.compact && m.load_contract.profiles.compact.max_tokens_hint) {
1600
1700
  result.max_tokens_hint = m.load_contract.profiles.compact.max_tokens_hint;
1601
1701
  }
1602
1702
  } else if (profile === 'scenario') {
1603
- if (payload.scenarios && payload.scenarios.length > 0) {
1604
- result.content = { scenarios: payload.scenarios };
1605
- } else {
1606
- result.content = null;
1607
- }
1703
+ result.content = { scenarios: payload.scenarios || [] };
1608
1704
  } else if (profile === 'full') {
1609
1705
  result.content = { manifest: m, payload };
1610
1706
  } else {
@@ -1613,13 +1709,13 @@ function loadV1Unsafe(inputPath, opts = {}) {
1613
1709
 
1614
1710
  if (as === 'prompt') {
1615
1711
  const c = result.content;
1616
- if (c === null) {
1712
+ if (!c || (c.scenarios && c.scenarios.length === 0 && !c.highest_question && !(c.axioms && c.axioms.length) && !(c.boundaries && c.boundaries.length))) {
1617
1713
  return {
1618
1714
  status: result.status,
1619
1715
  profile: result.profile,
1620
- profile_available: false,
1716
+ profile_available: result.profile_available,
1621
1717
  available_profiles: result.available_profiles,
1622
- text: `KDNA Judgment Asset: ${result.title || 'untitled'}\nAsset ID: ${result.asset_id || 'unknown'}\nProfile: ${result.profile}\n\nProfile "${result.profile}" is not available for this asset. Available profiles: ${result.available_profiles.join(', ')}`,
1718
+ text: `KDNA Judgment Asset: ${result.title || 'untitled'}\nAsset ID: ${result.asset_id || 'unknown'}\nProfile: ${result.profile}\n\nNo content available for this profile. Available profiles: ${result.available_profiles.join(', ')}`,
1623
1719
  };
1624
1720
  }
1625
1721
  let text = 'KDNA Judgment Asset: ' + (result.title || 'untitled') + '\n';