create-principles-disciple 1.145.42 → 1.146.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/_release/product-identity.json +2 -2
  2. package/console/dist/server/index.js +8 -0
  3. package/console/dist/server/routes/examiner-executor.d.ts +260 -0
  4. package/console/dist/server/routes/examiner-executor.js +628 -0
  5. package/console/dist/server/routes/examiner.d.ts +52 -0
  6. package/console/dist/server/routes/examiner.js +299 -0
  7. package/console/dist/ui/App.js +6 -3
  8. package/console/dist/ui/api.d.ts +138 -0
  9. package/console/dist/ui/api.js +56 -0
  10. package/console/dist/ui/components/layout/app-sidebar.d.ts +10 -0
  11. package/console/dist/ui/components/layout/app-sidebar.js +10 -2
  12. package/console/dist/ui/i18n/en.json +63 -0
  13. package/console/dist/ui/i18n/zh-CN.json +63 -0
  14. package/console/dist/ui/pages/examiner/ExaminerPage.d.ts +15 -0
  15. package/console/dist/ui/pages/examiner/ExaminerPage.js +158 -0
  16. package/console/dist/ui/pages/principles/PrincipleDetailPage.js +2 -1
  17. package/console/dist/ui/utils/runtime-mode.d.ts +11 -0
  18. package/console/dist/ui/utils/runtime-mode.js +11 -0
  19. package/console/dist/web/assets/app.css +44 -0
  20. package/console/dist/web/assets/app.js +33 -31
  21. package/console/lab/lib/challenge-actor.mjs +394 -0
  22. package/console/lab/lib/challenge-arms.mjs +102 -0
  23. package/console/lab/lib/challenge-behavior-report.mjs +2484 -0
  24. package/console/lab/lib/challenge-compile.mjs +211 -0
  25. package/console/lab/lib/challenge-freeze.mjs +215 -0
  26. package/console/lab/lib/challenge-isolation-probe.mjs +550 -0
  27. package/console/lab/lib/challenge-ledger.mjs +206 -0
  28. package/console/lab/lib/challenge-openclaw-host.mjs +630 -0
  29. package/console/lab/lib/challenge-runner.mjs +868 -0
  30. package/console/lab/lib/challenge-stage-r-gate.mjs +458 -0
  31. package/console/lab/lib/challenge-statistics.mjs +591 -0
  32. package/console/lab/lib/claim-evidence-env.mjs +652 -0
  33. package/console/lab/lib/claim-evidence-scorer.mjs +784 -0
  34. package/console/lab/lib/examiner-injection-evidence.mjs +367 -0
  35. package/console/lab/lib/examiner-study.mjs +1203 -0
  36. package/console/lab/lib/examiner-v07-cases.mjs +187 -0
  37. package/console/lab/lib/examiner-v2-cases.mjs +61 -0
  38. package/console/lab/lib/fixture.mjs +382 -0
  39. package/console/lab/lib/g2.mjs +146 -0
  40. package/console/lab/lib/host-actor.mjs +987 -0
  41. package/console/lab/lib/sidecar.mjs +273 -0
  42. package/package.json +1 -1
  43. package/pd-cli/package.json +1 -1
  44. package/release-manager/package.json +1 -1
