create-principles-disciple 1.145.42 → 1.145.43

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