create-principles-disciple 1.145.43 → 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.
- 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/package.json +1 -1
- package/release-manager/package.json +1 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// SPEC-PD-EXAMINER-001 v0.9 §7.2/§7.3 — the six-level injection evidence chain.
|
|
2
|
+
//
|
|
3
|
+
// CONFIGURED → SELECTED → SUBMITTED → HOST_ACCEPTED → CONTEXT_PRESENT → RUNTIME_LOADED
|
|
4
|
+
//
|
|
5
|
+
// Every level must carry ACTUAL EVIDENCE (an observation + its producer + a
|
|
6
|
+
// verifiable content hash). A boolean declaration is never evidence; SUBMITTED
|
|
7
|
+
// is never RUNTIME_LOADED; and each host declares the highest level it can
|
|
8
|
+
// PROVE so a report can state the honest ceiling instead of implying more.
|
|
9
|
+
//
|
|
10
|
+
// Naming aligns with the PRODUCTION contract
|
|
11
|
+
// (principles-core/runtime-v2/types/intervention-evidence-contract.ts:
|
|
12
|
+
// prepared/submitted/host_accepted/context_present/runtime_loaded —
|
|
13
|
+
// 'prepared' there covers this module's CONFIGURED+SELECTED planning states);
|
|
14
|
+
// the levels are UPPER-CASE here to match the v0.9 SPEC tables verbatim.
|
|
15
|
+
//
|
|
16
|
+
// Offline-stub scope: the offline runner CAN honestly prove the whole chain
|
|
17
|
+
// about ITS OWN pipeline (the payload text provably appears in the composed
|
|
18
|
+
// actor prompt and is consumed by the scripted actor loop). Such chains are
|
|
19
|
+
// producer-tagged 'runner-offline-stub' and scope-tagged 'offline-stub' —
|
|
20
|
+
// they prove the contract machinery, NEVER real-host behavior.
|
|
21
|
+
//
|
|
22
|
+
// Zero network, zero clock, zero production writes.
|
|
23
|
+
|
|
24
|
+
import { hashOfCanonical, sha256 } from './challenge-freeze.mjs';
|
|
25
|
+
import { verifyOfflineRuntimeReceipt } from './challenge-actor.mjs';
|
|
26
|
+
|
|
27
|
+
export const INJECTION_EVIDENCE_SCHEMA_V1 = 'examiner-injection-evidence.v1';
|
|
28
|
+
export const INJECTION_EVIDENCE_SCHEMA_V2 = 'examiner-injection-evidence.v2';
|
|
29
|
+
export const INJECTION_EVIDENCE_SCHEMA = INJECTION_EVIDENCE_SCHEMA_V2;
|
|
30
|
+
|
|
31
|
+
export const INJECTION_EVIDENCE_LEVELS = [
|
|
32
|
+
'CONFIGURED', 'SELECTED', 'SUBMITTED', 'HOST_ACCEPTED', 'CONTEXT_PRESENT', 'RUNTIME_LOADED',
|
|
33
|
+
];
|
|
34
|
+
// §7.3: a full Principle content-effect conclusion requires at least
|
|
35
|
+
// CONTEXT_PRESENT; below that the study completes contract-wise only.
|
|
36
|
+
export const FULL_CONCLUSION_MINIMUM = 'CONTEXT_PRESENT';
|
|
37
|
+
|
|
38
|
+
export const EVIDENCE_PRODUCERS = ['runner-offline-stub', 'trusted-host-probe'];
|
|
39
|
+
// Declared per-host ceilings (production evidence recorders, Phase 0 audit §3).
|
|
40
|
+
// 'offline-stub' is not a host — it is the lab's own pipeline scope.
|
|
41
|
+
export const HOST_MAX_PROVABLE_LEVEL = {
|
|
42
|
+
openclaw: 'SUBMITTED',
|
|
43
|
+
codex: 'SUBMITTED',
|
|
44
|
+
workbuddy: 'SUBMITTED',
|
|
45
|
+
'offline-stub': 'RUNTIME_LOADED',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const LEVEL_INDEX = Object.fromEntries(INJECTION_EVIDENCE_LEVELS.map((level, i) => [level, i]));
|
|
49
|
+
const text = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build one arm's injection evidence chain. Each stage argument is either
|
|
53
|
+
* null/undefined (not evidenced — the chain simply stops there) or
|
|
54
|
+
* { evidenceRef, contentHash, note? } describing the ACTUAL observation.
|
|
55
|
+
* `producedBy` must be a whitelisted producer: the RUNNER (offline) or a
|
|
56
|
+
* TRUSTED HOST PROBE (real hosts). Actors never construct chains.
|
|
57
|
+
*
|
|
58
|
+
* v2 every chain is BOUND to its attempt: study/unit/case/arm/repetition/
|
|
59
|
+
* attempt identity plus the prompt hash and (above SUBMITTED) the runtime's
|
|
60
|
+
* own load receipt hash. Attribution can no longer be inferred from the
|
|
61
|
+
* directory a sidecar happens to sit in.
|
|
62
|
+
*
|
|
63
|
+
* @returns chain { schema, producedBy, scope, studyId, unitId, caseId, armId,
|
|
64
|
+
* repetitionId, attemptId, principleHash, promptHash, receiptHash,
|
|
65
|
+
* expectedLevel, stages, provenLevel, chainHash }
|
|
66
|
+
*/
|
|
67
|
+
export function buildInjectionEvidenceChain({
|
|
68
|
+
producedBy, scope = 'offline-stub',
|
|
69
|
+
studyId, caseId, armId, repetitionId, attemptId,
|
|
70
|
+
principleHash, promptHash = null, receiptHash = null, expectedLevel = null,
|
|
71
|
+
configured, selected, submitted, hostAccepted, contextPresent, runtimeLoaded,
|
|
72
|
+
}) {
|
|
73
|
+
if (!EVIDENCE_PRODUCERS.includes(producedBy)) {
|
|
74
|
+
throw new Error(`buildInjectionEvidenceChain: producedBy must be one of ${EVIDENCE_PRODUCERS.join(' | ')} (got '${String(producedBy)}') — actor-supplied evidence is refused by construction`);
|
|
75
|
+
}
|
|
76
|
+
// P1-2 B: producer and scope are bound by construction. An offline producer
|
|
77
|
+
// can never mint a real-host chain (and a host probe never mints an offline
|
|
78
|
+
// one), so a scope string alone is no longer the only separation.
|
|
79
|
+
if (producedBy === 'runner-offline-stub' && scope !== 'offline-stub') {
|
|
80
|
+
throw new Error(`buildInjectionEvidenceChain: the offline-stub producer must mint scope 'offline-stub' (got '${String(scope)}') — an offline pipeline can never attest a real host`);
|
|
81
|
+
}
|
|
82
|
+
if (producedBy === 'trusted-host-probe' && scope === 'offline-stub') {
|
|
83
|
+
throw new Error('buildInjectionEvidenceChain: a trusted host probe must mint a real-host scope, never the offline-stub scope');
|
|
84
|
+
}
|
|
85
|
+
if (!text(studyId)) throw new Error('buildInjectionEvidenceChain: studyId required — evidence must be bound to the study identity');
|
|
86
|
+
if (!text(caseId) || !text(repetitionId) || !text(attemptId)) {
|
|
87
|
+
throw new Error('buildInjectionEvidenceChain: caseId/repetitionId/attemptId required — evidence must be bound to one attempt');
|
|
88
|
+
}
|
|
89
|
+
if (!text(armId)) throw new Error('buildInjectionEvidenceChain: armId required');
|
|
90
|
+
if (!/^[a-f0-9]{64}$/.test(String(principleHash || ''))) {
|
|
91
|
+
throw new Error('buildInjectionEvidenceChain: principleHash (sha256 of the frozen principle payload) required — evidence must bind to content');
|
|
92
|
+
}
|
|
93
|
+
if (promptHash !== null && !/^[a-f0-9]{64}$/.test(String(promptHash))) {
|
|
94
|
+
throw new Error('buildInjectionEvidenceChain: promptHash must be the sha256 of the prompt the runtime received');
|
|
95
|
+
}
|
|
96
|
+
if (receiptHash !== null && !/^[a-f0-9]{64}$/.test(String(receiptHash))) {
|
|
97
|
+
throw new Error('buildInjectionEvidenceChain: receiptHash must be the runtime receipt hash');
|
|
98
|
+
}
|
|
99
|
+
const stageInputs = { CONFIGURED: configured, SELECTED: selected, SUBMITTED: submitted, HOST_ACCEPTED: hostAccepted, CONTEXT_PRESENT: contextPresent, RUNTIME_LOADED: runtimeLoaded };
|
|
100
|
+
const stages = {};
|
|
101
|
+
for (const level of INJECTION_EVIDENCE_LEVELS) {
|
|
102
|
+
const stage = stageInputs[level];
|
|
103
|
+
if (stage === null || stage === undefined) continue;
|
|
104
|
+
if (typeof stage !== 'object' || !text(stage.evidenceRef)) {
|
|
105
|
+
throw new Error(`buildInjectionEvidenceChain: stage ${level} must be {evidenceRef, contentHash?, note?} with a real evidence reference — a boolean/empty claim is not evidence`);
|
|
106
|
+
}
|
|
107
|
+
// normalize the known fields; PRESERVE producer-added observation flags
|
|
108
|
+
// (e.g. forbiddenContentPresent) — they are stage content, not metadata.
|
|
109
|
+
stages[level] = {
|
|
110
|
+
...stage,
|
|
111
|
+
contentHash: /^[a-f0-9]{64}$/.test(String(stage.contentHash || '')) ? stage.contentHash : null,
|
|
112
|
+
...(text(stage.note) ? {} : { note: undefined }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// provenLevel = highest CONSECUTIVE evidenced level from CONFIGURED. A chain
|
|
116
|
+
// that jumps (e.g. RUNTIME_LOADED without SUBMITTED) is malformed: each level
|
|
117
|
+
// presupposes the previous one, so a gap means the higher claim is unanchored.
|
|
118
|
+
let provenLevel = 'NONE';
|
|
119
|
+
for (const level of INJECTION_EVIDENCE_LEVELS) {
|
|
120
|
+
if (stages[level]) provenLevel = level;
|
|
121
|
+
else break;
|
|
122
|
+
}
|
|
123
|
+
const jumped = INJECTION_EVIDENCE_LEVELS.some((level, i) => stages[level] && i > 0 && !stages[INJECTION_EVIDENCE_LEVELS[i - 1]]);
|
|
124
|
+
if (jumped) {
|
|
125
|
+
throw new Error('buildInjectionEvidenceChain: evidence chain has a gap — every level presupposes its predecessor (SUBMITTED before HOST_ACCEPTED before CONTEXT_PRESENT before RUNTIME_LOADED)');
|
|
126
|
+
}
|
|
127
|
+
// Anything above SUBMITTED asserts that the RECEIVING side acknowledged and
|
|
128
|
+
// loaded the prompt. Only the runtime's own receipt can attest that; without
|
|
129
|
+
// one the chain is capped at SUBMITTED — never silently accepted.
|
|
130
|
+
if (LEVEL_INDEX[provenLevel] > LEVEL_INDEX.SUBMITTED && receiptHash === null) {
|
|
131
|
+
throw new Error(`buildInjectionEvidenceChain: '${provenLevel}' requires the runtime's own load receipt — without it the provable ceiling is SUBMITTED`);
|
|
132
|
+
}
|
|
133
|
+
if (LEVEL_INDEX[provenLevel] > LEVEL_INDEX.SELECTED && promptHash === null) {
|
|
134
|
+
throw new Error(`buildInjectionEvidenceChain: '${provenLevel}' requires the prompt hash actually handed to the runtime`);
|
|
135
|
+
}
|
|
136
|
+
const core = {
|
|
137
|
+
schema: INJECTION_EVIDENCE_SCHEMA,
|
|
138
|
+
producedBy,
|
|
139
|
+
scope,
|
|
140
|
+
studyId,
|
|
141
|
+
unitId: `${caseId}/${armId}/r${repetitionId}`,
|
|
142
|
+
caseId,
|
|
143
|
+
armId,
|
|
144
|
+
repetitionId,
|
|
145
|
+
attemptId,
|
|
146
|
+
principleHash,
|
|
147
|
+
promptHash,
|
|
148
|
+
receiptHash,
|
|
149
|
+
expectedLevel: text(expectedLevel) ? expectedLevel : null,
|
|
150
|
+
stages,
|
|
151
|
+
provenLevel,
|
|
152
|
+
};
|
|
153
|
+
return { ...core, chainHash: hashOfCanonical(core) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Fail-closed verification of a chain (post-hoc / cross-check use).
|
|
158
|
+
* Tamper detection = chainHash recompute; provenance = producer whitelist;
|
|
159
|
+
* scope honesty = offline chains can never satisfy a real-host requirement;
|
|
160
|
+
* attribution = the chain's binding must agree with the attempt the report is
|
|
161
|
+
* about (`expected`). Legacy v1 chains stay verifiable but can never prove
|
|
162
|
+
* anything above SUBMITTED (they carry no runtime receipt).
|
|
163
|
+
*/
|
|
164
|
+
export function verifyInjectionEvidenceChain(chain, { requireLevel = null, requireScope = null, expected = null } = {}) {
|
|
165
|
+
const problems = [];
|
|
166
|
+
if (!chain || chain.chainHash === undefined) return { ok: false, problems: ['no injection evidence chain supplied'] };
|
|
167
|
+
const { chainHash, ...core } = chain;
|
|
168
|
+
if (!/^[a-f0-9]{64}$/.test(String(chainHash)) || hashOfCanonical(core) !== chainHash) {
|
|
169
|
+
problems.push('chainHash mismatch — the evidence chain was modified after generation (or forged)');
|
|
170
|
+
}
|
|
171
|
+
if (!EVIDENCE_PRODUCERS.includes(chain.producedBy)) {
|
|
172
|
+
problems.push(`producer '${String(chain.producedBy)}' is not whitelisted — actor-authored evidence is refused`);
|
|
173
|
+
}
|
|
174
|
+
if (chain.provenLevel !== 'NONE' && !INJECTION_EVIDENCE_LEVELS.includes(chain.provenLevel)) {
|
|
175
|
+
problems.push(`provenLevel '${String(chain.provenLevel)}' is not a level`);
|
|
176
|
+
}
|
|
177
|
+
// P1-2 B: producer ↔ scope binding, independent of the scope string alone.
|
|
178
|
+
if (chain.producedBy === 'runner-offline-stub' && chain.scope !== 'offline-stub') {
|
|
179
|
+
problems.push(`the offline-stub producer claims scope '${String(chain.scope)}' — an offline pipeline can never attest a real host`);
|
|
180
|
+
}
|
|
181
|
+
if (chain.producedBy === 'trusted-host-probe' && chain.scope === 'offline-stub') {
|
|
182
|
+
problems.push('a trusted host probe claims the offline-stub scope — producer and scope disagree');
|
|
183
|
+
}
|
|
184
|
+
if (chain.scope !== 'offline-stub' && chain.scope !== 'real-host') {
|
|
185
|
+
problems.push(`scope '${String(chain.scope)}' is not a known scope`);
|
|
186
|
+
}
|
|
187
|
+
const isV2 = chain.schema === INJECTION_EVIDENCE_SCHEMA_V2;
|
|
188
|
+
if (!isV2 && chain.schema !== INJECTION_EVIDENCE_SCHEMA_V1) {
|
|
189
|
+
problems.push(`schema '${String(chain.schema)}' unsupported`);
|
|
190
|
+
}
|
|
191
|
+
if (LEVEL_INDEX[chain.provenLevel] > LEVEL_INDEX.SUBMITTED && !/^[a-f0-9]{64}$/.test(String(chain.receiptHash || ''))) {
|
|
192
|
+
// Legacy v1 chains claimed these levels with no receipt at all: the claim
|
|
193
|
+
// is NOT_PROVEN. New chains cannot even be built this way.
|
|
194
|
+
problems.push(`'${chain.provenLevel}' claimed without a runtime load receipt — only SUBMITTED or below is provable`);
|
|
195
|
+
}
|
|
196
|
+
if (isV2) {
|
|
197
|
+
for (const field of ['studyId', 'unitId', 'caseId', 'armId', 'repetitionId', 'attemptId']) {
|
|
198
|
+
if (!text(chain[field])) problems.push(`chain binding '${field}' missing — the evidence cannot be attributed to a specific attempt`);
|
|
199
|
+
}
|
|
200
|
+
if (text(chain.unitId) && chain.unitId !== `${chain.caseId}/${chain.armId}/r${chain.repetitionId}`) {
|
|
201
|
+
problems.push('chain unitId does not agree with its case/arm/repetition binding');
|
|
202
|
+
}
|
|
203
|
+
if (LEVEL_INDEX[chain.provenLevel] > LEVEL_INDEX.SELECTED && !/^[a-f0-9]{64}$/.test(String(chain.promptHash || ''))) {
|
|
204
|
+
problems.push('chain proves SUBMITTED or above without the prompt hash the runtime received');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (expected && typeof expected === 'object') {
|
|
208
|
+
for (const field of ['studyId', 'unitId', 'caseId', 'armId', 'repetitionId', 'attemptId', 'promptHash']) {
|
|
209
|
+
if (expected[field] === undefined || expected[field] === null) continue;
|
|
210
|
+
if (chain[field] !== expected[field]) {
|
|
211
|
+
problems.push(`chain ${field} '${String(chain[field])}' does not match the attempt's '${String(expected[field])}'`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (text(requireLevel)) {
|
|
216
|
+
const have = chain.provenLevel === 'NONE' ? -1 : LEVEL_INDEX[chain.provenLevel];
|
|
217
|
+
if (have < LEVEL_INDEX[requireLevel]) {
|
|
218
|
+
problems.push(`chain proves only up to '${chain.provenLevel}' but '${requireLevel}' is required`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (text(requireScope) && chain.scope !== requireScope) {
|
|
222
|
+
problems.push(`chain scope '${chain.scope}' does not satisfy required scope '${requireScope}' — an offline-stub chain can never prove a real-host injection`);
|
|
223
|
+
}
|
|
224
|
+
return { ok: problems.length === 0, problems, provenLevel: chain.provenLevel };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Offline-stub chain collector — the lab runner's LEGITIMATE producer. Proves
|
|
229
|
+
* what the offline pipeline can prove about itself, and NOTHING MORE:
|
|
230
|
+
* CONFIGURED — the frozen arm config carries the (possibly empty) payload
|
|
231
|
+
* SELECTED — the arm selection output names this payload (policy output)
|
|
232
|
+
* SUBMITTED — the prompt was composed and handed to the runtime
|
|
233
|
+
* HOST_ACCEPTED — the RUNTIME's own receipt acknowledges the hand-off
|
|
234
|
+
* CONTEXT_PRESENT — the payload text is verbatim INSIDE the prompt the
|
|
235
|
+
* runtime received (receipt prompt hash)
|
|
236
|
+
* RUNTIME_LOADED — the runtime loaded that exact prompt (receipt)
|
|
237
|
+
*
|
|
238
|
+
* The receipt is generated by the runtime itself (challenge-actor.mjs) from
|
|
239
|
+
* the string it was handed; the runner cannot author it. Without a receipt the
|
|
240
|
+
* chain stops at SELECTED — SUBMITTED is provable from the composed prompt, but
|
|
241
|
+
* nothing above it. A control arm with an empty payload legitimately proves the
|
|
242
|
+
* whole chain with principleHash = sha256('') — the provable absence of a
|
|
243
|
+
* payload.
|
|
244
|
+
*
|
|
245
|
+
* `forbiddenContent` (optional): on a CONTROL arm of a full-library study the
|
|
246
|
+
* library payload is legitimate — the FORBIDDEN content is the target
|
|
247
|
+
* principle text. When supplied, the CONTEXT_PRESENT stage additionally
|
|
248
|
+
* records `forbiddenContentPresent` so Treatment Fidelity can count control
|
|
249
|
+
* contamination without guessing from hashes.
|
|
250
|
+
*/
|
|
251
|
+
export function collectOfflineStubInjectionEvidence({ studyId, unit, arm, armPrompt, receipt = null, attemptRef, selectionRef, forbiddenContent = null }) {
|
|
252
|
+
if (!arm || !arm.delivery) throw new Error('collectOfflineStubInjectionEvidence: arm (frozen arm config) required');
|
|
253
|
+
if (!text(studyId)) throw new Error('collectOfflineStubInjectionEvidence: studyId required — evidence must be bound to the study identity');
|
|
254
|
+
if (!unit || !text(unit.caseId) || !text(unit.armId) || !text(unit.repetitionId) || !text(unit.attemptId)) {
|
|
255
|
+
throw new Error('collectOfflineStubInjectionEvidence: unit {caseId, armId, repetitionId, attemptId} required — evidence must be bound to one attempt');
|
|
256
|
+
}
|
|
257
|
+
if (!text(armPrompt)) throw new Error('collectOfflineStubInjectionEvidence: armPrompt (the composed actor-visible prompt) required');
|
|
258
|
+
if (!text(attemptRef)) throw new Error('collectOfflineStubInjectionEvidence: attemptRef (the persisted attempt record reference) required');
|
|
259
|
+
const binding = {
|
|
260
|
+
studyId,
|
|
261
|
+
caseId: unit.caseId,
|
|
262
|
+
armId: unit.armId,
|
|
263
|
+
repetitionId: unit.repetitionId,
|
|
264
|
+
attemptId: unit.attemptId,
|
|
265
|
+
};
|
|
266
|
+
const payloadText = arm.delivery.payloadText;
|
|
267
|
+
const empty = payloadText === '';
|
|
268
|
+
const promptHash = sha256(armPrompt);
|
|
269
|
+
const evidence = (evidenceRef, extra = {}) => ({ evidenceRef, ...extra });
|
|
270
|
+
const configured = evidence(`arm-configs.json#arm-${unit.armId}`, {
|
|
271
|
+
contentHash: arm.delivery.payloadHash,
|
|
272
|
+
note: empty ? 'control arm: empty payload CONFIGURED (principle OFF)' : 'arm payload CONFIGURED from the frozen study materials',
|
|
273
|
+
});
|
|
274
|
+
const selected = evidence(selectionRef || `arm-selection:${unit.armId}`, {
|
|
275
|
+
contentHash: arm.delivery.payloadHash,
|
|
276
|
+
note: 'offline deterministic selection: the arm payload is the complete selection output for this arm',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (receipt === null || receipt === undefined) {
|
|
280
|
+
// The runtime produced no load receipt: nothing above SELECTED is proven.
|
|
281
|
+
return buildInjectionEvidenceChain({
|
|
282
|
+
producedBy: 'runner-offline-stub',
|
|
283
|
+
scope: 'offline-stub',
|
|
284
|
+
...binding,
|
|
285
|
+
principleHash: sha256(payloadText),
|
|
286
|
+
promptHash,
|
|
287
|
+
receiptHash: null,
|
|
288
|
+
expectedLevel: 'SELECTED',
|
|
289
|
+
configured,
|
|
290
|
+
selected,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const receiptCheck = verifyOfflineRuntimeReceipt(receipt);
|
|
295
|
+
if (!receiptCheck.ok) {
|
|
296
|
+
throw new Error(`collectOfflineStubInjectionEvidence: runtime receipt invalid (${receiptCheck.problems.join('; ')}) — RUNTIME_LOADED cannot be claimed`);
|
|
297
|
+
}
|
|
298
|
+
if (receipt.receivedPromptHash !== promptHash) {
|
|
299
|
+
throw new Error('collectOfflineStubInjectionEvidence: the runtime receipt is for a DIFFERENT prompt than the one this attempt composed — the evidence chain is unbound');
|
|
300
|
+
}
|
|
301
|
+
if (!empty && !armPrompt.includes(payloadText)) {
|
|
302
|
+
throw new Error('collectOfflineStubInjectionEvidence: CONTEXT_PRESENT failed — the payload text is NOT inside the prompt the runtime received; refusing to claim presence');
|
|
303
|
+
}
|
|
304
|
+
const contextStage = {
|
|
305
|
+
// The decisive check: the payload text verbatim inside the prompt, and the
|
|
306
|
+
// prompt the runtime actually loaded (its own receipt hash).
|
|
307
|
+
evidenceRef: `prompt-contains-payload:${unit.armId}@${receipt.receiptHash.slice(0, 12)}`,
|
|
308
|
+
contentHash: promptHash,
|
|
309
|
+
receiptHash: receipt.receiptHash,
|
|
310
|
+
note: empty ? 'empty payload trivially present (nothing to leak)' : 'payload text verified verbatim inside the prompt the offline runtime received',
|
|
311
|
+
};
|
|
312
|
+
if (text(forbiddenContent)) {
|
|
313
|
+
// Contamination observation, recorded — never silently swallowed. The
|
|
314
|
+
// chain still proves its own stages; fidelity decides what it means.
|
|
315
|
+
contextStage.forbiddenContentPresent = armPrompt.includes(forbiddenContent);
|
|
316
|
+
}
|
|
317
|
+
return buildInjectionEvidenceChain({
|
|
318
|
+
producedBy: 'runner-offline-stub',
|
|
319
|
+
scope: 'offline-stub',
|
|
320
|
+
...binding,
|
|
321
|
+
// The provable-absence hash for control arms: sha256 of the empty payload.
|
|
322
|
+
principleHash: sha256(payloadText),
|
|
323
|
+
promptHash,
|
|
324
|
+
receiptHash: receipt.receiptHash,
|
|
325
|
+
expectedLevel: 'RUNTIME_LOADED',
|
|
326
|
+
configured,
|
|
327
|
+
selected,
|
|
328
|
+
submitted: evidence(`actor-prompt:${unit.armId}`, { contentHash: promptHash, note: 'composed actor prompt handed to the offline runtime' }),
|
|
329
|
+
hostAccepted: evidence(`offline-runtime-receipt:${unit.armId}`, {
|
|
330
|
+
contentHash: promptHash,
|
|
331
|
+
receiptHash: receipt.receiptHash,
|
|
332
|
+
note: `offline-stub scope: the runtime (${receipt.runtimeId}) acknowledged the hand-off in its own receipt — never a claim about a real host`,
|
|
333
|
+
}),
|
|
334
|
+
contextPresent: contextStage,
|
|
335
|
+
runtimeLoaded: evidence(`${attemptRef}#trace.runtimeReceipt`, {
|
|
336
|
+
contentHash: promptHash,
|
|
337
|
+
receiptHash: receipt.receiptHash,
|
|
338
|
+
note: 'the runtime loaded this exact prompt and the trace carries its receipt (bound to the attempt record)',
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* §2.2.5 capacity competition record for full-library arms: which eligible
|
|
345
|
+
* principles made it into the delivered set, which were evicted (budget), and
|
|
346
|
+
* the deterministic selection policy snapshot.
|
|
347
|
+
*/
|
|
348
|
+
export function recordCapacityCompetition({ eligible, delivered, budgetChars, policy, targetPrincipleId }) {
|
|
349
|
+
if (!Array.isArray(eligible) || !Array.isArray(delivered)) throw new Error('recordCapacityCompetition: eligible[] and delivered[] required');
|
|
350
|
+
if (!Number.isInteger(budgetChars) || budgetChars <= 0) throw new Error('recordCapacityCompetition: budgetChars required');
|
|
351
|
+
if (!text(policy)) throw new Error('recordCapacityCompetition: policy name required (e.g. legacy_fifo_prefix_v1 | fair_rotation_v1)');
|
|
352
|
+
const deliveredIds = new Set(delivered.map((p) => p.id));
|
|
353
|
+
const evicted = eligible.filter((p) => !deliveredIds.has(p.id));
|
|
354
|
+
const targetDelivered = deliveredIds.has(targetPrincipleId);
|
|
355
|
+
const core = {
|
|
356
|
+
schema: 'examiner-capacity-competition.v1',
|
|
357
|
+
policy,
|
|
358
|
+
budgetChars,
|
|
359
|
+
eligibleCount: eligible.length,
|
|
360
|
+
delivered: delivered.map((p) => ({ id: p.id, contentHash: p.contentHash })).sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
361
|
+
evicted: evicted.map((p) => ({ id: p.id, contentHash: p.contentHash })).sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
362
|
+
targetPrincipleId,
|
|
363
|
+
targetDelivered,
|
|
364
|
+
deliveredContentHash: sha256(delivered.map((p) => `${p.id}:${p.contentHash}`).sort().join('\n')),
|
|
365
|
+
};
|
|
366
|
+
return { ...core, recordHash: hashOfCanonical(core) };
|
|
367
|
+
}
|