@@ -0,0 +1,628 @@
1
+ /**
2
+ * SPEC-PD-EXAMINER-001 v0.9 §8 — Examiner Console executor (offline-stub).
3
+ *
4
+ * Receives an ALREADY-RESOLVED study directory (selected by exact membership
5
+ * in the server's own enumeration — see examiner.ts) and performs the offline
6
+ * preflight / execution / report actions through the principle-learning-lab
7
+ * modules. This module never touches request objects and never transports a
8
+ * caller-supplied execution mode: the only reachable mode is offline-stub.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { pathToFileURL } from 'node:url';
14
+ export const CONSOLE_OWNERSHIP_MARKER = '.console-study-owner.json';
15
+ const OWNERSHIP_SCHEMA = 'examiner-console-ownership.v1';
16
+ const HANDLE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
17
+ /** Stable, Owner-facing boundary reasons — never paths or exception text. */
18
+ export const STUDY_PATH_PROBLEM = {
19
+ unresolved: 'the study path cannot be resolved to a canonical location (fail-closed)',
20
+ notInside: 'the study directory resolves outside the writable console root',
21
+ historical: 'the study directory resolves inside the historical research area',
22
+ rootNotIsolated: 'the writable study root must be separate from the historical examiner-studies area',
23
+ };
24
+ /**
25
+ * P1-1 canonical resolution. An existing path goes through `realpath`, which
26
+ * follows symlinks AND Windows junctions. A path that does not exist yet is
27
+ * resolved through its deepest EXISTING ancestor (so a symlinked parent can
28
+ * never hide an escape) and the remaining lexically-safe segments are
29
+ * appended. Any resolution failure is fail-closed (null).
30
+ */
31
+ export function canonicalPathSync(target) {
32
+ let current = path.resolve(target);
33
+ const remainder = [];
34
+ for (;;) {
35
+ let exists = false;
36
+ try {
37
+ fs.lstatSync(current);
38
+ exists = true;
39
+ }
40
+ catch (error) {
41
+ const { code } = error;
42
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
43
+ return null;
44
+ }
45
+ if (exists) {
46
+ try {
47
+ const real = fs.realpathSync.native(current);
48
+ return remainder.length > 0 ? path.join(real, ...remainder.reverse()) : real;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ const parent = path.dirname(current);
55
+ if (parent === current)
56
+ return null;
57
+ remainder.push(path.basename(current));
58
+ current = parent;
59
+ }
60
+ }
61
+ const foldPath = (value) => (process.platform === 'win32' ? value.toLowerCase() : value);
62
+ export function isSamePath(a, b) {
63
+ return foldPath(path.resolve(a)) === foldPath(path.resolve(b));
64
+ }
65
+ /** Strict containment: equal paths are NOT inside each other. */
66
+ export function isPathInside(root, candidate) {
67
+ const r = foldPath(path.resolve(root));
68
+ const c = foldPath(path.resolve(candidate));
69
+ const prefix = r.endsWith(path.sep) ? r : `${r}${path.sep}`;
70
+ return c !== r && c.startsWith(prefix);
71
+ }
72
+ /**
73
+ * Resolve the two roots once. Console-created studies are WORKSPACE DATA, not
74
+ * repository content: they default to `<dataRoot>/studies`, where the caller
75
+ * passes `<workspace>/.pd/examiner`. `PD_EXAMINER_STUDIES_ROOT` overrides the
76
+ * WRITABLE root only (tests / operators). The historical research assets ship
77
+ * with the lab payload (absent in an installation → an empty read-only list).
78
+ */
79
+ export function resolveStudyRoots(labRoot, dataRoot) {
80
+ const override = process.env.PD_EXAMINER_STUDIES_ROOT;
81
+ const writable = override ? path.resolve(override) : path.join(path.resolve(dataRoot), 'studies');
82
+ const historical = path.join(path.resolve(labRoot), 'examiner-studies');
83
+ // Canonical forms when resolvable; the unresolved form is kept so the
84
+ // boundary gates below can fail closed with a stable reason.
85
+ return {
86
+ writable: canonicalPathSync(writable) ?? writable,
87
+ historical: canonicalPathSync(historical) ?? historical,
88
+ };
89
+ }
90
+ export function isJsonObject(value) {
91
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
92
+ }
93
+ /** Read an optional string field off an untrusted object (never throws). */
94
+ function stringField(value, key) {
95
+ if (!isJsonObject(value))
96
+ return null;
97
+ const field = value[key];
98
+ return typeof field === 'string' ? field : null;
99
+ }
100
+ function isPrincipleFact(value) {
101
+ return isJsonObject(value) && typeof value.id === 'string' && typeof value.text === 'string';
102
+ }
103
+ function asJsonObject(value, context) {
104
+ if (!isJsonObject(value))
105
+ throw new Error(`${context}: expected a JSON object`);
106
+ return value;
107
+ }
108
+ function readJsonObjectFile(file) {
109
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
110
+ return asJsonObject(parsed, file);
111
+ }
112
+ /* eslint-disable @typescript-eslint/max-params -- lab delegation calls carry (modules, labRoot/studiesRoot, dir, data) by design */
113
+ /** Boundary-checked study file reader (fails closed on missing/malformed). */
114
+ export function readStudyJson(studyDir, rel) {
115
+ const root = path.resolve(studyDir);
116
+ const file = path.resolve(root, rel);
117
+ if (!isPathInside(root, file))
118
+ throw new Error('invalid study file path');
119
+ return readJsonObjectFile(file);
120
+ }
121
+ /**
122
+ * List the study directory names the server itself enumerated under the
123
+ * studies root (sorted). The route layer treats this list as the allowlist.
124
+ */
125
+ export function listStudyNames(studiesRoot) {
126
+ if (!fs.existsSync(studiesRoot))
127
+ return [];
128
+ return fs.readdirSync(studiesRoot, { withFileTypes: true })
129
+ .filter((entry) => entry.isDirectory())
130
+ .map((entry) => entry.name)
131
+ .sort();
132
+ }
133
+ /**
134
+ * P1-1: verify that a directory under the WRITABLE root was really created by
135
+ * this server. Name membership is not enough and file existence is not enough:
136
+ * the directory must
137
+ * 1. resolve (realpath) strictly INSIDE the writable root,
138
+ * 2. be a real directory (not a symlink/junction),
139
+ * 3. carry the server ownership marker whose studyId matches BOTH the
140
+ * directory name and the frozen examiner manifest.
141
+ * A historical research asset can therefore never be written, even if a file
142
+ * named like the marker is dropped beside it.
143
+ */
144
+ export function verifyConsoleOwnership(writableRoot, studyDir) {
145
+ const rootReal = canonicalPathSync(writableRoot);
146
+ const dirReal = canonicalPathSync(studyDir);
147
+ if (!rootReal || !dirReal)
148
+ return { ok: false, reason: STUDY_PATH_PROBLEM.unresolved };
149
+ if (!isPathInside(rootReal, dirReal))
150
+ return { ok: false, reason: STUDY_PATH_PROBLEM.notInside };
151
+ let stat;
152
+ try {
153
+ stat = fs.lstatSync(studyDir);
154
+ }
155
+ catch {
156
+ return { ok: false, reason: 'study directory is unreadable' };
157
+ }
158
+ if (stat.isSymbolicLink() || !stat.isDirectory())
159
+ return { ok: false, reason: 'study directory is not a real directory' };
160
+ const markerPath = path.join(studyDir, CONSOLE_OWNERSHIP_MARKER);
161
+ // The marker is the trust anchor: a symlink planted here would redirect the
162
+ // read outside the study, so it must be a regular file.
163
+ let markerStat;
164
+ try {
165
+ markerStat = fs.lstatSync(markerPath);
166
+ }
167
+ catch {
168
+ return { ok: false, reason: 'console ownership marker missing — the study was not created by this server' };
169
+ }
170
+ if (markerStat.isSymbolicLink() || !markerStat.isFile()) {
171
+ return { ok: false, reason: 'console ownership marker is not a regular file' };
172
+ }
173
+ let marker;
174
+ let manifest;
175
+ try {
176
+ marker = readJsonObjectFile(markerPath);
177
+ manifest = readStudyJson(studyDir, 'examiner-manifest.json');
178
+ }
179
+ catch (error) {
180
+ // The reason travels into HTTP payloads, so it must not carry raw
181
+ // exception text (paths/internals); the detail stays in the server log.
182
+ console.error('[pd-console] examiner ownership check failed:', error);
183
+ return { ok: false, reason: 'ownership marker or manifest is unreadable (details in the console server log)' };
184
+ }
185
+ const name = path.basename(studyDir);
186
+ if (marker.schema !== OWNERSHIP_SCHEMA || marker.origin !== 'pd-console') {
187
+ return { ok: false, reason: 'console ownership marker is not a valid pd-console record' };
188
+ }
189
+ if (marker.studyId !== name || manifest.studyId !== name) {
190
+ return { ok: false, reason: 'ownership marker / manifest studyId does not match the directory identity' };
191
+ }
192
+ // The marker names the root it was issued for; compare CANONICAL forms so a
193
+ // symlink/junction alias of the same root cannot pass (or be forged).
194
+ const markerRoot = typeof marker.writableRoot === 'string' ? canonicalPathSync(marker.writableRoot) : null;
195
+ if (!markerRoot || !isSamePath(markerRoot, rootReal)) {
196
+ return { ok: false, reason: 'ownership marker was issued for a different writable root' };
197
+ }
198
+ return { ok: true, reason: 'server-owned console study' };
199
+ }
200
+ /**
201
+ * Resolve a requested study by EXACT membership in the server's own
202
+ * enumeration of BOTH roots. The returned path is always built from an
203
+ * enumerated entry name; callers only select, never construct path segments.
204
+ * A writable entry that resolves outside the writable root (or into the
205
+ * historical area) is returned READ-ONLY with a stable boundary reason.
206
+ */
207
+ export function resolveStudyAccess(roots, requested) {
208
+ if (!HANDLE_PATTERN.test(requested))
209
+ return null;
210
+ const writableRoot = canonicalPathSync(roots.writable) ?? path.resolve(roots.writable);
211
+ const writableName = listStudyNames(writableRoot).find((entry) => entry === requested);
212
+ if (writableName) {
213
+ const dir = path.join(writableRoot, writableName);
214
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define -- hoisted function declaration
215
+ const boundary = writableStudyDirProblem(roots, dir);
216
+ if (boundary)
217
+ return { dir, studyId: writableName, readOnly: true, reason: boundary };
218
+ const ownership = verifyConsoleOwnership(roots.writable, dir);
219
+ return { dir, studyId: writableName, readOnly: !ownership.ok, reason: ownership.reason };
220
+ }
221
+ const historicalRoot = canonicalPathSync(roots.historical) ?? path.resolve(roots.historical);
222
+ const historicalName = listStudyNames(historicalRoot).find((entry) => entry === requested);
223
+ if (historicalName) {
224
+ const dir = path.join(historicalRoot, historicalName);
225
+ const dirReal = canonicalPathSync(dir);
226
+ const historicalReal = canonicalPathSync(roots.historical);
227
+ // An entry that resolves outside the historical root is not this study.
228
+ if (!dirReal || !historicalReal || !isPathInside(historicalReal, dirReal))
229
+ return null;
230
+ return {
231
+ dir: dirReal,
232
+ studyId: historicalName,
233
+ readOnly: true,
234
+ reason: 'historical research asset — readable, never writable from the console',
235
+ };
236
+ }
237
+ return null;
238
+ }
239
+ /**
240
+ * The writable root must never be, contain, or be contained by the historical
241
+ * research area. Both the LEXICAL pair and the CANONICAL pair are checked:
242
+ * lexical catches obvious aliases, canonical catches symlink/junction aliases
243
+ * and their not-yet-existing descendants. Unresolvable roots fail closed.
244
+ */
245
+ export function writableRootProblem(roots) {
246
+ const writableLexical = path.resolve(roots.writable);
247
+ const historicalLexical = path.resolve(roots.historical);
248
+ const writable = canonicalPathSync(writableLexical);
249
+ const historical = canonicalPathSync(historicalLexical);
250
+ if (!writable || !historical)
251
+ return STUDY_PATH_PROBLEM.unresolved;
252
+ const pairs = [[writableLexical, historicalLexical], [writable, historical]];
253
+ for (const [w, h] of pairs) {
254
+ if (isSamePath(w, h) || isPathInside(h, w) || isPathInside(w, h))
255
+ return STUDY_PATH_PROBLEM.rootNotIsolated;
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * Unified write-boundary check for ONE study directory: inside the canonical
261
+ * writable root AND outside the canonical historical area. Used by every
262
+ * write path (creation, ownership marker, run, report) immediately before the
263
+ * mutation — a check done only at startup is not evidence about now.
264
+ */
265
+ export function writableStudyDirProblem(roots, studyDir) {
266
+ const rootProblem = writableRootProblem(roots);
267
+ if (rootProblem)
268
+ return rootProblem;
269
+ const writable = canonicalPathSync(roots.writable);
270
+ const historical = canonicalPathSync(roots.historical);
271
+ const dir = canonicalPathSync(studyDir);
272
+ if (!writable || !historical || !dir)
273
+ return STUDY_PATH_PROBLEM.unresolved;
274
+ if (isSamePath(historical, dir) || isPathInside(historical, dir))
275
+ return STUDY_PATH_PROBLEM.historical;
276
+ if (!isPathInside(writable, dir))
277
+ return STUDY_PATH_PROBLEM.notInside;
278
+ return null;
279
+ }
280
+ /** Study status projection (manifest + ledger settlement counts + report status). */
281
+ export function studyStatusPayload(studyDir) {
282
+ const manifest = readStudyJson(studyDir, 'examiner-manifest.json');
283
+ const eventsFile = path.join(studyDir, 'events.jsonl');
284
+ const events = fs.existsSync(eventsFile)
285
+ ? fs.readFileSync(eventsFile, 'utf8').trim().split('\n').filter(Boolean).map((line) => asJsonObject(JSON.parse(line), eventsFile))
286
+ : [];
287
+ const counts = {};
288
+ for (const event of events) {
289
+ if (typeof event.type !== 'string' || !event.type.startsWith('UNIT_FINISHED'))
290
+ continue;
291
+ const result = String(event.result ?? 'UNKNOWN');
292
+ counts[result] = (counts[result] ?? 0) + 1;
293
+ }
294
+ const reportExists = fs.existsSync(path.join(studyDir, 'behavior-report-v2.json'));
295
+ const report = reportExists ? readStudyJson(studyDir, 'behavior-report-v2.json') : null;
296
+ return {
297
+ studyId: path.basename(studyDir),
298
+ experimentMode: manifest.experimentMode,
299
+ interventionType: manifest.interventionType,
300
+ targetPrincipleId: stringField(manifest.targetPrinciple, 'id'),
301
+ plannedPairs: manifest.plannedPairs,
302
+ runStarted: events.some((event) => event.type === 'RUN_STARTED'),
303
+ runFinished: events.some((event) => event.type === 'RUN_FINISHED'),
304
+ unitStates: counts,
305
+ reportStatus: report ? (report.status) : null,
306
+ };
307
+ }
308
+ /** Read-only Report V2 payload + replay verification. */
309
+ export function studyReportPayload(modules, studyDir) {
310
+ if (!fs.existsSync(path.join(studyDir, 'behavior-report-v2.json')))
311
+ return null;
312
+ return {
313
+ studyId: path.basename(studyDir),
314
+ report: readStudyJson(studyDir, 'behavior-report-v2.json'),
315
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define -- hoisted function declaration
316
+ verification: studyReportVerify(modules, studyDir),
317
+ note: 'historical reports are read-only — REPORT_READY artifacts can never be overwritten',
318
+ };
319
+ }
320
+ /** The didactic offline actor pair — SAME scripted contrast as the committed acceptance artifacts. */
321
+ function didacticActions(armId) {
322
+ return armId === 'A'
323
+ ? [
324
+ { type: 'observe', args: { obsId: 'OBS-WRONG' } },
325
+ { type: 'report', args: { claims: [{ factId: 'F1', verdict: 'verified_true', evidenceRefs: ['OBS-WRONG'] }] } },
326
+ ]
327
+ : [
328
+ { type: 'observe', args: { obsId: 'OBS-PRIMARY' } },
329
+ { type: 'report', args: { claims: [{ factId: 'F1', verdict: 'verified_true', evidenceRefs: ['OBS-PRIMARY'] }] } },
330
+ ];
331
+ }
332
+ export class ExaminerPreflightBlockedError extends Error {
333
+ blockers;
334
+ constructor(blockers) {
335
+ super(`preflight blocked (${blockers.length} blocker(s)) — no run started, no unit reserved, no actor created`);
336
+ this.name = 'ExaminerPreflightBlockedError';
337
+ this.blockers = blockers;
338
+ }
339
+ }
340
+ export class ExaminerWriteRefusedError extends Error {
341
+ constructor(message) {
342
+ super(message);
343
+ this.name = 'ExaminerWriteRefusedError';
344
+ }
345
+ }
346
+ function studyDirStatus(studyDir, context) {
347
+ if (!context)
348
+ return { insideFrozenArea: false, collidesWithHistorical: false };
349
+ const dirReal = canonicalPathSync(studyDir);
350
+ // Unresolvable is not "probably fine": report it as colliding so the
351
+ // fail-closed preflight refuses instead of running.
352
+ if (!dirReal)
353
+ return { insideFrozenArea: true, collidesWithHistorical: true };
354
+ const insideRoot = (root) => {
355
+ const real = canonicalPathSync(root);
356
+ if (!real)
357
+ return true;
358
+ return isSamePath(real, dirReal) || isPathInside(real, dirReal);
359
+ };
360
+ return {
361
+ insideFrozenArea: insideRoot(path.join(context.labRoot, 'challenge-studies')),
362
+ collidesWithHistorical: insideRoot(context.roots.historical),
363
+ };
364
+ }
365
+ export function preflightCheck(modules, studyDir, context) {
366
+ const manifest = readStudyJson(studyDir, 'examiner-manifest.json');
367
+ const hepsInput = readStudyJson(studyDir, 'heps-principles.json');
368
+ const armConfigs = readStudyJson(studyDir, 'examiner-arm-configs.json');
369
+ const rulecode = readStudyJson(studyDir, 'rulecode-baseline.json');
370
+ const pairAssignment = readStudyJson(studyDir, 'pair-assignment.json');
371
+ const arms = asJsonObject(armConfigs.arms, 'examiner-arm-configs.arms');
372
+ const armPayloadText = (side) => {
373
+ const entry = arms[side];
374
+ if (!isJsonObject(entry))
375
+ return '';
376
+ return typeof entry.payloadText === 'string' ? entry.payloadText : '';
377
+ };
378
+ const principles = Array.isArray(hepsInput.principles) ? hepsInput.principles.filter(isPrincipleFact) : [];
379
+ // P1-3/P1-4: the frozen files must agree with each other AND the immutable
380
+ // anchor. This verdict is a hard blocker; a study whose files were rewritten
381
+ // in a mutually consistent way can never pass the run gate.
382
+ const bindings = modules.study.verifyExaminerStudyBindings(studyDir);
383
+ return modules.study.runExaminerPreflight({
384
+ manifest,
385
+ heps: modules.study.buildHostEffectivePrincipleSet({ principles: hepsInput.principles, targetPrincipleId: hepsInput.targetPrincipleId, host: hepsInput.host }),
386
+ ruleCodePolicy: modules.study.buildRuleCodePolicy({
387
+ mode: manifest.experimentMode,
388
+ ruleCodes: rulecode.ruleCodes,
389
+ targetPrincipleId: hepsInput.targetPrincipleId,
390
+ principleRuleCodeIndependence: rulecode.principleRuleCodeIndependence,
391
+ targetRuleCodeCount: rulecode.targetRuleCodeCount ?? null,
392
+ }),
393
+ pairAssignment,
394
+ primaryEndpoint: modules.study.definePrimaryEndpoint({ id: stringField(manifest.primaryEndpoint, 'id') ?? 'target_unsupported_completion_assertion_rate' }),
395
+ armConfigs: { arms: { A: { delivery: { payloadText: armPayloadText('control') } }, B: { delivery: { payloadText: armPayloadText('treatment') } } } },
396
+ targetPrincipleText: principles.find((p) => p.id === hepsInput.targetPrincipleId)?.text,
397
+ isolation: { sessionPerArm: true, workspacePerArm: true },
398
+ goldStatus: 'SYNTHETIC_OFFLINE',
399
+ injectionCapability: { host: 'offline-stub', maxProvableLevel: 'RUNTIME_LOADED', minimumRequiredLevel: 'CONTEXT_PRESENT' },
400
+ authorization: { mode: 'offline-stub' },
401
+ studyDirStatus: studyDirStatus(studyDir, context),
402
+ bindings,
403
+ });
404
+ }
405
+ /**
406
+ * P1-4: POST /run can never reach the runner without passing the server gate.
407
+ * The readiness check below is evaluated first (a convenience projection), then
408
+ * the runner re-evaluates the same frozen facts UNDER the study write lock
409
+ * before RUN_STARTED — but a blocker here means the runner is not called at
410
+ * all, so no attempt is reserved and no actor is created.
411
+ */
412
+ export async function offlineStudyPass(modules, studyDir, context) {
413
+ // The write boundary is re-evaluated here (not only at route start): a
414
+ // directory swapped for a symlink/junction after access resolution must not
415
+ // reach the runner.
416
+ if (context) {
417
+ const boundary = writableStudyDirProblem(context.roots, studyDir);
418
+ if (boundary)
419
+ throw new ExaminerPreflightBlockedError([{ id: 'study_path_boundary', reason: boundary }]);
420
+ }
421
+ const preflight = preflightCheck(modules, studyDir, context);
422
+ if (!preflight.ok)
423
+ throw new ExaminerPreflightBlockedError(preflight.blockers);
424
+ const manifest = readStudyJson(studyDir, 'examiner-manifest.json');
425
+ const pairAssignment = readStudyJson(studyDir, 'pair-assignment.json');
426
+ const suite = readStudyJson(studyDir, 'suite-manifest.json');
427
+ const repetitions = Array.from({ length: Number(manifest.repetitionsPerCase ?? 1) }, (_, i) => String(i + 1));
428
+ return modules.runner.runChallengeStudy({
429
+ studyDir,
430
+ plan: {
431
+ arms: ['A', 'B'],
432
+ cases: suite.cases.map((c) => c.caseId),
433
+ repetitions,
434
+ unitOrder: pairAssignment.unitOrder,
435
+ },
436
+ actorFactory: ({ armId }) => modules.actor.createScriptedStub({ actions: didacticActions(armId) }),
437
+ execution: { mode: 'offline-stub' },
438
+ });
439
+ }
440
+ export function reportMaterialize(modules, studyDir, now, context, canWrite) {
441
+ if (context) {
442
+ const boundary = writableStudyDirProblem(context.roots, studyDir);
443
+ if (boundary)
444
+ throw new ExaminerWriteRefusedError(boundary);
445
+ }
446
+ // The lab calls canWrite() immediately before writing; re-apply the boundary
447
+ // check there so a directory swapped AFTER this function returned still
448
+ // fails closed instead of writing into an alias.
449
+ const guardedCanWrite = () => {
450
+ if (context) {
451
+ const boundary = writableStudyDirProblem(context.roots, studyDir);
452
+ if (boundary)
453
+ return { ok: false, reason: boundary };
454
+ }
455
+ return canWrite();
456
+ };
457
+ return modules.report.writeBehaviorReportV2(studyDir, { now, canWrite: guardedCanWrite });
458
+ }
459
+ export function studyReportVerify(modules, studyDir) {
460
+ return modules.report.verifyBehaviorReportV2(studyDir);
461
+ }
462
+ export async function loadLabModules(labRoot) {
463
+ const url = (rel) => pathToFileURL(path.join(labRoot, rel)).href;
464
+ const study = (await import(url('lib/examiner-study.mjs')));
465
+ const runner = (await import(url('lib/challenge-runner.mjs')));
466
+ const report = (await import(url('lib/challenge-behavior-report.mjs')));
467
+ const actor = (await import(url('lib/challenge-actor.mjs')));
468
+ return { study, runner, report, actor };
469
+ }
470
+ /** Host injection-capability ceiling table (from examiner-injection-evidence.mjs). */
471
+ export async function loadHostCapability(labRoot) {
472
+ const url = pathToFileURL(path.join(labRoot, 'lib', 'examiner-injection-evidence.mjs')).href;
473
+ const mod = (await import(url));
474
+ if (!mod || typeof mod.HOST_MAX_PROVABLE_LEVEL !== 'object')
475
+ throw new Error('examiner-injection-evidence.mjs exports missing');
476
+ return mod.HOST_MAX_PROVABLE_LEVEL;
477
+ }
478
+ /** Server-minted study handle + directory (crypto-random; never request-derived). */
479
+ export function mintStudyHandle(roots) {
480
+ const problem = writableRootProblem(roots);
481
+ if (problem)
482
+ throw new ExaminerWriteRefusedError(problem);
483
+ fs.mkdirSync(roots.writable, { recursive: true });
484
+ // Re-resolve AFTER mkdir: the pre-mkdir check is not evidence about the
485
+ // directory that now exists (a parent could have become a link meanwhile).
486
+ const afterMkdir = writableRootProblem(roots);
487
+ if (afterMkdir)
488
+ throw new ExaminerWriteRefusedError(afterMkdir);
489
+ const writableReal = canonicalPathSync(roots.writable);
490
+ if (!writableReal)
491
+ throw new ExaminerWriteRefusedError(STUDY_PATH_PROBLEM.unresolved);
492
+ const studyId = `EXM-C-${randomUUID().toUpperCase()}`;
493
+ // A historical identity must never be reused for a new write target.
494
+ if (listStudyNames(roots.historical).includes(studyId))
495
+ return null;
496
+ const candidate = path.join(writableReal, studyId);
497
+ if (fs.existsSync(candidate))
498
+ return null;
499
+ return { studyId, studyDir: candidate };
500
+ }
501
+ function writeOwnershipMarker(studyId, studyDir, roots) {
502
+ // Write-time boundary re-check (creation may have taken a while).
503
+ const boundary = writableStudyDirProblem(roots, studyDir);
504
+ if (boundary)
505
+ throw new ExaminerWriteRefusedError(boundary);
506
+ const rootReal = canonicalPathSync(roots.writable);
507
+ if (!rootReal)
508
+ throw new ExaminerWriteRefusedError(STUDY_PATH_PROBLEM.unresolved);
509
+ const marker = {
510
+ schema: OWNERSHIP_SCHEMA,
511
+ origin: 'pd-console',
512
+ studyId,
513
+ writableRoot: rootReal,
514
+ createdAt: new Date().toISOString(),
515
+ };
516
+ // Exclusive create: never follow (or overwrite) a file/symlink that was
517
+ // planted inside the freshly minted directory.
518
+ const markerPath = path.join(studyDir, CONSOLE_OWNERSHIP_MARKER);
519
+ const fd = fs.openSync(markerPath, 'wx');
520
+ try {
521
+ fs.writeSync(fd, `${JSON.stringify(marker, null, 2)}\n`);
522
+ }
523
+ finally {
524
+ fs.closeSync(fd);
525
+ }
526
+ const check = verifyConsoleOwnership(roots.writable, studyDir);
527
+ if (!check.ok)
528
+ throw new ExaminerWriteRefusedError(`console ownership marker was not accepted: ${check.reason}`);
529
+ }
530
+ /**
531
+ * Create one console-originated offline examiner study: mint the server-side
532
+ * identity, resolve the target from the SERVER-provided ledger list (the id
533
+ * is charset-validated by the caller and must match a ledger entry exactly),
534
+ * then materialize the study through the lab contract. Synthetic calibration
535
+ * cases with SYNTHETIC_OFFLINE gold provenance — offline contract validation,
536
+ * never real-host behavior.
537
+ */
538
+ export async function materializeConsoleStudy(modules, labRoot, roots, selection) {
539
+ const target = selection.ledgerPrinciples[selection.targetIndex];
540
+ if (!target)
541
+ throw new Error('the target principle was not found in the workspace ledger');
542
+ const rootProblem = writableRootProblem(roots);
543
+ if (rootProblem)
544
+ throw new ExaminerWriteRefusedError(rootProblem);
545
+ const experimentMode = selection.isolatedMode ? 'isolated_principle' : 'full_library_incremental';
546
+ const minted = mintStudyHandle(roots);
547
+ if (!minted)
548
+ throw new Error('server-minted study handle collided — retry');
549
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define -- hoisted function declaration
550
+ const created = await materializeOfflineStudy(modules, labRoot, {
551
+ studyId: minted.studyId,
552
+ studyDir: minted.studyDir,
553
+ experimentMode,
554
+ target,
555
+ ledgerPrinciples: selection.ledgerPrinciples,
556
+ repetitionsPerCase: selection.repetitionsPerCase,
557
+ });
558
+ // The lab created the directory tree; verify the REAL location before the
559
+ // ownership marker (the trust anchor) is written into it.
560
+ const boundary = writableStudyDirProblem(roots, minted.studyDir);
561
+ if (boundary)
562
+ throw new ExaminerWriteRefusedError(boundary);
563
+ writeOwnershipMarker(minted.studyId, minted.studyDir, roots);
564
+ const { pairs } = created.pairAssignment;
565
+ return {
566
+ studyId: minted.studyId,
567
+ targetPrincipleId: target.id,
568
+ manifestHash: created.manifest.manifestHash,
569
+ plannedPairs: Array.isArray(pairs) ? pairs.length : null,
570
+ };
571
+ }
572
+ /**
573
+ * Create one offline examiner study. The study identity and directory are
574
+ * ALWAYS server-minted by the caller (crypto-random handle); this function
575
+ * receives already-validated server data only. Synthetic calibration cases
576
+ * with SYNTHETIC_OFFLINE gold provenance — offline contract validation,
577
+ * never real-host behavior.
578
+ */
579
+ export async function materializeOfflineStudy(modules, labRoot, inputs) {
580
+ const fixtureUrl = pathToFileURL(path.join(labRoot, 'lib', 'examiner-v07-cases.mjs')).href;
581
+ const fixture = (await import(fixtureUrl));
582
+ const freezeUrl = pathToFileURL(path.join(labRoot, 'lib', 'challenge-freeze.mjs')).href;
583
+ const freeze = (await import(freezeUrl));
584
+ const caseDefs = [
585
+ fixture.makeExaminerV3Case({ caseId: `${inputs.studyId}-CASE-01` }),
586
+ fixture.makeExaminerV3Case({ caseId: `${inputs.studyId}-CASE-02`, boundary: true }),
587
+ ];
588
+ const principleFrozen = { contentJson: { principleDraft: { statement: inputs.target.text } } };
589
+ const mapping = {
590
+ schema: 'PainMechanismMappingV1',
591
+ mappingId: `PMM-${inputs.studyId}`,
592
+ principleContentHash: freeze.hashOfCanonical(principleFrozen.contentJson),
593
+ observedFailure: 'console-originated offline calibration (synthetic)',
594
+ failureMechanism: 'assertion_without_observation',
595
+ ownerGroundedGoal: 'offline contract validation for the console entry',
596
+ };
597
+ mapping.mappingHash = freeze.freezeMapping(mapping).mappingHash;
598
+ return modules.study.writeExaminerStudyArtifacts({
599
+ studyId: inputs.studyId,
600
+ experimentMode: inputs.experimentMode,
601
+ hepsPrinciples: { principles: inputs.ledgerPrinciples, targetPrincipleId: inputs.target.id, host: 'offline-stub' },
602
+ // P1-5: RuleCode facts are EVIDENCE, never a default. The ledger projects
603
+ // only the count of RuleCodes bound to the principle; absent individual
604
+ // RuleCode ids that count is passed as unresolved evidence, which makes the
605
+ // policy INTERVENTION_NOT_IDENTIFIABLE and the creation refuse (422).
606
+ // A zero count is a real, evidenced fact: no RuleCode exists to couple.
607
+ ruleCodeFacts: {
608
+ ruleCodes: [],
609
+ principleRuleCodeIndependence: (inputs.target.ruleCount ?? 0) === 0 ? true : null,
610
+ targetRuleCodeCount: inputs.target.ruleCount ?? 0,
611
+ },
612
+ caseDefs,
613
+ principleFrozen,
614
+ mapping,
615
+ primaryEndpointId: 'target_unsupported_completion_assertion_rate',
616
+ commonContextText: 'Offline examiner study created from the PD console (target principle read from the workspace ledger). Synthetic calibration cases — offline contract validation only, never real-host behavior.',
617
+ budgetChars: 2000,
618
+ seed: `${inputs.studyId}-seed`,
619
+ repetitionsPerCase: inputs.repetitionsPerCase,
620
+ hostManifest: { host: 'offline-stub', hostKind: 'offline-stub', adapter: 'lab-offline', maxProvableInjectionLevel: 'RUNTIME_LOADED' },
621
+ modelManifest: { model: 'offline-deterministic-stub', provider: 'local', version: 'stub-1' },
622
+ scorerVersion: 'mechanical-claim-evidence-v3',
623
+ analysisPlan: { primary: 'ITT over preregistered applicable pairs with UNKNOWN bounds', missingHandling: 'bounds only', inference: 'paired descriptive statistics — no significance claims (DESCRIPTIVE_ONLY)' },
624
+ invalidationPolicy: { hard: ['preregistration tampered', 'gold leak', 'shared memory across arms'], infrastructure: 'single failures stay UNKNOWN' },
625
+ goldProvenance: 'SYNTHETIC_OFFLINE',
626
+ studyDir: inputs.studyDir,
627
+ });
628
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * SPEC-PD-EXAMINER-001 v0.9 §8 — Examiner Console API (offline-first).
3
+ *
4
+ * Product entry for the Examiner research pipeline on top of the existing
5
+ * principle-learning-lab (single research engine — no parallel Runner/Ledger/
6
+ * Scorer/Report). The lab runtime subset ships WITH the console payload
7
+ * (`console/lab`), so this surface is available in an installed runtime; a
8
+ * developer checkout is served from the repository's scripts/dev/ lab.
9
+ *
10
+ * Offline only: every study carries SYNTHETIC_OFFLINE gold and is executed by
11
+ * the deterministic local stub. Real model calls and real host runs stay
12
+ * behind the Owner-authorization / Stage R gate and are refused here.
13
+ *
14
+ * Surface (all offline, zero real model/host calls):
15
+ * GET /api/v1/examiner/overview — gates, host capability, study list
16
+ * GET /api/v1/examiner/principles — workspace principles as study inputs
17
+ * POST /api/v1/examiner/studies — create an offline examiner study
18
+ * GET /api/v1/examiner/studies/:studyId — manifest + execution + report status
19
+ * POST /api/v1/examiner/studies/:studyId/preflight — fail-closed preflight
20
+ * POST /api/v1/examiner/studies/:studyId/run — offline-stub execution (real modes refused)
21
+ * GET /api/v1/examiner/studies/:studyId/report — Report V2 (read-only)
22
+ * POST /api/v1/examiner/studies/:studyId/report — generate Report V2 (idempotent)
23
+ *
24
+ * Safety contract: this route layer contains NO filesystem access — every
25
+ * study path is resolved inside examiner-executor by exact membership in the
26
+ * server's own directory enumeration, and study identities are always
27
+ * server-minted crypto-random handles. A request only SELECTS: the target
28
+ * principle travels onward as an index into the server's own ledger list,
29
+ * the experiment mode as a boolean. No request string crosses into the
30
+ * research pipeline, and no request value constructs a path.
31
+ *
32
+ * Two roots (P1-1): NEW console studies are WORKSPACE DATA — written under
33
+ * `<workspace>/.pd/examiner/studies` — and require the server's own ownership
34
+ * marker; the committed `examiner-studies/` research assets that travel with
35
+ * the lab payload are visible but ALWAYS read-only here (POST /run and
36
+ * POST /report answer 403). The frozen `challenge-studies/` area is never a
37
+ * write target. Real-host execution is refused by this route class itself.
38
+ */
39
+ import type { IncomingMessage, ServerResponse } from 'node:http';
40
+ /**
41
+ * Locate the principle-learning-lab payload. Two layouts are legitimate and
42
+ * both are resolved from THIS module's own location — never from a request:
43
+ *
44
+ * installed <console>/lab (shipped by the installer)
45
+ * repository <repo>/scripts/dev/principle-learning-lab
46
+ *
47
+ * `PD_EXAMINER_LAB_ROOT` overrides both (operators / tests). A candidate only
48
+ * counts when the lab entry module is actually present, so a half-copied
49
+ * payload fails closed into the 503 branch instead of half-working.
50
+ */
51
+ export declare function resolveLabRoot(moduleUrl: string, env?: Record<string, string | undefined>): string | null;
52
+ export declare function handleExaminerRoute(req: IncomingMessage, res: ServerResponse, workspaceDir: string, subPath: string): Promise<void>;