@kontourai/lookout 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.
@@ -0,0 +1,173 @@
1
+ import { open } from "node:fs/promises";
2
+ import { createCheckRunner } from "./check-runner.js";
3
+ import { loadRegistry } from "./registry.js";
4
+ import { createLookoutSnapshotStore } from "./snapshot-store.js";
5
+ import { createObservationStore } from "./observation-store.js";
6
+ import { createSurveyEmitter } from "./survey-emission.js";
7
+ export async function runCli(options = {}) {
8
+ const argv = options.argv ?? process.argv.slice(2);
9
+ const stdout = options.stdout ?? process.stdout;
10
+ const stderr = options.stderr ?? process.stderr;
11
+ const parsed = parseArgs(argv);
12
+ if (typeof parsed === "string") {
13
+ stderr.write(`${parsed}\n`);
14
+ return 2;
15
+ }
16
+ let registry;
17
+ try {
18
+ registry = await (options.loadRegistry ?? loadRegistry)(parsed.registryPath);
19
+ }
20
+ catch (error) {
21
+ stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
22
+ return 1;
23
+ }
24
+ if (parsed.command === "emit-survey") {
25
+ const source = registry.get(parsed.id);
26
+ if (!source) {
27
+ stderr.write(`Unknown source id: ${parsed.id}\n`);
28
+ return 1;
29
+ }
30
+ let value;
31
+ try {
32
+ value = await (options.readObservation ?? readObservation)(parsed.observationPath);
33
+ }
34
+ catch (error) {
35
+ stderr.write(`Could not read observation: ${error instanceof Error ? error.message : String(error)}\n`);
36
+ return 1;
37
+ }
38
+ const result = await (options.emitSurvey ?? emitSurvey)(parsed.id, value, registry, parsed.observationRoot);
39
+ if (!result.ok) {
40
+ stderr.write(`${result.error.kind}: ${result.error.message}\n`);
41
+ return 1;
42
+ }
43
+ stdout.write(`${JSON.stringify(result.value)}\n`);
44
+ return 0;
45
+ }
46
+ const runner = options.runner ?? createCheckRunner({
47
+ store: createLookoutSnapshotStore(parsed.snapshotRoot),
48
+ });
49
+ if (parsed.all) {
50
+ const results = await runner.checkAll(registry.list());
51
+ for (const result of results)
52
+ stdout.write(`${JSON.stringify(result)}\n`);
53
+ return 0;
54
+ }
55
+ const source = registry.get(parsed.id);
56
+ if (!source) {
57
+ stderr.write(`Unknown source id: ${parsed.id}\n`);
58
+ return 1;
59
+ }
60
+ stdout.write(`${JSON.stringify(await runner.check(source))}\n`);
61
+ return 0;
62
+ }
63
+ function parseArgs(argv) {
64
+ if (argv[0] === "emit-survey")
65
+ return parseEmitArgs(argv);
66
+ if (argv[0] !== "check")
67
+ return "Usage: lookout check <id>|--all [--registry path] [--snapshot-root path]\n lookout emit-survey <id> --observation <path|-> [--registry path] [--observation-root path]";
68
+ const parsed = { command: "check", all: false };
69
+ for (let index = 1; index < argv.length; index += 1) {
70
+ const arg = argv[index];
71
+ if (arg === "--all") {
72
+ if (parsed.all)
73
+ return "--all may be specified only once";
74
+ parsed.all = true;
75
+ }
76
+ else if (arg === "--registry" || arg === "--snapshot-root") {
77
+ const value = argv[index + 1];
78
+ if (!value || value.startsWith("--"))
79
+ return `${arg} requires a path`;
80
+ if (arg === "--registry")
81
+ parsed.registryPath = value;
82
+ else
83
+ parsed.snapshotRoot = value;
84
+ index += 1;
85
+ }
86
+ else if (arg.startsWith("--")) {
87
+ return `Unknown option: ${arg}`;
88
+ }
89
+ else if (parsed.id) {
90
+ return "Only one source id may be checked at a time";
91
+ }
92
+ else {
93
+ parsed.id = arg;
94
+ }
95
+ }
96
+ if (parsed.all && parsed.id)
97
+ return "A source id and --all are mutually exclusive";
98
+ if (!parsed.all && !parsed.id)
99
+ return "check requires a source id or --all";
100
+ return parsed;
101
+ }
102
+ function parseEmitArgs(argv) {
103
+ let id;
104
+ let observationPath;
105
+ let registryPath;
106
+ let observationRoot;
107
+ for (let index = 1; index < argv.length; index += 1) {
108
+ const arg = argv[index];
109
+ if (arg === "--registry" || arg === "--observation" || arg === "--observation-root") {
110
+ const value = argv[++index];
111
+ if (!value || (value.startsWith("--") && value !== "-"))
112
+ return `${arg} requires a path`;
113
+ if (arg === "--registry")
114
+ registryPath = value;
115
+ else if (arg === "--observation")
116
+ observationPath = value;
117
+ else
118
+ observationRoot = value;
119
+ }
120
+ else if (arg.startsWith("--"))
121
+ return `Unknown option: ${arg}`;
122
+ else if (id)
123
+ return "Only one source id may be emitted at a time";
124
+ else
125
+ id = arg;
126
+ }
127
+ if (!id)
128
+ return "emit-survey requires a source id";
129
+ if (!observationPath)
130
+ return "emit-survey requires --observation <path|->";
131
+ return { command: "emit-survey", id, observationPath, registryPath, observationRoot };
132
+ }
133
+ async function readObservation(file) {
134
+ const maxBytes = 1024 * 1024;
135
+ const text = file === "-" ? await new Promise((resolve, reject) => { let body = ""; let bytes = 0; let rejected = false; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { if (rejected)
136
+ return; bytes += Buffer.byteLength(chunk); if (bytes > maxBytes) {
137
+ rejected = true;
138
+ reject(new Error(`observation exceeds ${maxBytes} bytes`));
139
+ return;
140
+ } body += chunk; }); process.stdin.on("end", () => { if (!rejected)
141
+ resolve(body); }); process.stdin.on("error", reject); }) : await readBoundedFile(file, maxBytes);
142
+ if (Buffer.byteLength(text) > maxBytes)
143
+ throw new Error(`observation exceeds ${maxBytes} bytes`);
144
+ return JSON.parse(text);
145
+ }
146
+ async function readBoundedFile(file, maxBytes) {
147
+ const handle = await open(file, "r");
148
+ try {
149
+ const buffer = Buffer.allocUnsafe(maxBytes + 1);
150
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
151
+ if (bytesRead > maxBytes)
152
+ throw new Error(`observation exceeds ${maxBytes} bytes`);
153
+ return buffer.subarray(0, bytesRead).toString("utf8");
154
+ }
155
+ finally {
156
+ await handle.close();
157
+ }
158
+ }
159
+ function cliEntities(observation) {
160
+ const grouped = new Map();
161
+ for (const proposal of observation.proposals) {
162
+ const key = proposal.pathIndices?.length ? proposal.pathIndices.join(".") : "root";
163
+ grouped.set(key, [...(grouped.get(key) ?? []), proposal]);
164
+ }
165
+ return [...grouped].map(([key, proposals]) => ({ key, proposals }));
166
+ }
167
+ async function emitSurvey(sourceId, value, registry, observationRoot) {
168
+ if (!value || typeof value !== "object" || Array.isArray(value))
169
+ return { ok: false, error: { kind: "invalid-input", message: "Observation document must be an object" } };
170
+ const document = value;
171
+ const source = registry.get(sourceId);
172
+ return createSurveyEmitter({ store: createObservationStore({ root: observationRoot }) }).emit({ source, current: document.observation, check: document.check, callbacks: { selectEntities: cliEntities, entityIdentity: (entity) => entity.key, proposalsFor: (entity) => entity.proposals, fieldIdentity: (_entity, proposal) => proposal.fieldPath } });
173
+ }
@@ -0,0 +1,20 @@
1
+ export { runCli } from "./cli.js";
2
+ export type { RunCliOptions } from "./cli.js";
3
+ export { createCheckRunner } from "./check-runner.js";
4
+ export type { CheckRunner, CreateCheckRunnerOptions, FetchSource } from "./check-runner.js";
5
+ export type { ChangedResult, CheckResult, CheckResultCommon, ErrorResult, LookoutErrorKind, Unchanged304Result, UnchangedHashResult, } from "./check-result.js";
6
+ export { defaultProviderResolver } from "./provider-resolution.js";
7
+ export type { ProviderResolver } from "./provider-resolution.js";
8
+ export { loadRegistry, LookoutRegistry, parseRegistry, RegistryValidationError, } from "./registry.js";
9
+ export type { LookoutRegistryDocument, LookoutSource, LookoutSourceKind, RenderPolicy, } from "./registry.js";
10
+ export { createLookoutSnapshotStore } from "./snapshot-store.js";
11
+ export { canonicalValueKey } from "./canonical-value.js";
12
+ export type { CanonicalValueKey, CanonicalValueFormatVersion, CanonicalValueResult, DiffKernelError, DiffKernelErrorKind, DiffResult, IdentityResult, } from "./canonical-value.js";
13
+ export { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
14
+ export type { KeyedMultisetFacts, KeyedMultisetOptions, StructuralComparison, StructuralComparisonOptions, StructuralFacts, } from "./structural-diff.js";
15
+ export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
16
+ export type { FieldChangedEvent, FieldChangeKind, NewEntityAppearedEvent, ProposalDiffEvent, ProposalEvidence, ProposalIdentity, ProposalOccurrencePair, ProposalSetDiff, ProposalSetDiffInput, ProposalSetFacts, ProposalSetObservation, ProvenanceChangeFact, } from "./proposal-diff.js";
17
+ export { createObservationStore } from "./observation-store.js";
18
+ export type { CreateObservationStoreOptions, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalObservationRecordInput, StoredProposalObservationV1 } from "./observation-store.js";
19
+ export { createSurveyEmitter } from "./survey-emission.js";
20
+ export type { BaselineEstablishedFact, CreateSurveyEmitterOptions, EmissionError, EmissionErrorKind, EmissionFact, EmissionResult, EmissionSuccess, EmitSurveyInput, SurveyEmitter } from "./survey-emission.js";
@@ -0,0 +1,10 @@
1
+ export { runCli } from "./cli.js";
2
+ export { createCheckRunner } from "./check-runner.js";
3
+ export { defaultProviderResolver } from "./provider-resolution.js";
4
+ export { loadRegistry, LookoutRegistry, parseRegistry, RegistryValidationError, } from "./registry.js";
5
+ export { createLookoutSnapshotStore } from "./snapshot-store.js";
6
+ export { canonicalValueKey } from "./canonical-value.js";
7
+ export { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
8
+ export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
9
+ export { createObservationStore } from "./observation-store.js";
10
+ export { createSurveyEmitter } from "./survey-emission.js";
@@ -0,0 +1,55 @@
1
+ import type { ExtractionProposal } from "@kontourai/traverse";
2
+ import type { ProposalSetObservation } from "./proposal-diff.js";
3
+ export interface ObservationCheckAnchor {
4
+ readonly checkedAt: string;
5
+ readonly resultKind: "changed" | "unchanged-hash";
6
+ readonly currentSnapshotRef: string;
7
+ }
8
+ export interface ProposalObservationRecordInput {
9
+ readonly observation: ProposalSetObservation;
10
+ readonly recordedAt: string;
11
+ readonly check: ObservationCheckAnchor;
12
+ }
13
+ export interface StoredProposalObservationV1 {
14
+ readonly version: 1;
15
+ readonly observationId: string;
16
+ readonly sourceKey: string;
17
+ readonly sourceId: string;
18
+ readonly snapshotRef: string;
19
+ readonly observedAt: string;
20
+ readonly recordedAt: string;
21
+ readonly check: ObservationCheckAnchor;
22
+ readonly proposals: readonly ExtractionProposal[];
23
+ }
24
+ export type ObservationStoreErrorKind = "invalid-input" | "corrupt-state" | "continuity-conflict" | "io-error";
25
+ export interface ObservationStoreError {
26
+ readonly kind: ObservationStoreErrorKind;
27
+ readonly message: string;
28
+ readonly cause?: unknown;
29
+ }
30
+ export type ObservationStoreResult<T> = {
31
+ readonly ok: true;
32
+ readonly value: T;
33
+ readonly warnings?: readonly string[];
34
+ } | {
35
+ readonly ok: false;
36
+ readonly error: ObservationStoreError;
37
+ };
38
+ export interface ObservationStore {
39
+ loadLatest(sourceId: string): Promise<ObservationStoreResult<StoredProposalObservationV1 | null>>;
40
+ commit(input: ProposalObservationRecordInput, expectedPriorId: string | null): Promise<ObservationStoreResult<StoredProposalObservationV1>>;
41
+ }
42
+ export interface ObservationStoreFaults {
43
+ readonly beforeSerialize?: () => void;
44
+ readonly beforeTempWrite?: (kind: "record" | "pointer") => void;
45
+ readonly beforeFileSync?: (kind: "record" | "pointer") => void;
46
+ readonly beforeRecordRename?: () => void;
47
+ readonly beforePointerRename?: () => void;
48
+ readonly beforeDirectorySync?: (kind: "record" | "pointer") => void;
49
+ readonly beforePrune?: () => void;
50
+ }
51
+ export interface CreateObservationStoreOptions {
52
+ readonly root?: string;
53
+ readonly faults?: ObservationStoreFaults;
54
+ }
55
+ export declare function createObservationStore(options?: CreateObservationStoreOptions): ObservationStore;
@@ -0,0 +1,241 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, readdir, rename, rm, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ function sourceKey(sourceId) {
5
+ return `${encodeURIComponent(sourceId).replaceAll("%", "_").slice(0, 48)}-${createHash("sha256").update(sourceId).digest("hex").slice(0, 16)}`;
6
+ }
7
+ function stable(value) {
8
+ if (Array.isArray(value))
9
+ return value.map(stable);
10
+ if (value && typeof value === "object")
11
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, stable(item)]));
12
+ return value;
13
+ }
14
+ function canonical(value) { return `${JSON.stringify(stable(value))}\n`; }
15
+ function digest(body) { return createHash("sha256").update(canonical(body)).digest("hex"); }
16
+ function buildRecord(input) {
17
+ try {
18
+ const { observation, check } = input;
19
+ if (!observation || typeof observation.sourceId !== "string" || observation.sourceId === "" || typeof observation.snapshotRef !== "string" || observation.snapshotRef === "" || typeof observation.observedAt !== "string" || observation.observedAt === "" || !Array.isArray(observation.proposals) || observation.proposals.some((proposal) => !validProposal(proposal))) {
20
+ return { ok: false, error: { kind: "invalid-input", message: "Current proposal observation is malformed" } };
21
+ }
22
+ if (!check || check.currentSnapshotRef !== observation.snapshotRef || typeof check.checkedAt !== "string" || (check.resultKind !== "changed" && check.resultKind !== "unchanged-hash") || typeof input.recordedAt !== "string") {
23
+ return { ok: false, error: { kind: "invalid-input", message: "Check anchor must match the current observation snapshot" } };
24
+ }
25
+ const proposals = [...observation.proposals].sort((a, b) => canonical(a).localeCompare(canonical(b)));
26
+ const body = { version: 1, sourceKey: sourceKey(observation.sourceId), sourceId: observation.sourceId, snapshotRef: observation.snapshotRef, observedAt: observation.observedAt, recordedAt: input.recordedAt, check, proposals };
27
+ try {
28
+ return { ok: true, value: { ...body, observationId: digest(body) } };
29
+ }
30
+ catch (cause) {
31
+ return { ok: false, error: { kind: "invalid-input", message: "Observation could not be serialized", cause } };
32
+ }
33
+ }
34
+ catch (cause) {
35
+ return { ok: false, error: { kind: "invalid-input", message: "Current proposal observation could not be inspected", cause } };
36
+ }
37
+ }
38
+ function validProposal(value) {
39
+ if (!value || typeof value !== "object" || Array.isArray(value))
40
+ return false;
41
+ const item = value;
42
+ if (typeof item.fieldPath !== "string" || item.fieldPath === "" || typeof item.confidence !== "number" || !Number.isFinite(item.confidence) || typeof item.extractor !== "string" || item.extractor === "")
43
+ return false;
44
+ if (item.pathIndices !== undefined && (!Array.isArray(item.pathIndices) || item.pathIndices.some((part) => !Number.isSafeInteger(part) || part < 0)))
45
+ return false;
46
+ if (!item.provenance || typeof item.provenance !== "object" || Array.isArray(item.provenance))
47
+ return false;
48
+ const provenance = item.provenance;
49
+ return typeof provenance.locator === "string" && provenance.locator !== "" && typeof provenance.excerpt === "string";
50
+ }
51
+ function validate(value, expectedSourceId) {
52
+ if (!value || typeof value !== "object" || Array.isArray(value))
53
+ return { ok: false, error: { kind: "corrupt-state", message: "Stored observation is not an object" } };
54
+ const item = value;
55
+ if (item.version !== 1 || item.sourceId !== expectedSourceId || item.sourceKey !== sourceKey(expectedSourceId) || typeof item.observationId !== "string" || !/^[a-f0-9]{64}$/.test(item.observationId) || typeof item.snapshotRef !== "string" || item.snapshotRef === "" || typeof item.observedAt !== "string" || item.observedAt === "" || typeof item.recordedAt !== "string" || item.recordedAt === "" || !Array.isArray(item.proposals) || item.proposals.some((proposal) => !validProposal(proposal)) || !item.check || typeof item.check !== "object" || typeof item.check.checkedAt !== "string" || item.check.checkedAt === "" || (item.check.resultKind !== "changed" && item.check.resultKind !== "unchanged-hash") || item.check.currentSnapshotRef !== item.snapshotRef) {
56
+ return { ok: false, error: { kind: "corrupt-state", message: "Stored observation schema or continuity is invalid" } };
57
+ }
58
+ const { observationId, ...body } = item;
59
+ try {
60
+ if (digest(body) !== observationId)
61
+ return { ok: false, error: { kind: "corrupt-state", message: "Stored observation digest does not match its body" } };
62
+ }
63
+ catch (cause) {
64
+ return { ok: false, error: { kind: "corrupt-state", message: "Stored observation is not serializable", cause } };
65
+ }
66
+ return { ok: true, value: item };
67
+ }
68
+ async function rejectSymlink(file, allowMissing = true) {
69
+ try {
70
+ if ((await lstat(file)).isSymbolicLink())
71
+ throw new Error(`Refusing symbolic link: ${file}`);
72
+ }
73
+ catch (cause) {
74
+ if (allowMissing && cause.code === "ENOENT")
75
+ return;
76
+ throw cause;
77
+ }
78
+ }
79
+ async function syncDirectory(dir, fault) { fault?.(); const handle = await open(dir, "r"); try {
80
+ await handle.sync();
81
+ }
82
+ finally {
83
+ await handle.close();
84
+ } }
85
+ async function atomicWrite(file, bytes, kind, faults) {
86
+ const temp = `${file}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
87
+ await rejectSymlink(file);
88
+ faults?.beforeTempWrite?.(kind);
89
+ const handle = await open(temp, "wx", 0o600);
90
+ try {
91
+ await handle.writeFile(bytes, "utf8");
92
+ faults?.beforeFileSync?.(kind);
93
+ await handle.sync();
94
+ await handle.close();
95
+ kind === "record" ? faults?.beforeRecordRename?.() : faults?.beforePointerRename?.();
96
+ await rename(temp, file);
97
+ try {
98
+ await syncDirectory(path.dirname(file), () => faults?.beforeDirectorySync?.(kind));
99
+ return { renamed: true };
100
+ }
101
+ catch (cause) {
102
+ return { renamed: true, durabilityWarning: `${kind} rename completed but directory fsync failed: ${cause instanceof Error ? cause.message : String(cause)}` };
103
+ }
104
+ }
105
+ catch (error) {
106
+ await handle.close().catch(() => undefined);
107
+ await unlink(temp).catch(() => undefined);
108
+ throw error;
109
+ }
110
+ }
111
+ export function createObservationStore(options = {}) {
112
+ const root = options.root ?? path.join(process.cwd(), ".kontourai", "lookout", "observations");
113
+ async function loadLatest(sourceId) {
114
+ try {
115
+ const dir = path.join(root, sourceKey(sourceId));
116
+ await rejectSymlink(root);
117
+ await rejectSymlink(dir);
118
+ let pointerText;
119
+ const pointerPath = path.join(dir, "latest.json");
120
+ try {
121
+ await rejectSymlink(pointerPath);
122
+ pointerText = await readFile(pointerPath, "utf8");
123
+ }
124
+ catch (cause) {
125
+ if (cause.code === "ENOENT")
126
+ return { ok: true, value: null };
127
+ throw cause;
128
+ }
129
+ const pointer = JSON.parse(pointerText);
130
+ if (pointer.version !== 1 || pointer.sourceId !== sourceId || typeof pointer.observationId !== "string" || !/^[a-f0-9]{64}$/.test(pointer.observationId))
131
+ return { ok: false, error: { kind: "corrupt-state", message: "Latest pointer is invalid" } };
132
+ const recordPath = path.join(dir, `${pointer.observationId}.json`);
133
+ await rejectSymlink(recordPath);
134
+ const parsed = JSON.parse(await readFile(recordPath, "utf8"));
135
+ const checked = validate(parsed, sourceId);
136
+ if (!checked.ok)
137
+ return checked;
138
+ if (checked.value.observationId !== pointer.observationId)
139
+ return { ok: false, error: { kind: "corrupt-state", message: "Latest pointer does not match the record" } };
140
+ return checked;
141
+ }
142
+ catch (cause) {
143
+ return { ok: false, error: { kind: "io-error", message: "Could not load latest observation", cause } };
144
+ }
145
+ }
146
+ async function commit(input, expectedPriorId) {
147
+ let made;
148
+ try {
149
+ options.faults?.beforeSerialize?.();
150
+ made = buildRecord(input);
151
+ }
152
+ catch (cause) {
153
+ return { ok: false, error: { kind: "invalid-input", message: "Observation serialization failed", cause } };
154
+ }
155
+ if (!made.ok)
156
+ return made;
157
+ const record = made.value;
158
+ const dir = path.join(root, record.sourceKey);
159
+ const lockPath = path.join(dir, ".lock");
160
+ try {
161
+ await rejectSymlink(root);
162
+ await mkdir(root, { recursive: true });
163
+ await rejectSymlink(root, false);
164
+ await rejectSymlink(dir);
165
+ await mkdir(dir, { recursive: true });
166
+ await rejectSymlink(dir, false);
167
+ await rejectSymlink(lockPath);
168
+ }
169
+ catch (cause) {
170
+ return { ok: false, error: { kind: "io-error", message: "Could not prepare observation directory", cause } };
171
+ }
172
+ let lock;
173
+ try {
174
+ lock = await open(lockPath, "wx", 0o600);
175
+ }
176
+ catch (cause) {
177
+ return cause.code === "EEXIST" ? { ok: false, error: { kind: "continuity-conflict", message: "Another writer holds the source lock", cause } } : { ok: false, error: { kind: "io-error", message: "Could not acquire source lock", cause } };
178
+ }
179
+ const recordPath = path.join(dir, `${record.observationId}.json`);
180
+ let createdRecord = false;
181
+ let pointerCommitted = false;
182
+ try {
183
+ const current = await loadLatest(record.sourceId);
184
+ if (!current.ok)
185
+ return current;
186
+ if ((current.value?.observationId ?? null) !== expectedPriorId)
187
+ return { ok: false, error: { kind: "continuity-conflict", message: "Latest observation changed before commit" } };
188
+ const recordBytes = canonical(record);
189
+ try {
190
+ await rejectSymlink(recordPath);
191
+ const existing = await readFile(recordPath, "utf8");
192
+ if (existing !== recordBytes)
193
+ return { ok: false, error: { kind: "corrupt-state", message: "Digest-addressed record already exists with different bytes" } };
194
+ }
195
+ catch (cause) {
196
+ if (cause.code !== "ENOENT")
197
+ throw cause;
198
+ const installed = await atomicWrite(recordPath, recordBytes, "record", options.faults);
199
+ createdRecord = installed.renamed;
200
+ if (installed.durabilityWarning) {
201
+ await rm(recordPath, { force: true });
202
+ await syncDirectory(dir);
203
+ createdRecord = false;
204
+ return { ok: false, error: { kind: "io-error", message: installed.durabilityWarning } };
205
+ }
206
+ }
207
+ const pointer = await atomicWrite(path.join(dir, "latest.json"), canonical({ version: 1, sourceId: record.sourceId, observationId: record.observationId }), "pointer", options.faults);
208
+ pointerCommitted = pointer.renamed;
209
+ const warnings = pointer.durabilityWarning ? [pointer.durabilityWarning] : [];
210
+ try {
211
+ options.faults?.beforePrune?.();
212
+ const records = (await readdir(dir)).filter((name) => /^[a-f0-9]{64}\.json$/.test(name));
213
+ const inspected = await Promise.all(records.map(async (name) => ({ name, text: await readFile(path.join(dir, name), "utf8") })));
214
+ const valid = inspected.map(({ name, text }) => { try {
215
+ const checked = validate(JSON.parse(text), record.sourceId);
216
+ return checked.ok ? { name, recordedAt: checked.value.recordedAt, id: checked.value.observationId } : null;
217
+ }
218
+ catch {
219
+ return null;
220
+ } }).filter((item) => item !== null);
221
+ const preserve = new Set([record.observationId, expectedPriorId].filter((item) => item !== null));
222
+ const extras = valid.filter((item) => !preserve.has(item.id)).sort((a, b) => b.recordedAt.localeCompare(a.recordedAt) || b.id.localeCompare(a.id));
223
+ await Promise.all(extras.map((item) => rm(path.join(dir, item.name), { force: true })));
224
+ }
225
+ catch (cause) {
226
+ warnings.push(`Observation committed but retention cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`);
227
+ }
228
+ return { ok: true, value: record, ...(warnings.length ? { warnings } : {}) };
229
+ }
230
+ catch (cause) {
231
+ if (createdRecord && !pointerCommitted)
232
+ await rm(recordPath, { force: true }).catch(() => undefined);
233
+ return { ok: false, error: { kind: "io-error", message: "Could not commit observation", cause } };
234
+ }
235
+ finally {
236
+ await lock.close().catch(() => undefined);
237
+ await rm(lockPath, { force: true }).catch(() => undefined);
238
+ }
239
+ }
240
+ return { loadLatest, commit };
241
+ }
@@ -0,0 +1,80 @@
1
+ import type { ExtractionProposal } from "@kontourai/traverse";
2
+ import { type DiffResult, type IdentityResult } from "./canonical-value.js";
3
+ declare const proposalIdentityBrand: unique symbol;
4
+ export type ProposalIdentity = string & {
5
+ readonly [proposalIdentityBrand]: "ProposalIdentity";
6
+ };
7
+ export interface ProposalSetObservation {
8
+ readonly sourceId: string;
9
+ readonly snapshotRef: string;
10
+ readonly observedAt: string;
11
+ readonly proposals: readonly ExtractionProposal[];
12
+ }
13
+ export interface ProposalEvidence {
14
+ readonly sourceId: string;
15
+ readonly snapshotRef: string;
16
+ readonly observedAt: string;
17
+ readonly entityKey: string;
18
+ readonly fieldKey: string;
19
+ readonly value: unknown;
20
+ readonly confidence: number;
21
+ readonly provenance: ExtractionProposal["provenance"];
22
+ readonly extractor: string;
23
+ readonly fieldPath: string;
24
+ readonly pathIndices?: readonly number[];
25
+ }
26
+ export type FieldChangeKind = "value-populated" | "value-updated" | "items-added" | "items-removed" | "value-replaced";
27
+ export interface NewEntityAppearedEvent {
28
+ readonly kind: "new-entity-appeared";
29
+ readonly entityKey: string;
30
+ readonly current: readonly ProposalEvidence[];
31
+ }
32
+ interface FieldChangedEventCommon {
33
+ readonly kind: "field-changed";
34
+ readonly entityKey: string;
35
+ readonly fieldKey: string;
36
+ readonly changeKind: FieldChangeKind;
37
+ }
38
+ export type FieldChangedEvent = FieldChangedEventCommon & ({
39
+ readonly prior: ProposalEvidence;
40
+ readonly current: ProposalEvidence;
41
+ } | {
42
+ readonly prior: ProposalEvidence;
43
+ readonly current?: never;
44
+ } | {
45
+ readonly prior?: never;
46
+ readonly current: ProposalEvidence;
47
+ });
48
+ export type ProposalDiffEvent = NewEntityAppearedEvent | FieldChangedEvent;
49
+ export interface ProposalOccurrencePair {
50
+ readonly prior: ExtractionProposal;
51
+ readonly current: ExtractionProposal;
52
+ }
53
+ export interface ProvenanceChangeFact {
54
+ readonly entityKey: string;
55
+ readonly fieldKey: string;
56
+ readonly prior: ProposalEvidence;
57
+ readonly current: ProposalEvidence;
58
+ }
59
+ export interface ProposalSetFacts {
60
+ readonly retainedProposalOccurrences: readonly ProposalOccurrencePair[];
61
+ readonly addedProposalOccurrences: readonly ExtractionProposal[];
62
+ readonly removedProposalOccurrences: readonly ExtractionProposal[];
63
+ readonly provenanceChanges: readonly ProvenanceChangeFact[];
64
+ readonly removedEntities: readonly string[];
65
+ }
66
+ export interface ProposalSetDiff {
67
+ readonly events: readonly ProposalDiffEvent[];
68
+ readonly facts: ProposalSetFacts;
69
+ }
70
+ export interface ProposalSetDiffInput<E> {
71
+ readonly prior: ProposalSetObservation;
72
+ readonly current: ProposalSetObservation;
73
+ readonly selectEntities: (observation: ProposalSetObservation) => readonly E[];
74
+ readonly entityIdentity: (entity: E) => string | IdentityResult;
75
+ readonly proposalsFor: (entity: E) => readonly ExtractionProposal[];
76
+ readonly fieldIdentity: (entity: E, proposal: ExtractionProposal) => string | IdentityResult;
77
+ }
78
+ export declare function extractionProposalIdentity(proposal: ExtractionProposal): IdentityResult<ProposalIdentity>;
79
+ export declare function diffProposalSets<E>(input: ProposalSetDiffInput<E>): DiffResult<ProposalSetDiff>;
80
+ export {};