@aikdna/kdna-studio-core 1.4.2 → 1.5.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/README.md +54 -11
- package/package.json +3 -2
- package/src/cards/index.js +7 -2
- package/src/compile/index.js +86 -18
- package/src/compile/source-authority-gate.js +156 -0
- package/src/compile/truth-charter-gate.js +157 -0
- package/src/creator-identity.js +134 -11
- package/src/distillation/index.js +268 -0
- package/src/index.js +2 -0
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# KDNA Studio Core
|
|
2
2
|
|
|
3
|
-
**KDNA Studio turns
|
|
3
|
+
**KDNA Studio Core is the judgment asset refinery.** It turns scattered notes, documents, works, and feedback into loadable .kdna judgment assets — not by compressing content, but by distilling the stable judgment patterns embedded within it.
|
|
4
4
|
|
|
5
|
-
AI may propose judgment candidates. Humans must confirm judgment. Only human-locked judgment can compile into KDNA.
|
|
5
|
+
AI may propose judgment candidates from content analysis. Humans must confirm judgment. Only human-locked judgment can compile into KDNA.
|
|
6
6
|
|
|
7
|
-
Open-source Studio-compatible authoring kernel for creating trusted `.kdna` assets — JS/npm package.
|
|
7
|
+
Open-source Studio-compatible authoring kernel for creating trusted `.kdna` assets — JS/npm package. Supports two authoring paths: interview-first (expert self-expression) and distillation-first (pattern extraction from existing content). Both end with Human Judgment Lock.
|
|
8
8
|
|
|
9
9
|
**KDNA Studio Core is the JS authoring kernel.** It is not a UI tool and not a CLI package. It is a pure-logic engine for creating KDNA judgment cards, Human Locks, compiler output, and authoring provenance from JavaScript applications and Studio-compatible tools.
|
|
10
10
|
|
|
@@ -13,6 +13,11 @@ Studio-compatible authoring pipeline that performs human confirmation,
|
|
|
13
13
|
validation, canonicalization, identity generation, digest computation, signing,
|
|
14
14
|
optional encryption, and provenance recording.
|
|
15
15
|
|
|
16
|
+
**Hard boundary:** Optional encryption, when supported, MUST be represented as
|
|
17
|
+
protected entries inside the `.kdna` container (RFC-0008). App-private encrypted
|
|
18
|
+
envelopes or transfer wrappers that cannot be opened by KDNA Core are NOT
|
|
19
|
+
conforming KDNA runtime assets.
|
|
20
|
+
|
|
16
21
|
| Library | Language | Role |
|
|
17
22
|
|---------|----------|------|
|
|
18
23
|
| `@aikdna/kdna-cli` | JS/npm | **Operate** KDNA — install, verify, load, compare, publish |
|
|
@@ -24,6 +29,9 @@ optional encryption, and provenance recording.
|
|
|
24
29
|
|
|
25
30
|
- **Project Model** — `studio.project.json` with full metadata, provenance tracking
|
|
26
31
|
- **Evidence Room** — import raw material (text, markdown, interviews, cases)
|
|
32
|
+
- **Distillation Target** — declare domain category, owner scope, granularity, task scope, include/exclude areas, and load condition before extraction
|
|
33
|
+
- **Evidence Relevance** — classify source material as relevant, weakly relevant, out-of-scope, or split-domain before distillation
|
|
34
|
+
- **Scope Gate** — mark candidates with `scope_fit`, relevance score, and suggested split domain before they can become cards
|
|
27
35
|
- **Judgment Cards** — 8 card types: axiom, ontology, stance, framework, misunderstanding, self_check, banned_term, terminology
|
|
28
36
|
- **Human Lock** — AI proposes, human confirms. Only locked cards compile.
|
|
29
37
|
- **Authoring Provenance** — every compiled manifest records Studio-compatible
|
|
@@ -49,6 +57,15 @@ optional encryption, and provenance recording.
|
|
|
49
57
|
Evidence Room → Judgment Cards → Human Lock → Quality Gate → Compile → Validate → Export
|
|
50
58
|
```
|
|
51
59
|
|
|
60
|
+
For distillation-first authoring, the flow starts with an explicit target:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Declare Domain + Scope → Import Evidence → Classify Relevance → Distill Candidates
|
|
64
|
+
→ Scope Gate → Human Review → Promote to Cards → Human Lock → Compile → Export
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A single `.kdna` asset should stay scoped to one domain and loading condition. If a task needs several judgment domains, create multiple domain assets and compose them through a KDNA Cluster rather than making one broad file.
|
|
68
|
+
|
|
52
69
|
## Install
|
|
53
70
|
|
|
54
71
|
```bash
|
|
@@ -63,6 +80,17 @@ The command-line authoring entry is a separate package:
|
|
|
63
80
|
npm install -g @aikdna/kdna-studio-cli
|
|
64
81
|
kdna-studio create my_domain --name @yourscope/my_domain
|
|
65
82
|
kdna-studio import my_domain ./notes.md
|
|
83
|
+
kdna-studio target declare my_domain \
|
|
84
|
+
--category expression_writing \
|
|
85
|
+
--scope personal \
|
|
86
|
+
--granularity core_principles \
|
|
87
|
+
--task "longform article review" \
|
|
88
|
+
--include "argument structure,tone,revision" \
|
|
89
|
+
--exclude "life habits,food preference"
|
|
90
|
+
kdna-studio source classify my_domain
|
|
91
|
+
kdna-studio distill my_domain --candidates candidates.json
|
|
92
|
+
kdna-studio candidate accept my_domain <candidate-id>
|
|
93
|
+
kdna-studio candidate promote my_domain
|
|
66
94
|
kdna-studio card add my_domain axiom \
|
|
67
95
|
--field one_sentence="Judgment principle" \
|
|
68
96
|
--field full_statement="What the agent should do differently" \
|
|
@@ -79,15 +107,31 @@ kdna publish dist/my_domain.kdna
|
|
|
79
107
|
## Quick Start
|
|
80
108
|
|
|
81
109
|
```js
|
|
82
|
-
const {
|
|
110
|
+
const {
|
|
111
|
+
project: projectApi,
|
|
112
|
+
cards: cardApi,
|
|
113
|
+
distillation
|
|
114
|
+
} = require('@aikdna/kdna-studio-core');
|
|
83
115
|
|
|
84
116
|
// 1. Create a project
|
|
85
|
-
const project = createProject('writing_judgment', 'domain', {
|
|
117
|
+
const project = projectApi.createProject('writing_judgment', 'domain', {
|
|
86
118
|
author: { name: 'Writing Expert', id: 'writer_001' }
|
|
87
119
|
});
|
|
88
120
|
|
|
121
|
+
// Optional: declare a distillation target before extracting from evidence.
|
|
122
|
+
const target = distillation.createDistillationTarget({
|
|
123
|
+
domainName: 'writing_judgment',
|
|
124
|
+
domainCategory: 'expression_writing',
|
|
125
|
+
ownerScope: 'personal',
|
|
126
|
+
granularity: 'core_principles',
|
|
127
|
+
taskScope: 'longform article diagnosis and revision',
|
|
128
|
+
includeAreas: ['argument structure', 'reader framing', 'evidence density'],
|
|
129
|
+
excludeAreas: ['life habits', 'food preference']
|
|
130
|
+
});
|
|
131
|
+
project.distillation_target = target;
|
|
132
|
+
|
|
89
133
|
// 2. Create judgment cards
|
|
90
|
-
const card = createCard('axiom', {
|
|
134
|
+
const card = cardApi.createCard('axiom', {
|
|
91
135
|
one_sentence: 'Most writing problems are structural, not language-level.',
|
|
92
136
|
full_statement: 'When reviewing content, diagnose structure before language.',
|
|
93
137
|
why: 'Surface polishing on structurally weak content wastes effort.',
|
|
@@ -98,18 +142,17 @@ const card = createCard('axiom', {
|
|
|
98
142
|
project.cards.push(card);
|
|
99
143
|
|
|
100
144
|
// 3. Human Lock
|
|
101
|
-
const locked = lockCard(card, {
|
|
145
|
+
const locked = cardApi.lockCard(card, {
|
|
102
146
|
by: 'writer_001',
|
|
103
147
|
statement: 'This represents my professional writing judgment.',
|
|
104
148
|
checked: { applies_when: true, does_not_apply_when: true, failure_risk: true }
|
|
105
149
|
});
|
|
106
150
|
|
|
107
151
|
// 4. Check readiness
|
|
108
|
-
const
|
|
109
|
-
const gate = checkHumanLockGate(project);
|
|
152
|
+
const gate = projectApi.checkHumanLockGate(project);
|
|
110
153
|
if (!gate.blocked) {
|
|
111
154
|
// 5. Export
|
|
112
|
-
const json = exportProject(project);
|
|
155
|
+
const json = projectApi.exportProject(project);
|
|
113
156
|
console.log('Ready to publish');
|
|
114
157
|
}
|
|
115
158
|
```
|
|
@@ -162,7 +205,7 @@ KDNA Studio Core is open source. Official KDNA Studio App, hosted collaboration,
|
|
|
162
205
|
|
|
163
206
|
## Related
|
|
164
207
|
|
|
165
|
-
- [KDNA
|
|
208
|
+
- [KDNA Core](https://github.com/aikdna/kdna) — Official format
|
|
166
209
|
- [kdna-cli](https://github.com/aikdna/kdna-cli) — CLI tools
|
|
167
210
|
- [kdna-core-swift](https://github.com/aikdna/kdna-core-swift) — Swift runtime for macOS/iOS
|
|
168
211
|
- [aikdna.com](https://aikdna.com) — Website
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikdna/kdna-studio-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"validate"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@aikdna/kdna-core": "^0.
|
|
27
|
+
"@aikdna/kdna-core": "^0.9.1",
|
|
28
|
+
"cbor-x": "^1.6.4"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=18"
|
package/src/cards/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
const { cardJudgmentFingerprint } = require('../judgment-fields');
|
|
13
13
|
|
|
14
14
|
const VALID_STATES = ['draft', 'revised', 'locked', 'tested', 'published', 'deprecated'];
|
|
15
|
-
const CARD_TYPES = ['axiom', 'ontology', 'misunderstanding', 'boundary', 'self_check', 'risk', 'aesthetic', 'scenario', 'case'];
|
|
15
|
+
const CARD_TYPES = ['axiom', 'ontology', 'misunderstanding', 'boundary', 'self_check', 'risk', 'aesthetic', 'scenario', 'case', 'stance', 'framework', 'term', 'banned_term', 'reasoning', 'evolution_stage'];
|
|
16
16
|
|
|
17
17
|
const TRANSITIONS = {
|
|
18
18
|
draft: ['revised', 'deprecated'],
|
|
@@ -66,7 +66,7 @@ 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
|
|
69
|
+
// Schema gate per KDNA SPEC
|
|
70
70
|
if (card.type === 'axiom') {
|
|
71
71
|
if (!card.fields?.full_statement || card.fields.full_statement.length < 20) {
|
|
72
72
|
throw new Error(`Axiom ${card.id} cannot be locked: missing or too-short full_statement. SPEC requires a complete, testable explanation.`);
|
|
@@ -75,6 +75,11 @@ function lockCard(card, lockPayload) {
|
|
|
75
75
|
throw new Error(`Axiom ${card.id} cannot be locked: missing or too-short why. SPEC requires an explanation of failure mode.`);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
if (card.type === 'misunderstanding') {
|
|
79
|
+
if (!card.fields?.key_distinction || card.fields.key_distinction.length < 20) {
|
|
80
|
+
throw new Error(`Misunderstanding ${card.id} cannot be locked: missing or too-short key_distinction. SPEC requires a clear conceptual boundary.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
78
83
|
|
|
79
84
|
const lockedCard = { ...card, fields: { ...card.fields } };
|
|
80
85
|
lockedCard.human_lock = {
|
package/src/compile/index.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Compile locked cards into KDNA domain JSON files — SPEC-compatible output.
|
|
3
3
|
*
|
|
4
|
-
* KDNA
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* - KDNA_Scenarios.json: { meta, scenes[] }
|
|
9
|
-
* - KDNA_Reasoning.json: { meta, reasoning_chains[] }
|
|
10
|
-
* - KDNA_Evolution.json: { meta, stages[], capability_layers[], measurements[] }
|
|
4
|
+
* KDNA Container:
|
|
5
|
+
* - Judgment content is encoded as CBOR payload (payload.kdnab)
|
|
6
|
+
* - Individual KDNA_Core.json etc. are NOT exposed as ZIP entries
|
|
7
|
+
* - kdna.json manifest contains metadata only, no judgment content
|
|
11
8
|
*
|
|
12
9
|
* Only locked cards enter compilation. Draft/Revised excluded silently.
|
|
13
10
|
*/
|
|
11
|
+
|
|
12
|
+
const cbor = require('cbor-x');
|
|
14
13
|
const crypto = require('crypto');
|
|
15
14
|
|
|
16
15
|
function uuidv7() {
|
|
@@ -240,8 +239,8 @@ function compileManifest(project, files, identity = null) {
|
|
|
240
239
|
.digest('hex');
|
|
241
240
|
const manifest = {
|
|
242
241
|
format: 'kdna',
|
|
243
|
-
format_version: '
|
|
244
|
-
spec_version: '
|
|
242
|
+
format_version: '2.0',
|
|
243
|
+
spec_version: '2.0',
|
|
245
244
|
name: project.name,
|
|
246
245
|
domain_id: assetIdentity.domain_id,
|
|
247
246
|
asset_uid: assetIdentity.asset_uid,
|
|
@@ -259,6 +258,17 @@ function compileManifest(project, files, identity = null) {
|
|
|
259
258
|
license: project.license || { type: 'CC-BY-4.0' },
|
|
260
259
|
description: project.release?.description || project.name,
|
|
261
260
|
file_count: kdnaFileCount,
|
|
261
|
+
container: {
|
|
262
|
+
type: 'kdna-container-v2',
|
|
263
|
+
payload: 'payload.kdnab',
|
|
264
|
+
payload_encoding: 'cbor',
|
|
265
|
+
payload_schema: 'kdna-payload-v2',
|
|
266
|
+
payload_digest: `sha256:${crypto.createHash('sha256').update(files['payload.kdnab']).digest('hex')}`,
|
|
267
|
+
},
|
|
268
|
+
runtime: {
|
|
269
|
+
min_runtime_version: '0.3.0',
|
|
270
|
+
load_contract: 'context-capsule-v1',
|
|
271
|
+
},
|
|
262
272
|
creator: project.creator_identity ? {
|
|
263
273
|
creator_id: project.creator_identity.creator_id,
|
|
264
274
|
display_name: project.creator_identity.display_name,
|
|
@@ -405,8 +415,28 @@ function buildReports(project, files, identity, provenance, stats) {
|
|
|
405
415
|
};
|
|
406
416
|
}
|
|
407
417
|
|
|
408
|
-
function compileDomain(project) {
|
|
418
|
+
function compileDomain(project, options = {}) {
|
|
409
419
|
const cards = project.cards || [];
|
|
420
|
+
|
|
421
|
+
// ── Empty-domain gate (PR-2) ────────────────────────────────────
|
|
422
|
+
// A KDNA domain with no locked judgment content of any kind is not a
|
|
423
|
+
// domain — it is a content-shaped empty file. Refuse to compile so the
|
|
424
|
+
// downstream Registry / Lab / Studio export never advertises an empty
|
|
425
|
+
// judgment asset as "successfully compiled".
|
|
426
|
+
const lockedCards = cards.filter(c => c.locked);
|
|
427
|
+
const hasJudgmentContent = lockedCards.some(c =>
|
|
428
|
+
['axiom', 'misunderstanding', 'scenario', 'case', 'self_check', 'boundary', 'risk', 'ontology', 'aesthetic'].includes(c.type)
|
|
429
|
+
);
|
|
430
|
+
if (!hasJudgmentContent) {
|
|
431
|
+
const err = new Error(
|
|
432
|
+
'refusing to compile empty KDNA domain: no locked judgment content ' +
|
|
433
|
+
`(axiom / misunderstanding / scenario / case / self_check / boundary / risk / ontology / aesthetic). ` +
|
|
434
|
+
`Found ${lockedCards.length} locked card(s) and ${cards.length} total card(s).`
|
|
435
|
+
);
|
|
436
|
+
err.code = 'EMPTY_DOMAIN';
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
|
|
410
440
|
const core = compileCore(cards, project);
|
|
411
441
|
const patterns = compilePatterns(cards, project);
|
|
412
442
|
const scenarios = compileScenarios(cards, project);
|
|
@@ -414,13 +444,50 @@ function compileDomain(project) {
|
|
|
414
444
|
const reasoning = compileReasoning(cards, project);
|
|
415
445
|
const evolution = compileEvolution(cards, project);
|
|
416
446
|
|
|
447
|
+
// ── RFC-0013 §3.1/§3.2 Compile Gates (PR-3) ───────────────────
|
|
448
|
+
// Run the Source Authority Graph gate and the Truth Charter gate
|
|
449
|
+
// BEFORE packaging. PR-2: strictAuthority now defaults to true so
|
|
450
|
+
// missing/unstable SAG/TC are surfaced as gate errors that block
|
|
451
|
+
// compilation. Pass options.strictAuthority = false to opt out
|
|
452
|
+
// (legacy workspaces only).
|
|
453
|
+
const strictAuthority = options.strictAuthority !== false;
|
|
454
|
+
const { runSagGate } = require('./source-authority-gate');
|
|
455
|
+
const { runTcGate } = require('./truth-charter-gate');
|
|
456
|
+
const sag = runSagGate(options.sourceAuthority, { strict: strictAuthority });
|
|
457
|
+
const tc = runTcGate(options.truthCharter, {
|
|
458
|
+
strict: strictAuthority,
|
|
459
|
+
sourceAuthority: options.sourceAuthority || null,
|
|
460
|
+
patterns: patterns || null,
|
|
461
|
+
});
|
|
462
|
+
const gates = { sag, tc, strict_authority: strictAuthority };
|
|
463
|
+
// Gate policy: strictAuthority=true + any gate.status === 'fail' => throw.
|
|
464
|
+
if (strictAuthority && (sag.status === 'fail' || tc.status === 'fail')) {
|
|
465
|
+
const allErrors = [...sag.errors, ...tc.errors];
|
|
466
|
+
const err = new Error(
|
|
467
|
+
`Strict-authority compile failed. ${allErrors.length} gate error(s):\n` +
|
|
468
|
+
allErrors.map((e) => ` - ${e}`).join('\n'),
|
|
469
|
+
);
|
|
470
|
+
err.code = 'GATE_FAIL';
|
|
471
|
+
err.gates = gates;
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
|
|
417
475
|
const files = {};
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
476
|
+
|
|
477
|
+
// Encode judgment as CBOR payload
|
|
478
|
+
const payload = {
|
|
479
|
+
kind: 'kdna.payload',
|
|
480
|
+
payload_version: '2.0',
|
|
481
|
+
domain: { name: project.name, version: (project.release && project.release.version) || '0.1.0' },
|
|
482
|
+
judgment: { core, patterns },
|
|
483
|
+
profiles: {},
|
|
484
|
+
integrity: {},
|
|
485
|
+
};
|
|
486
|
+
if (scenarios) payload.judgment.scenarios = scenarios;
|
|
487
|
+
if (cases) payload.judgment.cases = cases;
|
|
488
|
+
if (reasoning) payload.judgment.reasoning = reasoning;
|
|
489
|
+
if (evolution) payload.judgment.evolution = evolution;
|
|
490
|
+
files['payload.kdnab'] = cbor.encode(payload);
|
|
424
491
|
|
|
425
492
|
// ── KDNA Card (governance metadata) ─────────────────────────────
|
|
426
493
|
// Must be added BEFORE digest computation so it is included in content_digest.
|
|
@@ -455,6 +522,7 @@ function compileDomain(project) {
|
|
|
455
522
|
files,
|
|
456
523
|
stats,
|
|
457
524
|
identity,
|
|
525
|
+
gates,
|
|
458
526
|
};
|
|
459
527
|
}
|
|
460
528
|
|
|
@@ -520,7 +588,7 @@ function generateReadme(project, options = {}) {
|
|
|
520
588
|
|
|
521
589
|
if (lockedSelfChecks.length > 0) {
|
|
522
590
|
lines.push('## Eval Score'); lines.push('');
|
|
523
|
-
lines.push(`- quality_badge: ${tests.filter(t => t.result === 'with_kdna_better').length >=
|
|
591
|
+
lines.push(`- quality_badge: ${tests.filter(t => t.result === 'with_kdna_better').length >= 3 ? 'tested' : 'untested'}`);
|
|
524
592
|
lines.push(`- eval cases: ${tests.length}`);
|
|
525
593
|
lines.push('');
|
|
526
594
|
}
|
|
@@ -537,4 +605,4 @@ function generateReadme(project, options = {}) {
|
|
|
537
605
|
return lines.join('\n');
|
|
538
606
|
}
|
|
539
607
|
|
|
540
|
-
module.exports = { compileDomain, compileCore, compilePatterns, compileScenarios, compileCases, compileReasoning, compileEvolution, compileManifest, generateReadme, buildAssetIdentity, computeContentDigest };
|
|
608
|
+
module.exports = { compileDomain, compileCore, compilePatterns, compileScenarios, compileCases, compileReasoning, compileEvolution, compileManifest, generateReadme, buildAssetIdentity, computeContentDigest, runSagGate: require('./source-authority-gate').runSagGate, runTcGate: require('./truth-charter-gate').runTcGate };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source Authority Graph (SAG) compile gate for kdna-studio-core.
|
|
3
|
+
*
|
|
4
|
+
* Implements the SAG gate from RFC-0013 §3.1 / §9 #3.
|
|
5
|
+
*
|
|
6
|
+
* Behavior:
|
|
7
|
+
* - Default mode: all rules are reported as WARNINGS (soft).
|
|
8
|
+
* - Strict-authority mode: hard-rule violations become ERRORS.
|
|
9
|
+
* - If no sourceAuthority provided, gate skips silently
|
|
10
|
+
* (backwards-compatible: legacy workspaces without SAG pass through).
|
|
11
|
+
*
|
|
12
|
+
* Rules checked:
|
|
13
|
+
* R1. precedence_order entries must all reference an existing source id.
|
|
14
|
+
* R2. At least one source must have authority: "current_highest"
|
|
15
|
+
* (when SAG is present; soft warning in default mode).
|
|
16
|
+
* R3. authority/status consistency:
|
|
17
|
+
* - authority: "deprecated" requires status: "deprecated"
|
|
18
|
+
* - authority: "current_highest" requires status: "active"
|
|
19
|
+
* - deprecated sources must not appear in precedence_order.
|
|
20
|
+
* R4. In precedence_order (highest precedence first), the first
|
|
21
|
+
* current_highest must not be preceded by any lower-authority
|
|
22
|
+
* source.
|
|
23
|
+
* R5. sources_contain_pii=true without author_consent_on_file is
|
|
24
|
+
* a soft warning (we are not the consent authority).
|
|
25
|
+
*
|
|
26
|
+
* Output contract:
|
|
27
|
+
* {
|
|
28
|
+
* gate: 'source_authority',
|
|
29
|
+
* status: 'skipped' | 'pass' | 'warn' | 'fail',
|
|
30
|
+
* errors: [string, ...], // strict-only; empty in default mode
|
|
31
|
+
* warnings: [string, ...], // always populated for any rule
|
|
32
|
+
* source_authority: object | null,
|
|
33
|
+
* strict_authority: boolean
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const AUTHORITY_RANK = {
|
|
38
|
+
current_highest: 4,
|
|
39
|
+
thought_mine: 3,
|
|
40
|
+
historical_baseline: 2,
|
|
41
|
+
exemplar_case: 1,
|
|
42
|
+
deprecated: 0,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function isPlainObject(v) {
|
|
46
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runSagGate(sourceAuthority, opts = {}) {
|
|
50
|
+
const strict = !!opts.strict;
|
|
51
|
+
const result = {
|
|
52
|
+
gate: 'source_authority',
|
|
53
|
+
status: 'skipped',
|
|
54
|
+
errors: [],
|
|
55
|
+
warnings: [],
|
|
56
|
+
source_authority: sourceAuthority || null,
|
|
57
|
+
strict_authority: strict,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (!isPlainObject(sourceAuthority)) {
|
|
61
|
+
result.status = 'skipped';
|
|
62
|
+
result.warnings.push('No source_authority.json provided; SAG gate skipped.');
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// R1. precedence_order references must be valid source ids.
|
|
67
|
+
const sources = Array.isArray(sourceAuthority.sources) ? sourceAuthority.sources : [];
|
|
68
|
+
const sourceIds = new Set();
|
|
69
|
+
for (const s of sources) {
|
|
70
|
+
if (isPlainObject(s) && typeof s.id === 'string') {
|
|
71
|
+
sourceIds.add(s.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const order = Array.isArray(sourceAuthority.precedence_order) ? sourceAuthority.precedence_order : [];
|
|
75
|
+
const missing = order.filter((id) => !sourceIds.has(id));
|
|
76
|
+
if (missing.length > 0) {
|
|
77
|
+
const msg = `source_authority.json: precedence_order references unknown source id(s): ${missing.join(', ')}.`;
|
|
78
|
+
if (strict) result.errors.push(msg);
|
|
79
|
+
else result.warnings.push(msg);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// R2. At least one current_highest source.
|
|
83
|
+
const highest = sources.filter(
|
|
84
|
+
(s) => isPlainObject(s) && s.authority === 'current_highest',
|
|
85
|
+
);
|
|
86
|
+
if (highest.length === 0) {
|
|
87
|
+
const msg = 'source_authority.json: no source has authority "current_highest"; at least one current_highest source is required to establish current authority.';
|
|
88
|
+
if (strict) result.errors.push(msg);
|
|
89
|
+
else result.warnings.push(msg);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// R3. authority/status consistency.
|
|
93
|
+
for (const s of sources) {
|
|
94
|
+
if (!isPlainObject(s)) continue;
|
|
95
|
+
if (s.authority === 'deprecated' && s.status !== 'deprecated') {
|
|
96
|
+
const msg = `source_authority.json: source "${s.id}" has authority "deprecated" but status is "${s.status}"; status MUST be "deprecated" when authority is "deprecated".`;
|
|
97
|
+
if (strict) result.errors.push(msg);
|
|
98
|
+
else result.warnings.push(msg);
|
|
99
|
+
}
|
|
100
|
+
if (s.authority === 'current_highest' && s.status !== 'active') {
|
|
101
|
+
const msg = `source_authority.json: source "${s.id}" has authority "current_highest" but status is "${s.status}"; status MUST be "active" for current_highest sources.`;
|
|
102
|
+
if (strict) result.errors.push(msg);
|
|
103
|
+
else result.warnings.push(msg);
|
|
104
|
+
}
|
|
105
|
+
if (s.authority === 'deprecated' && order.includes(s.id)) {
|
|
106
|
+
const msg = `source_authority.json: deprecated source "${s.id}" appears in precedence_order; deprecated sources cannot be authoritative precursors.`;
|
|
107
|
+
if (strict) result.errors.push(msg);
|
|
108
|
+
else result.warnings.push(msg);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// R4. In precedence_order, the first current_highest must not be
|
|
113
|
+
// preceded by any lower-authority source.
|
|
114
|
+
if (order.length > 0 && highest.length > 0) {
|
|
115
|
+
const firstHighestIdx = Math.min(
|
|
116
|
+
...order
|
|
117
|
+
.map((id, i) => ({ id, i, isHighest: highest.some((h) => h.id === id) }))
|
|
118
|
+
.filter((e) => e.isHighest)
|
|
119
|
+
.map((e) => e.i),
|
|
120
|
+
);
|
|
121
|
+
if (Number.isFinite(firstHighestIdx)) {
|
|
122
|
+
for (let i = 0; i < firstHighestIdx; i++) {
|
|
123
|
+
const id = order[i];
|
|
124
|
+
const src = sources.find((s) => isPlainObject(s) && s.id === id);
|
|
125
|
+
if (!src) continue;
|
|
126
|
+
if (
|
|
127
|
+
AUTHORITY_RANK[src.authority] !== undefined &&
|
|
128
|
+
AUTHORITY_RANK[src.authority] < AUTHORITY_RANK.current_highest
|
|
129
|
+
) {
|
|
130
|
+
const msg = `source_authority.json: lower-authority source "${src.id}" (${src.authority}) appears before current_highest in precedence_order; current_highest must override.`;
|
|
131
|
+
if (strict) result.errors.push(msg);
|
|
132
|
+
else result.warnings.push(msg);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// R5. PII without consent is a soft warning only.
|
|
139
|
+
const sensitivity = sourceAuthority.sensitivity || {};
|
|
140
|
+
if (sensitivity.sources_contain_pii === true && sensitivity.author_consent_on_file !== true) {
|
|
141
|
+
result.warnings.push(
|
|
142
|
+
'source_authority.json: sensitivity.sources_contain_pii is true but author_consent_on_file is not true; recording author consent is recommended before publishing.',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (result.errors.length > 0) {
|
|
147
|
+
result.status = 'fail';
|
|
148
|
+
} else if (result.warnings.length > 0) {
|
|
149
|
+
result.status = 'warn';
|
|
150
|
+
} else {
|
|
151
|
+
result.status = 'pass';
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { runSagGate, AUTHORITY_RANK };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truth Charter (TC) compile gate for kdna-studio-core.
|
|
3
|
+
*
|
|
4
|
+
* Implements the TC gate from RFC-0013 §3.2 / §9 #3.
|
|
5
|
+
*
|
|
6
|
+
* Behavior:
|
|
7
|
+
* - Default mode: WARNING only. Errors are downgraded to warnings.
|
|
8
|
+
* - Strict-authority mode: ERROR for synthesized/draft/deprecated
|
|
9
|
+
* tc_status; PASS only for tc_status: "locked".
|
|
10
|
+
* - If no truthCharter provided, gate skips silently
|
|
11
|
+
* (backwards-compatible: legacy workspaces without TC pass through).
|
|
12
|
+
*
|
|
13
|
+
* Rules checked:
|
|
14
|
+
* R1. tc_status must be one of: draft / synthesized / locked / deprecated.
|
|
15
|
+
* R2. tc_status: "synthesized" + strict-authority -> ERROR
|
|
16
|
+
* (synthesized means no real author-locked truth; cannot be
|
|
17
|
+
* officially published without explicit lock).
|
|
18
|
+
* R3. tc_status: "deprecated" + strict-authority -> ERROR
|
|
19
|
+
* (deprecated charters cannot govern new compilations).
|
|
20
|
+
* R4. tc_status: "locked" requires locked_at and locked_by fields.
|
|
21
|
+
* R5. renamed_terms: if renamed_terms are present and a
|
|
22
|
+
* patterns.terminology is supplied, check that each old term is
|
|
23
|
+
* either in banned_terms or its replacement is in standard_terms.
|
|
24
|
+
* (Soft check; mismatches are warnings, not errors.)
|
|
25
|
+
* R6. forbidden_simplifications: presence is recorded; we do NOT
|
|
26
|
+
* perform LLM-based semantic verification (would require a
|
|
27
|
+
* judgment call outside the gate's scope). Always PASS.
|
|
28
|
+
*
|
|
29
|
+
* Cross-file consistency (when both SAG and TC are supplied):
|
|
30
|
+
* If sourceAuthority has any current_highest source of type
|
|
31
|
+
* "human_locked_charter" and TC exists, TC.judgment_authority_holder
|
|
32
|
+
* must be present and non-empty.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const VALID_TC_STATUS = ['draft', 'synthesized', 'locked', 'deprecated'];
|
|
36
|
+
|
|
37
|
+
function isPlainObject(v) {
|
|
38
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runTcGate(truthCharter, opts = {}) {
|
|
42
|
+
const strict = !!opts.strict;
|
|
43
|
+
const sourceAuthority = opts.sourceAuthority || null;
|
|
44
|
+
const patterns = opts.patterns || null; // KDNA_Patterns.json content
|
|
45
|
+
const result = {
|
|
46
|
+
gate: 'truth_charter',
|
|
47
|
+
status: 'skipped',
|
|
48
|
+
errors: [],
|
|
49
|
+
warnings: [],
|
|
50
|
+
truth_charter: truthCharter || null,
|
|
51
|
+
strict_authority: strict,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (!isPlainObject(truthCharter)) {
|
|
55
|
+
result.status = 'skipped';
|
|
56
|
+
result.warnings.push('No truth_charter.json provided; TC gate skipped.');
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// R1. tc_status must be valid.
|
|
61
|
+
const tcStatus = truthCharter.tc_status;
|
|
62
|
+
if (!VALID_TC_STATUS.includes(tcStatus)) {
|
|
63
|
+
result.errors.push(
|
|
64
|
+
`truth_charter.json: tc_status "${tcStatus}" is not one of [${VALID_TC_STATUS.join(', ')}].`,
|
|
65
|
+
);
|
|
66
|
+
result.status = 'fail';
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// R2. synthesized + strict -> ERROR.
|
|
71
|
+
if (tcStatus === 'synthesized') {
|
|
72
|
+
const msg = 'truth_charter.json: tc_status is "synthesized" (no author-locked truth); strict-authority requires "locked".';
|
|
73
|
+
if (strict) result.errors.push(msg);
|
|
74
|
+
else result.warnings.push(msg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// R3. deprecated + strict -> ERROR.
|
|
78
|
+
if (tcStatus === 'deprecated') {
|
|
79
|
+
const msg = 'truth_charter.json: tc_status is "deprecated"; deprecated charters cannot govern new compilations under strict-authority.';
|
|
80
|
+
if (strict) result.errors.push(msg);
|
|
81
|
+
else result.warnings.push(msg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// R4. locked requires locked_at and locked_by.
|
|
85
|
+
if (tcStatus === 'locked') {
|
|
86
|
+
if (!truthCharter.locked_at || !truthCharter.locked_by) {
|
|
87
|
+
result.errors.push(
|
|
88
|
+
'truth_charter.json: tc_status is "locked" but locked_at or locked_by is missing.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// R5. renamed_terms soft check against patterns.terminology.
|
|
94
|
+
// Only fires when the project's terminology actually has content; an
|
|
95
|
+
// empty terminology (from a card-only project) is treated as
|
|
96
|
+
// "not yet declared" and the soft check is skipped.
|
|
97
|
+
if (
|
|
98
|
+
Array.isArray(truthCharter.renamed_terms) &&
|
|
99
|
+
isPlainObject(patterns) &&
|
|
100
|
+
isPlainObject(patterns.terminology) &&
|
|
101
|
+
(Array.isArray(patterns.terminology.standard_terms) && patterns.terminology.standard_terms.length > 0 ||
|
|
102
|
+
Array.isArray(patterns.terminology.banned_terms) && patterns.terminology.banned_terms.length > 0)
|
|
103
|
+
) {
|
|
104
|
+
const term = patterns.terminology;
|
|
105
|
+
const banned = new Set(
|
|
106
|
+
Array.isArray(term.banned_terms) ? term.banned_terms.map((t) => t && t.term).filter(Boolean) : [],
|
|
107
|
+
);
|
|
108
|
+
const standard = new Set(
|
|
109
|
+
Array.isArray(term.standard_terms) ? term.standard_terms.map((t) => t && t.term).filter(Boolean) : [],
|
|
110
|
+
);
|
|
111
|
+
for (const r of truthCharter.renamed_terms) {
|
|
112
|
+
if (!isPlainObject(r)) continue;
|
|
113
|
+
const oldName = r.old;
|
|
114
|
+
const newName = r.new;
|
|
115
|
+
const oldBanned = oldName && banned.has(oldName);
|
|
116
|
+
const newStandard = newName && standard.has(newName);
|
|
117
|
+
if (oldName && !oldBanned) {
|
|
118
|
+
result.warnings.push(
|
|
119
|
+
`truth_charter.json renamed_terms: old term "${oldName}" is not in KDNA_Patterns.json.terminology.banned_terms; consider adding it to make the rename enforceable.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (newName && !newStandard) {
|
|
123
|
+
result.warnings.push(
|
|
124
|
+
`truth_charter.json renamed_terms: new term "${newName}" is not in KDNA_Patterns.json.terminology.standard_terms; consider adding it.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Cross-file consistency: SAG has human_locked_charter current_highest
|
|
131
|
+
// => TC.judgment_authority_holder must be present and non-empty.
|
|
132
|
+
if (sourceAuthority && isPlainObject(sourceAuthority)) {
|
|
133
|
+
const sources = Array.isArray(sourceAuthority.sources) ? sourceAuthority.sources : [];
|
|
134
|
+
const hasHumanLockedCharter = sources.some(
|
|
135
|
+
(s) => isPlainObject(s) && s.authority === 'current_highest' && s.type === 'human_locked_charter',
|
|
136
|
+
);
|
|
137
|
+
if (hasHumanLockedCharter) {
|
|
138
|
+
const holder = truthCharter.judgment_authority_holder;
|
|
139
|
+
if (!holder || (typeof holder === 'string' && holder.trim() === '')) {
|
|
140
|
+
const msg = 'truth_charter.json: SAG has a current_highest source of type human_locked_charter, but TC.judgment_authority_holder is missing or empty; cross-file consistency requires both.';
|
|
141
|
+
if (strict) result.errors.push(msg);
|
|
142
|
+
else result.warnings.push(msg);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (result.errors.length > 0) {
|
|
148
|
+
result.status = 'fail';
|
|
149
|
+
} else if (result.warnings.length > 0) {
|
|
150
|
+
result.status = 'warn';
|
|
151
|
+
} else {
|
|
152
|
+
result.status = 'pass';
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { runTcGate, VALID_TC_STATUS };
|
package/src/creator-identity.js
CHANGED
|
@@ -34,9 +34,10 @@ function creatorFingerprint(publicKeyPem) {
|
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Generate a new Ed25519 keypair and persist to identityDir.
|
|
37
|
-
*
|
|
37
|
+
* If passphrase is provided, the private key is encrypted with AES-256-GCM
|
|
38
|
+
* (key derived via PBKDF2-SHA256). Without passphrase, plaintext with 0o600.
|
|
38
39
|
*/
|
|
39
|
-
function initIdentity(displayName, identityDir = null) {
|
|
40
|
+
function initIdentity(displayName, identityDir = null, passphrase = null) {
|
|
40
41
|
const dir = identityDir || defaultIdentityDir();
|
|
41
42
|
fs.mkdirSync(dir, { recursive: true });
|
|
42
43
|
|
|
@@ -55,7 +56,11 @@ function initIdentity(displayName, identityDir = null) {
|
|
|
55
56
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
const privateKeyData = passphrase
|
|
60
|
+
? encryptPrivateKey(privateKey, passphrase)
|
|
61
|
+
: privateKey;
|
|
62
|
+
|
|
63
|
+
fs.writeFileSync(privateKeyPath, privateKeyData, { mode: 0o600 });
|
|
59
64
|
fs.writeFileSync(publicKeyPath, publicKey, { mode: 0o644 });
|
|
60
65
|
|
|
61
66
|
const creatorId = creatorFingerprint(publicKey);
|
|
@@ -67,6 +72,7 @@ function initIdentity(displayName, identityDir = null) {
|
|
|
67
72
|
identity_dir: dir,
|
|
68
73
|
created_at: new Date().toISOString(),
|
|
69
74
|
verified: false,
|
|
75
|
+
encrypted: !!passphrase,
|
|
70
76
|
};
|
|
71
77
|
|
|
72
78
|
fs.writeFileSync(identityJsonPath, JSON.stringify(identity, null, 2), { mode: 0o644 });
|
|
@@ -106,8 +112,9 @@ function loadIdentity(identityDir = null) {
|
|
|
106
112
|
/**
|
|
107
113
|
* Sign a payload with the creator's Ed25519 private key.
|
|
108
114
|
* Returns the signature in "ed25519:<hex>" format.
|
|
115
|
+
* If the key is encrypted, passphrase is required.
|
|
109
116
|
*/
|
|
110
|
-
function signPayload(payload, identityDir = null) {
|
|
117
|
+
function signPayload(payload, identityDir = null, passphrase = null) {
|
|
111
118
|
const dir = identityDir || defaultIdentityDir();
|
|
112
119
|
const privateKeyPath = path.join(dir, PRIVATE_KEY_FILE);
|
|
113
120
|
|
|
@@ -117,21 +124,134 @@ function signPayload(payload, identityDir = null) {
|
|
|
117
124
|
);
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
|
|
127
|
+
let privateKeyPem = fs.readFileSync(privateKeyPath, 'utf8');
|
|
128
|
+
if (isEncryptedKey(privateKeyPem)) {
|
|
129
|
+
if (!passphrase) throw new Error('Private key is encrypted. Provide --passphrase to sign.');
|
|
130
|
+
privateKeyPem = decryptPrivateKey(privateKeyPem, passphrase);
|
|
131
|
+
}
|
|
132
|
+
|
|
121
133
|
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload));
|
|
122
134
|
const sig = crypto.sign(null, data, privateKeyPem);
|
|
123
135
|
return `ed25519:${sig.toString('hex')}`;
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
/**
|
|
127
|
-
* Sign a Human Lock payload
|
|
128
|
-
* the
|
|
129
|
-
*
|
|
130
|
-
* Payload: `${cardId}\n${statement}\n${judgmentFingerprint}`
|
|
139
|
+
* Sign a Human Lock payload.
|
|
140
|
+
* If the key is encrypted, passphrase is required.
|
|
131
141
|
*/
|
|
132
|
-
function signHumanLock(cardId, statement, judgmentFingerprint, identityDir = null) {
|
|
142
|
+
function signHumanLock(cardId, statement, judgmentFingerprint, identityDir = null, passphrase = null) {
|
|
133
143
|
const lockPayload = [cardId, statement, judgmentFingerprint].join('\n');
|
|
134
|
-
return signPayload(lockPayload, identityDir);
|
|
144
|
+
return signPayload(lockPayload, identityDir, passphrase);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Private Key Encryption ────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function encryptPrivateKey(pem, passphrase) {
|
|
150
|
+
const salt = crypto.randomBytes(16);
|
|
151
|
+
const key = crypto.pbkdf2Sync(passphrase, salt, 100000, 32, 'sha256');
|
|
152
|
+
const iv = crypto.randomBytes(12);
|
|
153
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
154
|
+
const ciphertext = Buffer.concat([cipher.update(Buffer.from(pem)), cipher.final()]);
|
|
155
|
+
return JSON.stringify({
|
|
156
|
+
encrypted: true,
|
|
157
|
+
kdf: 'pbkdf2-sha256',
|
|
158
|
+
iterations: 100000,
|
|
159
|
+
salt: salt.toString('base64'),
|
|
160
|
+
iv: iv.toString('base64'),
|
|
161
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
162
|
+
ciphertext: ciphertext.toString('base64'),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function decryptPrivateKey(envelope, passphrase) {
|
|
167
|
+
const env = typeof envelope === 'string' ? JSON.parse(envelope) : envelope;
|
|
168
|
+
if (!env.encrypted) throw new Error('Private key is not encrypted');
|
|
169
|
+
const key = crypto.pbkdf2Sync(passphrase, Buffer.from(env.salt, 'base64'), env.iterations, 32, 'sha256');
|
|
170
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
|
|
171
|
+
decipher.setAuthTag(Buffer.from(env.tag, 'base64'));
|
|
172
|
+
try {
|
|
173
|
+
return Buffer.concat([decipher.update(Buffer.from(env.ciphertext, 'base64')), decipher.final()]).toString('utf8');
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (e.code === 'ERR_OSSL_EVP_BAD_DECRYPT' || e.message.includes('bad decrypt')) {
|
|
176
|
+
throw new Error('Wrong passphrase — cannot decrypt private key.');
|
|
177
|
+
}
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isEncryptedKey(content) {
|
|
183
|
+
try { const o = JSON.parse(content); return o.encrypted === true; }
|
|
184
|
+
catch { return false; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Key Rotation ────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Rotate the creator's private key. Generates a new Ed25519 keypair,
|
|
191
|
+
* signs the rotation event with the old key, and saves both.
|
|
192
|
+
* The old public key is recorded in creator.json under previous_keys.
|
|
193
|
+
*/
|
|
194
|
+
function rotateIdentity(passphrase = null, identityDir = null) {
|
|
195
|
+
const identity = loadIdentity(identityDir);
|
|
196
|
+
if (!identity) throw new Error('No identity found. Run identity init first.');
|
|
197
|
+
|
|
198
|
+
const dir = identityDir || defaultIdentityDir();
|
|
199
|
+
const oldPrivPath = path.join(dir, PRIVATE_KEY_FILE);
|
|
200
|
+
const oldPubPath = path.join(dir, PUBLIC_KEY_FILE);
|
|
201
|
+
|
|
202
|
+
if (!fs.existsSync(oldPrivPath) || !fs.existsSync(oldPubPath)) {
|
|
203
|
+
throw new Error('Key files not found for rotation.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Decrypt old key if needed
|
|
207
|
+
let oldPrivatePem = fs.readFileSync(oldPrivPath, 'utf8');
|
|
208
|
+
if (isEncryptedKey(oldPrivatePem)) {
|
|
209
|
+
if (!passphrase) throw new Error('Private key is encrypted. Provide passphrase to rotate.');
|
|
210
|
+
oldPrivatePem = decryptPrivateKey(oldPrivatePem, passphrase);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Generate new keypair
|
|
214
|
+
const { publicKey: newPub, privateKey: newPriv } = crypto.generateKeyPairSync('ed25519', {
|
|
215
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
216
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Sign rotation event with old key
|
|
220
|
+
const rotationPayload = [
|
|
221
|
+
identity.creator_id,
|
|
222
|
+
creatorFingerprint(newPub),
|
|
223
|
+
new Date().toISOString(),
|
|
224
|
+
].join('\n');
|
|
225
|
+
const rotationSig = `ed25519:${crypto.sign(null, Buffer.from(rotationPayload), oldPrivatePem).toString('hex')}`;
|
|
226
|
+
|
|
227
|
+
// Save new keypair
|
|
228
|
+
const newPrivData = passphrase ? encryptPrivateKey(newPriv, passphrase) : newPriv;
|
|
229
|
+
fs.writeFileSync(oldPrivPath, newPrivData, { mode: 0o600 });
|
|
230
|
+
fs.writeFileSync(oldPubPath, newPub, { mode: 0o644 });
|
|
231
|
+
|
|
232
|
+
// Update identity record
|
|
233
|
+
const newCreatorId = creatorFingerprint(newPub);
|
|
234
|
+
const identityJson = path.join(dir, IDENTITY_JSON_FILE);
|
|
235
|
+
let idData = {};
|
|
236
|
+
try { idData = JSON.parse(fs.readFileSync(identityJson, 'utf8')); } catch {}
|
|
237
|
+
|
|
238
|
+
const previousKeys = idData.previous_keys || [];
|
|
239
|
+
previousKeys.push({
|
|
240
|
+
creator_id: identity.creator_id,
|
|
241
|
+
public_key: identity.public_key,
|
|
242
|
+
rotated_at: new Date().toISOString(),
|
|
243
|
+
rotation_signature: rotationSig,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
idData.creator_id = newCreatorId;
|
|
247
|
+
idData.public_key = newPub;
|
|
248
|
+
idData.rotated_at = new Date().toISOString();
|
|
249
|
+
idData.encrypted = !!passphrase;
|
|
250
|
+
idData.previous_keys = previousKeys;
|
|
251
|
+
|
|
252
|
+
fs.writeFileSync(identityJson, JSON.stringify(idData, null, 2), { mode: 0o644 });
|
|
253
|
+
|
|
254
|
+
return { ...idData, creator_id: newCreatorId };
|
|
135
255
|
}
|
|
136
256
|
|
|
137
257
|
/**
|
|
@@ -160,5 +280,8 @@ module.exports = {
|
|
|
160
280
|
loadPublicKey,
|
|
161
281
|
privateKeyPath,
|
|
162
282
|
defaultIdentityDir,
|
|
283
|
+
encryptPrivateKey,
|
|
284
|
+
decryptPrivateKey,
|
|
285
|
+
isEncryptedKey,
|
|
163
286
|
CREATOR_ID_PREFIX,
|
|
164
287
|
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Domain-First Distillation — Core API
|
|
2
|
+
// Aligned with SOURCE_DISTILLATION_CONTRACT.md v0.2
|
|
3
|
+
|
|
4
|
+
// ─── Domain Taxonomy ───────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const DOMAIN_CATEGORIES = Object.freeze({
|
|
7
|
+
expression_writing: {
|
|
8
|
+
id: 'expression_writing',
|
|
9
|
+
displayName: 'Expression & Writing',
|
|
10
|
+
examples: ['writing_style', 'blog_voice', 'social_media_tone', 'article_structure'],
|
|
11
|
+
},
|
|
12
|
+
aesthetic_creation: {
|
|
13
|
+
id: 'aesthetic_creation',
|
|
14
|
+
displayName: 'Aesthetic & Creation',
|
|
15
|
+
examples: ['visual_design', 'video_rhythm', 'cover_art', 'brand_aesthetics'],
|
|
16
|
+
},
|
|
17
|
+
professional_field: {
|
|
18
|
+
id: 'professional_field',
|
|
19
|
+
displayName: 'Professional Field',
|
|
20
|
+
examples: ['legal_judgment', 'medical_diagnosis', 'education_standards'],
|
|
21
|
+
},
|
|
22
|
+
decision_preference: {
|
|
23
|
+
id: 'decision_preference',
|
|
24
|
+
displayName: 'Decision Preference',
|
|
25
|
+
examples: ['product_decisions', 'investment_criteria', 'prioritization_methods'],
|
|
26
|
+
},
|
|
27
|
+
communication_style: {
|
|
28
|
+
id: 'communication_style',
|
|
29
|
+
displayName: 'Communication Style',
|
|
30
|
+
examples: ['client_communication', 'team_management', 'conflict_handling'],
|
|
31
|
+
},
|
|
32
|
+
workflow_process: {
|
|
33
|
+
id: 'workflow_process',
|
|
34
|
+
displayName: 'Workflow & Process',
|
|
35
|
+
examples: ['project_reviews', 'meeting_standards', 'sales_followups'],
|
|
36
|
+
},
|
|
37
|
+
life_preference: {
|
|
38
|
+
id: 'life_preference',
|
|
39
|
+
displayName: 'Life Preference',
|
|
40
|
+
examples: ['schedule_preferences', 'learning_habits', 'consumption_choices'],
|
|
41
|
+
},
|
|
42
|
+
team_organization: {
|
|
43
|
+
id: 'team_organization',
|
|
44
|
+
displayName: 'Team & Organization',
|
|
45
|
+
examples: ['team_brand', 'hiring_criteria', 'service_standards'],
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const OWNER_SCOPES = Object.freeze({
|
|
50
|
+
personal: {
|
|
51
|
+
id: 'personal',
|
|
52
|
+
displayName: 'Personal',
|
|
53
|
+
description: 'One person\'s individual standards. Extracts personal preferences, boundaries, taste.',
|
|
54
|
+
},
|
|
55
|
+
team: {
|
|
56
|
+
id: 'team',
|
|
57
|
+
displayName: 'Team',
|
|
58
|
+
description: 'Shared team conventions. Extracts team-wide standards, agreed practices.',
|
|
59
|
+
},
|
|
60
|
+
organization: {
|
|
61
|
+
id: 'organization',
|
|
62
|
+
displayName: 'Organization',
|
|
63
|
+
description: 'Company/organization policies. Extracts organizational values, compliance boundaries.',
|
|
64
|
+
},
|
|
65
|
+
field: {
|
|
66
|
+
id: 'field',
|
|
67
|
+
displayName: 'Industry / Field',
|
|
68
|
+
description: 'Industry/profession-wide. Extracts domain expertise beyond any single practitioner.',
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const GRANULARITY_LEVELS = Object.freeze({
|
|
73
|
+
core_principles: {
|
|
74
|
+
id: 'core_principles',
|
|
75
|
+
displayName: 'Core Principles',
|
|
76
|
+
description: 'High-level axioms and boundaries. Foundational beliefs, what the person consistently prioritizes and rejects.',
|
|
77
|
+
},
|
|
78
|
+
concrete_patterns: {
|
|
79
|
+
id: 'concrete_patterns',
|
|
80
|
+
displayName: 'Concrete Patterns',
|
|
81
|
+
description: 'Recurring decision patterns. Specific rules and detectable habits.',
|
|
82
|
+
},
|
|
83
|
+
specific_scenarios: {
|
|
84
|
+
id: 'specific_scenarios',
|
|
85
|
+
displayName: 'Specific Scenarios',
|
|
86
|
+
description: 'Scenario-level triggers. Context-specific judgment triggers and responses.',
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const EVIDENCE_RELEVANCE = Object.freeze({
|
|
91
|
+
relevant: { id: 'relevant', label: 'Relevant' },
|
|
92
|
+
weakly_relevant: { id: 'weakly_relevant', label: 'Weakly Relevant' },
|
|
93
|
+
out_of_scope: { id: 'out_of_scope', label: 'Out of Scope' },
|
|
94
|
+
split_domain: { id: 'split_domain', label: 'Split Domain' },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─── Distillation Target ───────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function createDistillationTarget({
|
|
100
|
+
domainName,
|
|
101
|
+
domainCategory = 'expression_writing',
|
|
102
|
+
ownerScope = 'personal',
|
|
103
|
+
granularity = 'core_principles',
|
|
104
|
+
taskScope = '',
|
|
105
|
+
includeAreas = [],
|
|
106
|
+
excludeAreas = [],
|
|
107
|
+
loadCondition = '',
|
|
108
|
+
}) {
|
|
109
|
+
if (!domainName || typeof domainName !== 'string') {
|
|
110
|
+
throw new Error('domainName is required');
|
|
111
|
+
}
|
|
112
|
+
if (!DOMAIN_CATEGORIES[domainCategory]) {
|
|
113
|
+
throw new Error(`Invalid domainCategory: ${domainCategory}. Must be one of: ${Object.keys(DOMAIN_CATEGORIES).join(', ')}`);
|
|
114
|
+
}
|
|
115
|
+
if (!OWNER_SCOPES[ownerScope]) {
|
|
116
|
+
throw new Error(`Invalid ownerScope: ${ownerScope}`);
|
|
117
|
+
}
|
|
118
|
+
if (!GRANULARITY_LEVELS[granularity]) {
|
|
119
|
+
throw new Error(`Invalid granularity: ${granularity}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id: `tgt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
124
|
+
domain_name: domainName,
|
|
125
|
+
domain_category: domainCategory,
|
|
126
|
+
owner_scope: ownerScope,
|
|
127
|
+
granularity,
|
|
128
|
+
task_scope: taskScope,
|
|
129
|
+
include_areas: includeAreas,
|
|
130
|
+
exclude_areas: excludeAreas,
|
|
131
|
+
load_condition: loadCondition || `Load when the task involves ${DOMAIN_CATEGORIES[domainCategory].displayName.toLowerCase()} judgment.`,
|
|
132
|
+
declared_at: new Date().toISOString(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateDistillationTarget(target) {
|
|
137
|
+
const errors = [];
|
|
138
|
+
if (!target || !target.domain_name) errors.push('domain_name is required');
|
|
139
|
+
if (!DOMAIN_CATEGORIES[target.domain_category]) errors.push(`invalid domain_category: ${target.domain_category}`);
|
|
140
|
+
if (!OWNER_SCOPES[target.owner_scope]) errors.push(`invalid owner_scope: ${target.owner_scope}`);
|
|
141
|
+
if (!GRANULARITY_LEVELS[target.granularity]) errors.push(`invalid granularity: ${target.granularity}`);
|
|
142
|
+
if (!target.task_scope || target.task_scope.trim().length === 0) errors.push('task_scope is required');
|
|
143
|
+
return { valid: errors.length === 0, errors };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function targetScopeDescription(target) {
|
|
147
|
+
const cat = DOMAIN_CATEGORIES[target.domain_category];
|
|
148
|
+
const scope = OWNER_SCOPES[target.owner_scope];
|
|
149
|
+
const gran = GRANULARITY_LEVELS[target.granularity];
|
|
150
|
+
return `Domain: ${cat.displayName}. Scope: ${scope.displayName}. Granularity: ${gran.displayName}. Task: ${target.task_scope}. ${target.load_condition}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Scope Gate ────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function applyScopeGate(candidate, target) {
|
|
156
|
+
const text = `${candidate.one_sentence || ''} ${candidate.full_statement || ''}`.toLowerCase();
|
|
157
|
+
const domainWords = [target.domain_category, ...(target.include_areas || [])];
|
|
158
|
+
|
|
159
|
+
let scopeFit = true;
|
|
160
|
+
let relevanceScore = 50;
|
|
161
|
+
let relevanceEvidence = null;
|
|
162
|
+
let suggestedSplitDomain = null;
|
|
163
|
+
|
|
164
|
+
const domainMatch = domainWords.some(w => text.includes(w.toLowerCase()));
|
|
165
|
+
const excludeMatch = (target.exclude_areas || []).some(w => text.includes(w.toLowerCase()));
|
|
166
|
+
|
|
167
|
+
if (domainMatch && !excludeMatch) {
|
|
168
|
+
scopeFit = true;
|
|
169
|
+
relevanceScore = 80;
|
|
170
|
+
} else if (!domainMatch && (target.include_areas || []).length > 0) {
|
|
171
|
+
scopeFit = false;
|
|
172
|
+
relevanceScore = 20;
|
|
173
|
+
relevanceEvidence = 'No match with declared include areas';
|
|
174
|
+
} else if (excludeMatch) {
|
|
175
|
+
scopeFit = false;
|
|
176
|
+
relevanceScore = 10;
|
|
177
|
+
relevanceEvidence = 'Matched exclude area';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!scopeFit) {
|
|
181
|
+
for (const [catId, cat] of Object.entries(DOMAIN_CATEGORIES)) {
|
|
182
|
+
if (catId !== target.domain_category) {
|
|
183
|
+
if (text.includes(catId) || text.includes(cat.displayName.toLowerCase())) {
|
|
184
|
+
suggestedSplitDomain = cat.displayName;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
...candidate,
|
|
193
|
+
scope_fit: scopeFit,
|
|
194
|
+
domain_relevance_score: relevanceScore,
|
|
195
|
+
relevance_evidence: relevanceEvidence,
|
|
196
|
+
suggested_split_domain: suggestedSplitDomain,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function candidateStatusSummary(candidates) {
|
|
201
|
+
let proposed = 0, accepted = 0, rejected = 0, modified = 0, blocked = 0, outOfScope = 0;
|
|
202
|
+
for (const c of candidates) {
|
|
203
|
+
if (c.sensitive_content_flag) { blocked++; continue; }
|
|
204
|
+
if (!c.scope_fit) { outOfScope++; }
|
|
205
|
+
switch (c.status || c.candidate_status) {
|
|
206
|
+
case 'proposed': proposed++; break;
|
|
207
|
+
case 'accepted': accepted++; break;
|
|
208
|
+
case 'rejected': rejected++; break;
|
|
209
|
+
case 'modified': modified++; break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { proposed, accepted, rejected, modified, blocked, outOfScope };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Sensitive Inference Filter ────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const SENSITIVE_KEYWORDS = {
|
|
218
|
+
identity: [
|
|
219
|
+
'gender identity', 'sexual orientation', 'ethnicity', 'race', 'racial',
|
|
220
|
+
'性别认同', '性取向', '种族', '民族',
|
|
221
|
+
],
|
|
222
|
+
health: [
|
|
223
|
+
'medical condition', 'mental health', 'disability', 'diagnosis', 'medication', 'therapy', 'treatment',
|
|
224
|
+
'疾病', '病史', '诊断', '药物', '治疗', '心理疾病', '精神健康', '残疾', '残障',
|
|
225
|
+
],
|
|
226
|
+
political: [
|
|
227
|
+
'political affiliation', 'voting', 'activist', 'party member',
|
|
228
|
+
'政治立场', '党派', '党员', '政治倾向',
|
|
229
|
+
],
|
|
230
|
+
religious: [
|
|
231
|
+
'religious belief', 'faith', 'church', 'prayer', 'worship', 'spiritual practice',
|
|
232
|
+
'宗教信仰', '教会', '祈祷', '礼拜', '信教',
|
|
233
|
+
],
|
|
234
|
+
financial: [
|
|
235
|
+
'income', 'net worth', 'salary', 'debt', 'bank account', 'savings amount',
|
|
236
|
+
'收入', '工资', '存款', '负债', '资产净值', '银行卡号', '账户余额',
|
|
237
|
+
],
|
|
238
|
+
intimate: [
|
|
239
|
+
'relationship status', 'marriage', 'divorce', 'family structure',
|
|
240
|
+
'婚姻状况', '离婚', '家庭结构', '感情状况', '亲密关系',
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
function checkSensitiveContent(text) {
|
|
245
|
+
const lower = text.toLowerCase();
|
|
246
|
+
for (const [domain, keywords] of Object.entries(SENSITIVE_KEYWORDS)) {
|
|
247
|
+
for (const keyword of keywords) {
|
|
248
|
+
if (lower.includes(keyword.toLowerCase())) {
|
|
249
|
+
return { flagged: true, reason: `May involve sensitive domain: ${domain}` };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { flagged: false, reason: null };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
DOMAIN_CATEGORIES,
|
|
258
|
+
OWNER_SCOPES,
|
|
259
|
+
GRANULARITY_LEVELS,
|
|
260
|
+
EVIDENCE_RELEVANCE,
|
|
261
|
+
SENSITIVE_KEYWORDS,
|
|
262
|
+
createDistillationTarget,
|
|
263
|
+
validateDistillationTarget,
|
|
264
|
+
targetScopeDescription,
|
|
265
|
+
applyScopeGate,
|
|
266
|
+
candidateStatusSummary,
|
|
267
|
+
checkSensitiveContent,
|
|
268
|
+
};
|
package/src/index.js
CHANGED
|
@@ -30,6 +30,7 @@ const provenance = require('./provenance');
|
|
|
30
30
|
const quality = require('./quality');
|
|
31
31
|
const testlab = require('./testlab');
|
|
32
32
|
const versioning = require('./versioning');
|
|
33
|
+
const distillation = require('./distillation');
|
|
33
34
|
const feynman = require('./cards/feynman');
|
|
34
35
|
const contradiction = require('./quality/contradiction');
|
|
35
36
|
const validateCards = require('./quality/validate-cards');
|
|
@@ -46,6 +47,7 @@ module.exports = {
|
|
|
46
47
|
governance,
|
|
47
48
|
i18n,
|
|
48
49
|
creator: creatorIdentity,
|
|
50
|
+
distillation,
|
|
49
51
|
|
|
50
52
|
// Experimental
|
|
51
53
|
evidence,
|