@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28

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 (64) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +47 -3
  4. package/dist/capabilities/anything.js +26 -0
  5. package/dist/capabilities/doctor.js +11 -4
  6. package/dist/capabilities/ground-transfer.js +168 -1
  7. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  8. package/dist/capabilities/route-provider-conformance.js +341 -0
  9. package/dist/scripts/anything-path-measure-tests.js +133 -0
  10. package/dist/scripts/anything-path-measure.js +150 -0
  11. package/dist/scripts/anything-tests.js +136 -2
  12. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
  13. package/dist/scripts/bootstrap-tests.js +71 -1
  14. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  15. package/dist/scripts/doctor-tests.js +37 -1
  16. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  17. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  18. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  19. package/dist/scripts/dsh-llm-tests.js +437 -0
  20. package/dist/scripts/g5-guard-tests.js +20 -1
  21. package/dist/scripts/g5-guard.js +5 -1
  22. package/dist/scripts/m3-cohort-tests.js +714 -0
  23. package/dist/scripts/m3-cohort.js +310 -0
  24. package/dist/scripts/nightly-evidence-tests.js +44 -2
  25. package/dist/scripts/nightly-evidence.js +21 -4
  26. package/dist/scripts/outcome-projection-tests.js +427 -0
  27. package/dist/scripts/persona-sim-tests.js +603 -0
  28. package/dist/scripts/persona-sim.js +1081 -0
  29. package/dist/scripts/replay-async.js +2 -0
  30. package/dist/scripts/replay.js +2 -0
  31. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  32. package/dist/scripts/session-zone-crash.js +49 -0
  33. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  34. package/dist/scripts/session-zone-observation-tests.js +492 -0
  35. package/dist/scripts/session-zone-observe.js +135 -0
  36. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  37. package/dist/scripts/session-zones-tests.js +17 -1
  38. package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
  39. package/dist/scripts/state-cli.js +16 -0
  40. package/dist/scripts/time-eval-tests.js +2 -0
  41. package/dist/scripts/unified-tests.js +37 -1
  42. package/dist/scripts/z3-race-tests.js +6 -1
  43. package/dist/src/city-scenario-tier.js +378 -0
  44. package/dist/src/dsh-llm.js +84 -14
  45. package/dist/src/index.js +160 -1
  46. package/dist/src/loop.js +16 -5
  47. package/dist/src/m3-cohort.js +1577 -0
  48. package/dist/src/outcome-projection.js +746 -0
  49. package/dist/src/session-zone-ledger.js +312 -0
  50. package/dist/src/session-zone-observation.js +354 -0
  51. package/dist/src/session-zone-wiring.js +658 -0
  52. package/dist/src/session-zones.js +7 -4
  53. package/dist/src/state-ledger.js +293 -40
  54. package/dist/src/unified.js +69 -5
  55. package/extension/manifest.json +2 -2
  56. package/package.json +1 -1
  57. package/ts/capabilities/anything.ts +27 -0
  58. package/ts/scripts/state-cli.ts +18 -0
  59. package/ts/src/contracts.ts +4 -0
  60. package/ts/src/dsh-llm.ts +111 -9
  61. package/ts/src/index.ts +175 -2
  62. package/ts/src/loop.ts +15 -5
  63. package/ts/src/state-ledger.ts +294 -40
  64. package/ts/src/unified.ts +91 -18
