@aikdna/kdna-studio-core 1.3.3 → 1.4.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-studio-core",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
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,34 @@ 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
+ const excluded = new Set(['signature.json', '.DS_Store']);
50
52
  const payload = Object.keys(files)
51
53
  .filter(name => !excluded.has(name))
52
- .filter(name => !name.startsWith('reports/') && name !== 'build-receipt.json')
53
54
  .sort()
54
- .map(name => `${name}\n${canonicalEntryBytes(name, files[name])}`)
55
- .join('\n---entry---\n');
55
+ .map(name => {
56
+ let content = files[name];
57
+ if (name === 'mimetype') content = 'application/vnd.aikdna.kdna+zip';
58
+ const buf = name.endsWith('.json')
59
+ ? Buffer.from(canonicalizeJson(name, content))
60
+ : Buffer.from(content);
61
+ const hash = crypto.createHash('sha256').update(buf).digest('hex');
62
+ return `${name}:${hash}`;
63
+ })
64
+ .join('\n');
56
65
  return `sha256:${crypto.createHash('sha256').update(payload).digest('hex')}`;
57
66
  }
58
67
 
@@ -241,12 +250,19 @@ function compileManifest(project, files, identity = null) {
241
250
  license: project.license || { type: 'CC-BY-4.0' },
242
251
  description: project.release?.description || project.name,
243
252
  file_count: kdnaFileCount,
253
+ creator: project.creator_identity ? {
254
+ creator_id: project.creator_identity.creator_id,
255
+ display_name: project.creator_identity.display_name,
256
+ public_key: project.creator_identity.public_key,
257
+ verified: project.creator_identity.verified || false,
258
+ } : null,
244
259
  authoring: {
245
260
  created_by: 'kdna-studio-sdk',
246
261
  authoring_tool: 'KDNA Studio Core',
247
262
  authoring_tool_version: version,
248
263
  compiler: '@aikdna/kdna-studio-core',
249
264
  compiler_version: version,
265
+ source_mode: project.source_mode || 'blank',
250
266
  asset_uid: assetIdentity.asset_uid,
251
267
  project_uid: assetIdentity.project_uid,
252
268
  build_id: assetIdentity.build_id,
@@ -259,6 +275,7 @@ function compileManifest(project, files, identity = null) {
259
275
  human_confirmed: lockedCards.length > 0,
260
276
  compiled_at: assetIdentity.compiled_at,
261
277
  },
278
+ lineage: project.lineage || { type: 'original' },
262
279
  created: project.created || new Date().toISOString().slice(0, 10),
263
280
  updated: project.updated || new Date().toISOString().slice(0, 10),
264
281
  };
@@ -395,9 +412,10 @@ function compileDomain(project) {
395
412
  if (cases) files['KDNA_Cases.json'] = JSON.stringify(cases, null, 2);
396
413
  if (reasoning) files['KDNA_Reasoning.json'] = JSON.stringify(reasoning, null, 2);
397
414
  if (evolution) files['KDNA_Evolution.json'] = JSON.stringify(evolution, null, 2);
398
- const identity = buildAssetIdentity(project, files);
399
415
 
400
416
  // ── KDNA Card (governance metadata) ─────────────────────────────
417
+ // Must be added BEFORE digest computation so it is included in content_digest.
418
+ const identity = buildAssetIdentity(project, files);
401
419
  const provenance = require('../provenance').buildProvenance(project, files, identity);
402
420
  const { generateKdnaCard } = require('../governance');
403
421
  const kdnaCard = generateKdnaCard(project, {}, provenance);
@@ -412,10 +430,14 @@ function compileDomain(project) {
412
430
  kdna_files: Object.keys(files).filter(f => f.startsWith('KDNA_')).length,
413
431
  total_files: Object.keys(files).length,
414
432
  };
415
- Object.assign(files, buildReports(project, files, identity, provenance, stats));
433
+
434
+ // Compute content_digest once with all files present, BEFORE building reports.
416
435
  identity.content_digest = computeContentDigest(files);
417
436
  provenance.content_digest = identity.content_digest;
418
437
  provenance.content_fingerprint = identity.content_digest;
438
+
439
+ // Now build reports/receipt — they will all see the same digest.
440
+ Object.assign(files, buildReports(project, files, identity, provenance, stats));
419
441
  files['reports/provenance-report.json'] = JSON.stringify(provenance, null, 2);
420
442
  files['kdna.json'] = JSON.stringify(compileManifest(project, files, identity), null, 2);
421
443
  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` });