@unson/brainbase-mcp 0.23.1 → 0.23.3

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,1519 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ /** Public normal-judgment history contract. Value-proof records use a separate contract. */
5
+ export const JUDGMENT_HISTORY_RECORD_SCHEMA = 'brainbase-judgment-history-record-v1';
6
+ export const JUDGMENT_HISTORY_CONTRACT_VERSION = 'brainbase.judgment-history.v1';
7
+ export const JUDGMENT_HISTORY_FEEDBACK_SCHEMA = 'brainbase-judgment-history-feedback-v1';
8
+ export const JUDGMENT_HISTORY_ENTRYPOINTS = Object.freeze([
9
+ 'codex',
10
+ 'claude_code',
11
+ 'mana',
12
+ 'company_os',
13
+ 'unknown'
14
+ ]);
15
+ export class JudgmentHistoryReaderError extends Error {
16
+ statusCode;
17
+ code;
18
+ constructor(statusCode, code, message) {
19
+ super(message);
20
+ this.statusCode = statusCode;
21
+ this.code = code;
22
+ this.name = 'JudgmentHistoryReaderError';
23
+ }
24
+ }
25
+ export class JudgmentHistoryFeedbackError extends Error {
26
+ statusCode;
27
+ code;
28
+ constructor(statusCode, code, message) {
29
+ super(message);
30
+ this.statusCode = statusCode;
31
+ this.code = code;
32
+ this.name = 'JudgmentHistoryFeedbackError';
33
+ }
34
+ }
35
+ export class JudgmentHistoryFeedbackConflictError extends JudgmentHistoryFeedbackError {
36
+ existing;
37
+ constructor(existing) {
38
+ super(409, 'feedback_conflict', 'The event_id is already recorded with different content');
39
+ this.existing = existing;
40
+ this.name = 'JudgmentHistoryFeedbackConflictError';
41
+ }
42
+ }
43
+ const DEFAULT_LIMIT = 50;
44
+ const MAX_LIMIT = 100;
45
+ export const DEFAULT_LOCAL_JUDGMENT_HISTORY_SCAN_CAP = 4_096;
46
+ export const DEFAULT_LOCAL_JUDGMENT_HISTORY_RECORD_BYTES = 1_024 * 1_024;
47
+ const JST_OFFSET_MS = 9 * 60 * 60 * 1000;
48
+ const SAFE_ID = /^[^/\\\u0000-\u001f\u007f]+$/u;
49
+ const ADOPTION_FILE = /^[a-f0-9]{64}\.json$/u;
50
+ const FINAL_FILE = /^(.+)\.final\.json$/u;
51
+ const FEEDBACK_DIR = 'feedback';
52
+ const FEEDBACK_RECORD_DIR = /^[a-f0-9]{64}$/u;
53
+ const FEEDBACK_FILE = /^([a-f0-9]{64})\.json$/u;
54
+ const FEEDBACK_KINDS = ['feedback', 'result', 'correction'];
55
+ function isObject(value) {
56
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
57
+ }
58
+ function text(value) {
59
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
60
+ }
61
+ function sha256(value) {
62
+ return createHash('sha256').update(value).digest('hex');
63
+ }
64
+ export function canonicalJson(value) {
65
+ if (value === null || typeof value === 'boolean' || typeof value === 'string')
66
+ return JSON.stringify(value);
67
+ if (typeof value === 'number') {
68
+ if (!Number.isFinite(value))
69
+ throw new TypeError('non-finite number in canonical JSON');
70
+ return JSON.stringify(value);
71
+ }
72
+ if (Array.isArray(value))
73
+ return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
74
+ if (isObject(value)) {
75
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
76
+ }
77
+ throw new TypeError('unsupported value in canonical JSON');
78
+ }
79
+ function parseIso(value) {
80
+ const candidate = text(value);
81
+ if (!candidate || !Number.isFinite(Date.parse(candidate)))
82
+ return null;
83
+ return new Date(candidate).toISOString();
84
+ }
85
+ function entrypoint(value, fallback = 'unknown') {
86
+ const candidate = text(value);
87
+ return candidate && JUDGMENT_HISTORY_ENTRYPOINTS.includes(candidate)
88
+ ? candidate
89
+ : fallback;
90
+ }
91
+ function safeId(value) {
92
+ const candidate = text(value);
93
+ if (!candidate || candidate.length > 512 || !SAFE_ID.test(candidate))
94
+ return null;
95
+ return candidate;
96
+ }
97
+ function feedbackKind(value) {
98
+ const candidate = text(value);
99
+ return candidate && FEEDBACK_KINDS.includes(candidate)
100
+ ? candidate
101
+ : null;
102
+ }
103
+ function normalizeFeedbackContent(value) {
104
+ if (!isObject(value))
105
+ return null;
106
+ const keys = Object.keys(value);
107
+ if (keys.some((key) => key !== 'summary' && key !== 'outcome_status'))
108
+ return null;
109
+ const summary = stringOrNull(value.summary);
110
+ if (!summary)
111
+ return null;
112
+ const content = { summary };
113
+ if ('outcome_status' in value) {
114
+ const outcome = text(value.outcome_status);
115
+ if (outcome !== 'unknown' && outcome !== 'unconfirmed' && outcome !== 'confirmed')
116
+ return null;
117
+ return { ...content, outcome_status: outcome };
118
+ }
119
+ return content;
120
+ }
121
+ function normalizeFeedbackEvent(value) {
122
+ if (!isObject(value))
123
+ return null;
124
+ const recordId = safeId(value.record_id);
125
+ const eventId = safeId(value.event_id);
126
+ const kind = feedbackKind(value.kind);
127
+ const content = normalizeFeedbackContent(value.content);
128
+ if (!recordId || !eventId || !kind || !content)
129
+ return null;
130
+ return { record_id: recordId, event_id: eventId, kind, content };
131
+ }
132
+ function normalizeFeedbackInput(value) {
133
+ if (!isObject(value))
134
+ throw new JudgmentHistoryFeedbackError(400, 'invalid_feedback', 'Feedback body must be an object');
135
+ const keys = Object.keys(value);
136
+ if (keys.some((key) => key !== 'record_id' && key !== 'event_id' && key !== 'kind' && key !== 'content')) {
137
+ throw new JudgmentHistoryFeedbackError(400, 'invalid_feedback', 'Feedback body contains unsupported fields');
138
+ }
139
+ const event = normalizeFeedbackEvent(value);
140
+ if (!event)
141
+ throw new JudgmentHistoryFeedbackError(400, 'invalid_feedback', 'Feedback fields are invalid');
142
+ return event;
143
+ }
144
+ function feedbackPathKey(value) {
145
+ return sha256(value);
146
+ }
147
+ function stringOrNull(value) {
148
+ const candidate = text(value);
149
+ return candidate && candidate.length <= 16_384 ? candidate : null;
150
+ }
151
+ function publicBlock(...values) {
152
+ for (const value of values)
153
+ if (isObject(value))
154
+ return value;
155
+ return null;
156
+ }
157
+ function field(object, ...names) {
158
+ if (!object)
159
+ return undefined;
160
+ for (const name of names)
161
+ if (name in object)
162
+ return object[name];
163
+ return undefined;
164
+ }
165
+ function normalizeAvailability(value) {
166
+ const candidate = text(value);
167
+ return candidate === 'recorded' || candidate === 'permission_denied' || candidate === 'unavailable' || candidate === 'unknown'
168
+ ? candidate
169
+ : 'unknown';
170
+ }
171
+ function normalizeReference(value) {
172
+ if (!isObject(value))
173
+ return null;
174
+ const ref = safeId(value.ref);
175
+ if (!ref)
176
+ return null;
177
+ return {
178
+ ref,
179
+ kind: stringOrNull(value.kind),
180
+ version: stringOrNull(value.version),
181
+ digest: stringOrNull(value.digest),
182
+ why: stringOrNull(value.why),
183
+ usage: stringOrNull(value.usage),
184
+ availability: normalizeAvailability(value.availability)
185
+ };
186
+ }
187
+ function normalizeAlternative(value) {
188
+ if (!isObject(value))
189
+ return null;
190
+ const label = stringOrNull(value.label ?? value.option ?? value.name);
191
+ if (!label)
192
+ return null;
193
+ const adoptedValue = value.adopted ?? value.selected;
194
+ const adopted = typeof adoptedValue === 'boolean'
195
+ ? adoptedValue
196
+ : text(adoptedValue)?.toLowerCase() === 'adopted' || text(adoptedValue)?.toLowerCase() === 'selected'
197
+ ? true
198
+ : text(adoptedValue)?.toLowerCase() === 'rejected' || text(adoptedValue)?.toLowerCase() === 'not_selected'
199
+ ? false
200
+ : null;
201
+ return {
202
+ label,
203
+ evaluation: stringOrNull(value.evaluation ?? value.assessment),
204
+ adopted
205
+ };
206
+ }
207
+ function normalizeReferences(value) {
208
+ if (!Array.isArray(value))
209
+ return null;
210
+ const normalized = value.map(normalizeReference).filter((item) => item !== null);
211
+ return normalized.length === value.length ? normalized : null;
212
+ }
213
+ function normalizeAlternatives(value) {
214
+ if (!Array.isArray(value))
215
+ return null;
216
+ const normalized = value.map(normalizeAlternative).filter((item) => item !== null);
217
+ return normalized.length === value.length ? normalized : null;
218
+ }
219
+ function normalizeExecution(value) {
220
+ const object = isObject(value) ? value : null;
221
+ const statusValue = text(field(object, 'status'));
222
+ const status = statusValue === 'pending'
223
+ || statusValue === 'completed'
224
+ || statusValue === 'failed'
225
+ || statusValue === 'held'
226
+ || statusValue === 'cancelled'
227
+ || statusValue === 'unknown'
228
+ ? statusValue
229
+ : 'unknown';
230
+ const outcomeValue = text(field(object, 'outcome_status', 'outcomeStatus'));
231
+ const outcome_status = outcomeValue === 'unconfirmed' || outcomeValue === 'confirmed' || outcomeValue === 'unknown'
232
+ ? outcomeValue
233
+ : 'unknown';
234
+ return {
235
+ status,
236
+ result_summary: stringOrNull(field(object, 'result_summary', 'resultSummary')),
237
+ outcome_status
238
+ };
239
+ }
240
+ function missingFields(record, explicit) {
241
+ const values = new Set(Array.isArray(explicit) ? explicit.filter((value) => typeof value === 'string') : []);
242
+ if (record.entrypoint === 'unknown')
243
+ values.add('entrypoint');
244
+ if (record.project_code === null)
245
+ values.add('project_code');
246
+ if (record.judgment.status === 'unknown')
247
+ values.add('judgment.status');
248
+ if (!record.judgment.summary)
249
+ values.add('judgment.summary');
250
+ if (!record.judgment.reason)
251
+ values.add('judgment.reason');
252
+ if (record.judgment.selected_references === null)
253
+ values.add('judgment.selected_references');
254
+ if (record.judgment.alternatives === null)
255
+ values.add('judgment.alternatives');
256
+ if (record.execution.status === 'unknown')
257
+ values.add('execution.status');
258
+ if (!record.execution.result_summary)
259
+ values.add('execution.result_summary');
260
+ if (record.execution.outcome_status === 'unknown')
261
+ values.add('execution.outcome_status');
262
+ return [...values].sort();
263
+ }
264
+ function normalizeRecord(adoption, sessionRef, fileName, configuredEntrypoint) {
265
+ const adoptionSchema = text(adoption.schema_version);
266
+ if (adoptionSchema !== 'brainbase-judgment-adoption-v1'
267
+ && adoptionSchema !== 'brainbase-judgment-adoption-v2')
268
+ return null;
269
+ const request = isObject(adoption.request) ? adoption.request : null;
270
+ const context = request && isObject(request.conversation_context) ? request.conversation_context : null;
271
+ const receipt = isObject(adoption.receipt) ? adoption.receipt : null;
272
+ if (!request || !context || !receipt)
273
+ return null;
274
+ const requestText = text(request.request);
275
+ const turnId = text(request.turn_id);
276
+ const resolutionId = safeId(receipt.resolution_id);
277
+ const requestDigest = text(adoption.request_text_digest);
278
+ const acceptedAt = parseIso(adoption.accepted_at);
279
+ const receiptResolvedAt = parseIso(receipt.resolved_at);
280
+ const receiptStatus = text(receipt.status);
281
+ if (!requestText || !turnId || !resolutionId || !requestDigest || requestDigest !== sha256(requestText)
282
+ || !acceptedAt || !receiptResolvedAt
283
+ || (receiptStatus !== 'resolved' && receiptStatus !== 'needs_clarification'))
284
+ return null;
285
+ if (text(context.schema_version) !== 'brainbase-conversation-context-v1'
286
+ || text(context.session_ref) !== sessionRef
287
+ || text(receipt.turn_id) !== turnId)
288
+ return null;
289
+ const receiptDigest = text(adoption.receipt_digest);
290
+ if (adoptionSchema === 'brainbase-judgment-adoption-v2') {
291
+ if (!receiptDigest || receiptDigest !== sha256(canonicalJson(receipt)))
292
+ return null;
293
+ }
294
+ else if (receiptDigest && receiptDigest !== sha256(canonicalJson(receipt))) {
295
+ return null;
296
+ }
297
+ const recordedAt = receiptResolvedAt ?? acceptedAt;
298
+ if (!recordedAt)
299
+ return null;
300
+ const explicitEntrypoint = entrypoint(adoption.entrypoint ?? receipt.entrypoint ?? request.entrypoint ?? context.entrypoint, configuredEntrypoint);
301
+ const projectCode = stringOrNull(receipt.project_code ?? request.project_code);
302
+ const publicJudgment = publicBlock(adoption.public_judgment, adoption.judgment, receipt.public_judgment, receipt.judgment, adoption.normal_judgment);
303
+ const ownerAudit = isObject(adoption.owner_audit) ? adoption.owner_audit : null;
304
+ const summary = stringOrNull(field(publicJudgment, 'summary', 'decision_summary', 'display_summary'))
305
+ ?? stringOrNull(ownerAudit?.decision);
306
+ const reasonValue = field(publicJudgment, 'reason', 'decision_reason', 'why');
307
+ const reason = stringOrNull(reasonValue)
308
+ ?? (Array.isArray(receipt.reconciliation_reasons)
309
+ ? receipt.reconciliation_reasons.filter((value) => typeof value === 'string').join(' / ') || null
310
+ : null);
311
+ const selectedReferencesValue = field(publicJudgment, 'selected_references', 'selectedReferences');
312
+ const alternativesValue = field(publicJudgment, 'alternatives', 'options');
313
+ const selectedReferences = selectedReferencesValue === undefined
314
+ ? null
315
+ : normalizeReferences(selectedReferencesValue);
316
+ const alternatives = alternativesValue === undefined ? null : normalizeAlternatives(alternativesValue);
317
+ const judgmentStatusValue = text(field(publicJudgment, 'status')) ?? text(receipt.status);
318
+ const judgmentStatus = judgmentStatusValue === 'resolved' || judgmentStatusValue === 'needs_clarification'
319
+ ? judgmentStatusValue
320
+ : 'unknown';
321
+ const executionValue = publicBlock(adoption.execution, receipt.execution, adoption.public_execution, receipt.public_execution);
322
+ const judgment = {
323
+ status: judgmentStatus,
324
+ summary,
325
+ reason,
326
+ selected_references: selectedReferences,
327
+ alternatives
328
+ };
329
+ const execution = normalizeExecution(executionValue);
330
+ const baseRecord = {
331
+ schema_version: JUDGMENT_HISTORY_RECORD_SCHEMA,
332
+ record_id: resolutionId,
333
+ entrypoint: explicitEntrypoint,
334
+ recorded_at: recordedAt,
335
+ project_code: projectCode,
336
+ turn_ref: `${sessionRef}/${sha256(turnId)}`,
337
+ judgment,
338
+ execution
339
+ };
340
+ const explicitMissing = field(publicJudgment, 'missing_fields') ?? adoption.missing_fields;
341
+ void fileName;
342
+ return { ...baseRecord, missing_fields: missingFields(baseRecord, explicitMissing) };
343
+ }
344
+ function sha256Text(value) {
345
+ return typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value);
346
+ }
347
+ function validHostOwnerAudit(value, receiptDigest) {
348
+ if (!isObject(value)
349
+ || value.schema_version !== 'brainbase-owner-audit-v1'
350
+ || value.source_receipt_digest !== receiptDigest
351
+ || typeof value.display_line !== 'string'
352
+ || value.display_line.length === 0
353
+ || value.text_digest !== sha256(value.display_line))
354
+ return false;
355
+ return true;
356
+ }
357
+ function validHostAuditContract(value) {
358
+ if (!isObject(value) || value.schema_version !== 'brainbase-owner-audit-contract-v1'
359
+ || !['preserve', 'host_projection'].includes(String(value.repair_body_policy)))
360
+ return false;
361
+ const zeroCallLine = value.zero_call_display_line;
362
+ const zeroCallDigest = value.zero_call_display_line_digest;
363
+ if (!(zeroCallLine === null && zeroCallDigest === null
364
+ || typeof zeroCallLine === 'string' && zeroCallDigest === sha256(zeroCallLine)))
365
+ return false;
366
+ const pairs = [
367
+ ['autonomy_continuation_progress_line', 'autonomy_continuation_progress_line_digest'],
368
+ ['autonomy_continuation_complete_line', 'autonomy_continuation_complete_line_digest'],
369
+ ['outcome_continuation_progress_line', 'outcome_continuation_progress_line_digest'],
370
+ ['outcome_continuation_complete_line', 'outcome_continuation_complete_line_digest'],
371
+ ['stop_repair_complete_line', 'stop_repair_complete_line_digest']
372
+ ];
373
+ return pairs.every(([lineKey, digestKey]) => {
374
+ const line = value[lineKey];
375
+ const digest = value[digestKey];
376
+ return line === undefined && digest === undefined
377
+ || typeof line === 'string' && digest === sha256(line);
378
+ });
379
+ }
380
+ /**
381
+ * Validates the Host v2 episode binding. Host v2 deliberately stores the
382
+ * request in turn_input and the route receipt in initial_route_receipt;
383
+ * there is no nested adoption object or episode digest to replay here.
384
+ */
385
+ function hostEpisodeBinding(episode, sessionRef, key) {
386
+ if (episode.schema_version !== 'brainbase-judgment-episode-v1'
387
+ || episode.state !== 'open'
388
+ || !parseIso(episode.started_at)
389
+ || !isObject(episode.turn_input)
390
+ || !isObject(episode.initial_route_receipt))
391
+ return null;
392
+ const turnInput = episode.turn_input;
393
+ const receipt = episode.initial_route_receipt;
394
+ const context = isObject(turnInput.conversation_context) ? turnInput.conversation_context : null;
395
+ const turnId = text(turnInput.turn_id);
396
+ const request = text(turnInput.request);
397
+ const receiptDigest = text(episode.initial_route_receipt_digest);
398
+ if (!context || !turnId || !request || !receiptDigest
399
+ || !sha256Text(receiptDigest)
400
+ || /^[a-f0-9]{64}$/u.test(key) && sha256(turnId) !== key
401
+ || text(context.schema_version) !== 'brainbase-conversation-context-v1'
402
+ || text(context.session_ref) !== sessionRef
403
+ || !sha256Text(text(context.source_digest))
404
+ || text(context.source_digest) !== sha256(canonicalJson(withoutDigest(context, 'source_digest')))
405
+ || text(episode.request_text_digest) !== sha256(request)
406
+ || receiptDigest !== sha256(canonicalJson(receipt))
407
+ || text(receipt.turn_id) !== turnId
408
+ || text(receipt.request_digest) !== sha256(canonicalJson(turnInput))
409
+ || text(receipt.context_digest) !== sha256(canonicalJson(context))
410
+ || !isObject(receipt.host_binding)
411
+ || receipt.host_binding.status !== 'managed'
412
+ || !Array.isArray(receipt.active_node_definitions)
413
+ || !safeId(receipt.resolution_id)
414
+ || !['resolved', 'needs_classification', 'needs_policy_resolution'].includes(String(receipt.status))) {
415
+ return null;
416
+ }
417
+ if (!validHostOwnerAudit(episode.owner_audit, receiptDigest))
418
+ return null;
419
+ if (episode.audit_contract !== undefined && !validHostAuditContract(episode.audit_contract))
420
+ return null;
421
+ const origin = episode.episode_origin;
422
+ const application = episode.route_application;
423
+ const lifecycleValid = origin === undefined && application === undefined
424
+ || origin === 'user_prompt_submit' && application === 'pre_generation'
425
+ || origin === 'stop_delegation_recovery' && application === 'post_generation_recovery'
426
+ || origin === 'pre_tool_delegation_recovery' && application === 'pre_tool_execution'
427
+ || origin === 'pre_tool_automation_recovery' && application === 'pre_tool_execution';
428
+ if (!lifecycleValid)
429
+ return null;
430
+ return { turnId, request, turnInput, context, receipt, receiptDigest };
431
+ }
432
+ function validJournalEvent(value, sessionRef, key, eventKey) {
433
+ if (value.schema_version !== 'brainbase-judgment-tool-event-v1')
434
+ return false;
435
+ // Host v2 events are bound to the turn by their directory and to the
436
+ // tool-use identity by the event filename. They intentionally have no
437
+ // session_ref/turn_id/event_digest envelope.
438
+ if (Number.isSafeInteger(value.event_sequence)) {
439
+ if (value.event_sequence < 0
440
+ || !parseIso(value.recorded_at)
441
+ || !text(value.tool_use_id)
442
+ || !text(value.tool_name)
443
+ || !text(value.event_kind)
444
+ || typeof value.success !== 'boolean'
445
+ || !Array.isArray(value.satisfies)
446
+ || !value.satisfies.every((item) => typeof item === 'string')
447
+ || !sha256Text(text(value.input_digest))
448
+ || !sha256Text(text(value.response_digest))
449
+ || !sha256Text(text(value.event_fingerprint))
450
+ || !(text(value.display_line) || value.event_kind === 'turn_resolution' && value.display_line === null))
451
+ return false;
452
+ return Boolean(eventKey && /^[a-f0-9]{64}$/u.test(eventKey)
453
+ && sha256(text(value.tool_use_id)) === eventKey);
454
+ }
455
+ // Legacy OSS journal events remain readable for older normal records.
456
+ if (text(value.session_ref) !== sessionRef
457
+ || !text(value.turn_id)
458
+ || !parseIso(value.recorded_at)
459
+ || typeof value.sequence !== 'number'
460
+ || !Number.isInteger(value.sequence)
461
+ || value.sequence < 1
462
+ || !text(value.tool_use_id)
463
+ || !text(value.tool_name)
464
+ || !['routing', 'search', 'retrieval'].includes(String(value.audit_kind))
465
+ || typeof value.success !== 'boolean'
466
+ || !Array.isArray(value.satisfies)
467
+ || !value.satisfies.every((item) => typeof item === 'string')
468
+ || !text(value.display_line)
469
+ || !text(value.event_fingerprint)
470
+ || !verifyArtifactDigest(value, 'event_digest', true))
471
+ return false;
472
+ return !/^[a-f0-9]{64}$/u.test(key) || sha256(text(value.turn_id)) === key;
473
+ }
474
+ function validEpisodeBinding(episode, sessionRef, configuredEntrypoint, key = '') {
475
+ // Host v2 uses the canonical route receipt/turn_input pair described above.
476
+ // Keep the older nested-adoption episode validator below for historical OSS
477
+ // fixtures and previously recorded normal judgments.
478
+ if (hostEpisodeBinding(episode, sessionRef, key))
479
+ return true;
480
+ if (episode.schema_version !== 'brainbase-judgment-episode-v1'
481
+ || text(episode.session_ref) !== sessionRef
482
+ || !text(episode.turn_id)
483
+ || !parseIso(episode.started_at)
484
+ || !text(episode.request_text_digest)
485
+ || !text(episode.initial_receipt_digest)
486
+ || !isObject(episode.adoption)
487
+ || !isObject(episode.audit_contract)
488
+ || !text(episode.episode_digest)
489
+ || !verifyArtifactDigest(episode, 'episode_digest', true))
490
+ return false;
491
+ const adoption = episode.adoption;
492
+ const request = isObject(adoption.request) ? adoption.request : null;
493
+ const context = request && isObject(request.conversation_context) ? request.conversation_context : null;
494
+ const receipt = isObject(adoption.receipt) ? adoption.receipt : null;
495
+ if (!request || !context || !receipt
496
+ || text(request.turn_id) !== text(episode.turn_id)
497
+ || text(request.request) === null
498
+ || text(context.session_ref) !== sessionRef
499
+ || text(episode.request_text_digest) !== sha256(text(request.request)))
500
+ return false;
501
+ if (text(episode.initial_receipt_digest) !== sha256(canonicalJson(receipt)))
502
+ return false;
503
+ return normalizeRecord(adoption, sessionRef, 'episode.json', configuredEntrypoint) !== null;
504
+ }
505
+ function validHostAdoption(adoption, binding) {
506
+ const schema = text(adoption.schema_version);
507
+ if (schema !== 'brainbase-judgment-adoption-v1' && schema !== 'brainbase-judgment-adoption-v2')
508
+ return false;
509
+ if (text(adoption.request_text_digest) !== sha256(binding.request)
510
+ || !parseIso(adoption.accepted_at)
511
+ || !isObject(adoption.receipt)
512
+ || canonicalJson(adoption.receipt) !== canonicalJson(binding.receipt))
513
+ return false;
514
+ const receiptDigest = text(adoption.receipt_digest);
515
+ if (schema === 'brainbase-judgment-adoption-v2') {
516
+ if (!receiptDigest || receiptDigest !== binding.receiptDigest)
517
+ return false;
518
+ if (!validHostOwnerAudit(adoption.owner_audit, binding.receiptDigest))
519
+ return false;
520
+ }
521
+ else if (receiptDigest && receiptDigest !== binding.receiptDigest) {
522
+ return false;
523
+ }
524
+ return true;
525
+ }
526
+ function normalizeHostEpisodeRecord(binding, episode, adoption, configuredEntrypoint, sessionRef) {
527
+ if (adoption && !validHostAdoption(adoption, binding))
528
+ return null;
529
+ const receipt = binding.receipt;
530
+ const resolutionId = safeId(receipt.resolution_id);
531
+ const recordedAt = parseIso(adoption?.accepted_at) ?? parseIso(episode.started_at);
532
+ if (!resolutionId || !recordedAt)
533
+ return null;
534
+ const ownerAudit = isObject(adoption?.owner_audit)
535
+ ? adoption?.owner_audit
536
+ : isObject(episode.owner_audit) ? episode.owner_audit : null;
537
+ const publicJudgment = publicBlock(adoption?.public_judgment, adoption?.judgment, receipt.public_judgment, receipt.judgment, adoption?.normal_judgment);
538
+ const summary = stringOrNull(field(publicJudgment, 'summary', 'decision_summary', 'display_summary'))
539
+ ?? stringOrNull(ownerAudit?.decision);
540
+ const reasonValue = field(publicJudgment, 'reason', 'decision_reason', 'why');
541
+ const reason = stringOrNull(reasonValue)
542
+ ?? (Array.isArray(receipt.reconciliation_reasons)
543
+ ? receipt.reconciliation_reasons.filter((value) => typeof value === 'string').join(' / ') || null
544
+ : null);
545
+ const selectedReferencesValue = field(publicJudgment, 'selected_references', 'selectedReferences');
546
+ const alternativesValue = field(publicJudgment, 'alternatives', 'options');
547
+ const judgment = {
548
+ status: normalizeStatus(receipt.status),
549
+ summary,
550
+ reason,
551
+ selected_references: selectedReferencesValue === undefined ? null : normalizeReferences(selectedReferencesValue),
552
+ alternatives: alternativesValue === undefined ? null : normalizeAlternatives(alternativesValue)
553
+ };
554
+ const execution = normalizeExecution(publicBlock(adoption?.execution, receipt.execution, adoption?.public_execution, receipt.public_execution));
555
+ const baseRecord = {
556
+ schema_version: JUDGMENT_HISTORY_RECORD_SCHEMA,
557
+ record_id: resolutionId,
558
+ entrypoint: entrypoint(adoption?.entrypoint ?? receipt.entrypoint ?? binding.turnInput.entrypoint
559
+ ?? (isObject(binding.turnInput.conversation_context) && isObject(binding.turnInput.conversation_context.runtime)
560
+ ? binding.turnInput.conversation_context.runtime.entrypoint ?? binding.turnInput.conversation_context.runtime.host : undefined), configuredEntrypoint),
561
+ recorded_at: recordedAt,
562
+ project_code: stringOrNull(receipt.project_code ?? binding.turnInput.project_code),
563
+ turn_ref: `${sessionRef}/${sha256(binding.turnId)}`,
564
+ judgment,
565
+ execution
566
+ };
567
+ return {
568
+ ...baseRecord,
569
+ missing_fields: missingFields(baseRecord, field(publicJudgment, 'missing_fields') ?? adoption?.missing_fields)
570
+ };
571
+ }
572
+ function withoutDigest(value, digestKey) {
573
+ const projection = { ...value };
574
+ delete projection[digestKey];
575
+ return projection;
576
+ }
577
+ function verifyArtifactDigest(value, digestKey, required = false) {
578
+ const digest = text(value[digestKey]);
579
+ return Boolean(digest) && digest === sha256(canonicalJson(withoutDigest(value, digestKey)))
580
+ || !required && !digest;
581
+ }
582
+ async function readBoundedJson(path, maxBytes) {
583
+ let fileStats;
584
+ try {
585
+ fileStats = await lstat(path);
586
+ }
587
+ catch (error) {
588
+ if (error.code === 'ENOENT')
589
+ return { value: null, missing: true, invalid: false };
590
+ return { value: null, missing: false, invalid: true };
591
+ }
592
+ if (fileStats.isSymbolicLink() || !fileStats.isFile() || fileStats.size > maxBytes) {
593
+ return { value: null, missing: false, invalid: true };
594
+ }
595
+ try {
596
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
597
+ return isObject(parsed)
598
+ ? { value: parsed, missing: false, invalid: false }
599
+ : { value: null, missing: false, invalid: true };
600
+ }
601
+ catch {
602
+ return { value: null, missing: false, invalid: true };
603
+ }
604
+ }
605
+ function normalizeStoredFeedback(value, recordPathKey, eventPathKey) {
606
+ if (value.schema_version !== JUDGMENT_HISTORY_FEEDBACK_SCHEMA)
607
+ return null;
608
+ if (!verifyArtifactDigest(value, 'feedback_digest', true))
609
+ return null;
610
+ const event = normalizeFeedbackEvent(value);
611
+ if (!event || feedbackPathKey(event.record_id) !== recordPathKey || feedbackPathKey(event.event_id) !== eventPathKey)
612
+ return null;
613
+ const recordedAt = parseIso(value.recorded_at);
614
+ if (!recordedAt)
615
+ return null;
616
+ return { ...event, recorded_at: recordedAt };
617
+ }
618
+ async function readLocalFeedback(root, records, maxRecordBytes, consume) {
619
+ const byRecordPath = new Map(records.map((record) => [feedbackPathKey(record.record_id), record.record_id]));
620
+ const events = new Map();
621
+ const feedbackRoot = join(root, FEEDBACK_DIR);
622
+ let feedbackStats;
623
+ try {
624
+ feedbackStats = await lstat(feedbackRoot);
625
+ }
626
+ catch (error) {
627
+ if (error.code === 'ENOENT')
628
+ return { events: new Map(), invalidCount: 0 };
629
+ return { events: new Map(), invalidCount: 1 };
630
+ }
631
+ if (feedbackStats.isSymbolicLink() || !feedbackStats.isDirectory())
632
+ return { events: new Map(), invalidCount: 1 };
633
+ let recordEntries;
634
+ try {
635
+ recordEntries = await readdir(feedbackRoot, { withFileTypes: true });
636
+ }
637
+ catch {
638
+ return { events: new Map(), invalidCount: 1 };
639
+ }
640
+ let invalidCount = 0;
641
+ outer: for (const recordEntry of recordEntries.sort((left, right) => left.name.localeCompare(right.name))) {
642
+ if (!FEEDBACK_RECORD_DIR.test(recordEntry.name))
643
+ continue;
644
+ const recordId = byRecordPath.get(recordEntry.name);
645
+ if (!recordId || recordEntry.isSymbolicLink() || !recordEntry.isDirectory()) {
646
+ invalidCount += 1;
647
+ continue;
648
+ }
649
+ let eventEntries;
650
+ try {
651
+ eventEntries = await readdir(join(feedbackRoot, recordEntry.name), { withFileTypes: true });
652
+ }
653
+ catch {
654
+ invalidCount += 1;
655
+ continue;
656
+ }
657
+ for (const eventEntry of eventEntries.sort((left, right) => left.name.localeCompare(right.name))) {
658
+ const eventMatch = FEEDBACK_FILE.exec(eventEntry.name);
659
+ if (!eventMatch)
660
+ continue;
661
+ if (consume())
662
+ break outer;
663
+ if (eventEntry.isSymbolicLink() || !eventEntry.isFile()) {
664
+ invalidCount += 1;
665
+ continue;
666
+ }
667
+ const result = await readBoundedJson(join(feedbackRoot, recordEntry.name, eventEntry.name), maxRecordBytes);
668
+ const event = result.value && normalizeStoredFeedback(result.value, recordEntry.name, eventMatch[1]);
669
+ if (result.invalid || !event) {
670
+ invalidCount += 1;
671
+ continue;
672
+ }
673
+ const recordEvents = events.get(recordId) ?? new Map();
674
+ if (recordEvents.has(event.event_id)) {
675
+ invalidCount += 1;
676
+ continue;
677
+ }
678
+ recordEvents.set(event.event_id, event);
679
+ events.set(recordId, recordEvents);
680
+ }
681
+ }
682
+ const publicEvents = new Map();
683
+ for (const [recordId, recordEvents] of events) {
684
+ const ordered = [...recordEvents.values()]
685
+ .sort((left, right) => left.event_id.localeCompare(right.event_id))
686
+ .map(({ record_id, event_id, kind, content }) => ({ record_id, event_id, kind, content }));
687
+ publicEvents.set(recordId, ordered);
688
+ }
689
+ return { events: publicEvents, invalidCount };
690
+ }
691
+ function groupFor(groups, sessionRef, key) {
692
+ const groupKey = `${sessionRef}\u0000${key}`;
693
+ const existing = groups.get(groupKey);
694
+ if (existing)
695
+ return existing;
696
+ const group = { sessionRef, key, events: [], invalid: 0, eventDirectorySeen: false };
697
+ groups.set(groupKey, group);
698
+ return group;
699
+ }
700
+ function sourceBlocks(value) {
701
+ if (!value)
702
+ return [];
703
+ const blocks = [];
704
+ for (const key of ['public_judgment', 'judgment', 'public_selection', 'selection', 'evaluation']) {
705
+ if (isObject(value[key]))
706
+ blocks.push(value[key]);
707
+ }
708
+ // Host persists only the public frame projection in safe_metadata.
709
+ // Catalog metadata is not an individual reference version or digest.
710
+ const metadata = isObject(value.safe_metadata) ? value.safe_metadata : null;
711
+ if (value.success === true && metadata) {
712
+ const frame = isObject(metadata.judgment_frame) ? metadata.judgment_frame : null;
713
+ if (frame)
714
+ blocks.push({
715
+ selected_references: frame.selected_references,
716
+ alternatives: Array.isArray(frame.alternatives) ? frame.alternatives.map((option) => isObject(option) ? {
717
+ label: option.label,
718
+ evaluation: null,
719
+ adopted: typeof frame.chosen_option === 'string' ? option.label === frame.chosen_option : null
720
+ } : option) : null
721
+ });
722
+ const contract = isObject(metadata.turn_contract) ? metadata.turn_contract : null;
723
+ if (contract && value.event_kind === 'turn_resolution')
724
+ blocks.push({
725
+ status: contract.status,
726
+ summary: contract.status === 'resolved' ? '判断契約を確定' : null,
727
+ reason: null
728
+ });
729
+ }
730
+ // Some writers put the public fields directly on the final/event envelope.
731
+ if (['summary', 'reason', 'selected_references', 'selectedReferences', 'alternatives', 'status'].some((key) => key in value)) {
732
+ blocks.push(value);
733
+ }
734
+ return blocks;
735
+ }
736
+ function executionBlocks(value) {
737
+ if (!value)
738
+ return [];
739
+ const blocks = [];
740
+ for (const key of ['execution', 'public_execution', 'execution_outcome']) {
741
+ if (isObject(value[key]))
742
+ blocks.push(value[key]);
743
+ }
744
+ if (['result_summary', 'resultSummary', 'outcome_status', 'outcomeStatus'].some((key) => key in value))
745
+ blocks.push(value);
746
+ return blocks;
747
+ }
748
+ function firstPresent(blocks, ...names) {
749
+ for (const block of blocks) {
750
+ for (const name of names) {
751
+ if (name in block)
752
+ return block[name];
753
+ }
754
+ }
755
+ return undefined;
756
+ }
757
+ function normalizeStatus(value) {
758
+ const candidate = text(value);
759
+ if (candidate === 'resolved')
760
+ return 'resolved';
761
+ if (candidate === 'needs_clarification'
762
+ || candidate === 'needs_classification'
763
+ || candidate === 'needs_policy_resolution')
764
+ return 'needs_clarification';
765
+ return 'unknown';
766
+ }
767
+ function mergeJudgment(fallback, blocks, explicitMissing) {
768
+ const statusValue = firstPresent(blocks, 'status');
769
+ const summaryValue = firstPresent(blocks, 'summary', 'decision_summary', 'display_summary');
770
+ const reasonValue = firstPresent(blocks, 'reason', 'decision_reason', 'why');
771
+ const referencesValue = firstPresent(blocks, 'selected_references', 'selectedReferences', 'references');
772
+ const alternativesValue = firstPresent(blocks, 'alternatives', 'options');
773
+ return {
774
+ status: statusValue === undefined ? fallback.status : normalizeStatus(statusValue),
775
+ summary: summaryValue === undefined ? fallback.summary : stringOrNull(summaryValue),
776
+ reason: reasonValue === undefined ? fallback.reason : stringOrNull(reasonValue),
777
+ selected_references: referencesValue === undefined ? fallback.selected_references : normalizeReferences(referencesValue),
778
+ alternatives: alternativesValue === undefined ? fallback.alternatives : normalizeAlternatives(alternativesValue)
779
+ };
780
+ }
781
+ function mergeExecution(fallback, blocks, completed) {
782
+ const statusValue = firstPresent(blocks, 'status');
783
+ const resultValue = firstPresent(blocks, 'result_summary', 'resultSummary');
784
+ const outcomeValue = firstPresent(blocks, 'outcome_status', 'outcomeStatus');
785
+ const projected = normalizeExecution({
786
+ ...(statusValue === undefined ? {} : { status: statusValue }),
787
+ ...(resultValue === undefined ? {} : { result_summary: resultValue }),
788
+ ...(outcomeValue === undefined ? {} : { outcome_status: outcomeValue })
789
+ });
790
+ const status = statusValue === undefined
791
+ ? (completed && fallback.status === 'unknown' ? 'completed' : fallback.status)
792
+ : projected.status;
793
+ return {
794
+ status,
795
+ result_summary: resultValue === undefined ? fallback.result_summary : projected.result_summary,
796
+ outcome_status: outcomeValue === undefined ? fallback.outcome_status : projected.outcome_status
797
+ };
798
+ }
799
+ function finalTurnId(final, key) {
800
+ return text(final.turn_id) ?? safeId(key);
801
+ }
802
+ function finalRecordId(final, sessionRef, turnId) {
803
+ return safeId(final.record_id ?? final.resolution_id ?? final.decision_id ?? final.final_digest)
804
+ ?? sha256(`${sessionRef}/${turnId}`);
805
+ }
806
+ function hostEventSetDigest(events) {
807
+ const ordered = [...events].sort((left, right) => Number(left.event_sequence) - Number(right.event_sequence));
808
+ if (ordered.some((event, index) => event.event_sequence !== index || !sha256Text(text(event.event_fingerprint)))) {
809
+ return null;
810
+ }
811
+ return sha256(canonicalJson(ordered.map((event) => ({
812
+ event_sequence: event.event_sequence,
813
+ event_fingerprint: event.event_fingerprint
814
+ }))));
815
+ }
816
+ /**
817
+ * Host may replace the bootstrap route with the TurnContract recorded in a
818
+ * successful resolver event before writing the v2 final. Older finals keep
819
+ * the bootstrap digest, so both receipts are valid only when the replacement
820
+ * is itself bound to this episode and event stream.
821
+ */
822
+ function hostReceiptDigestCandidates(binding, events) {
823
+ const digests = new Set([binding.receiptDigest]);
824
+ for (const event of events) {
825
+ if (event.schema_version !== 'brainbase-judgment-tool-event-v1'
826
+ || event.success !== true
827
+ || !Array.isArray(event.satisfies)
828
+ || !event.satisfies.includes('judgment.resolve_turn'))
829
+ continue;
830
+ const metadata = isObject(event.safe_metadata) ? event.safe_metadata : null;
831
+ const contract = metadata && isObject(metadata.turn_contract) ? metadata.turn_contract : null;
832
+ if (!contract
833
+ || text(contract.turn_id) !== binding.turnId
834
+ || !sha256Text(text(contract.request_digest))
835
+ || text(contract.context_digest) !== text(binding.receipt.context_digest)
836
+ || !isObject(contract.host_binding)
837
+ || contract.host_binding.status !== 'managed'
838
+ || !Array.isArray(contract.active_node_definitions)
839
+ || !safeId(contract.resolution_id)
840
+ || !['resolved', 'needs_classification', 'needs_policy_resolution'].includes(String(contract.status)))
841
+ continue;
842
+ digests.add(sha256(canonicalJson(contract)));
843
+ }
844
+ return digests;
845
+ }
846
+ function compareCodePoints(left, right) {
847
+ const leftText = String(left);
848
+ const rightText = String(right);
849
+ return leftText < rightText ? -1 : leftText > rightText ? 1 : 0;
850
+ }
851
+ function normalizeFinalRecord(final, sessionRef, key, configuredEntrypoint, turnInput, events, expectedInitialReceiptDigests, expectedTurnId) {
852
+ const schema = text(final.schema_version);
853
+ const hostV2 = schema === 'brainbase-judgment-episode-final-v2';
854
+ const hostV1 = schema === 'brainbase-judgment-episode-final-v1'
855
+ && text(final.initial_route_receipt_digest) !== null;
856
+ if (!hostV2 && !hostV1 && schema !== 'brainbase-judgment-final-v1')
857
+ return null;
858
+ const finalDigest = text(final.final_digest);
859
+ if (finalDigest && !verifyArtifactDigest(final, 'final_digest', true))
860
+ return null;
861
+ // Host canonical finals may omit final_digest, but an unbound final-only
862
+ // artifact without that digest cannot establish a trusted source.
863
+ if (!finalDigest && (!expectedInitialReceiptDigests || expectedInitialReceiptDigests.size === 0))
864
+ return null;
865
+ const finalSession = text(final.session_ref);
866
+ const turnId = text(final.turn_id) ?? text(turnInput?.turn_id) ?? expectedTurnId;
867
+ const initialReceiptDigest = text(final.initial_route_receipt_digest) ?? text(final.initial_receipt_digest);
868
+ const answerDigest = text(final.answer_digest);
869
+ const eventSetDigest = text(final.event_set_digest);
870
+ const recordedAt = parseIso(final.finalized_at);
871
+ if (finalSession && finalSession !== sessionRef)
872
+ return null;
873
+ if (!turnId || !recordedAt || !initialReceiptDigest || !eventSetDigest
874
+ || typeof final.event_count !== 'number' || !Number.isInteger(final.event_count)
875
+ || final.event_count < 0 || final.event_count !== events.length)
876
+ return null;
877
+ if (/^[a-f0-9]{64}$/u.test(key) && sha256(turnId) !== key)
878
+ return null;
879
+ if (expectedInitialReceiptDigests && !expectedInitialReceiptDigests.has(initialReceiptDigest))
880
+ return null;
881
+ if (hostV2 || hostV1) {
882
+ if (!expectedInitialReceiptDigests || expectedInitialReceiptDigests.size === 0
883
+ || (hostV2 && final.execution_outcome_schema_version !== 'judgment_execution_outcome.v1')
884
+ || (hostV2 && final.owner_audit_complete !== true)
885
+ || (hostV2 && (!Number.isSafeInteger(final.owner_audit_line_count) || final.owner_audit_line_count < 0))
886
+ || (hostV2 && typeof final.assistant_audit_prefix_matched !== 'boolean')
887
+ || !(answerDigest === null || sha256Text(answerDigest)))
888
+ return null;
889
+ const expectedEventSetDigest = hostV2
890
+ ? hostEventSetDigest(events)
891
+ : sha256(canonicalJson(events.map((event) => event.event_fingerprint).sort(compareCodePoints)));
892
+ if (!expectedEventSetDigest || eventSetDigest !== expectedEventSetDigest)
893
+ return null;
894
+ }
895
+ else {
896
+ if (!answerDigest || eventSetDigest !== sha256(canonicalJson(events)))
897
+ return null;
898
+ }
899
+ const eventBlocks = events.flatMap((event) => sourceBlocks(event)).reverse();
900
+ const blocks = [...sourceBlocks(final), ...eventBlocks];
901
+ const explicitEntrypoint = entrypoint(final.entrypoint ?? final.source_entrypoint ?? turnInput?.entrypoint
902
+ ?? events.map(event => isObject(event.safe_metadata) && isObject(event.safe_metadata.runtime) ? event.safe_metadata.runtime.entrypoint : null).reverse().find(value => JUDGMENT_HISTORY_ENTRYPOINTS.includes(value))
903
+ ?? (isObject(final.execution_outcome) && isObject(final.execution_outcome.host) ? final.execution_outcome.host.type : undefined), configuredEntrypoint);
904
+ const projectCode = stringOrNull(final.project_code ?? final.project ?? turnInput?.project_code);
905
+ const judgment = {
906
+ status: 'unknown',
907
+ summary: null,
908
+ reason: null,
909
+ selected_references: null,
910
+ alternatives: null
911
+ };
912
+ const execution = { status: 'unknown', result_summary: null, outcome_status: 'unknown' };
913
+ const completionStatus = text(final.completion_status);
914
+ const completed = completionStatus === 'complete';
915
+ const finalExecutionStatus = completed
916
+ ? 'completed'
917
+ : completionStatus === 'audit_degraded' ? 'held' : 'unknown';
918
+ const baseRecord = {
919
+ schema_version: JUDGMENT_HISTORY_RECORD_SCHEMA,
920
+ record_id: finalRecordId(final, sessionRef, turnId),
921
+ entrypoint: explicitEntrypoint,
922
+ recorded_at: recordedAt,
923
+ project_code: projectCode,
924
+ turn_ref: `${sessionRef}/${sha256(turnId)}`,
925
+ judgment: mergeJudgment(judgment, blocks, field(blocks[0] ?? null, 'missing_fields')),
926
+ execution: mergeExecution({ ...execution, status: finalExecutionStatus }, [...executionBlocks(final), ...events.flatMap((event) => executionBlocks(event)).reverse()], completed)
927
+ };
928
+ return { ...baseRecord, missing_fields: missingFields(baseRecord, firstPresent(blocks, 'missing_fields')) };
929
+ }
930
+ function mergeRecordWithFinal(adoptionRecord, final, sessionRef, key, turnInput, events, expectedInitialReceiptDigests, expectedTurnId) {
931
+ const projected = normalizeFinalRecord(final, sessionRef, key, adoptionRecord.entrypoint, turnInput, events, expectedInitialReceiptDigests, expectedTurnId);
932
+ if (!projected)
933
+ return null;
934
+ const blocks = [...sourceBlocks(final), ...events.flatMap((event) => sourceBlocks(event)).reverse()];
935
+ const execution = mergeExecution(adoptionRecord.execution, [...executionBlocks(final), ...events.flatMap((event) => executionBlocks(event)).reverse()], text(final.completion_status) === 'complete');
936
+ const baseRecord = {
937
+ ...adoptionRecord,
938
+ recorded_at: projected.recorded_at,
939
+ project_code: adoptionRecord.project_code ?? projected.project_code,
940
+ entrypoint: adoptionRecord.entrypoint === 'unknown' ? projected.entrypoint : adoptionRecord.entrypoint,
941
+ judgment: mergeJudgment(adoptionRecord.judgment, blocks, undefined),
942
+ execution
943
+ };
944
+ return { ...baseRecord, missing_fields: missingFields(baseRecord, firstPresent(blocks, 'missing_fields')) };
945
+ }
946
+ async function readLocalRecords(root, configuredEntrypoint, maxFiles, maxRecordBytes) {
947
+ const sessions = await readdir(root, { withFileTypes: true });
948
+ const groups = new Map();
949
+ let invalidCount = 0;
950
+ let candidateCount = 0;
951
+ let capped = false;
952
+ const stop = () => {
953
+ if (candidateCount >= maxFiles) {
954
+ capped = true;
955
+ return true;
956
+ }
957
+ candidateCount += 1;
958
+ return false;
959
+ };
960
+ outer: for (const session of sessions.sort((left, right) => left.name.localeCompare(right.name))) {
961
+ if (session.isSymbolicLink()) {
962
+ invalidCount += 1;
963
+ continue;
964
+ }
965
+ if (!session.isDirectory() || !SAFE_ID.test(session.name))
966
+ continue;
967
+ const sessionPath = join(root, session.name);
968
+ let names;
969
+ try {
970
+ names = await readdir(sessionPath, { withFileTypes: true });
971
+ }
972
+ catch {
973
+ invalidCount += 1;
974
+ continue;
975
+ }
976
+ const groupMap = new Map();
977
+ const recognized = names.map((entry) => {
978
+ const name = entry.name;
979
+ if (ADOPTION_FILE.test(name))
980
+ return { entry, kind: 'adoption', key: name.slice(0, -5) };
981
+ const finalMatch = FINAL_FILE.exec(name);
982
+ if (finalMatch)
983
+ return { entry, kind: 'final', key: finalMatch[1] };
984
+ const episodeMatch = /^(.+)\.episode\.json$/u.exec(name);
985
+ if (episodeMatch)
986
+ return { entry, kind: 'episode', key: episodeMatch[1] };
987
+ const inputMatch = /^(.+)\.turn-input\.json$/u.exec(name);
988
+ if (inputMatch)
989
+ return { entry, kind: 'turnInput', key: inputMatch[1] };
990
+ if (name.endsWith('.feedback.events'))
991
+ return null;
992
+ if (name.endsWith('.events'))
993
+ return { entry, kind: 'events', key: name.slice(0, -7) };
994
+ return null;
995
+ }).filter((candidate) => candidate !== null)
996
+ .sort((left, right) => left.entry.name.localeCompare(right.entry.name));
997
+ for (const candidate of recognized) {
998
+ const key = safeId(candidate.key);
999
+ if (!key) {
1000
+ invalidCount += 1;
1001
+ continue;
1002
+ }
1003
+ if (stop())
1004
+ break outer;
1005
+ const group = groupMap.get(key) ?? groupFor(groups, session.name, key);
1006
+ groupMap.set(key, group);
1007
+ const path = join(sessionPath, candidate.entry.name);
1008
+ if (candidate.kind === 'events') {
1009
+ group.eventDirectorySeen = true;
1010
+ if (candidate.entry.isSymbolicLink() || !candidate.entry.isDirectory()) {
1011
+ group.invalid += 1;
1012
+ continue;
1013
+ }
1014
+ let eventEntries;
1015
+ try {
1016
+ eventEntries = await readdir(path, { withFileTypes: true });
1017
+ }
1018
+ catch {
1019
+ group.invalid += 1;
1020
+ continue;
1021
+ }
1022
+ for (const eventEntry of eventEntries.sort((left, right) => left.name.localeCompare(right.name))) {
1023
+ if (!eventEntry.name.endsWith('.json'))
1024
+ continue;
1025
+ if (stop())
1026
+ break outer;
1027
+ const eventResult = await readBoundedJson(join(path, eventEntry.name), maxRecordBytes);
1028
+ const eventKey = eventEntry.name.endsWith('.json') ? eventEntry.name.slice(0, -5) : undefined;
1029
+ if (eventResult.invalid || !eventResult.value || !validJournalEvent(eventResult.value, session.name, candidate.key, eventKey)) {
1030
+ group.invalid += 1;
1031
+ continue;
1032
+ }
1033
+ group.events.push(eventResult.value);
1034
+ }
1035
+ continue;
1036
+ }
1037
+ const result = await readBoundedJson(path, maxRecordBytes);
1038
+ if (result.invalid || !result.value) {
1039
+ group.invalid += 1;
1040
+ continue;
1041
+ }
1042
+ if (candidate.kind === 'adoption')
1043
+ group.adoption = result.value;
1044
+ else if (candidate.kind === 'final')
1045
+ group.final = result.value;
1046
+ else if (candidate.kind === 'episode')
1047
+ group.episode = result.value;
1048
+ else
1049
+ group.turnInput = result.value;
1050
+ }
1051
+ }
1052
+ const records = [];
1053
+ const seen = new Set();
1054
+ for (const group of groups.values()) {
1055
+ const hostBinding = group.episode
1056
+ ? hostEpisodeBinding(group.episode, group.sessionRef, group.key)
1057
+ : null;
1058
+ const episodeValid = group.episode
1059
+ ? Boolean(hostBinding) || validEpisodeBinding(group.episode, group.sessionRef, configuredEntrypoint, group.key)
1060
+ : false;
1061
+ if (group.episode && !episodeValid)
1062
+ group.invalid += 1;
1063
+ const episodeReceiptDigest = hostBinding?.receiptDigest
1064
+ ?? (episodeValid ? text(group.episode?.initial_receipt_digest) ?? undefined : undefined);
1065
+ const turnInput = group.turnInput ?? hostBinding?.turnInput;
1066
+ if (group.turnInput && hostBinding
1067
+ && canonicalJson(group.turnInput) !== canonicalJson(hostBinding.turnInput)) {
1068
+ group.invalid += 1;
1069
+ }
1070
+ let adoption = group.adoption;
1071
+ if (!adoption && episodeValid && group.episode && isObject(group.episode.adoption)) {
1072
+ adoption = group.episode.adoption;
1073
+ }
1074
+ let record = null;
1075
+ if (adoption) {
1076
+ if (hostBinding) {
1077
+ if (validHostAdoption(adoption, hostBinding)) {
1078
+ record = normalizeHostEpisodeRecord(hostBinding, group.episode, adoption, configuredEntrypoint, group.sessionRef);
1079
+ }
1080
+ else {
1081
+ group.invalid += 1;
1082
+ }
1083
+ }
1084
+ else {
1085
+ record = normalizeRecord(adoption, group.sessionRef, `${group.key}.json`, configuredEntrypoint);
1086
+ if (!record)
1087
+ group.invalid += 1;
1088
+ }
1089
+ }
1090
+ else if (hostBinding && group.episode) {
1091
+ // Host episodes are canonical even when an adoption projection is not
1092
+ // present. No semantic fields are inferred beyond the signed receipt.
1093
+ record = normalizeHostEpisodeRecord(hostBinding, group.episode, undefined, configuredEntrypoint, group.sessionRef);
1094
+ if (!record)
1095
+ group.invalid += 1;
1096
+ }
1097
+ const adoptionReceipt = adoption && isObject(adoption.receipt) ? adoption.receipt : null;
1098
+ const expectedInitialReceiptDigests = hostBinding
1099
+ ? hostReceiptDigestCandidates(hostBinding, group.events)
1100
+ : (() => {
1101
+ const digest = adoptionReceipt
1102
+ ? sha256(canonicalJson(adoptionReceipt))
1103
+ : episodeReceiptDigest;
1104
+ return digest ? new Set([digest]) : undefined;
1105
+ })();
1106
+ if (group.final) {
1107
+ const finalRecord = record
1108
+ ? mergeRecordWithFinal(record, group.final, group.sessionRef, group.key, turnInput, group.events, expectedInitialReceiptDigests, hostBinding?.turnId)
1109
+ : normalizeFinalRecord(group.final, group.sessionRef, group.key, configuredEntrypoint, turnInput, group.events, expectedInitialReceiptDigests, hostBinding?.turnId);
1110
+ record = finalRecord ?? record;
1111
+ if (!finalRecord)
1112
+ group.invalid += 1;
1113
+ }
1114
+ if (!record) {
1115
+ if (group.invalid === 0 && (adoption || group.final || group.episode || group.turnInput || group.events.length > 0))
1116
+ group.invalid += 1;
1117
+ invalidCount += group.invalid;
1118
+ continue;
1119
+ }
1120
+ invalidCount += group.invalid;
1121
+ if (seen.has(record.record_id)) {
1122
+ invalidCount += 1;
1123
+ continue;
1124
+ }
1125
+ seen.add(record.record_id);
1126
+ records.push(record);
1127
+ }
1128
+ if (!capped) {
1129
+ const feedback = await readLocalFeedback(root, records, maxRecordBytes, stop);
1130
+ invalidCount += feedback.invalidCount;
1131
+ for (let index = 0; index < records.length; index += 1) {
1132
+ const record = records[index];
1133
+ const events = feedback.events.get(record.record_id);
1134
+ if (events && events.length > 0)
1135
+ records[index] = { ...record, feedback_events: events };
1136
+ }
1137
+ }
1138
+ records.sort(compareRecords);
1139
+ const contentDigest = sha256(canonicalJson({ records, candidateCount, invalidCount, capped }));
1140
+ return { records, invalidCount, candidateCount, capped, contentDigest };
1141
+ }
1142
+ function compareRecords(left, right) {
1143
+ const dateOrder = right.recorded_at.localeCompare(left.recorded_at);
1144
+ return dateOrder || right.record_id.localeCompare(left.record_id);
1145
+ }
1146
+ function sourceSnapshotId(value) {
1147
+ return sha256(canonicalJson(value));
1148
+ }
1149
+ function unavailableCoverage(storage, reason) {
1150
+ return {
1151
+ complete: false,
1152
+ storage,
1153
+ reason,
1154
+ sources: [{ entrypoint: 'unknown', status: 'unavailable', reason }],
1155
+ total: null
1156
+ };
1157
+ }
1158
+ /** Reads only the configured local owner journal; it never searches or uploads another journal. */
1159
+ export function createLocalJudgmentHistorySource(options) {
1160
+ if (typeof options.root !== 'string' || !options.root.trim())
1161
+ throw new TypeError('root is required');
1162
+ const configuredEntrypoint = options.entrypoint ?? 'unknown';
1163
+ if (!JUDGMENT_HISTORY_ENTRYPOINTS.includes(configuredEntrypoint)) {
1164
+ throw new TypeError('entrypoint is invalid');
1165
+ }
1166
+ const maxFiles = options.max_files ?? DEFAULT_LOCAL_JUDGMENT_HISTORY_SCAN_CAP;
1167
+ const maxRecordBytes = options.max_record_bytes ?? DEFAULT_LOCAL_JUDGMENT_HISTORY_RECORD_BYTES;
1168
+ if (!Number.isInteger(maxFiles) || maxFiles < 1)
1169
+ throw new TypeError('max_files must be a positive integer');
1170
+ if (!Number.isInteger(maxRecordBytes) || maxRecordBytes < 1)
1171
+ throw new TypeError('max_record_bytes must be a positive integer');
1172
+ const root = resolve(options.root);
1173
+ return {
1174
+ storage: 'local',
1175
+ async read() {
1176
+ try {
1177
+ const rootStats = await lstat(root);
1178
+ if (rootStats.isSymbolicLink()) {
1179
+ return { status: 'unavailable', records: null, coverage: unavailableCoverage('local', 'journal_symlink_rejected') };
1180
+ }
1181
+ if (!rootStats.isDirectory())
1182
+ return { status: 'unavailable', records: null, coverage: unavailableCoverage('local', 'journal_not_directory') };
1183
+ }
1184
+ catch (error) {
1185
+ if (error.code === 'ENOENT') {
1186
+ return { status: 'unavailable', records: null, coverage: unavailableCoverage('local', 'journal_unavailable') };
1187
+ }
1188
+ return { status: 'unavailable', records: null, coverage: unavailableCoverage('local', 'journal_unreadable') };
1189
+ }
1190
+ let result;
1191
+ try {
1192
+ result = await readLocalRecords(root, configuredEntrypoint, maxFiles, maxRecordBytes);
1193
+ }
1194
+ catch {
1195
+ return { status: 'unavailable', records: null, coverage: unavailableCoverage('local', 'journal_unreadable') };
1196
+ }
1197
+ const status = result.invalidCount > 0 || result.capped ? 'partial' : 'available';
1198
+ const reason = result.capped ? 'journal_scan_capped' : result.invalidCount > 0 ? 'some_journal_records_unreadable' : null;
1199
+ const entrypoints = [...new Set(result.records.map((record) => record.entrypoint))];
1200
+ const sources = (entrypoints.length > 0 ? entrypoints : [configuredEntrypoint]).map((sourceEntrypoint) => ({
1201
+ entrypoint: sourceEntrypoint,
1202
+ status,
1203
+ reason
1204
+ }));
1205
+ const coverage = {
1206
+ complete: status === 'available',
1207
+ storage: 'local',
1208
+ reason,
1209
+ sources,
1210
+ total: status === 'available' ? result.records.length : null
1211
+ };
1212
+ return {
1213
+ status,
1214
+ records: result.records,
1215
+ coverage,
1216
+ snapshot_id: sourceSnapshotId({
1217
+ status,
1218
+ records: result.records,
1219
+ coverage,
1220
+ candidate_count: result.candidateCount,
1221
+ invalid_count: result.invalidCount,
1222
+ capped: result.capped,
1223
+ content_digest: result.contentDigest
1224
+ })
1225
+ };
1226
+ }
1227
+ };
1228
+ }
1229
+ function feedbackPayload(event, recordedAt) {
1230
+ const withoutDigest = {
1231
+ schema_version: JUDGMENT_HISTORY_FEEDBACK_SCHEMA,
1232
+ record_id: event.record_id,
1233
+ event_id: event.event_id,
1234
+ kind: event.kind,
1235
+ content: event.content,
1236
+ recorded_at: recordedAt
1237
+ };
1238
+ return { ...withoutDigest, feedback_digest: sha256(canonicalJson(withoutDigest)) };
1239
+ }
1240
+ function sameFeedbackEvent(left, right) {
1241
+ return canonicalJson(left) === canonicalJson(right);
1242
+ }
1243
+ async function requireDirectory(path, code) {
1244
+ let stats;
1245
+ try {
1246
+ stats = await lstat(path);
1247
+ }
1248
+ catch (error) {
1249
+ if (error.code !== 'ENOENT') {
1250
+ throw new JudgmentHistoryFeedbackError(503, code, 'Feedback storage is unavailable');
1251
+ }
1252
+ await mkdir(path, { recursive: true });
1253
+ stats = await lstat(path);
1254
+ }
1255
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
1256
+ throw new JudgmentHistoryFeedbackError(503, code, 'Feedback storage is unavailable');
1257
+ }
1258
+ }
1259
+ /**
1260
+ * Append-only local feedback writer for normal judgment records. It writes a
1261
+ * separate immutable event file below the configured journal and always
1262
+ * confirms the event through the same reader before returning success.
1263
+ */
1264
+ export function createLocalJudgmentHistoryFeedbackWriter(options) {
1265
+ if (!options || typeof options.root !== 'string' || !options.root.trim())
1266
+ throw new TypeError('root is required');
1267
+ if (!options.reader || typeof options.reader.detail !== 'function')
1268
+ throw new TypeError('reader is required');
1269
+ const root = resolve(options.root);
1270
+ const now = options.now ?? (() => new Date());
1271
+ return async (input) => {
1272
+ const event = normalizeFeedbackInput(input);
1273
+ const before = await options.reader.detail(event.record_id);
1274
+ if (before.status === 'unavailable') {
1275
+ throw new JudgmentHistoryFeedbackError(503, 'judgment_history_unavailable', 'The source record is unavailable');
1276
+ }
1277
+ if (!before.record) {
1278
+ if (before.status === 'partial') {
1279
+ throw new JudgmentHistoryFeedbackError(503, 'judgment_history_partial', 'The source record cannot be confirmed while coverage is partial');
1280
+ }
1281
+ throw new JudgmentHistoryFeedbackError(404, 'judgment_history_not_found', 'No judgment history record matches this id');
1282
+ }
1283
+ const existingBefore = before.record.feedback_events?.find((candidate) => candidate.event_id === event.event_id);
1284
+ if (existingBefore) {
1285
+ if (!sameFeedbackEvent(existingBefore, event))
1286
+ throw new JudgmentHistoryFeedbackConflictError(existingBefore);
1287
+ return { event: existingBefore, created: false, status: 'already_saved' };
1288
+ }
1289
+ let rootStats;
1290
+ try {
1291
+ rootStats = await lstat(root);
1292
+ }
1293
+ catch {
1294
+ throw new JudgmentHistoryFeedbackError(503, 'feedback_storage_unavailable', 'Feedback storage is unavailable');
1295
+ }
1296
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
1297
+ throw new JudgmentHistoryFeedbackError(503, 'feedback_storage_unavailable', 'Feedback storage is unavailable');
1298
+ }
1299
+ const feedbackRoot = join(root, FEEDBACK_DIR);
1300
+ await requireDirectory(feedbackRoot, 'feedback_storage_unavailable');
1301
+ const recordDirectory = join(feedbackRoot, feedbackPathKey(event.record_id));
1302
+ await requireDirectory(recordDirectory, 'feedback_storage_unavailable');
1303
+ const eventPath = join(recordDirectory, `${feedbackPathKey(event.event_id)}.json`);
1304
+ const recordedAtDate = now();
1305
+ if (!(recordedAtDate instanceof Date) || !Number.isFinite(recordedAtDate.getTime())) {
1306
+ throw new JudgmentHistoryFeedbackError(500, 'feedback_clock_invalid', 'Feedback clock is invalid');
1307
+ }
1308
+ const recordedAt = recordedAtDate.toISOString();
1309
+ const payload = feedbackPayload(event, recordedAt);
1310
+ let created = false;
1311
+ try {
1312
+ await writeFile(eventPath, `${canonicalJson(payload)}\n`, { encoding: 'utf8', flag: 'wx' });
1313
+ created = true;
1314
+ }
1315
+ catch (error) {
1316
+ if (error.code !== 'EEXIST') {
1317
+ throw new JudgmentHistoryFeedbackError(503, 'feedback_storage_unavailable', 'Feedback storage is unavailable');
1318
+ }
1319
+ const existingResult = await readBoundedJson(eventPath, DEFAULT_LOCAL_JUDGMENT_HISTORY_RECORD_BYTES);
1320
+ const existing = existingResult.value
1321
+ ? normalizeStoredFeedback(existingResult.value, feedbackPathKey(event.record_id), feedbackPathKey(event.event_id))
1322
+ : null;
1323
+ if (!existing)
1324
+ throw new JudgmentHistoryFeedbackError(409, 'feedback_conflict', 'The event_id is already occupied by an invalid event');
1325
+ if (!sameFeedbackEvent(existing, event))
1326
+ throw new JudgmentHistoryFeedbackConflictError(existing);
1327
+ }
1328
+ const after = await options.reader.detail(event.record_id);
1329
+ if (after.status === 'unavailable' || !after.record) {
1330
+ throw new JudgmentHistoryFeedbackError(503, 'feedback_readback_missing', 'Feedback was not readable from the canonical source');
1331
+ }
1332
+ const readBack = after.record.feedback_events?.find((candidate) => candidate.event_id === event.event_id);
1333
+ if (!readBack)
1334
+ throw new JudgmentHistoryFeedbackError(500, 'feedback_readback_missing', 'Feedback was not readable from the canonical source');
1335
+ if (!sameFeedbackEvent(readBack, event))
1336
+ throw new JudgmentHistoryFeedbackConflictError(readBack);
1337
+ return { event: readBack, created, status: created ? 'saved' : 'already_saved' };
1338
+ };
1339
+ }
1340
+ export async function appendLocalJudgmentHistoryFeedback(options) {
1341
+ return createLocalJudgmentHistoryFeedbackWriter(options)(options.input);
1342
+ }
1343
+ function startOfJstDay(date) {
1344
+ const shifted = new Date(date.getTime() + JST_OFFSET_MS);
1345
+ const utc = new Date(Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()));
1346
+ return new Date(utc.getTime() - JST_OFFSET_MS);
1347
+ }
1348
+ function periodStart(period, now) {
1349
+ if (period === 'all')
1350
+ return null;
1351
+ const day = startOfJstDay(now);
1352
+ if (period === 'past30days')
1353
+ return new Date(day.getTime() - 29 * 24 * 60 * 60 * 1000);
1354
+ const shifted = new Date(day.getTime() + JST_OFFSET_MS);
1355
+ const weekday = shifted.getUTCDay();
1356
+ const daysFromMonday = (weekday + 6) % 7;
1357
+ return new Date(day.getTime() - daysFromMonday * 24 * 60 * 60 * 1000);
1358
+ }
1359
+ function normalizeQuery(query) {
1360
+ const period = query?.period ?? 'all';
1361
+ const project = query?.project ?? null;
1362
+ const selectedEntrypoint = query?.entrypoint ?? null;
1363
+ const limit = query?.limit ?? DEFAULT_LIMIT;
1364
+ if (period !== 'week' && period !== 'past30days' && period !== 'all')
1365
+ throw new JudgmentHistoryReaderError(400, 'invalid_period', 'period is invalid');
1366
+ if (project !== null && (!text(project) || project.length > 256 || /[\u0000-\u001f\u007f/\\]/u.test(project))) {
1367
+ throw new JudgmentHistoryReaderError(400, 'invalid_project', 'project is invalid');
1368
+ }
1369
+ if (selectedEntrypoint !== null && !JUDGMENT_HISTORY_ENTRYPOINTS.includes(selectedEntrypoint)) {
1370
+ throw new JudgmentHistoryReaderError(400, 'invalid_entrypoint', 'entrypoint is invalid');
1371
+ }
1372
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT)
1373
+ throw new JudgmentHistoryReaderError(400, 'invalid_limit', 'limit must be between 1 and 100');
1374
+ return { period, project, entrypoint: selectedEntrypoint, cursor: query?.cursor ?? null, limit };
1375
+ }
1376
+ function filterKey(query, effectivePeriodStart, periodAnchor) {
1377
+ return canonicalJson({
1378
+ period: query.period,
1379
+ period_start: effectivePeriodStart?.toISOString() ?? null,
1380
+ period_anchor: periodAnchor?.toISOString() ?? null,
1381
+ project: query.project,
1382
+ entrypoint: query.entrypoint
1383
+ });
1384
+ }
1385
+ function encodeCursor(value) {
1386
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
1387
+ }
1388
+ function decodeCursor(value, filter, snapshot) {
1389
+ if (typeof value !== 'string' || value.length > 2048)
1390
+ throw new JudgmentHistoryReaderError(400, 'invalid_cursor', 'cursor is invalid');
1391
+ try {
1392
+ const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
1393
+ if (!isObject(decoded) || decoded.filter !== filter || decoded.snapshot !== snapshot || !Number.isInteger(decoded.offset) || decoded.offset < 0) {
1394
+ throw new Error('cursor binding mismatch');
1395
+ }
1396
+ return decoded.offset;
1397
+ }
1398
+ catch (error) {
1399
+ if (error instanceof JudgmentHistoryReaderError)
1400
+ throw error;
1401
+ throw new JudgmentHistoryReaderError(400, 'invalid_cursor', 'cursor is invalid or stale');
1402
+ }
1403
+ }
1404
+ function buildSourceSnapshot(snapshot) {
1405
+ const records = snapshot.records;
1406
+ return {
1407
+ status: snapshot.status,
1408
+ records,
1409
+ coverage: snapshot.coverage,
1410
+ snapshot_id: snapshot.snapshot_id ?? sourceSnapshotId({ status: snapshot.status, records, coverage: snapshot.coverage })
1411
+ };
1412
+ }
1413
+ /** Reader shared by local, organization, Mana, and Company OS adapters. */
1414
+ export function createJudgmentHistoryReader(options) {
1415
+ if (!options?.source || typeof options.source.read !== 'function')
1416
+ throw new TypeError('source is required');
1417
+ const now = options.now ?? (() => new Date());
1418
+ return {
1419
+ async home(inputQuery = {}) {
1420
+ const query = normalizeQuery(inputQuery);
1421
+ if (query.project !== null && options.authorizeProject && !(await options.authorizeProject(query.project))) {
1422
+ throw new JudgmentHistoryReaderError(403, 'project_scope_forbidden', 'project is outside the trusted scope');
1423
+ }
1424
+ const source = buildSourceSnapshot(await options.source.read());
1425
+ let records = source.records;
1426
+ const filters = { period: query.period, project: query.project, entrypoint: query.entrypoint };
1427
+ if (source.status === 'unavailable' || records === null) {
1428
+ return {
1429
+ contract_version: JUDGMENT_HISTORY_CONTRACT_VERSION,
1430
+ status: source.status,
1431
+ records: null,
1432
+ coverage: { ...source.coverage, total: null },
1433
+ pagination: { cursor: query.cursor, next_cursor: null, limit: query.limit, has_more: false },
1434
+ filters
1435
+ };
1436
+ }
1437
+ let scopeUnverified = false;
1438
+ if (query.project === null && options.authorizeProject) {
1439
+ const visible = [];
1440
+ for (const record of records) {
1441
+ // A record without a trusted project binding must never enter a
1442
+ // scoped aggregate, because its owner cannot be authorized.
1443
+ if (record.project_code === null) {
1444
+ scopeUnverified = true;
1445
+ continue;
1446
+ }
1447
+ if (await options.authorizeProject(record.project_code))
1448
+ visible.push(record);
1449
+ }
1450
+ records = visible;
1451
+ }
1452
+ const effectiveNow = now();
1453
+ const start = periodStart(query.period, effectiveNow);
1454
+ const anchor = query.period === 'all' ? null : startOfJstDay(effectiveNow);
1455
+ const status = scopeUnverified && source.status === 'available' ? 'partial' : source.status;
1456
+ const baseCoverage = scopeUnverified
1457
+ ? { ...source.coverage, complete: false, reason: 'project_scope_unverified', total: null }
1458
+ : { ...source.coverage, total: null };
1459
+ const base = {
1460
+ contract_version: JUDGMENT_HISTORY_CONTRACT_VERSION,
1461
+ status,
1462
+ records: null,
1463
+ coverage: baseCoverage,
1464
+ pagination: { cursor: query.cursor, next_cursor: null, limit: query.limit, has_more: false },
1465
+ filters
1466
+ };
1467
+ const filtered = records.filter((record) => {
1468
+ if (query.project !== null && record.project_code !== query.project)
1469
+ return false;
1470
+ if (query.entrypoint !== null && record.entrypoint !== query.entrypoint)
1471
+ return false;
1472
+ if (start && Date.parse(record.recorded_at) < start.getTime())
1473
+ return false;
1474
+ return true;
1475
+ });
1476
+ const filter = filterKey(query, start, anchor);
1477
+ const offset = query.cursor ? decodeCursor(query.cursor, filter, source.snapshot_id) : 0;
1478
+ const page = filtered.slice(offset, offset + query.limit);
1479
+ const hasMore = offset + page.length < filtered.length;
1480
+ const nextCursor = hasMore ? encodeCursor({ filter, snapshot: source.snapshot_id, offset: offset + page.length }) : null;
1481
+ const coverage = {
1482
+ ...baseCoverage,
1483
+ total: baseCoverage.complete ? filtered.length : null
1484
+ };
1485
+ return {
1486
+ ...base,
1487
+ records: page,
1488
+ coverage,
1489
+ pagination: { cursor: query.cursor, next_cursor: nextCursor, limit: query.limit, has_more: hasMore }
1490
+ };
1491
+ },
1492
+ async detail(recordId) {
1493
+ const safeRecordId = safeId(recordId);
1494
+ if (!safeRecordId)
1495
+ throw new JudgmentHistoryReaderError(400, 'invalid_record_id', 'record_id is invalid');
1496
+ const source = buildSourceSnapshot(await options.source.read());
1497
+ if (source.status === 'unavailable' || source.records === null) {
1498
+ return {
1499
+ contract_version: JUDGMENT_HISTORY_CONTRACT_VERSION,
1500
+ status: source.status,
1501
+ record: null,
1502
+ coverage: source.coverage
1503
+ };
1504
+ }
1505
+ const record = source.records.find((candidate) => candidate.record_id === safeRecordId) ?? null;
1506
+ if (record && options.authorizeProject) {
1507
+ if (record.project_code === null || !(await options.authorizeProject(record.project_code))) {
1508
+ throw new JudgmentHistoryReaderError(403, 'project_scope_forbidden', 'record is outside the trusted scope');
1509
+ }
1510
+ }
1511
+ return {
1512
+ contract_version: JUDGMENT_HISTORY_CONTRACT_VERSION,
1513
+ status: source.status,
1514
+ record,
1515
+ coverage: { ...source.coverage, total: source.coverage.complete ? source.records.length : null }
1516
+ };
1517
+ }
1518
+ };
1519
+ }