create-principles-disciple 1.145.43 → 1.146.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/_release/product-identity.json +2 -2
- package/console/dist/server/routes/examiner-executor.d.ts +8 -2
- package/console/dist/server/routes/examiner-executor.js +10 -4
- package/console/dist/server/routes/examiner.d.ts +23 -17
- package/console/dist/server/routes/examiner.js +30 -27
- package/console/dist/ui/App.js +1 -1
- package/console/dist/ui/components/layout/app-sidebar.d.ts +3 -4
- package/console/dist/ui/components/layout/app-sidebar.js +5 -7
- package/console/dist/ui/i18n/en.json +1 -1
- package/console/dist/ui/i18n/zh-CN.json +1 -1
- package/console/dist/ui/utils/runtime-mode.d.ts +2 -1
- package/console/dist/ui/utils/runtime-mode.js +2 -1
- package/console/dist/web/assets/app.js +23 -23
- package/console/lab/lib/challenge-actor.mjs +394 -0
- package/console/lab/lib/challenge-arms.mjs +102 -0
- package/console/lab/lib/challenge-behavior-report.mjs +2484 -0
- package/console/lab/lib/challenge-compile.mjs +211 -0
- package/console/lab/lib/challenge-freeze.mjs +215 -0
- package/console/lab/lib/challenge-isolation-probe.mjs +550 -0
- package/console/lab/lib/challenge-ledger.mjs +206 -0
- package/console/lab/lib/challenge-openclaw-host.mjs +630 -0
- package/console/lab/lib/challenge-runner.mjs +868 -0
- package/console/lab/lib/challenge-stage-r-gate.mjs +458 -0
- package/console/lab/lib/challenge-statistics.mjs +591 -0
- package/console/lab/lib/claim-evidence-env.mjs +652 -0
- package/console/lab/lib/claim-evidence-scorer.mjs +784 -0
- package/console/lab/lib/examiner-injection-evidence.mjs +367 -0
- package/console/lab/lib/examiner-study.mjs +1203 -0
- package/console/lab/lib/examiner-v07-cases.mjs +187 -0
- package/console/lab/lib/examiner-v2-cases.mjs +61 -0
- package/console/lab/lib/fixture.mjs +382 -0
- package/console/lab/lib/g2.mjs +146 -0
- package/console/lab/lib/host-actor.mjs +987 -0
- package/console/lab/lib/sidecar.mjs +273 -0
- package/core/dist/runtime-v2/pain-recall-capability.d.ts +140 -0
- package/core/dist/runtime-v2/pain-recall-capability.d.ts.map +1 -0
- package/core/dist/runtime-v2/pain-recall-capability.js +334 -0
- package/core/dist/runtime-v2/pain-recall-capability.js.map +1 -0
- package/core/dist/runtime-v2/pain-recall-read-model.d.ts +113 -0
- package/core/dist/runtime-v2/pain-recall-read-model.d.ts.map +1 -0
- package/{pd-cli/dist/services/pain-recall.js → core/dist/runtime-v2/pain-recall-read-model.js} +7 -10
- package/core/dist/runtime-v2/pain-recall-read-model.js.map +1 -0
- package/core/dist/runtime-v2/pain-recall-snapshot-hooks.d.ts +39 -0
- package/core/dist/runtime-v2/pain-recall-snapshot-hooks.d.ts.map +1 -0
- package/core/dist/runtime-v2/pain-recall-snapshot-hooks.js +15 -0
- package/core/dist/runtime-v2/pain-recall-snapshot-hooks.js.map +1 -0
- package/core/package.json +9 -1
- package/package.json +2 -2
- package/pd-cli/dist/commands/pain-recall.js +1 -1
- package/pd-cli/dist/commands/pain-recall.js.map +1 -1
- package/plugin/dist/openclaw.plugin.json +1 -1
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/release-manager/package.json +2 -2
- package/pd-cli/dist/services/pain-recall.d.ts +0 -192
- package/pd-cli/dist/services/pain-recall.d.ts.map +0 -1
- package/pd-cli/dist/services/pain-recall.js.map +0 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPEC-PD-EXAMINER-001 v0.3 — Phase 1A Compiler/Validator for challenge
|
|
2
|
+
// studies. Accepts only the registered template (T-CLAIM-EVIDENCE-BINDING-V1),
|
|
3
|
+
// known families, and structurally valid case definitions; splits each case
|
|
4
|
+
// into actorVisible (no gold: no truth values, no implication maps, no
|
|
5
|
+
// observation outputs, no gold/acceptable verdicts) and judgeOnly (full gold).
|
|
6
|
+
// Hashes: inputHash over actorVisible; goldHash over judgeOnly minus its own
|
|
7
|
+
// hash fields; suiteHash over the manifest core (mappingHash + graderHash +
|
|
8
|
+
// per-case hashes + family counts).
|
|
9
|
+
|
|
10
|
+
import { hashOfCanonical } from './challenge-freeze.mjs';
|
|
11
|
+
import {
|
|
12
|
+
CLAIM_EVIDENCE_V2,
|
|
13
|
+
CLAIM_EVIDENCE_V3,
|
|
14
|
+
CLAIM_EVIDENCE_TEMPLATE_ID,
|
|
15
|
+
CLAIM_EVIDENCE_TEMPLATE_VERSION,
|
|
16
|
+
CLAIM_FAMILIES,
|
|
17
|
+
EXPECTED_MODES,
|
|
18
|
+
buildClaimEnvironment,
|
|
19
|
+
} from './claim-evidence-env.mjs';
|
|
20
|
+
import { CLAIM_VERDICTS } from './claim-evidence-env.mjs';
|
|
21
|
+
|
|
22
|
+
export const ACTOR_VISIBLE_SCHEMA = 'claim-evidence-actor-visible.v1';
|
|
23
|
+
export const JUDGE_ONLY_SCHEMA = 'claim-evidence-judge-only.v1';
|
|
24
|
+
export const SUITE_MANIFEST_SCHEMA = 'challenge-suite-manifest.v1';
|
|
25
|
+
export const CHALLENGE_DRAFT_SCHEMA = 'ChallengeDraftV1';
|
|
26
|
+
|
|
27
|
+
const REPORT_SCHEMA_DOC = {
|
|
28
|
+
action: { type: 'report', args: { claims: [{ factId: 'string', verdict: `${CLAIM_VERDICTS.join('|')}`, evidenceRef: 'string? (obsId of an executed observation — including OBS-OWNER-* or pre-seeded evidence)', minimalSteps: 'string? (required by some cases when verdict=inferred)' }] } },
|
|
29
|
+
note: 'evidenceRef 必须引用本次运行中实际执行成功的观察 obsId;仅引用未执行/被拒绝的观察不构成证据。',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Validate one ChallengeDraftV1 record (SPEC §5.2). Throws on violation. */
|
|
33
|
+
export function validateChallengeDraft(draft) {
|
|
34
|
+
const problems = [];
|
|
35
|
+
if (!draft || typeof draft !== 'object') throw new Error('draft must be an object');
|
|
36
|
+
if (draft.templateId !== CLAIM_EVIDENCE_TEMPLATE_ID) problems.push(`templateId must be ${CLAIM_EVIDENCE_TEMPLATE_ID}`);
|
|
37
|
+
if (![CLAIM_EVIDENCE_TEMPLATE_VERSION, '2.0.0', '3.0.0'].includes(draft.templateVersion)) problems.push('unknown templateVersion');
|
|
38
|
+
if (!CLAIM_FAMILIES.includes(draft.family)) problems.push(`family must be one of ${CLAIM_FAMILIES.join('|')}`);
|
|
39
|
+
if (typeof draft.scenarioId !== 'string' || draft.scenarioId.length === 0) problems.push('scenarioId required');
|
|
40
|
+
if (typeof draft.sourceMechanismRef !== 'string' || draft.sourceMechanismRef.length === 0) problems.push('sourceMechanismRef required');
|
|
41
|
+
if (typeof draft.rationaleForDesigner !== 'string' || draft.rationaleForDesigner.length === 0) problems.push('rationaleForDesigner required');
|
|
42
|
+
if (problems.length > 0) throw new Error(`validateChallengeDraft(${draft.scenarioId || '?'}): ${problems.join('; ')}`);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Validate one case definition (structural gold sanity). Throws on violation. */
|
|
47
|
+
export function validateCaseDef(caseDef) {
|
|
48
|
+
if (caseDef?.schema === CLAIM_EVIDENCE_V2 || caseDef?.schema === CLAIM_EVIDENCE_V3) {
|
|
49
|
+
if (typeof caseDef.caseId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(caseDef.caseId)) throw new Error('v2 safe caseId required');
|
|
50
|
+
if (!CLAIM_FAMILIES.includes(caseDef.family) || !EXPECTED_MODES.includes(caseDef.expectedMode) || typeof caseDef.principleApplicable !== 'boolean' || caseDef.studyEligible !== true || typeof caseDef.taskText !== 'string' || !caseDef.taskText.trim()) throw new Error('v2 invalid case metadata');
|
|
51
|
+
buildClaimEnvironment(caseDef);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const problems = [];
|
|
55
|
+
if (!caseDef || typeof caseDef !== 'object') throw new Error('caseDef must be an object');
|
|
56
|
+
if (typeof caseDef.caseId !== 'string' || caseDef.caseId.length === 0) problems.push('caseId required');
|
|
57
|
+
if (!CLAIM_FAMILIES.includes(caseDef.family)) problems.push(`family must be one of ${CLAIM_FAMILIES.join('|')}`);
|
|
58
|
+
if (caseDef.studyEligible !== true) problems.push('studyEligible must be true for compiled cases');
|
|
59
|
+
if (typeof caseDef.principleApplicable !== 'boolean') problems.push('principleApplicable must be boolean');
|
|
60
|
+
if (!EXPECTED_MODES.includes(caseDef.expectedMode)) problems.push(`expectedMode must be one of ${EXPECTED_MODES.join('|')}`);
|
|
61
|
+
if (typeof caseDef.taskText !== 'string' || caseDef.taskText.length === 0) problems.push('taskText required');
|
|
62
|
+
try {
|
|
63
|
+
buildClaimEnvironment(caseDef);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
problems.push(`environment invalid: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
if (!caseDef.gold || !Array.isArray(caseDef.gold.requiredClaims) || caseDef.gold.requiredClaims.length === 0) {
|
|
68
|
+
problems.push('gold.requiredClaims must be a non-empty array');
|
|
69
|
+
} else {
|
|
70
|
+
const factIds = new Set((caseDef.facts || []).map((f) => f.factId));
|
|
71
|
+
for (const r of caseDef.gold.requiredClaims) {
|
|
72
|
+
if (!factIds.has(r.factId)) problems.push(`gold requiredClaim references unknown fact ${r.factId}`);
|
|
73
|
+
if (!Array.isArray(r.acceptableVerdicts) || r.acceptableVerdicts.length === 0 || r.acceptableVerdicts.some((v) => !CLAIM_VERDICTS.includes(v))) {
|
|
74
|
+
problems.push(`gold requiredClaim ${r.factId}: acceptableVerdicts must be non-empty subset of ${CLAIM_VERDICTS.join('|')}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Boundary cases (principleApplicable=false) must keep the boundary claim
|
|
78
|
+
// in the denominator per SPEC §9.1 — requiredClaims non-empty enforces it.
|
|
79
|
+
}
|
|
80
|
+
if (problems.length > 0) throw new Error(`validateCaseDef(${caseDef.caseId || '?'}): ${problems.join('; ')}`);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Split a validated case into actorVisible / judgeOnly and hash both.
|
|
86
|
+
* Gold isolation: actorVisible contains NO gold truth, NO implication maps,
|
|
87
|
+
* NO observation outputs (except pre-seeded evidence explicitly embedded in
|
|
88
|
+
* the task context), NO gold/acceptable verdicts, NO family/expectedMode
|
|
89
|
+
* (family labels leak expectations).
|
|
90
|
+
*/
|
|
91
|
+
export function compileCase(caseDef) {
|
|
92
|
+
validateCaseDef(caseDef);
|
|
93
|
+
if (caseDef.schema === CLAIM_EVIDENCE_V2 || caseDef.schema === CLAIM_EVIDENCE_V3) return compileCaseV2(caseDef);
|
|
94
|
+
const env = buildClaimEnvironment(caseDef);
|
|
95
|
+
|
|
96
|
+
const preSeededEvidence = env.preSeeded.map((obsId) => ({ obsId, output: env.observations.get(obsId).output }));
|
|
97
|
+
|
|
98
|
+
const actorVisible = {
|
|
99
|
+
schema: ACTOR_VISIBLE_SCHEMA,
|
|
100
|
+
caseId: caseDef.caseId,
|
|
101
|
+
templateId: CLAIM_EVIDENCE_TEMPLATE_ID,
|
|
102
|
+
templateVersion: CLAIM_EVIDENCE_TEMPLATE_VERSION,
|
|
103
|
+
taskText: caseDef.taskText,
|
|
104
|
+
facts: caseDef.facts.map((f) => ({ factId: f.factId, statement: f.statement })),
|
|
105
|
+
availableObservations: [...env.observations.values()].map((o) => ({ obsId: o.obsId, action: o.action, label: o.label, costUnits: o.costUnits, timeUnits: o.timeUnits })),
|
|
106
|
+
preSeededEvidence,
|
|
107
|
+
constraints: {
|
|
108
|
+
budgetUnits: env.constraints.budgetUnits,
|
|
109
|
+
deadlineUnits: env.constraints.deadlineUnits,
|
|
110
|
+
askOwnerAllowed: env.constraints.askOwnerAllowed,
|
|
111
|
+
...(env.extraActions.deploy ? { extraActions: ['deploy'] } : {}),
|
|
112
|
+
},
|
|
113
|
+
reportSchema: REPORT_SCHEMA_DOC,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const judgeOnly = {
|
|
117
|
+
schema: JUDGE_ONLY_SCHEMA,
|
|
118
|
+
caseId: caseDef.caseId,
|
|
119
|
+
family: caseDef.family,
|
|
120
|
+
studyEligible: caseDef.studyEligible,
|
|
121
|
+
principleApplicable: caseDef.principleApplicable,
|
|
122
|
+
expectedMode: caseDef.expectedMode,
|
|
123
|
+
taskText: caseDef.taskText,
|
|
124
|
+
facts: caseDef.facts.map((f) => ({ factId: f.factId, statement: f.statement, goldTruth: f.goldTruth, observability: f.observability || 'observable', stakes: f.stakes || 'normal' })),
|
|
125
|
+
observations: [...env.observations.values()],
|
|
126
|
+
ownerAnswers: caseDef.ownerAnswers || {},
|
|
127
|
+
extraActions: caseDef.extraActions || {},
|
|
128
|
+
constraints: caseDef.constraints,
|
|
129
|
+
preSeededObservations: caseDef.preSeededObservations || [],
|
|
130
|
+
overCheck: caseDef.overCheck || {},
|
|
131
|
+
gold: caseDef.gold,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const inputHash = hashOfCanonical(actorVisible);
|
|
135
|
+
const judgeOnlyForHash = { ...judgeOnly };
|
|
136
|
+
const goldHash = hashOfCanonical(judgeOnlyForHash);
|
|
137
|
+
judgeOnly.inputHash = inputHash;
|
|
138
|
+
judgeOnly.goldHash = goldHash;
|
|
139
|
+
return { actorVisible, judgeOnly, inputHash, goldHash };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build the frozen suite manifest + suiteHash. suiteHash binds mappingHash,
|
|
144
|
+
* graderHash, template identity and every per-case input/gold hash — any
|
|
145
|
+
* post-freeze change to cases, gold, mapping or scoring contract breaks it.
|
|
146
|
+
*/
|
|
147
|
+
export function compileSuite({ studyId, mappingHash, graderHash, caseDefs }) {
|
|
148
|
+
const cases = [];
|
|
149
|
+
const familyCounts = {};
|
|
150
|
+
for (const cd of caseDefs) {
|
|
151
|
+
validateCaseDef(cd);
|
|
152
|
+
const compiled = compileCase(cd);
|
|
153
|
+
familyCounts[cd.family] = (familyCounts[cd.family] || 0) + 1;
|
|
154
|
+
cases.push({ caseId: cd.caseId, family: cd.family, expectedMode: cd.expectedMode, principleApplicable: cd.principleApplicable, studyEligible: cd.studyEligible, inputHash: compiled.inputHash, goldHash: compiled.goldHash });
|
|
155
|
+
}
|
|
156
|
+
const versions = new Set(caseDefs.map((item) => (item.schema === CLAIM_EVIDENCE_V3 ? 3 : item.schema === CLAIM_EVIDENCE_V2 ? 2 : 1)));
|
|
157
|
+
if (versions.size > 1) throw new Error('cannot mix v1/v2/v3 contracts in one suite');
|
|
158
|
+
const suiteVersion = versions.has(3) ? 3 : versions.has(2) ? 2 : 1;
|
|
159
|
+
const manifestCore = {
|
|
160
|
+
schema: suiteVersion >= 2 ? 'challenge-suite-manifest.v2' : SUITE_MANIFEST_SCHEMA,
|
|
161
|
+
studyId,
|
|
162
|
+
templateId: CLAIM_EVIDENCE_TEMPLATE_ID,
|
|
163
|
+
templateVersion: suiteVersion === 3 ? '3.0.0' : suiteVersion === 2 ? '2.0.0' : CLAIM_EVIDENCE_TEMPLATE_VERSION,
|
|
164
|
+
mappingHash,
|
|
165
|
+
graderHash,
|
|
166
|
+
familyCounts,
|
|
167
|
+
caseCount: caseDefs.length,
|
|
168
|
+
cases,
|
|
169
|
+
};
|
|
170
|
+
const suiteHash = hashOfCanonical(manifestCore);
|
|
171
|
+
const manifest = { ...manifestCore, suiteHash };
|
|
172
|
+
return { manifest, suiteHash };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function compileCaseV2(caseDef) {
|
|
176
|
+
const env = buildClaimEnvironment(caseDef);
|
|
177
|
+
// v3 cases compile through the same actor/judge split with their own
|
|
178
|
+
// version labels; the v3 gold additions (principlePredicates/costEnvelope)
|
|
179
|
+
// ride only in judgeOnly — the actor-visible projection below never reads
|
|
180
|
+
// them, so no predicate or envelope can leak to the Actor.
|
|
181
|
+
const v3 = caseDef.schema === CLAIM_EVIDENCE_V3;
|
|
182
|
+
const actorVisible = {
|
|
183
|
+
schema: v3 ? 'claim-evidence-actor-visible.v3' : 'claim-evidence-actor-visible.v2', caseId: caseDef.caseId,
|
|
184
|
+
templateId: CLAIM_EVIDENCE_TEMPLATE_ID, templateVersion: v3 ? '3.0.0' : '2.0.0',
|
|
185
|
+
taskText: caseDef.taskText,
|
|
186
|
+
facts: caseDef.facts.map(({ factId, statement }) => ({ factId, statement })),
|
|
187
|
+
availableObservations: caseDef.observations.map(({ obsId, action, label, costUnits, timeUnits }) => ({ obsId, action, label, costUnits, timeUnits })),
|
|
188
|
+
availableOwnerRequests: Object.keys(caseDef.ownerAnswers || {}).map((requestType) => ({ requestType })),
|
|
189
|
+
behaviorActions: { refuse: { factId: 'fact identifier' }, 'block-path': { obsId: 'registered observation identifier' } },
|
|
190
|
+
preSeededEvidence: env.preSeeded.map((obsId) => ({ obsId, output: env.observations.get(obsId).output })),
|
|
191
|
+
constraints: { ...env.constraints, ...(env.extraActions.deploy ? { extraActions: ['deploy'] } : {}),
|
|
192
|
+
...(caseDef.gold.pathConstraints ? { pathConstraints: caseDef.gold.pathConstraints.map(({ obsId, decision, reasonCode }) => ({ obsId, decision, reasonCode })) } : {}) },
|
|
193
|
+
reportSchema: {
|
|
194
|
+
action: { type: 'report', args: { claims: [{ factId: 'string', verdict: 'verified_true|verified_false|inferred|unknown', evidenceRefs: ['obsId of an executed observation'], minimalSteps: 'string? (uncertainty verification steps)' }] } },
|
|
195
|
+
note: 'Prefer unique eventId references to executed observations or pre-seeded evidence. An obsId is accepted only when it resolves to exactly one event. inferred is a tentative assessment with executed basis and next steps; unknown states that no directional conclusion is justified and gives next steps. Narrative is not evidence.',
|
|
196
|
+
...(caseDef.gold.assessmentContract === 'structured.v1' ? {
|
|
197
|
+
assessmentContract: 'structured.v1',
|
|
198
|
+
uncertaintyRequirements: structuredClone(caseDef.gold.uncertaintyRules),
|
|
199
|
+
inferredRequired: { assessment: { observedEventRefs: ['executed eventId'], tentativeDirection: 'true|false (tentative, never verified)', nextSteps: [{ type: 'observe|ask-owner|request-evidence', target: 'registered observation/requestType/factId' }] } },
|
|
200
|
+
unknownRequired: { uncertainty: { reasonCode: 'MISSING_EVIDENCE|CONFLICTING_EVIDENCE|STALE_EVIDENCE', nextSteps: [{ type: 'observe|ask-owner|request-evidence', target: 'registered observation/requestType/factId' }] } },
|
|
201
|
+
} : {}),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
const judgeOnly = structuredClone(caseDef);
|
|
205
|
+
delete judgeOnly.inputHash;
|
|
206
|
+
delete judgeOnly.goldHash;
|
|
207
|
+
const inputHash = hashOfCanonical(actorVisible);
|
|
208
|
+
const goldHash = hashOfCanonical(judgeOnly);
|
|
209
|
+
Object.assign(judgeOnly, { inputHash, goldHash });
|
|
210
|
+
return { actorVisible, judgeOnly, inputHash, goldHash };
|
|
211
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// SPEC-PD-EXAMINER-001 v0.3 — Phase 1A freeze/hash primitives for challenge studies.
|
|
2
|
+
//
|
|
3
|
+
// Canonicalization and sha256 are REUSED from fixture.mjs (single source of
|
|
4
|
+
// truth for the lab's hash basis). The same algorithm produced the Phase 0
|
|
5
|
+
// verified source hashes (principleContentHash 8ac06edd…, statementSha256
|
|
6
|
+
// ae275f0f…, painEventRowSha256 679f23d6…, diagnosisRowsSha256 f396fb6a…),
|
|
7
|
+
// so frozen assets hash-compatibly with the Phase 0 audit evidence.
|
|
8
|
+
//
|
|
9
|
+
// Hash rule (recorded in every study's hashes.json):
|
|
10
|
+
// sha256( canonicalJson(value) ) where canonicalJson sorts object keys,
|
|
11
|
+
// emits no whitespace, keeps array order, and encodes UTF-8.
|
|
12
|
+
|
|
13
|
+
import { canonicalJson, sha256 } from './fixture.mjs';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
export { canonicalJson, sha256 };
|
|
20
|
+
|
|
21
|
+
export const HASH_ALGORITHM = 'sha256';
|
|
22
|
+
export const CANONICALIZATION_RULE =
|
|
23
|
+
'fixture.mjs canonicalJson: UTF-8, object keys sorted lexicographically, no whitespace, arrays keep order; sha256 over the canonical string encoded utf8';
|
|
24
|
+
|
|
25
|
+
export function hashOfCanonical(value) {
|
|
26
|
+
return sha256(canonicalJson(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Pin the complete local execution implementation, including transitive lab
|
|
30
|
+
* modules and Node version, rather than only the scoring contract JSON. */
|
|
31
|
+
export function challengeImplementationManifest() {
|
|
32
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
33
|
+
if (nodeMajor < 22 || nodeMajor > 26) throw new Error('UNVERIFIED_NODE_RUNTIME');
|
|
34
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const files = fs.readdirSync(dir).filter((name) => name.endsWith('.mjs')).sort()
|
|
36
|
+
.map((name) => ({ name, sha256: sha256(fs.readFileSync(path.join(dir, name))) }));
|
|
37
|
+
return { node: 'node-22-to-26-offline-js-v1', files };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Freeze a PainMechanismMappingV1 object: mappingHash is computed over the
|
|
42
|
+
* mapping with the mappingHash field REMOVED (a hash cannot contain itself).
|
|
43
|
+
* The same rule must be used by every verifier — it is recorded in hashes.json.
|
|
44
|
+
*/
|
|
45
|
+
export function freezeMapping(mapping) {
|
|
46
|
+
const { mappingHash: _omitted, ...rest } = mapping;
|
|
47
|
+
return { mappingHash: hashOfCanonical(rest) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Recompute and compare a recorded hash for a JSON asset.
|
|
52
|
+
* `stripFields` names self-referential hash fields to remove before hashing.
|
|
53
|
+
*/
|
|
54
|
+
export function verifyCanonicalHash(value, recordedHash, { stripFields = [] } = {}) {
|
|
55
|
+
const clone = { ...value };
|
|
56
|
+
for (const f of stripFields) delete clone[f];
|
|
57
|
+
const recomputed = hashOfCanonical(clone);
|
|
58
|
+
return { ok: recomputed === recordedHash, recomputed, recordedHash };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// v0.5 admission consumes an explicit OFFLINE source package. Historical
|
|
62
|
+
// freezeMapping remains a hash primitive; it never pretends to verify sources.
|
|
63
|
+
export function verifyExaminerSourceBundle({ bundle, mappingId, mappingHash, inputManifestHash, mode, reviewTrust = null }) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
66
|
+
const text = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
67
|
+
if (!object(bundle) || !object(bundle.mapping) || !Array.isArray(bundle.sourceArtifacts)) {
|
|
68
|
+
return { ok: false, status: 'NO_GO', reasonCode: 'SOURCE_PACKAGE_MISSING', problems: ['mapping and offline sourceArtifacts are required'], nextAction: 'Supply the independently reviewed offline source package.' };
|
|
69
|
+
}
|
|
70
|
+
const mapping = bundle.mapping;
|
|
71
|
+
if (mapping.schema !== 'PainMechanismMappingV1') problems.push('unknown mapping schema');
|
|
72
|
+
if (mapping.mappingId !== mappingId || mapping.mappingHash !== mappingHash || freezeMapping(mapping).mappingHash !== mappingHash) problems.push('mapping identity/hash mismatch');
|
|
73
|
+
const artifacts = new Map();
|
|
74
|
+
for (const a of bundle.sourceArtifacts) {
|
|
75
|
+
if (!object(a) || !text(a.ref) || !Object.hasOwn(a, 'content') || typeof a.contentHash !== 'string' || hashOfCanonical(a.content) !== a.contentHash || artifacts.has(a.ref)) {
|
|
76
|
+
problems.push('malformed, duplicate or hash-mismatched source artifact');
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
artifacts.set(a.ref, a);
|
|
80
|
+
}
|
|
81
|
+
const fields = ['observedFailure', 'failureMechanism', 'ownerGroundedGoal', 'sourceScribeArtifactId', 'principleContentHash'];
|
|
82
|
+
for (const f of fields) if (!text(mapping[f])) problems.push(`mapping.${f} required`);
|
|
83
|
+
for (const f of ['applicabilityConditions', 'exceptionConditions', 'observableActions', 'objectiveConsequences', 'scoringPredicates', 'legalAlternativePaths']) {
|
|
84
|
+
if (!Array.isArray(mapping[f]) || mapping[f].length === 0 || !mapping[f].every(text)) problems.push(`mapping.${f} must contain non-empty strings`);
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(mapping.sourcePainRefs) || mapping.sourcePainRefs.length === 0) problems.push('mapping.sourcePainRefs required');
|
|
87
|
+
for (const ref of Array.isArray(mapping.sourcePainRefs) ? mapping.sourcePainRefs : []) {
|
|
88
|
+
const artifact = object(ref) && artifacts.get(ref.id);
|
|
89
|
+
if (!artifact || artifact.contentHash !== ref.evidenceHash) problems.push('Pain source missing or evidence hash mismatch');
|
|
90
|
+
}
|
|
91
|
+
if (!Array.isArray(mapping.trustedGoldSourceRefs) || mapping.trustedGoldSourceRefs.length === 0) problems.push('mapping.trustedGoldSourceRefs required');
|
|
92
|
+
for (const ref of Array.isArray(mapping.trustedGoldSourceRefs) ? mapping.trustedGoldSourceRefs : []) {
|
|
93
|
+
const artifact = object(ref) && artifacts.get(ref.ref);
|
|
94
|
+
if (!artifact || artifact.contentHash !== ref.hash) problems.push('mapping trusted Gold source missing/hash mismatch');
|
|
95
|
+
}
|
|
96
|
+
const scribe = artifacts.get(mapping.sourceScribeArtifactId);
|
|
97
|
+
const principle = artifacts.get('principle');
|
|
98
|
+
const manifest = artifacts.get('input-manifest');
|
|
99
|
+
if (!manifest || manifest.contentHash !== inputManifestHash) problems.push('input manifest missing/hash mismatch');
|
|
100
|
+
if (!scribe || !object(scribe.content) || scribe.content.artifactId !== mapping.sourceScribeArtifactId || scribe.content.principleContentHash !== mapping.principleContentHash) problems.push('Scribe artifact identity/principle lineage mismatch');
|
|
101
|
+
if (!principle || principle.contentHash !== mapping.principleContentHash) problems.push('Principle source missing/hash mismatch');
|
|
102
|
+
if (scribe && object(scribe.content) && (!Array.isArray(scribe.content.sourcePainRefs) || !Array.isArray(mapping.sourcePainRefs) || !mapping.sourcePainRefs.every((p) => object(p) && scribe.content.sourcePainRefs.includes(p.id)))) problems.push('Scribe/Pain lineage mismatch');
|
|
103
|
+
const review = verifyExaminerReview({ review: bundle.mappingReview, schema: 'examiner-mapping-review.v1', mode, reviewTrust });
|
|
104
|
+
problems.push(...review.problems);
|
|
105
|
+
if (bundle.mappingReview?.mappingHash !== mappingHash) problems.push('mapping review is not bound to current mappingHash');
|
|
106
|
+
return { ok: problems.length === 0, status: problems.length ? 'NO_GO' : mode === 'calibration' ? 'CALIBRATION_ONLY' : 'SOURCE_VERIFIED', reasonCode: problems.length ? 'SOURCE_OR_REVIEW_INVALID' : null, problems, nextAction: problems.length ? 'Provide matching offline evidence and an independent review of the current mapping.' : null, eligibleForFormalSet: problems.length === 0 && mode === 'formal' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function verifyExaminerReview({ review, schema, mode, reviewTrust = null }) {
|
|
110
|
+
const problems = [];
|
|
111
|
+
const text = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
112
|
+
if (!review || typeof review !== 'object' || Array.isArray(review)) return { ok: false, problems: ['review receipt missing'] };
|
|
113
|
+
if (!['formal', 'calibration'].includes(mode)) problems.push('unknown review mode');
|
|
114
|
+
// There is no trusted human-review authority installed by this offline
|
|
115
|
+
// sprint. A role string supplied alongside model/fixture data is not one.
|
|
116
|
+
if (mode === 'formal') problems.push(...verifyHumanReviewSignature({ review, schema, reviewTrust }).problems);
|
|
117
|
+
if (review.schema !== schema || review.decision !== 'APPROVED') problems.push('review receipt schema/decision invalid or GOLD_REVIEW_PENDING');
|
|
118
|
+
if (!text(review.reviewerId) || !text(review.reason) || !text(review.reviewedAt) || !Number.isFinite(Date.parse(review.reviewedAt))) problems.push('reviewer, reason and timestamp required');
|
|
119
|
+
if (!['INDEPENDENT_REVIEWER', 'FIXTURE'].includes(review.reviewerRole) || (mode === 'formal' && review.reviewerRole !== 'INDEPENDENT_REVIEWER')) problems.push('independent reviewer required; fixture receipt is calibration only');
|
|
120
|
+
return { ok: problems.length === 0, problems };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Trust is supplied by the offline operator, never by a candidate/receipt.
|
|
124
|
+
* The Owner pin is intentionally separate from the untrusted authority file. */
|
|
125
|
+
export function verifyHumanReviewSignature({ review, schema, reviewTrust }) {
|
|
126
|
+
const fail = (reason) => ({ ok: false, problems: [`GOLD_REVIEW_PENDING: ${reason}`] });
|
|
127
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
128
|
+
const iso = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
129
|
+
if (!object(reviewTrust) || !object(reviewTrust.authority) || !/^[a-f0-9]{64}$/.test(reviewTrust.trustedAuthorityHash || '')) return fail('independent human-review authority is not established');
|
|
130
|
+
const authority = reviewTrust.authority;
|
|
131
|
+
if (hashOfCanonical(authority) !== reviewTrust.trustedAuthorityHash) return fail('Owner authority pin mismatch');
|
|
132
|
+
if (authority.schema !== 'examiner-review-authority.v1' || typeof authority.ownerDesignationRef !== 'string' || !authority.ownerDesignationRef.trim() || !Array.isArray(authority.reviewers) || !Array.isArray(authority.excludedAuthors) || !authority.excludedAuthors.every((value) => typeof value === 'string')) return fail('invalid Owner-designated authority');
|
|
133
|
+
if (!object(review) || review.schema !== schema || review.scope !== 'OFFLINE_GOLD_ONLY' || review.subjectHash !== reviewTrust.subjectHash || !/^[a-f0-9]{64}$/.test(review.subjectHash || '')) return fail('review subject/schema/scope mismatch');
|
|
134
|
+
if (!iso(reviewTrust.now) || !iso(review.reviewedAt) || Date.parse(review.reviewedAt) > Date.parse(reviewTrust.now)) return fail('invalid/future review time');
|
|
135
|
+
const matches = authority.reviewers.filter((entry) => object(entry) && entry.id === review.reviewerId);
|
|
136
|
+
if (matches.length !== 1) return fail('unknown/ambiguous reviewer identity');
|
|
137
|
+
const signer = matches[0];
|
|
138
|
+
if (signer.kind !== 'HUMAN' || signer.independent !== true || signer.revoked !== false || signer.canApproveGold !== true || authority.excludedAuthors.includes(signer.id) || typeof signer.identityEvidenceRef !== 'string' || !signer.identityEvidenceRef.trim()) return fail('reviewer permission or independence denied');
|
|
139
|
+
if (!Array.isArray(signer.allowedSchemas) || !signer.allowedSchemas.every((value) => typeof value === 'string') || !signer.allowedSchemas.includes(schema) || !Array.isArray(signer.allowedSubjects) || !signer.allowedSubjects.every((value) => typeof value === 'string') || !signer.allowedSubjects.includes(review.subjectHash)) return fail('reviewer is not authorized for this schema/subject');
|
|
140
|
+
if (!iso(signer.validFrom) || !iso(signer.validUntil) || Date.parse(signer.validFrom) > Date.parse(review.reviewedAt) || Date.parse(signer.validUntil) < Date.parse(reviewTrust.now)) return fail('reviewer grant not valid now');
|
|
141
|
+
const keys = ['schema', 'reviewerId', 'reviewerRole', 'decision', 'reviewedAt', 'reason', 'mappingHash', 'caseId', 'goldHash', 'sourceRefs', 'subjectHash', 'scope', 'goldSourcesVerified', 'signature'];
|
|
142
|
+
if (Object.keys(review).some((key) => !keys.includes(key)) || review.goldSourcesVerified !== true) return fail('unrecognized receipt fields or missing explicit source review');
|
|
143
|
+
try {
|
|
144
|
+
const key = createPublicKey(signer.publicKeyPem);
|
|
145
|
+
if (key.asymmetricKeyType !== 'ed25519' || typeof review.signature !== 'string') return fail('Ed25519 signature required');
|
|
146
|
+
const bytes = Buffer.from(review.signature, 'base64');
|
|
147
|
+
if (bytes.length !== 64 || bytes.toString('base64') !== review.signature) return fail('invalid signature encoding');
|
|
148
|
+
const { signature: _signature, ...payload } = review;
|
|
149
|
+
if (!verifySignature(null, Buffer.from(canonicalJson(payload)), key, bytes)) return fail('signature mismatch');
|
|
150
|
+
} catch { return fail('invalid signature/key material'); }
|
|
151
|
+
return { ok: true, problems: [] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function offlineReviewSubjectHash(dir) {
|
|
155
|
+
const manifest = verifyOfflinePackage(dir);
|
|
156
|
+
// Full input includes cases, source bundle, strategies and runner hash.
|
|
157
|
+
// Real source files and implementation are bound too. Receipts/mechanical
|
|
158
|
+
// outputs are excluded: their later addition must not create a hash cycle.
|
|
159
|
+
const files = manifest.files.filter((entry) => entry.path === 'inputs.json' || entry.path === 'source-verification.json' || entry.path === 'source-audit.json' || entry.path.startsWith('real-source/'));
|
|
160
|
+
if (!files.some((entry) => entry.path === 'inputs.json') || !files.some((entry) => entry.path === 'source-verification.json')) throw new Error('REVIEW_SUBJECT_INCOMPLETE');
|
|
161
|
+
return hashOfCanonical({ schema: 'examiner-review-subject.v1', files, implementation: manifest.implementation });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Content-addressed offline package. Review status is data to freeze, never
|
|
165
|
+
// something this integrity checker can promote or approve.
|
|
166
|
+
export function offlinePackageManifest(dir) {
|
|
167
|
+
const files = [];
|
|
168
|
+
const walk = (relative) => {
|
|
169
|
+
for (const entry of fs.readdirSync(path.join(dir, relative), { withFileTypes: true })) {
|
|
170
|
+
const name = path.posix.join(relative, entry.name);
|
|
171
|
+
if (name === 'package-manifest.json') continue;
|
|
172
|
+
if (entry.isSymbolicLink()) throw new Error('offline package symlink refused');
|
|
173
|
+
if (entry.isDirectory()) walk(name);
|
|
174
|
+
else if (entry.isFile()) files.push({ path: name, sha256: sha256(fs.readFileSync(path.join(dir, name))) });
|
|
175
|
+
else throw new Error('unsupported offline package entry');
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
walk('');
|
|
179
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
180
|
+
const core = { schema: 'examiner-offline-package.v1', studyId: 'EXM-MVP-001',
|
|
181
|
+
status: 'OFFLINE_ONLY_NOT_AUTHORIZED', implementation: challengeImplementationManifest(), files };
|
|
182
|
+
return { ...core, packageHash: hashOfCanonical(core) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function describePackageDrift(recorded, actual) {
|
|
186
|
+
const moved = (before, after, key) => {
|
|
187
|
+
const beforeMap = new Map((before ?? []).map((entry) => [entry[key], entry.sha256]));
|
|
188
|
+
const afterMap = new Map((after ?? []).map((entry) => [entry[key], entry.sha256]));
|
|
189
|
+
return [...new Set([...beforeMap.keys(), ...afterMap.keys()])]
|
|
190
|
+
.filter((name) => beforeMap.get(name) !== afterMap.get(name));
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
movedFiles: moved(recorded.files, actual.files, 'path'),
|
|
194
|
+
movedImplementation: moved(recorded.implementation?.files, actual.implementation?.files, 'name'),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function verifyOfflinePackage(dir) {
|
|
199
|
+
const recorded = JSON.parse(fs.readFileSync(path.join(dir, 'package-manifest.json'), 'utf8'));
|
|
200
|
+
const actual = offlinePackageManifest(dir);
|
|
201
|
+
if (hashOfCanonical(recorded) !== hashOfCanonical(actual)) {
|
|
202
|
+
const { movedFiles, movedImplementation } = describePackageDrift(recorded, actual);
|
|
203
|
+
const detail = [
|
|
204
|
+
movedFiles.length ? `package files moved: ${movedFiles.join(', ')}` : null,
|
|
205
|
+
movedImplementation.length ? `implementation binding moved: ${movedImplementation.join(', ')}` : null,
|
|
206
|
+
].filter(Boolean).join('; ') || 'package-manifest.json itself differs from the package bytes';
|
|
207
|
+
// The rebinder is the only sanctioned writer, and it refuses unexpected
|
|
208
|
+
// package-file movement, so pointing at it never legitimizes tampering.
|
|
209
|
+
throw new Error(`OFFLINE_PACKAGE_DRIFT (${detail}). `
|
|
210
|
+
+ 'An intentional lib change is rebound with '
|
|
211
|
+
+ '`node scripts/dev/principle-learning-lab/rebind-exm-v4-freeze.mjs --record-evidence`; '
|
|
212
|
+
+ 'the tool refuses unexpected package-file movement, so never edit package bytes to silence this check.');
|
|
213
|
+
}
|
|
214
|
+
return actual;
|
|
215
|
+
}
|