@@ -0,0 +1,714 @@
1
+ /**
2
+ * M3 seed-cohort capture tests (issue #22).
3
+ *
4
+ * Covers explicit opt-in, HMAC pseudonymity, append-only idempotency, fail-closed
5
+ * transitions, path isolation, and the four synthetic-labelling falsifications:
6
+ * (a) a simulated cohort can never reach `business_pass=true`;
7
+ * (b) simulated records can never be mixed into a `real_seed_cohort` store or root;
8
+ * (c) a PII sentinel never reaches any persisted byte;
9
+ * (d) relabelling a synthetic export as real breaks the export attestation.
10
+ * Each falsification carries its red baseline: the same numbers, hand-written without the
11
+ * capture gate, are accepted by the scorer alone — which is exactly what the gate prevents.
12
+ *
13
+ * Run: cd ts && npx tsx scripts/m3-cohort-tests.ts
14
+ */
15
+ import assert from 'node:assert/strict';
16
+ import { spawnSync } from 'node:child_process';
17
+ import { createHash } from 'node:crypto';
18
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { buildExportPayload, canonicalJson, cohortRecordLines, enrollParticipant, exportM3Cohort, fileMode, initM3Cohort, inspectStoreLock, M3CohortError, M3_ACCEPTANCE, M3_EXCLUSION_CODES, M3_SIMULATION_PROVENANCE_SCHEMA, participantKeyFor, planKeyFor, readStoreForTests, recordNps, recordPlanDelivered, recordPlanFinalized, recordPoiLock, storeRootForStateRoot, unlockStore, verifyExportedEvidence, } from '../src/m3-cohort.js';
23
+ import { parseCohortRecord, parseManifest, scoreProductMetrics } from './product-metrics.js';
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const TS_ROOT = resolve(__dirname, '..');
26
+ const TSX = join(TS_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
27
+ const KEY = '0123456789abcdef0123456789abcdef';
28
+ const OTHER_KEY = 'fedcba9876543210fedcba9876543210';
29
+ const CONSENT = 'operator-consent-for-issue-22';
30
+ const SENTINEL = 'PRIVACY_SENTINEL_22@example.com';
31
+ const WINDOW_START = '2026-10-01T00:00:00.000Z';
32
+ const WINDOW_END = '2026-12-31T23:59:59.000Z';
33
+ const DIGEST = 'a'.repeat(64);
34
+ const SIMULATION = {
35
+ schema_version: M3_SIMULATION_PROVENANCE_SCHEMA,
36
+ kind: 'llm_persona',
37
+ persona_id: 'erhai-weekend-unwind',
38
+ product_model: 'MiniMax-M2',
39
+ persona_model: 'MiniMax-M2',
40
+ prompt_digest: DIGEST,
41
+ };
42
+ function stepClock(startIso = '2026-10-05T00:00:00.000Z') {
43
+ let ms = Date.parse(startIso);
44
+ return { now: () => { ms += 1_000; return new Date(ms); } };
45
+ }
46
+ const roots = [];
47
+ function newRoot() {
48
+ const root = mkdtempSync(join(tmpdir(), 'gotry-m3-cohort-'));
49
+ roots.push(root);
50
+ return root;
51
+ }
52
+ let n = 0;
53
+ async function pass(name, body) {
54
+ await body();
55
+ console.log(` ${++n}. ${name} OK`);
56
+ }
57
+ function expectCode(code, body) {
58
+ assert.throws(body, (error) => {
59
+ assert.ok(error instanceof M3CohortError, `expected M3CohortError, got ${String(error)}`);
60
+ assert.equal(error.code, code);
61
+ return true;
62
+ });
63
+ }
64
+ function common(root, key = KEY, consent = CONSENT) {
65
+ return { stateRoot: root, consent, hmacKey: key };
66
+ }
67
+ function initArgs(root, evidenceKind, key = KEY) {
68
+ return {
69
+ ...common(root, key),
70
+ cohort: `cohort-${evidenceKind}`,
71
+ evidenceKind,
72
+ windowStartAt: WINDOW_START,
73
+ windowEndAt: WINDOW_END,
74
+ timezone: 'Asia/Shanghai',
75
+ allowedAttribution: ['gotry_primary', 'gotry_assisted'],
76
+ };
77
+ }
78
+ /** A pid that is provably gone: spawn a trivial child, wait for it, reuse its pid. */
79
+ function findDeadPid() {
80
+ const child = spawnSync(process.execPath, ['-e', 'process.exit(0)'], { encoding: 'utf8', timeout: 30_000 });
81
+ const pid = child.pid;
82
+ assert.ok(typeof pid === 'number' && pid > 0, 'could not obtain a finished child pid');
83
+ for (let attempt = 0; attempt < 50; attempt += 1) {
84
+ try {
85
+ process.kill(pid, 0);
86
+ }
87
+ catch {
88
+ return pid;
89
+ }
90
+ }
91
+ assert.fail(`pid ${pid} did not become reapable`);
92
+ }
93
+ function allFileText(root) {
94
+ const parts = [];
95
+ function walk(path) {
96
+ if (!existsSync(path))
97
+ return;
98
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
99
+ const child = join(path, entry.name);
100
+ if (entry.isDirectory())
101
+ walk(child);
102
+ else
103
+ parts.push(readFileSync(child, 'utf8'));
104
+ }
105
+ }
106
+ walk(root);
107
+ return parts.join('\n');
108
+ }
109
+ /** `null` means "run with no HMAC key in the environment" — a default parameter cannot
110
+ * express that, because passing `undefined` would re-apply the default. */
111
+ function runCli(args, key = KEY) {
112
+ const env = { ...process.env };
113
+ if (key === null)
114
+ delete env['GOTRY_M3_COHORT_HMAC_KEY'];
115
+ else
116
+ env['GOTRY_M3_COHORT_HMAC_KEY'] = key;
117
+ return spawnSync(TSX, ['scripts/m3-cohort.ts', ...args], { cwd: TS_ROOT, env, encoding: 'utf8', timeout: 120_000 });
118
+ }
119
+ function runScorerCli(evidenceRoot) {
120
+ return spawnSync(TSX, ['scripts/product-metrics.ts', '--evidence-root', evidenceRoot, '--format', 'json'], {
121
+ cwd: TS_ROOT,
122
+ encoding: 'utf8',
123
+ timeout: 120_000,
124
+ });
125
+ }
126
+ /** A full funnel for one participant; returns its pseudonymous keys. */
127
+ function captureOne(root, kind, participant, clock, options = {}) {
128
+ const plan = `${participant}-plan`;
129
+ enrollParticipant({
130
+ ...common(root),
131
+ participant,
132
+ invited: options.invited ?? true,
133
+ participantConsent: options.consent ?? true,
134
+ testOrStaff: options.testOrStaff ?? false,
135
+ simulation: kind === 'synthetic_fixture' ? { ...SIMULATION, persona_id: 'erhai-weekend-unwind' } : undefined,
136
+ }, clock);
137
+ recordPlanDelivered({ ...common(root), participant, plan, attribution: 'gotry_primary' }, clock);
138
+ if (options.finalize !== false)
139
+ recordPlanFinalized({ ...common(root), participant, plan }, clock);
140
+ const score = options.nps === undefined ? 10 : options.nps;
141
+ if (score !== null)
142
+ recordNps({ ...common(root), participant, plan, score }, clock);
143
+ recordPoiLock({ ...common(root), participant, plan, lockedClaims: options.locked ?? 8, invalidClaims: options.invalid ?? 0 }, clock);
144
+ return {
145
+ participantKey: participantKeyFor(KEY, kind, participant),
146
+ planKey: planKeyFor(KEY, kind, participant, plan),
147
+ };
148
+ }
149
+ /** Hand-written evidence root that bypasses the capture gate entirely. This is the red
150
+ * baseline: the scorer alone cannot tell it is fabricated. */
151
+ function writeHandMadeEvidenceRoot(root, evidenceKind, participants) {
152
+ mkdirSync(root, { recursive: true });
153
+ const manifest = {
154
+ schema_version: 'gotry_m3_evidence_manifest_v1',
155
+ evidence_kind: evidenceKind,
156
+ cohort_id: `hmac-sha256:${'b'.repeat(64)}`,
157
+ locked_at: '2026-12-31T23:59:59.000Z',
158
+ window: { start_at: WINDOW_START, end_at: WINDOW_END, timezone: 'Asia/Shanghai' },
159
+ eligibility: {
160
+ sample_unit: 'unique_participant_with_eligible_delivered_plan',
161
+ requires_invitation: true,
162
+ requires_consent: true,
163
+ allowed_attribution: ['gotry_primary', 'gotry_assisted'],
164
+ exclusion_codes: [...M3_EXCLUSION_CODES],
165
+ },
166
+ metrics: {
167
+ sample_size: { minimum: M3_ACCEPTANCE.sampleMinimum, maximum: M3_ACCEPTANCE.sampleMaximum },
168
+ finalization_rate: { formula: 'finalized_eligible_delivered_plans / eligible_delivered_plans', minimum: M3_ACCEPTANCE.finalizationMinimum },
169
+ nps: { formula: '100 * (promoters_9_10 - detractors_0_6) / valid_responses', minimum: M3_ACCEPTANCE.npsMinimum },
170
+ poi_hallucination_rate: { formula: 'audited_invalid_poi_claims / locked_audited_poi_claims', exclusive_maximum: M3_ACCEPTANCE.poiExclusiveMaximum },
171
+ },
172
+ nightly: { requires_real_llm: true, requires_prompt_set_sha256: true, requires_output_sha256: true, requires_cost_usd: true },
173
+ };
174
+ const lines = [];
175
+ for (let i = 0; i < participants; i += 1) {
176
+ const hex = i.toString(16).padStart(4, '0');
177
+ lines.push(JSON.stringify({
178
+ schema_version: 'gotry_m3_cohort_record_v1',
179
+ participant_key: `hmac-sha256:${hex}${'c'.repeat(60)}`,
180
+ plan_key: `hmac-sha256:${hex}${'d'.repeat(60)}`,
181
+ invited: true,
182
+ consent: true,
183
+ test_or_staff: false,
184
+ attribution: 'gotry_primary',
185
+ delivered_at: '2026-11-01T00:00:00.000Z',
186
+ finalized_at: '2026-11-02T00:00:00.000Z',
187
+ nps_score: 10,
188
+ nps_recorded_at: '2026-11-03T00:00:00.000Z',
189
+ poi_audit: { locked_at: '2026-11-04T00:00:00.000Z', locked_claims: 10, invalid_claims: 0 },
190
+ }));
191
+ }
192
+ lines.push(JSON.stringify({
193
+ schema_version: 'gotry_m3_nightly_run_v1',
194
+ run_key: `hmac-sha256:${'e'.repeat(64)}`,
195
+ executed_at: '2026-11-05T00:00:00.000Z',
196
+ real_llm: true,
197
+ prompt_set_sha256: 'f'.repeat(64),
198
+ output_sha256: '9'.repeat(64),
199
+ cost_usd: 0.12,
200
+ }));
201
+ writeFileSync(join(root, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
202
+ writeFileSync(join(root, 'cohort.jsonl'), `${lines.join('\n')}\n`, 'utf8');
203
+ return root;
204
+ }
205
+ async function main() {
206
+ console.log('M3 cohort capture tests');
207
+ await pass('init freezes the scorer-shaped manifest; re-init is idempotent', () => {
208
+ const root = newRoot();
209
+ const first = initM3Cohort(initArgs(root, 'synthetic_fixture'), stepClock());
210
+ assert.equal(first.status, 'recorded');
211
+ assert.equal(first.evidence_kind, 'synthetic_fixture');
212
+ assert.match(first.cohort_ref, /^hmac-sha256:[0-9a-f]{64}$/);
213
+ const again = initM3Cohort(initArgs(root, 'synthetic_fixture'), stepClock());
214
+ assert.equal(again.status, 'unchanged');
215
+ assert.equal(again.cohort_ref, first.cohort_ref);
216
+ const { manifest } = readStoreForTests(root);
217
+ // The frozen evidence manifest must satisfy the real scorer parser verbatim.
218
+ const parsed = parseManifest(JSON.parse(JSON.stringify(manifest.evidence_manifest)));
219
+ assert.equal(parsed.evidence_kind, 'synthetic_fixture');
220
+ assert.equal(parsed.cohort_id, first.cohort_ref);
221
+ assert.deepEqual(parsed.eligibility.exclusion_codes.slice().sort(), [...M3_EXCLUSION_CODES].sort());
222
+ assert.equal(parsed.metrics.sample_size.minimum, 50);
223
+ assert.equal(parsed.metrics.nps.minimum, 40);
224
+ });
225
+ await pass('init refuses a changed window / changed kind on the same store', () => {
226
+ const root = newRoot();
227
+ initM3Cohort(initArgs(root, 'synthetic_fixture'), stepClock());
228
+ expectCode('cohort_already_initialized', () => initM3Cohort({ ...initArgs(root, 'synthetic_fixture'), windowEndAt: '2027-01-31T00:00:00.000Z' }, stepClock()));
229
+ expectCode('evidence_kind_mismatch', () => initM3Cohort(initArgs(root, 'real_seed_cohort'), stepClock()));
230
+ expectCode('cohort_key_mismatch', () => initM3Cohort(initArgs(root, 'synthetic_fixture', OTHER_KEY), stepClock()));
231
+ expectCode('cohort_consent_mismatch', () => initM3Cohort({ ...initArgs(root, 'synthetic_fixture'), consent: 'different consent' }, stepClock()));
232
+ });
233
+ await pass('opt-in only: missing key / consent / state root write nothing', () => {
234
+ const root = newRoot();
235
+ expectCode('missing_hmac_key', () => initM3Cohort({ ...initArgs(root, 'synthetic_fixture'), hmacKey: undefined }, stepClock()));
236
+ expectCode('invalid_hmac_key', () => initM3Cohort({ ...initArgs(root, 'synthetic_fixture'), hmacKey: 'too-short' }, stepClock()));
237
+ expectCode('missing_consent', () => initM3Cohort({ ...initArgs(root, 'synthetic_fixture'), consent: '' }, stepClock()));
238
+ expectCode('missing_state_root', () => initM3Cohort({ ...initArgs(root, 'synthetic_fixture'), stateRoot: '' }, stepClock()));
239
+ assert.equal(existsSync(storeRootForStateRoot(root)), false, 'no store directory may exist after refused commands');
240
+ expectCode('cohort_not_initialized', () => enrollParticipant({ ...common(root), participant: 'p1', invited: true, participantConsent: true, testOrStaff: false }, stepClock()));
241
+ assert.equal(existsSync(storeRootForStateRoot(root)), false);
242
+ });
243
+ await pass('product state roots are refused (ts/dsh-runtime, ~/.dsh, ~/.gotry, .git)', () => {
244
+ for (const unsafe of [
245
+ join(TS_ROOT, 'dsh-runtime'),
246
+ join(TS_ROOT, 'dsh-runtime', 'nested'),
247
+ join(newRoot(), '.dsh'),
248
+ join(newRoot(), '.gotry'),
249
+ join(resolve(TS_ROOT, '..'), '.git'),
250
+ ]) {
251
+ expectCode('unsafe_state_root', () => initM3Cohort(initArgs(unsafe, 'synthetic_fixture'), stepClock()));
252
+ }
253
+ });
254
+ await pass('(b) a simulated participant is refused by a real_seed_cohort store', () => {
255
+ const root = newRoot();
256
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), stepClock());
257
+ expectCode('simulation_forbidden_in_real_cohort', () => enrollParticipant({
258
+ ...common(root), participant: 'p1', invited: true, participantConsent: true, testOrStaff: false, simulation: SIMULATION,
259
+ }, stepClock()));
260
+ assert.equal(readStoreForTests(root).events.length, 0, 'a refused enrollment must append nothing');
261
+ });
262
+ await pass('(b) an unlabelled participant is refused by a synthetic_fixture store', () => {
263
+ const root = newRoot();
264
+ initM3Cohort(initArgs(root, 'synthetic_fixture'), stepClock());
265
+ expectCode('simulation_label_required', () => enrollParticipant({
266
+ ...common(root), participant: 'p1', invited: true, participantConsent: true, testOrStaff: false,
267
+ }, stepClock()));
268
+ assert.equal(readStoreForTests(root).events.length, 0);
269
+ });
270
+ await pass('funnel is append-only and idempotent; replays return unchanged', () => {
271
+ const root = newRoot();
272
+ const clock = stepClock();
273
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
274
+ captureOne(root, 'real_seed_cohort', 'participant-one', clock);
275
+ const after = readStoreForTests(root).events.length;
276
+ assert.equal(after, 5, 'enroll + deliver + finalize + nps + poi-lock');
277
+ const replays = [
278
+ enrollParticipant({ ...common(root), participant: 'participant-one', invited: true, participantConsent: true, testOrStaff: false }, clock),
279
+ recordPlanDelivered({ ...common(root), participant: 'participant-one', plan: 'participant-one-plan', attribution: 'gotry_primary' }, clock),
280
+ recordPlanFinalized({ ...common(root), participant: 'participant-one', plan: 'participant-one-plan' }, clock),
281
+ recordNps({ ...common(root), participant: 'participant-one', plan: 'participant-one-plan', score: 10 }, clock),
282
+ recordPoiLock({ ...common(root), participant: 'participant-one', plan: 'participant-one-plan', lockedClaims: 8, invalidClaims: 0 }, clock),
283
+ ];
284
+ for (const replay of replays)
285
+ assert.equal(replay.status, 'unchanged', `${replay.command} must be idempotent`);
286
+ assert.equal(readStoreForTests(root).events.length, after, 'idempotent replays must not grow the log');
287
+ });
288
+ await pass('out-of-order and out-of-range submissions fail closed', () => {
289
+ const root = newRoot();
290
+ const clock = stepClock();
291
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
292
+ expectCode('invalid_transition', () => recordPlanDelivered({ ...common(root), participant: 'ghost', plan: 'ghost-plan', attribution: 'gotry_primary' }, clock));
293
+ enrollParticipant({ ...common(root), participant: 'p1', invited: true, participantConsent: true, testOrStaff: false }, clock);
294
+ expectCode('invalid_transition', () => recordPlanFinalized({ ...common(root), participant: 'p1', plan: 'p1-plan' }, clock));
295
+ expectCode('invalid_transition', () => recordNps({ ...common(root), participant: 'p1', plan: 'p1-plan', score: 9 }, clock));
296
+ expectCode('invalid_transition', () => recordPoiLock({ ...common(root), participant: 'p1', plan: 'p1-plan', lockedClaims: 1, invalidClaims: 0 }, clock));
297
+ recordPlanDelivered({ ...common(root), participant: 'p1', plan: 'p1-plan', attribution: 'gotry_primary' }, clock);
298
+ expectCode('invalid_input', () => recordNps({ ...common(root), participant: 'p1', plan: 'p1-plan', score: 11 }, clock));
299
+ expectCode('invalid_input', () => recordNps({ ...common(root), participant: 'p1', plan: 'p1-plan', score: 1.5 }, clock));
300
+ expectCode('invalid_input', () => recordPoiLock({ ...common(root), participant: 'p1', plan: 'p1-plan', lockedClaims: 2, invalidClaims: 3 }, clock));
301
+ recordNps({ ...common(root), participant: 'p1', plan: 'p1-plan', score: 9 }, clock);
302
+ // The scorer throws on two NPS responses from one participant in the window, so the
303
+ // capture side refuses the second one at write time.
304
+ recordPlanDelivered({ ...common(root), participant: 'p1', plan: 'p1-plan-b', attribution: 'gotry_primary' }, clock);
305
+ expectCode('invalid_transition', () => recordNps({ ...common(root), participant: 'p1', plan: 'p1-plan-b', score: 8 }, clock));
306
+ });
307
+ await pass('plan keys and participant keys are domain-separated by evidence kind', () => {
308
+ assert.notEqual(participantKeyFor(KEY, 'real_seed_cohort', 'p1'), participantKeyFor(KEY, 'synthetic_fixture', 'p1'));
309
+ assert.notEqual(planKeyFor(KEY, 'real_seed_cohort', 'p1', 'plan'), planKeyFor(KEY, 'synthetic_fixture', 'p1', 'plan'));
310
+ assert.notEqual(participantKeyFor(KEY, 'real_seed_cohort', 'p1'), participantKeyFor(OTHER_KEY, 'real_seed_cohort', 'p1'));
311
+ });
312
+ await pass('(c) a PII sentinel never reaches a persisted byte', () => {
313
+ const root = newRoot();
314
+ const clock = stepClock();
315
+ initM3Cohort({ ...initArgs(root, 'synthetic_fixture'), cohort: SENTINEL }, clock);
316
+ captureOne(root, 'synthetic_fixture', SENTINEL, clock);
317
+ const evidenceRoot = join(root, 'evidence');
318
+ exportM3Cohort({ ...common(root), evidenceRoot, validate: undefined }, clock);
319
+ for (const scope of [storeRootForStateRoot(root), evidenceRoot]) {
320
+ const text = allFileText(scope);
321
+ assert.equal(text.includes(SENTINEL), false, `sentinel leaked into ${scope}`);
322
+ assert.equal(text.includes('PRIVACY_SENTINEL'), false, `sentinel fragment leaked into ${scope}`);
323
+ assert.equal(text.includes(KEY), false, `HMAC key leaked into ${scope}`);
324
+ }
325
+ });
326
+ await pass('store files are private (0600) under a private directory (0700)', () => {
327
+ const root = newRoot();
328
+ const clock = stepClock();
329
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
330
+ captureOne(root, 'real_seed_cohort', 'p1', clock);
331
+ const storeRoot = storeRootForStateRoot(root);
332
+ assert.equal(statSync(storeRoot).mode & 0o777, 0o700);
333
+ assert.equal(fileMode(join(storeRoot, 'manifest.json')), 0o600);
334
+ assert.equal(fileMode(join(storeRoot, 'events.jsonl')), 0o600);
335
+ });
336
+ await pass('a competing writer lock returns lock_busy and writes nothing', () => {
337
+ const root = newRoot();
338
+ const clock = stepClock();
339
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
340
+ const lockPath = join(storeRootForStateRoot(root), '.writer.lock');
341
+ writeFileSync(lockPath, '{"pid":1}', { mode: 0o600 });
342
+ try {
343
+ expectCode('lock_busy', () => enrollParticipant({ ...common(root), participant: 'p1', invited: true, participantConsent: true, testOrStaff: false }, clock));
344
+ assert.equal(readStoreForTests(root).events.length, 0);
345
+ }
346
+ finally {
347
+ rmSync(lockPath, { force: true });
348
+ }
349
+ });
350
+ await pass('export emits exactly the scorer v1 shapes and skips un-audited plans', () => {
351
+ const root = newRoot();
352
+ const clock = stepClock();
353
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
354
+ captureOne(root, 'real_seed_cohort', 'p1', clock);
355
+ // Delivered but never POI-locked: not exportable, because the scorer requires poi_audit
356
+ // and the capture side refuses to invent one.
357
+ enrollParticipant({ ...common(root), participant: 'p2', invited: true, participantConsent: true, testOrStaff: false }, clock);
358
+ recordPlanDelivered({ ...common(root), participant: 'p2', plan: 'p2-plan', attribution: 'gotry_assisted' }, clock);
359
+ const evidenceRoot = join(root, 'evidence');
360
+ const { payload } = exportM3Cohort({ ...common(root), evidenceRoot }, clock);
361
+ assert.equal(payload.cohort.length, 1);
362
+ assert.equal(payload.provenance.length, 0);
363
+ const record = payload.cohort[0];
364
+ assert.deepEqual(Object.keys(record).sort(), [
365
+ 'attribution', 'consent', 'delivered_at', 'finalized_at', 'invited', 'nps_recorded_at',
366
+ 'nps_score', 'participant_key', 'plan_key', 'poi_audit', 'schema_version', 'test_or_staff',
367
+ ]);
368
+ // The real consumer CLI must read the written root.
369
+ const scorer = runScorerCli(evidenceRoot);
370
+ assert.equal(scorer.status, 0, `${scorer.stderr}\n${scorer.stdout}`);
371
+ const summary = JSON.parse(scorer.stdout);
372
+ assert.equal(summary.evidence_kind, 'real_seed_cohort');
373
+ assert.equal(summary.sample.participants, 1);
374
+ assert.equal(summary.business_pass, false, 'one participant cannot clear the 50-sample floor');
375
+ assert.equal(verifyExportedEvidence(evidenceRoot, KEY).record_count, 1);
376
+ // No-overwrite: a second export into the same root refuses rather than rewriting.
377
+ expectCode('output_exists', () => exportM3Cohort({ ...common(root), evidenceRoot }, clock));
378
+ });
379
+ await pass('test_or_staff participants land in the scorer exclusion counters', () => {
380
+ const root = newRoot();
381
+ const clock = stepClock();
382
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
383
+ captureOne(root, 'real_seed_cohort', 'real-one', clock);
384
+ captureOne(root, 'real_seed_cohort', 'staff-one', clock, { testOrStaff: true });
385
+ captureOne(root, 'real_seed_cohort', 'uninvited-one', clock, { invited: false });
386
+ captureOne(root, 'real_seed_cohort', 'withdrawn-one', clock, { consent: false });
387
+ const evidenceRoot = join(root, 'evidence');
388
+ const { payload } = exportM3Cohort({ ...common(root), evidenceRoot }, clock);
389
+ const manifest = parseManifest(JSON.parse(JSON.stringify(payload.manifest)));
390
+ const cohort = payload.cohort.map((item, index) => parseCohortRecord(JSON.parse(JSON.stringify(item)), index));
391
+ const summary = scoreProductMetrics(manifest, cohort, []);
392
+ assert.equal(summary.exclusions['test_or_staff'], 1);
393
+ assert.equal(summary.exclusions['not_invited'], 1);
394
+ assert.equal(summary.exclusions['consent_missing_or_withdrawn'], 1);
395
+ assert.equal(summary.sample.participants, 1, 'only the clean participant is eligible');
396
+ });
397
+ await pass('(a) a flawless simulated cohort still cannot reach business_pass', () => {
398
+ const root = newRoot();
399
+ const clock = stepClock();
400
+ initM3Cohort(initArgs(root, 'synthetic_fixture'), clock);
401
+ for (let i = 0; i < M3_ACCEPTANCE.sampleMinimum; i += 1) {
402
+ captureOne(root, 'synthetic_fixture', `sim-${String(i).padStart(3, '0')}`, clock, { nps: 10, locked: 10, invalid: 0 });
403
+ }
404
+ const evidenceRoot = join(root, 'evidence');
405
+ const { payload } = exportM3Cohort({ ...common(root), evidenceRoot }, clock);
406
+ assert.equal(payload.cohort.length, 50);
407
+ assert.equal(payload.provenance.length, 50, 'every simulated record carries provenance');
408
+ // Append the nightly real-LLM record the gate also needs, so every single non-kind
409
+ // check passes and only evidence_kind is left standing between this and a pass.
410
+ const manifest = parseManifest(JSON.parse(JSON.stringify(payload.manifest)));
411
+ const cohort = payload.cohort.map((item, index) => parseCohortRecord(JSON.parse(JSON.stringify(item)), index));
412
+ const summary = scoreProductMetrics(manifest, cohort, [{
413
+ schema_version: 'gotry_m3_nightly_run_v1',
414
+ run_key: `hmac-sha256:${'e'.repeat(64)}`,
415
+ executed_at: '2026-11-05T00:00:00.000Z',
416
+ real_llm: true,
417
+ prompt_set_sha256: 'f'.repeat(64),
418
+ output_sha256: '9'.repeat(64),
419
+ cost_usd: 0.12,
420
+ }]);
421
+ assert.equal(summary.sample.pass, true);
422
+ assert.equal(summary.finalization.rate, 1);
423
+ assert.equal(summary.nps.score, 100);
424
+ assert.equal(summary.poi_hallucination.rate, 0);
425
+ assert.equal(summary.nightly.pass, true);
426
+ assert.equal(summary.business_pass, false, 'every metric passes and business_pass is still false');
427
+ assert.equal(summary.business_pass_reason, 'evidence_kind=synthetic_fixture cannot prove business pass');
428
+ });
429
+ await pass('(a)/(b) red baseline: the same numbers hand-written as real DO pass the scorer', () => {
430
+ // Without the capture gate nothing stops a fabricated real_seed_cohort root from
431
+ // scoring a full business pass. This is the failure the gate and the attestation
432
+ // exist to make detectable; it is reproduced here so the guard is not self-evident.
433
+ const realRoot = writeHandMadeEvidenceRoot(join(newRoot(), 'hand-made-real'), 'real_seed_cohort', 50);
434
+ const real = runScorerCli(realRoot);
435
+ assert.equal(real.status, 0, `${real.stderr}\n${real.stdout}`);
436
+ const realSummary = JSON.parse(real.stdout);
437
+ assert.equal(realSummary.business_pass, true, 'red baseline: the scorer alone accepts fabricated real evidence');
438
+ // The same bytes labelled synthetic are rejected — so only the label is load-bearing,
439
+ // and only the attestation can prove the label was not flipped after the fact.
440
+ const synthRoot = writeHandMadeEvidenceRoot(join(newRoot(), 'hand-made-synthetic'), 'synthetic_fixture', 50);
441
+ const synth = runScorerCli(synthRoot);
442
+ assert.equal(synth.status, 0, `${synth.stderr}\n${synth.stdout}`);
443
+ assert.equal(JSON.parse(synth.stdout).business_pass, false);
444
+ // A hand-made root carries no attestation at all, which the verifier refuses.
445
+ expectCode('export_validation_failed', () => verifyExportedEvidence(realRoot, KEY));
446
+ });
447
+ await pass('(d) relabelling a synthetic export as real breaks the attestation', () => {
448
+ const root = newRoot();
449
+ const clock = stepClock();
450
+ initM3Cohort(initArgs(root, 'synthetic_fixture'), clock);
451
+ captureOne(root, 'synthetic_fixture', 'sim-one', clock);
452
+ const evidenceRoot = join(root, 'evidence');
453
+ exportM3Cohort({ ...common(root), evidenceRoot }, clock);
454
+ const verified = verifyExportedEvidence(evidenceRoot, KEY);
455
+ assert.equal(verified.evidence_kind, 'synthetic_fixture');
456
+ assert.equal(verified.simulated_record_count, 1);
457
+ const manifestPath = join(evidenceRoot, 'manifest.json');
458
+ const original = readFileSync(manifestPath, 'utf8');
459
+ writeFileSync(manifestPath, original.replace('"synthetic_fixture"', '"real_seed_cohort"'), 'utf8');
460
+ // Red baseline for this falsification: the scorer trusts the manifest it is handed, so
461
+ // the one-field edit alone is enough to make it report a real cohort.
462
+ const relabelled = runScorerCli(evidenceRoot);
463
+ assert.equal(relabelled.status, 0, `${relabelled.stderr}\n${relabelled.stdout}`);
464
+ assert.equal(JSON.parse(relabelled.stdout).evidence_kind, 'real_seed_cohort');
465
+ // The attestation catches it: the manifest digest no longer matches.
466
+ expectCode('export_validation_failed', () => verifyExportedEvidence(evidenceRoot, KEY));
467
+ writeFileSync(manifestPath, original, 'utf8');
468
+ assert.equal(verifyExportedEvidence(evidenceRoot, KEY).evidence_kind, 'synthetic_fixture', 'restoring the byte restores the proof');
469
+ // Hiding the synthetic label by deleting the sidecar is caught too.
470
+ const provenancePath = join(evidenceRoot, 'provenance.jsonl');
471
+ const provenance = readFileSync(provenancePath, 'utf8');
472
+ writeFileSync(provenancePath, '', 'utf8');
473
+ expectCode('export_validation_failed', () => verifyExportedEvidence(evidenceRoot, KEY));
474
+ writeFileSync(provenancePath, provenance, 'utf8');
475
+ // Editing a cohort record is caught; a legitimate nightly append is not.
476
+ const cohortPath = join(evidenceRoot, 'cohort.jsonl');
477
+ const cohortBytes = readFileSync(cohortPath, 'utf8');
478
+ writeFileSync(cohortPath, cohortBytes.replace('"nps_score":10', '"nps_score":9'), 'utf8');
479
+ expectCode('export_validation_failed', () => verifyExportedEvidence(evidenceRoot, KEY));
480
+ writeFileSync(cohortPath, cohortBytes, 'utf8');
481
+ const nightlyLine = JSON.stringify({
482
+ schema_version: 'gotry_m3_nightly_run_v1',
483
+ run_key: `hmac-sha256:${'e'.repeat(64)}`,
484
+ executed_at: '2026-11-05T00:00:00.000Z',
485
+ real_llm: true,
486
+ prompt_set_sha256: 'f'.repeat(64),
487
+ output_sha256: '9'.repeat(64),
488
+ cost_usd: 0.12,
489
+ });
490
+ writeFileSync(cohortPath, `${cohortBytes}${nightlyLine}\n`, 'utf8');
491
+ assert.equal(verifyExportedEvidence(evidenceRoot, KEY).record_count, 1, 'a nightly append must not invalidate the cohort attestation');
492
+ assert.equal(cohortRecordLines(readFileSync(cohortPath, 'utf8')).length, 1);
493
+ });
494
+ await pass('(d) a fully recomputed forgery is caught by the keyed attestation', () => {
495
+ const root = newRoot();
496
+ const clock = stepClock();
497
+ initM3Cohort(initArgs(root, 'synthetic_fixture'), clock);
498
+ captureOne(root, 'synthetic_fixture', 'sim-one', clock);
499
+ const evidenceRoot = join(root, 'evidence');
500
+ exportM3Cohort({ ...common(root), evidenceRoot }, clock);
501
+ const manifestPath = join(evidenceRoot, 'manifest.json');
502
+ const provenancePath = join(evidenceRoot, 'provenance.jsonl');
503
+ const attestationPath = join(evidenceRoot, 'export-attestation.json');
504
+ const cohortBytes = readFileSync(join(evidenceRoot, 'cohort.jsonl'), 'utf8');
505
+ // The forger does the whole job: relabel the manifest, delete the synthetic labels,
506
+ // recompute all three digests, and rewrite the attestation's own kind and counters.
507
+ const forgedManifest = readFileSync(manifestPath, 'utf8').replace('"synthetic_fixture"', '"real_seed_cohort"');
508
+ writeFileSync(manifestPath, forgedManifest, 'utf8');
509
+ writeFileSync(provenancePath, '', 'utf8');
510
+ const attestation = JSON.parse(readFileSync(attestationPath, 'utf8'));
511
+ attestation['evidence_kind'] = 'real_seed_cohort';
512
+ attestation['simulated_record_count'] = 0;
513
+ attestation['manifest_sha256'] = createHash('sha256').update(forgedManifest).digest('hex');
514
+ attestation['provenance_jsonl_sha256'] = createHash('sha256').update('').digest('hex');
515
+ attestation['cohort_records_sha256'] = createHash('sha256').update(cohortRecordLines(cohortBytes).join('\n') + '\n').digest('hex');
516
+ writeFileSync(attestationPath, `${JSON.stringify(attestation, null, 2)}\n`, 'utf8');
517
+ // Red baseline: every unkeyed digest in the forged attestation now matches its file, so
518
+ // a byte-only verifier would accept it, and the scorer reads a real cohort.
519
+ assert.equal(attestation['manifest_sha256'], createHash('sha256').update(readFileSync(manifestPath, 'utf8')).digest('hex'));
520
+ assert.equal(attestation['provenance_jsonl_sha256'], createHash('sha256').update(readFileSync(provenancePath, 'utf8')).digest('hex'));
521
+ const forgedScore = runScorerCli(evidenceRoot);
522
+ assert.equal(forgedScore.status, 0, `${forgedScore.stderr}\n${forgedScore.stdout}`);
523
+ assert.equal(JSON.parse(forgedScore.stdout).evidence_kind, 'real_seed_cohort');
524
+ // Green: the MAC over the attestation body cannot be recomputed without the key.
525
+ expectCode('export_validation_failed', () => verifyExportedEvidence(evidenceRoot, KEY));
526
+ // A wrong key is refused too, and verify without a key at all refuses up front.
527
+ expectCode('export_validation_failed', () => verifyExportedEvidence(evidenceRoot, OTHER_KEY));
528
+ expectCode('missing_hmac_key', () => verifyExportedEvidence(evidenceRoot, undefined));
529
+ });
530
+ await pass('verify requires the capture key even for an untampered export', () => {
531
+ const root = newRoot();
532
+ const clock = stepClock();
533
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
534
+ captureOne(root, 'real_seed_cohort', 'real-one', clock);
535
+ const evidenceRoot = join(root, 'evidence');
536
+ exportM3Cohort({ ...common(root), evidenceRoot }, clock);
537
+ assert.equal(verifyExportedEvidence(evidenceRoot, KEY).ok, true);
538
+ expectCode('export_validation_failed', () => verifyExportedEvidence(evidenceRoot, OTHER_KEY));
539
+ expectCode('missing_hmac_key', () => verifyExportedEvidence(evidenceRoot, undefined));
540
+ const attestation = JSON.parse(readFileSync(join(evidenceRoot, 'export-attestation.json'), 'utf8'));
541
+ assert.match(String(attestation['attestation_hmac']), /^hmac-sha256:[0-9a-f]{64}$/);
542
+ assert.equal(allFileText(evidenceRoot).includes(KEY), false, 'the key itself never reaches the evidence root');
543
+ const cli = runCli(['verify', '--evidence-root', evidenceRoot], null);
544
+ assert.equal(cli.stderr.trim(), 'm3_cohort_error:missing_hmac_key', 'the CLI verify path needs the key too');
545
+ });
546
+ await pass('export is all-or-nothing: a partially occupied evidence root is refused untouched', () => {
547
+ const root = newRoot();
548
+ const clock = stepClock();
549
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
550
+ captureOne(root, 'real_seed_cohort', 'real-one', clock);
551
+ // A root already holding somebody else's cohort records but no manifest/attestation.
552
+ const evidenceRoot = join(root, 'half-occupied');
553
+ mkdirSync(evidenceRoot, { recursive: true });
554
+ const squatterCohort = '{"schema_version":"gotry_m3_cohort_record_v1","squatter":true}\n';
555
+ writeFileSync(join(evidenceRoot, 'cohort.jsonl'), squatterCohort, 'utf8');
556
+ writeFileSync(join(evidenceRoot, 'provenance.jsonl'), '', 'utf8');
557
+ expectCode('output_exists', () => exportM3Cohort({ ...common(root), evidenceRoot }, clock));
558
+ // Nothing of this export may remain: a real manifest sitting over foreign records would
559
+ // be read by the scorer, which never consults verify.
560
+ assert.equal(existsSync(join(evidenceRoot, 'manifest.json')), false, 'no manifest may be left behind');
561
+ assert.equal(existsSync(join(evidenceRoot, 'export-attestation.json')), false);
562
+ assert.equal(readFileSync(join(evidenceRoot, 'cohort.jsonl'), 'utf8'), squatterCohort, 'the pre-existing file is untouched');
563
+ // The same applies when only the manifest is squatting.
564
+ const second = join(root, 'manifest-only');
565
+ mkdirSync(second, { recursive: true });
566
+ writeFileSync(join(second, 'manifest.json'), '{}', 'utf8');
567
+ expectCode('output_exists', () => exportM3Cohort({ ...common(root), evidenceRoot: second }, clock));
568
+ assert.equal(existsSync(join(second, 'cohort.jsonl')), false);
569
+ assert.equal(readFileSync(join(second, 'manifest.json'), 'utf8'), '{}');
570
+ // A clean root still works afterwards.
571
+ const clean = join(root, 'clean');
572
+ assert.equal(exportM3Cohort({ ...common(root), evidenceRoot: clean }, clock).payload.cohort.length, 1);
573
+ });
574
+ await pass('a writer lock left by a dead process is reclaimed; a live one is not', () => {
575
+ const root = newRoot();
576
+ const clock = stepClock();
577
+ initM3Cohort(initArgs(root, 'real_seed_cohort'), clock);
578
+ const lockPath = join(storeRootForStateRoot(root), '.writer.lock');
579
+ // A lock naming this live process is a live writer: refused, and unlock refuses too.
580
+ writeFileSync(lockPath, JSON.stringify({ schema_version: 'gotry_m3_cohort_lock.v1', pid: process.pid }), { mode: 0o600 });
581
+ assert.equal(inspectStoreLock(root).reason, 'live_writer');
582
+ expectCode('lock_busy', () => enrollParticipant({ ...common(root), participant: 'p1', invited: true, participantConsent: true, testOrStaff: false }, clock));
583
+ expectCode('lock_busy', () => unlockStore(root));
584
+ assert.equal(existsSync(lockPath), true, 'a live writer keeps its lock');
585
+ // A lock with no readable pid is also refused: unknown is not dead.
586
+ writeFileSync(lockPath, 'not json at all', { mode: 0o600 });
587
+ assert.equal(inspectStoreLock(root).reason, 'unreadable');
588
+ expectCode('lock_busy', () => enrollParticipant({ ...common(root), participant: 'p1', invited: true, participantConsent: true, testOrStaff: false }, clock));
589
+ expectCode('lock_busy', () => unlockStore(root));
590
+ // A lock from a process that is gone must not wedge the store for weeks.
591
+ const deadPid = findDeadPid();
592
+ writeFileSync(lockPath, JSON.stringify({ schema_version: 'gotry_m3_cohort_lock.v1', pid: deadPid }), { mode: 0o600 });
593
+ const state = inspectStoreLock(root);
594
+ assert.equal(state.reason, 'stale_writer');
595
+ assert.equal(state.pid, deadPid);
596
+ const result = enrollParticipant({ ...common(root), participant: 'p1', invited: true, participantConsent: true, testOrStaff: false }, clock);
597
+ assert.equal(result.status, 'recorded', 'a stale lock is reclaimed rather than wedging the store');
598
+ assert.equal(existsSync(lockPath), false, 'the reclaimed lock is released again');
599
+ // The explicit CLI path: lock-status reports, unlock clears only a dead writer.
600
+ writeFileSync(lockPath, JSON.stringify({ schema_version: 'gotry_m3_cohort_lock.v1', pid: deadPid }), { mode: 0o600 });
601
+ const status = runCli(['lock-status', '--state-root', root]);
602
+ assert.equal(status.status, 0, status.stderr);
603
+ assert.equal(JSON.parse(status.stdout).reason, 'stale_writer');
604
+ const unlocked = runCli(['unlock', '--state-root', root]);
605
+ assert.equal(unlocked.status, 0, unlocked.stderr);
606
+ assert.equal(JSON.parse(unlocked.stdout).status, 'recorded');
607
+ assert.equal(existsSync(lockPath), false);
608
+ assert.equal(JSON.parse(runCli(['unlock', '--state-root', root]).stdout).status, 'unchanged');
609
+ });
610
+ await pass('(b) a synthetic export refuses to enter an evidence root attested as real', () => {
611
+ const realRoot = newRoot();
612
+ const realClock = stepClock();
613
+ initM3Cohort(initArgs(realRoot, 'real_seed_cohort'), realClock);
614
+ captureOne(realRoot, 'real_seed_cohort', 'real-one', realClock);
615
+ const evidenceRoot = join(realRoot, 'shared-evidence');
616
+ exportM3Cohort({ ...common(realRoot), evidenceRoot }, realClock);
617
+ const simRoot = newRoot();
618
+ const simClock = stepClock();
619
+ initM3Cohort(initArgs(simRoot, 'synthetic_fixture'), simClock);
620
+ captureOne(simRoot, 'synthetic_fixture', 'sim-one', simClock);
621
+ expectCode('evidence_root_conflict', () => exportM3Cohort({ ...common(simRoot), evidenceRoot }, simClock));
622
+ assert.equal(verifyExportedEvidence(evidenceRoot, KEY).evidence_kind, 'real_seed_cohort', 'the real root is untouched');
623
+ assert.equal(verifyExportedEvidence(evidenceRoot, KEY).simulated_record_count, 0);
624
+ });
625
+ await pass('export refuses a store whose manifest kind no longer matches its records', () => {
626
+ const root = newRoot();
627
+ const clock = stepClock();
628
+ initM3Cohort(initArgs(root, 'synthetic_fixture'), clock);
629
+ captureOne(root, 'synthetic_fixture', 'sim-one', clock);
630
+ const { manifest, events } = readStoreForTests(root);
631
+ const tampered = { ...manifest, evidence_manifest: { ...manifest.evidence_manifest, evidence_kind: 'real_seed_cohort' } };
632
+ expectCode('simulation_forbidden_in_real_cohort', () => buildExportPayload(tampered, events, '2026-11-01T00:00:00.000Z', KEY));
633
+ });
634
+ await pass('export is deterministic for the same capture log', () => {
635
+ const root = newRoot();
636
+ const clock = stepClock();
637
+ initM3Cohort(initArgs(root, 'synthetic_fixture'), clock);
638
+ captureOne(root, 'synthetic_fixture', 'sim-b', clock);
639
+ captureOne(root, 'synthetic_fixture', 'sim-a', clock);
640
+ const { manifest, events } = readStoreForTests(root);
641
+ const first = buildExportPayload(manifest, events, '2026-11-01T00:00:00.000Z', KEY);
642
+ const second = buildExportPayload(manifest, events, '2026-11-01T00:00:00.000Z', KEY);
643
+ assert.equal(canonicalJson(first), canonicalJson(second));
644
+ const keys = first.cohort.map(record => record.plan_key);
645
+ assert.deepEqual(keys, [...keys].sort(), 'records are emitted in plan-key order');
646
+ });
647
+ await pass('CLI: closed error codes, no --at flag, unknown flags refused', () => {
648
+ const root = newRoot();
649
+ const bad = runCli(['init', '--state-root', root, '--consent', CONSENT, '--cohort', 'c', '--evidence-kind', 'synthetic_fixture',
650
+ '--window-start', WINDOW_START, '--window-end', WINDOW_END, '--timezone', 'Asia/Shanghai', '--attribution', 'gotry_primary', '--at', '2026-10-05T00:00:00.000Z']);
651
+ assert.equal(bad.status, 2);
652
+ assert.equal(bad.stdout, '');
653
+ assert.equal(bad.stderr.trim(), 'm3_cohort_error:bad_args');
654
+ const noKey = runCli(['init', '--state-root', root, '--consent', CONSENT, '--cohort', 'c', '--evidence-kind', 'synthetic_fixture',
655
+ '--window-start', WINDOW_START, '--window-end', WINDOW_END, '--timezone', 'Asia/Shanghai', '--attribution', 'gotry_primary'], null);
656
+ assert.equal(noKey.stderr.trim(), 'm3_cohort_error:missing_hmac_key');
657
+ assert.equal(existsSync(storeRootForStateRoot(root)), false);
658
+ });
659
+ await pass('CLI: full real_seed_cohort funnel, export verification and scorer summary', () => {
660
+ const root = newRoot();
661
+ const base = ['--state-root', root, '--consent', CONSENT];
662
+ const init = runCli(['init', ...base, '--cohort', 'm3-seed', '--evidence-kind', 'real_seed_cohort',
663
+ '--window-start', WINDOW_START, '--window-end', WINDOW_END, '--timezone', 'Asia/Shanghai', '--attribution', 'gotry_primary']);
664
+ assert.equal(init.status, 0, `${init.stderr}\n${init.stdout}`);
665
+ for (const args of [
666
+ ['enroll', ...base, '--participant', 'cli-one', '--invited', '--participant-consent'],
667
+ ['deliver', ...base, '--participant', 'cli-one', '--plan', 'cli-one-plan', '--attribution', 'gotry_primary'],
668
+ ['finalize', ...base, '--participant', 'cli-one', '--plan', 'cli-one-plan'],
669
+ ['nps', ...base, '--participant', 'cli-one', '--plan', 'cli-one-plan', '--score', '9'],
670
+ ['poi-lock', ...base, '--participant', 'cli-one', '--plan', 'cli-one-plan', '--locked-claims', '12', '--invalid-claims', '0'],
671
+ ]) {
672
+ const run = runCli(args);
673
+ assert.equal(run.status, 0, `${args[0]}: ${run.stderr}\n${run.stdout}`);
674
+ assert.equal(JSON.parse(run.stdout).status, 'recorded');
675
+ }
676
+ const evidenceRoot = join(root, 'cli-evidence');
677
+ const exported = runCli(['export', ...base, '--evidence-root', evidenceRoot]);
678
+ assert.equal(exported.status, 0, `${exported.stderr}\n${exported.stdout}`);
679
+ const result = JSON.parse(exported.stdout);
680
+ assert.equal(result.record_count, 1);
681
+ assert.equal(result.simulated_record_count, 0);
682
+ assert.equal(result.verification.evidence_kind, 'real_seed_cohort');
683
+ assert.equal(result.scorer_summary.business_pass, false);
684
+ const verify = runCli(['verify', '--evidence-root', evidenceRoot]);
685
+ assert.equal(verify.status, 0, `${verify.stderr}\n${verify.stdout}`);
686
+ assert.equal(JSON.parse(verify.stdout).ok, true);
687
+ assert.equal(allFileText(evidenceRoot).includes('cli-one'), false, 'raw participant handles never reach the evidence root');
688
+ });
689
+ await pass('CLI: simulated enrolment requires the whole provenance block', () => {
690
+ const root = newRoot();
691
+ const base = ['--state-root', root, '--consent', CONSENT];
692
+ const init = runCli(['init', ...base, '--cohort', 'sim', '--evidence-kind', 'synthetic_fixture',
693
+ '--window-start', WINDOW_START, '--window-end', WINDOW_END, '--timezone', 'Asia/Shanghai', '--attribution', 'gotry_primary']);
694
+ assert.equal(init.status, 0, init.stderr);
695
+ const partial = runCli(['enroll', ...base, '--participant', 'sim-one', '--invited', '--participant-consent', '--simulated', '--persona-id', 'erhai-weekend-unwind']);
696
+ assert.equal(partial.stderr.trim(), 'm3_cohort_error:invalid_input');
697
+ const badDigest = runCli(['enroll', ...base, '--participant', 'sim-one', '--invited', '--participant-consent', '--simulated',
698
+ '--persona-id', 'erhai-weekend-unwind', '--product-model', 'MiniMax-M2', '--persona-model', 'MiniMax-M2', '--prompt-digest', 'not-a-digest']);
699
+ assert.equal(badDigest.stderr.trim(), 'm3_cohort_error:invalid_input');
700
+ const ok = runCli(['enroll', ...base, '--participant', 'sim-one', '--invited', '--participant-consent', '--simulated',
701
+ '--persona-id', 'erhai-weekend-unwind', '--product-model', 'MiniMax-M2', '--persona-model', 'MiniMax-M2', '--prompt-digest', DIGEST]);
702
+ assert.equal(ok.status, 0, `${ok.stderr}\n${ok.stdout}`);
703
+ const unlabelled = runCli(['enroll', ...base, '--participant', 'sim-two', '--invited', '--participant-consent']);
704
+ assert.equal(unlabelled.stderr.trim(), 'm3_cohort_error:simulation_label_required');
705
+ });
706
+ console.log(`M3 cohort capture tests: ${n} checks OK`);
707
+ }
708
+ try {
709
+ await main();
710
+ }
711
+ finally {
712
+ for (const root of roots)
713
+ rmSync(root, { recursive: true, force: true });
714
+ }