@aikdna/kdna-studio-core 1.3.3 → 1.4.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-core",
3
- "version": "1.3.3",
3
+ "version": "1.4.1",
4
4
  "description": "Studio-compatible authoring kernel for Human Lock, compile, and export of trusted .kdna assets.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.js",
@@ -66,12 +66,24 @@ function lockCard(card, lockPayload) {
66
66
  if (!lockPayload.checked?.does_not_apply_when) throw new Error('Must confirm does_not_apply_when reviewed');
67
67
  if (!lockPayload.checked?.failure_risk) throw new Error('Must confirm failure_risk reviewed');
68
68
 
69
+ // Schema gate: axiom cards MUST have full_statement and why per KDNA SPEC
70
+ if (card.type === 'axiom') {
71
+ if (!card.fields?.full_statement || card.fields.full_statement.length < 20) {
72
+ throw new Error(`Axiom ${card.id} cannot be locked: missing or too-short full_statement. SPEC requires a complete, testable explanation.`);
73
+ }
74
+ if (!card.fields?.why || card.fields.why.length < 20) {
75
+ throw new Error(`Axiom ${card.id} cannot be locked: missing or too-short why. SPEC requires an explanation of failure mode.`);
76
+ }
77
+ }
78
+
69
79
  const lockedCard = { ...card, fields: { ...card.fields } };
70
80
  lockedCard.human_lock = {
71
81
  by: lockPayload.by,
72
82
  at: new Date().toISOString(),
73
83
  statement: lockPayload.statement,
74
84
  checked: lockPayload.checked,
85
+ creator_id: lockPayload.creator_id || null,
86
+ signature: lockPayload.signature || null,
75
87
  judgment_fingerprint: cardJudgmentFingerprint(lockedCard),
76
88
  };
77
89
 
@@ -106,6 +118,7 @@ module.exports = {
106
118
  unlockCard,
107
119
  getLockedCards,
108
120
  getPublishableCards,
121
+ cardJudgmentFingerprint,
109
122
  // Feynman restatement (re-exported from feynman.js)
110
123
  createFeynmanRestatement: require('./feynman').createFeynmanRestatement,
111
124
  evaluateRestatementQuality: require('./feynman').evaluateRestatementQuality,
@@ -34,25 +34,38 @@ function stableStringify(value) {
34
34
  return JSON.stringify(value);
35
35
  }
36
36
 
37
- function canonicalEntryBytes(fileName, content) {
38
- if (fileName.endsWith('.json')) {
39
- try {
40
- return stableStringify(JSON.parse(content));
41
- } catch (_) {
42
- return content;
43
- }
37
+ function canonicalizeJson(name, content) {
38
+ const obj = JSON.parse(content);
39
+ if (name === 'kdna.json') {
40
+ const copy = { ...obj };
41
+ delete copy.signature;
42
+ delete copy.asset_digest;
43
+ delete copy.container_sha256;
44
+ delete copy.content_digest;
45
+ return stableStringify(copy);
44
46
  }
45
- return content;
47
+ return stableStringify(obj);
46
48
  }
47
49
 
48
50
  function computeContentDigest(files) {
49
- const excluded = new Set(['kdna.json', 'signature.json', '.DS_Store']);
51
+ // Content digest covers canonical judgment content + public asset metadata.
52
+ // Reports and build-receipt are build evidence, not content — they change with
53
+ // every build and would cause self-referencing if included.
54
+ const excluded = new Set(['signature.json', '.DS_Store', 'build-receipt.json']);
50
55
  const payload = Object.keys(files)
51
56
  .filter(name => !excluded.has(name))
52
- .filter(name => !name.startsWith('reports/') && name !== 'build-receipt.json')
57
+ .filter(name => !name.startsWith('reports/'))
53
58
  .sort()
54
- .map(name => `${name}\n${canonicalEntryBytes(name, files[name])}`)
55
- .join('\n---entry---\n');
59
+ .map(name => {
60
+ let content = files[name];
61
+ if (name === 'mimetype') content = 'application/vnd.aikdna.kdna+zip';
62
+ const buf = name.endsWith('.json')
63
+ ? Buffer.from(canonicalizeJson(name, content))
64
+ : Buffer.from(content);
65
+ const hash = crypto.createHash('sha256').update(buf).digest('hex');
66
+ return `${name}:${hash}`;
67
+ })
68
+ .join('\n');
56
69
  return `sha256:${crypto.createHash('sha256').update(payload).digest('hex')}`;
57
70
  }
58
71
 
@@ -241,12 +254,19 @@ function compileManifest(project, files, identity = null) {
241
254
  license: project.license || { type: 'CC-BY-4.0' },
242
255
  description: project.release?.description || project.name,
243
256
  file_count: kdnaFileCount,
257
+ creator: project.creator_identity ? {
258
+ creator_id: project.creator_identity.creator_id,
259
+ display_name: project.creator_identity.display_name,
260
+ public_key: project.creator_identity.public_key,
261
+ verified: project.creator_identity.verified || false,
262
+ } : null,
244
263
  authoring: {
245
264
  created_by: 'kdna-studio-sdk',
246
265
  authoring_tool: 'KDNA Studio Core',
247
266
  authoring_tool_version: version,
248
267
  compiler: '@aikdna/kdna-studio-core',
249
268
  compiler_version: version,
269
+ source_mode: project.source_mode || 'blank',
250
270
  asset_uid: assetIdentity.asset_uid,
251
271
  project_uid: assetIdentity.project_uid,
252
272
  build_id: assetIdentity.build_id,
@@ -259,6 +279,7 @@ function compileManifest(project, files, identity = null) {
259
279
  human_confirmed: lockedCards.length > 0,
260
280
  compiled_at: assetIdentity.compiled_at,
261
281
  },
282
+ lineage: project.lineage || { type: 'original' },
262
283
  created: project.created || new Date().toISOString().slice(0, 10),
263
284
  updated: project.updated || new Date().toISOString().slice(0, 10),
264
285
  };
@@ -395,9 +416,10 @@ function compileDomain(project) {
395
416
  if (cases) files['KDNA_Cases.json'] = JSON.stringify(cases, null, 2);
396
417
  if (reasoning) files['KDNA_Reasoning.json'] = JSON.stringify(reasoning, null, 2);
397
418
  if (evolution) files['KDNA_Evolution.json'] = JSON.stringify(evolution, null, 2);
398
- const identity = buildAssetIdentity(project, files);
399
419
 
400
420
  // ── KDNA Card (governance metadata) ─────────────────────────────
421
+ // Must be added BEFORE digest computation so it is included in content_digest.
422
+ const identity = buildAssetIdentity(project, files);
401
423
  const provenance = require('../provenance').buildProvenance(project, files, identity);
402
424
  const { generateKdnaCard } = require('../governance');
403
425
  const kdnaCard = generateKdnaCard(project, {}, provenance);
@@ -412,10 +434,14 @@ function compileDomain(project) {
412
434
  kdna_files: Object.keys(files).filter(f => f.startsWith('KDNA_')).length,
413
435
  total_files: Object.keys(files).length,
414
436
  };
415
- Object.assign(files, buildReports(project, files, identity, provenance, stats));
437
+
438
+ // Compute content_digest once with all files present, BEFORE building reports.
416
439
  identity.content_digest = computeContentDigest(files);
417
440
  provenance.content_digest = identity.content_digest;
418
441
  provenance.content_fingerprint = identity.content_digest;
442
+
443
+ // Now build reports/receipt — they will all see the same digest.
444
+ Object.assign(files, buildReports(project, files, identity, provenance, stats));
419
445
  files['reports/provenance-report.json'] = JSON.stringify(provenance, null, 2);
420
446
  files['kdna.json'] = JSON.stringify(compileManifest(project, files, identity), null, 2);
421
447
  stats.total_files = Object.keys(files).length;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Creator Identity — local Ed25519 keypair generation, loading, and signing.
3
+ *
4
+ * Creator IDs use the format: "kdna:creator:ed25519:<sha256-of-public-key>"
5
+ * Private keys are stored in ~/.kdna/identity/ by default.
6
+ *
7
+ * This is local-first — no registration, no upload, no cloud dependency.
8
+ * The private key is the root of creator identity; the public key fingerprint
9
+ * is the creator_id that appears in studio.project.json and exported .kdna assets.
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ const CREATOR_ID_PREFIX = 'kdna:creator:ed25519:';
18
+
19
+ function defaultIdentityDir() {
20
+ return process.env.KDNA_IDENTITY_DIR || path.join(os.homedir(), '.kdna', 'identity');
21
+ }
22
+
23
+ const PRIVATE_KEY_FILE = 'kdna.key';
24
+ const PUBLIC_KEY_FILE = 'kdna.pub';
25
+ const IDENTITY_JSON_FILE = 'creator.json';
26
+
27
+ /**
28
+ * Compute the creator_id fingerprint from an Ed25519 public key PEM.
29
+ */
30
+ function creatorFingerprint(publicKeyPem) {
31
+ const hash = crypto.createHash('sha256').update(publicKeyPem).digest('hex');
32
+ return `${CREATOR_ID_PREFIX}${hash}`;
33
+ }
34
+
35
+ /**
36
+ * Generate a new Ed25519 keypair and persist to identityDir.
37
+ * Returns the creator identity object. Does NOT overwrite existing keys.
38
+ */
39
+ function initIdentity(displayName, identityDir = null) {
40
+ const dir = identityDir || defaultIdentityDir();
41
+ fs.mkdirSync(dir, { recursive: true });
42
+
43
+ const privateKeyPath = path.join(dir, PRIVATE_KEY_FILE);
44
+ const publicKeyPath = path.join(dir, PUBLIC_KEY_FILE);
45
+ const identityJsonPath = path.join(dir, IDENTITY_JSON_FILE);
46
+
47
+ if (fs.existsSync(privateKeyPath) || fs.existsSync(publicKeyPath)) {
48
+ throw new Error(
49
+ `Identity keys already exist in ${dir}. Use loadIdentity() to access them, or remove the files to regenerate.`,
50
+ );
51
+ }
52
+
53
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
54
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
55
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
56
+ });
57
+
58
+ fs.writeFileSync(privateKeyPath, privateKey, { mode: 0o600 });
59
+ fs.writeFileSync(publicKeyPath, publicKey, { mode: 0o644 });
60
+
61
+ const creatorId = creatorFingerprint(publicKey);
62
+ const identity = {
63
+ creator_id: creatorId,
64
+ display_name: displayName || '',
65
+ public_key: publicKey,
66
+ public_key_path: publicKeyPath,
67
+ identity_dir: dir,
68
+ created_at: new Date().toISOString(),
69
+ verified: false,
70
+ };
71
+
72
+ fs.writeFileSync(identityJsonPath, JSON.stringify(identity, null, 2), { mode: 0o644 });
73
+
74
+ return identity;
75
+ }
76
+
77
+ /**
78
+ * Load an existing creator identity from disk.
79
+ * Returns null if no identity has been initialized.
80
+ */
81
+ function loadIdentity(identityDir = null) {
82
+ const dir = identityDir || defaultIdentityDir();
83
+ const identityJsonPath = path.join(dir, IDENTITY_JSON_FILE);
84
+ const publicKeyPath = path.join(dir, PUBLIC_KEY_FILE);
85
+
86
+ if (!fs.existsSync(identityJsonPath)) return null;
87
+
88
+ let identity;
89
+ try {
90
+ identity = JSON.parse(fs.readFileSync(identityJsonPath, 'utf8'));
91
+ } catch {
92
+ return null;
93
+ }
94
+
95
+ // Ensure public key is loaded (may have been deleted from JSON for security)
96
+ if (!identity.public_key && fs.existsSync(publicKeyPath)) {
97
+ identity.public_key = fs.readFileSync(publicKeyPath, 'utf8');
98
+ }
99
+
100
+ identity.identity_dir = dir;
101
+ identity.public_key_path = publicKeyPath;
102
+
103
+ return identity;
104
+ }
105
+
106
+ /**
107
+ * Sign a payload with the creator's Ed25519 private key.
108
+ * Returns the signature in "ed25519:<hex>" format.
109
+ */
110
+ function signPayload(payload, identityDir = null) {
111
+ const dir = identityDir || defaultIdentityDir();
112
+ const privateKeyPath = path.join(dir, PRIVATE_KEY_FILE);
113
+
114
+ if (!fs.existsSync(privateKeyPath)) {
115
+ throw new Error(
116
+ `No private key found at ${privateKeyPath}. Run identity init first.`,
117
+ );
118
+ }
119
+
120
+ const privateKeyPem = fs.readFileSync(privateKeyPath, 'utf8');
121
+ const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload));
122
+ const sig = crypto.sign(null, data, privateKeyPem);
123
+ return `ed25519:${sig.toString('hex')}`;
124
+ }
125
+
126
+ /**
127
+ * Sign a Human Lock payload — creates a deterministic signable string from
128
+ * the lock context and returns the signature.
129
+ *
130
+ * Payload: `${cardId}\n${statement}\n${judgmentFingerprint}`
131
+ */
132
+ function signHumanLock(cardId, statement, judgmentFingerprint, identityDir = null) {
133
+ const lockPayload = [cardId, statement, judgmentFingerprint].join('\n');
134
+ return signPayload(lockPayload, identityDir);
135
+ }
136
+
137
+ /**
138
+ * Get the public key PEM for the creator identity.
139
+ */
140
+ function loadPublicKey(identityDir = null) {
141
+ const dir = identityDir || defaultIdentityDir();
142
+ const publicKeyPath = path.join(dir, PUBLIC_KEY_FILE);
143
+ if (!fs.existsSync(publicKeyPath)) return null;
144
+ return fs.readFileSync(publicKeyPath, 'utf8');
145
+ }
146
+
147
+ /**
148
+ * Get the private key path. Used by consumers that need direct key access.
149
+ */
150
+ function privateKeyPath(identityDir = null) {
151
+ return path.join(identityDir || defaultIdentityDir(), PRIVATE_KEY_FILE);
152
+ }
153
+
154
+ module.exports = {
155
+ initIdentity,
156
+ loadIdentity,
157
+ signPayload,
158
+ signHumanLock,
159
+ creatorFingerprint,
160
+ loadPublicKey,
161
+ privateKeyPath,
162
+ defaultIdentityDir,
163
+ CREATOR_ID_PREFIX,
164
+ };
package/src/index.js CHANGED
@@ -19,6 +19,7 @@
19
19
 
20
20
  const cards = require('./cards');
21
21
  const compile = require('./compile');
22
+ const creatorIdentity = require('./creator-identity');
22
23
  const evidence = require('./evidence');
23
24
  const governance = require('./governance');
24
25
  const i18n = require('./i18n');
@@ -44,6 +45,7 @@ module.exports = {
44
45
  pipeline,
45
46
  governance,
46
47
  i18n,
48
+ creator: creatorIdentity,
47
49
 
48
50
  // Experimental
49
51
  evidence,
@@ -22,6 +22,10 @@ function createProject(name, type = 'domain', options = {}) {
22
22
  updated: new Date().toISOString().slice(0, 10),
23
23
  author: options.author || { name: '', id: '' },
24
24
  status: 'drafting',
25
+ source_mode: options.sourceMode || 'blank',
26
+ creator_identity: options.creatorIdentity || null,
27
+ lineage: options.lineage || null,
28
+ imported_source_folder: options.sourcePath || null,
25
29
  cards: [],
26
30
  evidence: [],
27
31
  tests: [],
@@ -32,6 +32,9 @@ function buildProvenance(project, compiledFiles, identity = {}) {
32
32
  domain_id: identity.domain_id || null,
33
33
  registry_name: identity.registry_name || project.name || null,
34
34
  author_id: project.author?.id || '',
35
+ creator_id: project.creator_identity?.creator_id || null,
36
+ source_mode: project.source_mode || 'blank',
37
+ lineage: project.lineage || { type: 'original' },
35
38
  locked_card_count: lockedCards.length,
36
39
  test_case_count: tests.length,
37
40
  built_at: identity.compiled_at || new Date().toISOString(),
@@ -30,6 +30,21 @@ function computeReadiness(project) {
30
30
  // ── Governance check (v0.6.1) ───────────────────────────────────
31
31
  const govResult = validateGovernance(project);
32
32
 
33
+ // ── Source mode trust checks (v1.4.0) ───────────────────────────
34
+ const sourceMode = project.source_mode || 'blank';
35
+ if (sourceMode === 'source_folder') {
36
+ blocking.push('source_folder: all imported cards must be re-locked — legacy trust is not inherited');
37
+ blocking.push('source_folder: schema audit required; verify all required fields before Human Lock');
38
+ }
39
+ if (sourceMode === 'kdna_asset') {
40
+ const hasRelevantLineage = project.lineage &&
41
+ (project.lineage.parent_name || project.lineage.parent_asset_uid);
42
+ if (!hasRelevantLineage) {
43
+ blocking.push('kdna_asset: lineage missing — must record parent KDNA identity');
44
+ }
45
+ warnings.push('kdna_asset: cards imported from existing KDNA must be re-locked; parent trust is not inherited');
46
+ }
47
+
33
48
  // ── I18N check (v1.2.0) ─────────────────────────────────────────
34
49
  const isOfficial = project.name?.startsWith('@aikdna/') || project.release?.official === true;
35
50
  const i18nCoverage = computeI18nCoverage(project);
@@ -77,6 +77,15 @@ function validateAxiom(card, issues) {
77
77
  issues.push({ type: 'too_short', severity: 'blocking', message: `${card.id}: one_sentence too short (${oneLiner.length} chars)`, fix: 'Make it a complete, specific judgment statement.' });
78
78
  }
79
79
 
80
+ // SPEC requirement: axiom MUST have full_statement and why
81
+ if (!card.fields?.full_statement || card.fields.full_statement.length < 20) {
82
+ issues.push({ type: 'missing_full_statement', severity: 'blocking', message: `${card.id}: full_statement missing or too short — SPEC requires a complete, testable explanation`, fix: 'Add a full_statement that is at least 20 characters and explains the principle in full.' });
83
+ }
84
+
85
+ if (!card.fields?.why || card.fields.why.length < 20) {
86
+ issues.push({ type: 'missing_why', severity: 'blocking', message: `${card.id}: why missing or too short — SPEC requires an explanation of what the agent would get wrong without this axiom`, fix: 'Add a why field that explains the failure mode this axiom prevents.' });
87
+ }
88
+
80
89
  // Check for dictionary-definition style (axiom should not start with "X is")
81
90
  if (/^\w+\s+is\s/.test(oneLiner) && oneLiner.length < 50) {
82
91
  issues.push({ type: 'definition_like', severity: 'warning', message: `${card.id}: one_sentence reads like a definition, not a judgment — rephrase as a principle` });