dsh-research-report 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/LICENSE +201 -0
- package/README.es.md +155 -0
- package/README.hi.md +155 -0
- package/README.md +155 -0
- package/README.pt.md +155 -0
- package/README.zh.md +155 -0
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/cordis.patch.yml +24 -0
- package/lib/index.js +2143 -0
- package/lib/types/assemble.d.ts +123 -0
- package/lib/types/assemble.d.ts.map +1 -0
- package/lib/types/assemble.js +239 -0
- package/lib/types/assemble.js.map +1 -0
- package/lib/types/config.d.ts +48 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +60 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/gather.d.ts +119 -0
- package/lib/types/gather.d.ts.map +1 -0
- package/lib/types/gather.js +165 -0
- package/lib/types/gather.js.map +1 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +71 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/ledger.d.ts +159 -0
- package/lib/types/ledger.d.ts.map +1 -0
- package/lib/types/ledger.js +276 -0
- package/lib/types/ledger.js.map +1 -0
- package/lib/types/provider-local.d.ts +137 -0
- package/lib/types/provider-local.d.ts.map +1 -0
- package/lib/types/provider-local.js +418 -0
- package/lib/types/provider-local.js.map +1 -0
- package/lib/types/service.d.ts +302 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/service.js +31 -0
- package/lib/types/service.js.map +1 -0
- package/lib/types/tools/evidence-add.d.ts +36 -0
- package/lib/types/tools/evidence-add.d.ts.map +1 -0
- package/lib/types/tools/evidence-add.js +107 -0
- package/lib/types/tools/evidence-add.js.map +1 -0
- package/lib/types/tools/ledger-query.d.ts +42 -0
- package/lib/types/tools/ledger-query.d.ts.map +1 -0
- package/lib/types/tools/ledger-query.js +154 -0
- package/lib/types/tools/ledger-query.js.map +1 -0
- package/lib/types/tools/research-report.d.ts +67 -0
- package/lib/types/tools/research-report.d.ts.map +1 -0
- package/lib/types/tools/research-report.js +345 -0
- package/lib/types/tools/research-report.js.map +1 -0
- package/lib/types/verify.d.ts +128 -0
- package/lib/types/verify.d.ts.map +1 -0
- package/lib/types/verify.js +208 -0
- package/lib/types/verify.js.map +1 -0
- package/lib/types/version.d.ts +7 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/version.js +7 -0
- package/lib/types/version.js.map +1 -0
- package/package.json +147 -0
- package/src/assemble.ts +302 -0
- package/src/config.ts +97 -0
- package/src/gather.ts +239 -0
- package/src/index.ts +139 -0
- package/src/ledger.ts +344 -0
- package/src/provider-local.ts +489 -0
- package/src/service.ts +322 -0
- package/src/tools/evidence-add.ts +132 -0
- package/src/tools/ledger-query.ts +191 -0
- package/src/tools/research-report.ts +424 -0
- package/src/verify.ts +285 -0
- package/src/version.ts +7 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-research-report` — a domain-agnostic verifiable research-report engine
|
|
3
|
+
* for DeepSeek Harness. A content-addressed evidence ledger (claim ↔ snapshot
|
|
4
|
+
* binding, tamper-evident) plus versioned sealed reports: every claim carries
|
|
5
|
+
* a verification verdict, and the manifest hash seals the report directory.
|
|
6
|
+
* Retrieval orchestration is deliberately NOT re-implemented here — evidence
|
|
7
|
+
* gathering reuses the official `ctx.web` seam and long runs ride `ctx.jobs`.
|
|
8
|
+
*
|
|
9
|
+
* One package carries the complete capability seam: `service.ts` is the
|
|
10
|
+
* Service Definition (`ctx.researchReport`, with the byte-frozen assemble
|
|
11
|
+
* contract), `provider-local.ts` is the local Provider, and `tools/` the
|
|
12
|
+
* model-facing Consumers.
|
|
13
|
+
*
|
|
14
|
+
* Function plugin — no default export (the Loader unwraps
|
|
15
|
+
* `exports.default ?? exports`, and a stray default would discard
|
|
16
|
+
* `name`/`inject`/`Config`/`apply`).
|
|
17
|
+
* @module dsh-research-report
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
// Type-only: registers the `ctx.systemPrompt` Context merge for the inject.
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
23
|
+
import { Config, resolveConfig } from './config.ts'
|
|
24
|
+
import { LocalResearchReportService } from './provider-local.ts'
|
|
25
|
+
import { makeEvidenceAddTool } from './tools/evidence-add.ts'
|
|
26
|
+
import { makeLedgerQueryTool } from './tools/ledger-query.ts'
|
|
27
|
+
import { makeResearchReportTool } from './tools/research-report.ts'
|
|
28
|
+
|
|
29
|
+
export const name = 'research-report'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Public services only. `web` (evidence capture) and `jobs` (background
|
|
33
|
+
* assembly) are deliberately OPTIONAL and resolved with `ctx.get` at call
|
|
34
|
+
* time: a composition without them still mounts, and the affected paths fail
|
|
35
|
+
* loud with an explicit reason.
|
|
36
|
+
*/
|
|
37
|
+
export const inject = ['tools', 'systemPrompt']
|
|
38
|
+
|
|
39
|
+
export { Config, resolveConfig } from './config.ts'
|
|
40
|
+
export type { ResolvedConfig } from './config.ts'
|
|
41
|
+
export { VERSION } from './version.ts'
|
|
42
|
+
export { ResearchReportService } from './service.ts'
|
|
43
|
+
export type {
|
|
44
|
+
AddEvidenceInput,
|
|
45
|
+
AssembleContext,
|
|
46
|
+
AssembleReportRequest,
|
|
47
|
+
AssembleReportResult,
|
|
48
|
+
ClaimRegistration,
|
|
49
|
+
ClaimVerdict,
|
|
50
|
+
ClaimView,
|
|
51
|
+
EvidenceInput,
|
|
52
|
+
EvidenceIntegrity,
|
|
53
|
+
EvidenceRecord,
|
|
54
|
+
EvidenceView,
|
|
55
|
+
LedgerSummary,
|
|
56
|
+
ReportSectionInput,
|
|
57
|
+
StoredVerdict,
|
|
58
|
+
VerdictStatus,
|
|
59
|
+
} from './service.ts'
|
|
60
|
+
export { EvidenceLedger, LedgerError, sha256Of } from './ledger.ts'
|
|
61
|
+
export type { LedgerClaimLine, LedgerIndexLine, LedgerVerdictLine } from './ledger.ts'
|
|
62
|
+
export {
|
|
63
|
+
combineOutcomes,
|
|
64
|
+
contextLabelOf,
|
|
65
|
+
extractCitations,
|
|
66
|
+
mapBridgeResults,
|
|
67
|
+
normalizeNumber,
|
|
68
|
+
verifyClaimText,
|
|
69
|
+
} from './verify.ts'
|
|
70
|
+
export type {
|
|
71
|
+
ByteCheckOutcome,
|
|
72
|
+
Citation,
|
|
73
|
+
CitationCheckRequest,
|
|
74
|
+
CitationCheckResult,
|
|
75
|
+
DataQualityBridge,
|
|
76
|
+
} from './verify.ts'
|
|
77
|
+
export {
|
|
78
|
+
CONTRADICTED_MARK,
|
|
79
|
+
MANIFEST_SCHEMA,
|
|
80
|
+
RequestValidationError,
|
|
81
|
+
UNVERIFIED_MARK,
|
|
82
|
+
buildManifest,
|
|
83
|
+
configFingerprint,
|
|
84
|
+
renderReportMarkdown,
|
|
85
|
+
serializeManifest,
|
|
86
|
+
slugify,
|
|
87
|
+
validateAssembleRequest,
|
|
88
|
+
versionIdOf,
|
|
89
|
+
} from './assemble.ts'
|
|
90
|
+
export type { ReportManifest, ReportPlan } from './assemble.ts'
|
|
91
|
+
export {
|
|
92
|
+
CaptureError,
|
|
93
|
+
GATHER_DEPTH_RESULTS,
|
|
94
|
+
captureFromFile,
|
|
95
|
+
captureFromWeb,
|
|
96
|
+
captureSnapshot,
|
|
97
|
+
gatherCandidates,
|
|
98
|
+
isUrlOrigin,
|
|
99
|
+
resolveWorkspacePath,
|
|
100
|
+
toWorkspaceRelative,
|
|
101
|
+
} from './gather.ts'
|
|
102
|
+
export type { CaptureDeps, GatherCandidate, GatherOutcome } from './gather.ts'
|
|
103
|
+
export { LocalResearchReportService, ResearchReportError } from './provider-local.ts'
|
|
104
|
+
|
|
105
|
+
/** The short prompt section: one role statement plus the workflow. */
|
|
106
|
+
const PROMPT_SECTION = [
|
|
107
|
+
'You have a verifiable research-report engine (dsh-research-report) whose reports prove every claim against stored evidence bytes.',
|
|
108
|
+
'When asked for a research deliverable: register evidence snapshots with evidence_add (URL or workspace path), then call research_report with sections whose paragraphs cite claim ids bound to those evidence ids. Every claim is verified against the stored snapshot bytes; unverified or contradicted claims stay visibly marked in the sealed report — never paper over them. ledger_query reads bindings and verdicts back.',
|
|
109
|
+
].join('\n')
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Mount the engine: resolve config (fail loud), construct the local provider
|
|
113
|
+
* (registering `ctx.researchReport` on this fiber), register the three tools,
|
|
114
|
+
* and contribute the short prompt section.
|
|
115
|
+
* @param ctx - the plugin context (host).
|
|
116
|
+
* @param config - raw plugin config.
|
|
117
|
+
*/
|
|
118
|
+
export function apply(ctx: Context, config: Config): void {
|
|
119
|
+
const resolved = resolveConfig(config)
|
|
120
|
+
const logger = ctx.logger('research-report')
|
|
121
|
+
if (!resolved.enabled) {
|
|
122
|
+
logger.info('disabled: enabled is false — no service, tools, or prompt section are mounted')
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The provider registers itself as ctx.researchReport on construction and
|
|
127
|
+
// is unregistered with this fiber (Service base semantics).
|
|
128
|
+
const service = new LocalResearchReportService(ctx, resolved, process.cwd())
|
|
129
|
+
|
|
130
|
+
ctx.effect(() => ctx.tools.register(makeEvidenceAddTool(service)), 'research-report: evidence_add tool')
|
|
131
|
+
ctx.effect(() => ctx.tools.register(makeResearchReportTool({ ctx, service })), 'research-report: research_report tool')
|
|
132
|
+
ctx.effect(() => ctx.tools.register(makeLedgerQueryTool(service)), 'research-report: ledger_query tool')
|
|
133
|
+
|
|
134
|
+
ctx.systemPrompt.section({
|
|
135
|
+
name: 'dsh-research-report:workflow',
|
|
136
|
+
order: 10,
|
|
137
|
+
text: PROMPT_SECTION,
|
|
138
|
+
})
|
|
139
|
+
}
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The evidence ledger: a content-addressed snapshot store with JSONL journals.
|
|
3
|
+
*
|
|
4
|
+
* Layout under the configured `ledgerRoot`:
|
|
5
|
+
* - `objects/<sha256>` — one immutable snapshot per content hash (same content
|
|
6
|
+
* is stored exactly once; an "update" is a new object, history is never
|
|
7
|
+
* rewritten).
|
|
8
|
+
* - `index.jsonl` — evidence registrations: id → hash, origin, capturedAt,
|
|
9
|
+
* title, bytes. Append-only.
|
|
10
|
+
* - `claims.jsonl` — claim registrations: id → text, evidenceIds, optional
|
|
11
|
+
* dataset bridge fields. Append-only.
|
|
12
|
+
* - `verdicts.jsonl` — verification verdicts; the latest line per claim wins.
|
|
13
|
+
*
|
|
14
|
+
* Tamper detection is the point of the design: every content read recomputes
|
|
15
|
+
* the SHA-256 of the object file and compares it against the indexed hash —
|
|
16
|
+
* a mismatch surfaces as `tampered`, a deleted object as `missing`.
|
|
17
|
+
*
|
|
18
|
+
* This module is pure Node (zero DSH imports) so it stays testable in
|
|
19
|
+
* isolation; policy (size caps, fetch) lives in the provider.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-research-report/ledger
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
25
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
|
|
28
|
+
/** SHA-256 hex of one UTF-8 string. */
|
|
29
|
+
export function sha256Of(content: string): string {
|
|
30
|
+
return createHash('sha256').update(content, 'utf8').digest('hex')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Error codes the ledger reports. */
|
|
34
|
+
export type LedgerErrorCode = 'ID_CONFLICT' | 'JOURNAL_CORRUPT' | 'IO'
|
|
35
|
+
|
|
36
|
+
/** A loud ledger failure (audit state must never degrade silently). */
|
|
37
|
+
export class LedgerError extends Error {
|
|
38
|
+
/** The machine-routable failure code. */
|
|
39
|
+
readonly code: LedgerErrorCode
|
|
40
|
+
constructor(code: LedgerErrorCode, message: string) {
|
|
41
|
+
super(message)
|
|
42
|
+
this.name = 'LedgerError'
|
|
43
|
+
this.code = code
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One line of `index.jsonl` — the durable evidence record. */
|
|
48
|
+
export interface LedgerIndexLine {
|
|
49
|
+
id: string
|
|
50
|
+
hash: string
|
|
51
|
+
title: string
|
|
52
|
+
origin: string
|
|
53
|
+
capturedAt: string
|
|
54
|
+
bytes: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One line of `claims.jsonl` — the durable claim registration. */
|
|
58
|
+
export interface LedgerClaimLine {
|
|
59
|
+
id: string
|
|
60
|
+
text: string
|
|
61
|
+
evidenceIds: string[]
|
|
62
|
+
dataset?: string
|
|
63
|
+
citations?: Array<{ id: string; path: string; value: number | string; tolerance?: number }>
|
|
64
|
+
registeredAt: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One line of `verdicts.jsonl` — the durable verdict record. */
|
|
68
|
+
export interface LedgerVerdictLine {
|
|
69
|
+
claimId: string
|
|
70
|
+
status: 'verified' | 'unverified' | 'contradicted'
|
|
71
|
+
note?: string
|
|
72
|
+
at: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Outcome of one {@link EvidenceLedger.putEvidence}. */
|
|
76
|
+
export interface PutOutcome {
|
|
77
|
+
/** The durable record (the existing one when deduplicated). */
|
|
78
|
+
record: LedgerIndexLine
|
|
79
|
+
/** True when this call appended a new registration. */
|
|
80
|
+
created: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Read one JSONL journal; a corrupt line fails loud with file and line number. */
|
|
84
|
+
async function readJournal<T>(file: string): Promise<T[]> {
|
|
85
|
+
let text: string
|
|
86
|
+
try {
|
|
87
|
+
text = await readFile(file, 'utf8')
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// A not-yet-created journal is an empty journal; anything else is loud.
|
|
90
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
91
|
+
throw new LedgerError('IO', `cannot read journal ${file}: ${(error as Error).message}`)
|
|
92
|
+
}
|
|
93
|
+
const lines: T[] = []
|
|
94
|
+
const rows = text.split('\n')
|
|
95
|
+
for (let index = 0; index < rows.length; index++) {
|
|
96
|
+
const row = rows[index]!
|
|
97
|
+
if (row.trim() === '') continue
|
|
98
|
+
try {
|
|
99
|
+
lines.push(JSON.parse(row) as T)
|
|
100
|
+
} catch {
|
|
101
|
+
throw new LedgerError('JOURNAL_CORRUPT', `corrupt JSONL at ${file}:${index + 1}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return lines
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The content-addressed evidence ledger. Writes are serialized through an
|
|
109
|
+
* internal promise queue so concurrent tool calls cannot interleave journals.
|
|
110
|
+
*/
|
|
111
|
+
export class EvidenceLedger {
|
|
112
|
+
/** Absolute ledger root directory. */
|
|
113
|
+
readonly root: string
|
|
114
|
+
|
|
115
|
+
/** Write serialization chain (never rejects — each link absorbs the previous error). */
|
|
116
|
+
private queue: Promise<void> = Promise.resolve()
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param root - absolute ledger root directory.
|
|
120
|
+
*/
|
|
121
|
+
constructor(root: string) {
|
|
122
|
+
this.root = root
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private get objectsDir(): string {
|
|
126
|
+
return path.join(this.root, 'objects')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private get indexFile(): string {
|
|
130
|
+
return path.join(this.root, 'index.jsonl')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private get claimsFile(): string {
|
|
134
|
+
return path.join(this.root, 'claims.jsonl')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private get verdictsFile(): string {
|
|
138
|
+
return path.join(this.root, 'verdicts.jsonl')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Run `work` after all previously queued writes settle. */
|
|
142
|
+
private enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
143
|
+
const run = this.queue.then(work)
|
|
144
|
+
this.queue = run.then(
|
|
145
|
+
() => undefined,
|
|
146
|
+
() => undefined,
|
|
147
|
+
)
|
|
148
|
+
return run
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Ensure the directory layout exists. */
|
|
152
|
+
private async ensureLayout(): Promise<void> {
|
|
153
|
+
await mkdir(this.objectsDir, { recursive: true })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Write one snapshot object atomically (tmp + rename); no-op when present. */
|
|
157
|
+
private async writeObject(hash: string, content: string): Promise<void> {
|
|
158
|
+
const target = path.join(this.objectsDir, hash)
|
|
159
|
+
try {
|
|
160
|
+
await readFile(target)
|
|
161
|
+
return // object exists — content-addressed storage is immutable
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
164
|
+
throw new LedgerError('IO', `cannot stat object ${hash}: ${(error as Error).message}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const temporary = path.join(this.objectsDir, `.${hash}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`)
|
|
168
|
+
await writeFile(temporary, content, 'utf8')
|
|
169
|
+
try {
|
|
170
|
+
await rename(temporary, target)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// A concurrent writer may have won the rename; the object is identical
|
|
173
|
+
// either way (same hash ⇒ same content), so only non-exists errors matter.
|
|
174
|
+
try {
|
|
175
|
+
await readFile(target)
|
|
176
|
+
} catch {
|
|
177
|
+
throw new LedgerError('IO', `cannot commit object ${hash}: ${(error as Error).message}`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Register one evidence snapshot. Same content dedupes to the stored object;
|
|
184
|
+
* a caller-chosen id that already exists with DIFFERENT content is refused
|
|
185
|
+
* loudly (history is never rewritten).
|
|
186
|
+
* @param input - id (optional), title, origin, content, capturedAt.
|
|
187
|
+
* @returns the record plus whether this call created it.
|
|
188
|
+
*/
|
|
189
|
+
async putEvidence(input: {
|
|
190
|
+
id?: string
|
|
191
|
+
title: string
|
|
192
|
+
origin: string
|
|
193
|
+
content: string
|
|
194
|
+
capturedAt: string
|
|
195
|
+
}): Promise<PutOutcome> {
|
|
196
|
+
return this.enqueue(async () => {
|
|
197
|
+
await this.ensureLayout()
|
|
198
|
+
const hash = sha256Of(input.content)
|
|
199
|
+
const id = input.id ?? `ev-${hash.slice(0, 12)}`
|
|
200
|
+
const index = await readJournal<LedgerIndexLine>(this.indexFile)
|
|
201
|
+
const existing = index.find(line => line.id === id)
|
|
202
|
+
if (existing !== undefined) {
|
|
203
|
+
if (existing.hash !== hash) {
|
|
204
|
+
throw new LedgerError(
|
|
205
|
+
'ID_CONFLICT',
|
|
206
|
+
`evidence id "${id}" is already registered with different content (indexed ${existing.hash}, new ${hash}); choose a new id — snapshots are immutable`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
return { record: existing, created: false }
|
|
210
|
+
}
|
|
211
|
+
await this.writeObject(hash, input.content)
|
|
212
|
+
const record: LedgerIndexLine = {
|
|
213
|
+
id,
|
|
214
|
+
hash,
|
|
215
|
+
title: input.title,
|
|
216
|
+
origin: input.origin,
|
|
217
|
+
capturedAt: input.capturedAt,
|
|
218
|
+
bytes: Buffer.byteLength(input.content, 'utf8'),
|
|
219
|
+
}
|
|
220
|
+
await appendFile(this.indexFile, `${JSON.stringify(record)}\n`, 'utf8')
|
|
221
|
+
return { record, created: true }
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Register claims (id → text, evidenceIds). Re-registering a claim id with
|
|
227
|
+
* a different text or different bindings is refused loudly.
|
|
228
|
+
* @param claims - the registrations to append.
|
|
229
|
+
* @param registeredAt - ISO-8601 registration time.
|
|
230
|
+
* @returns the durable claim lines (existing lines for idempotent repeats).
|
|
231
|
+
*/
|
|
232
|
+
async registerClaims(
|
|
233
|
+
claims: Array<Omit<LedgerClaimLine, 'registeredAt'>>,
|
|
234
|
+
registeredAt: string,
|
|
235
|
+
): Promise<LedgerClaimLine[]> {
|
|
236
|
+
return this.enqueue(async () => {
|
|
237
|
+
await this.ensureLayout()
|
|
238
|
+
const journal = await readJournal<LedgerClaimLine>(this.claimsFile)
|
|
239
|
+
const out: LedgerClaimLine[] = []
|
|
240
|
+
for (const claim of claims) {
|
|
241
|
+
const existing = journal.find(line => line.id === claim.id)
|
|
242
|
+
if (existing !== undefined) {
|
|
243
|
+
const same = existing.text === claim.text
|
|
244
|
+
&& JSON.stringify(existing.evidenceIds) === JSON.stringify(claim.evidenceIds)
|
|
245
|
+
&& existing.dataset === claim.dataset
|
|
246
|
+
if (!same) {
|
|
247
|
+
throw new LedgerError(
|
|
248
|
+
'ID_CONFLICT',
|
|
249
|
+
`claim id "${claim.id}" is already registered with different text or bindings; choose a new claim id — registrations are immutable`,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
out.push(existing)
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
const line: LedgerClaimLine = { ...claim, registeredAt }
|
|
256
|
+
await appendFile(this.claimsFile, `${JSON.stringify(line)}\n`, 'utf8')
|
|
257
|
+
journal.push(line)
|
|
258
|
+
out.push(line)
|
|
259
|
+
}
|
|
260
|
+
return out
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Append one verdict (latest per claim wins on read).
|
|
266
|
+
* @param verdict - claimId, status, optional note.
|
|
267
|
+
* @param at - ISO-8601 write time.
|
|
268
|
+
*/
|
|
269
|
+
async recordVerdict(verdict: Omit<LedgerVerdictLine, 'at'>, at: string): Promise<void> {
|
|
270
|
+
await this.enqueue(async () => {
|
|
271
|
+
await this.ensureLayout()
|
|
272
|
+
const line: LedgerVerdictLine = { ...verdict, at }
|
|
273
|
+
await appendFile(this.verdictsFile, `${JSON.stringify(line)}\n`, 'utf8')
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read the evidence index.
|
|
279
|
+
* @returns every registration in append order.
|
|
280
|
+
*/
|
|
281
|
+
async listEvidence(): Promise<LedgerIndexLine[]> {
|
|
282
|
+
return readJournal<LedgerIndexLine>(this.indexFile)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Read one evidence registration.
|
|
287
|
+
* @param id - the ledger id.
|
|
288
|
+
* @returns the record, or undefined when unknown.
|
|
289
|
+
*/
|
|
290
|
+
async getEvidence(id: string): Promise<LedgerIndexLine | undefined> {
|
|
291
|
+
const index = await this.listEvidence()
|
|
292
|
+
return index.find(line => line.id === id)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Read every claim registration.
|
|
297
|
+
* @returns every claim in append order.
|
|
298
|
+
*/
|
|
299
|
+
async listClaims(): Promise<LedgerClaimLine[]> {
|
|
300
|
+
return readJournal<LedgerClaimLine>(this.claimsFile)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Read one claim registration.
|
|
305
|
+
* @param id - the claim id.
|
|
306
|
+
* @returns the claim line, or undefined when unknown.
|
|
307
|
+
*/
|
|
308
|
+
async getClaim(id: string): Promise<LedgerClaimLine | undefined> {
|
|
309
|
+
const claims = await this.listClaims()
|
|
310
|
+
return claims.find(line => line.id === id)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Fold the verdict journal to the latest verdict per claim.
|
|
315
|
+
* @returns claimId → latest stored verdict.
|
|
316
|
+
*/
|
|
317
|
+
async latestVerdicts(): Promise<Map<string, LedgerVerdictLine>> {
|
|
318
|
+
const journal = await readJournal<LedgerVerdictLine>(this.verdictsFile)
|
|
319
|
+
const latest = new Map<string, LedgerVerdictLine>()
|
|
320
|
+
for (const line of journal) latest.set(line.claimId, line)
|
|
321
|
+
return latest
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read one snapshot and recompute its hash — the tamper-detection path.
|
|
326
|
+
* @param id - the ledger id.
|
|
327
|
+
* @returns content plus integrity (`ok` | `tampered` | `missing`), or
|
|
328
|
+
* undefined when the id is unknown.
|
|
329
|
+
*/
|
|
330
|
+
async readContent(id: string): Promise<{ content: string; integrity: 'ok' | 'tampered' | 'missing' } | undefined> {
|
|
331
|
+
const record = await this.getEvidence(id)
|
|
332
|
+
if (record === undefined) return undefined
|
|
333
|
+
let content: string
|
|
334
|
+
try {
|
|
335
|
+
content = await readFile(path.join(this.objectsDir, record.hash), 'utf8')
|
|
336
|
+
} catch (error) {
|
|
337
|
+
// A deleted object degrades to the `missing` integrity state, never an
|
|
338
|
+
// unhandled failure; anything else is loud.
|
|
339
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { content: '', integrity: 'missing' }
|
|
340
|
+
throw new LedgerError('IO', `cannot read object ${record.hash}: ${(error as Error).message}`)
|
|
341
|
+
}
|
|
342
|
+
return { content, integrity: sha256Of(content) === record.hash ? 'ok' : 'tampered' }
|
|
343
|
+
}
|
|
344
|
+
}
|