@kontourai/lookout 0.5.1 → 0.5.2

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/README.md CHANGED
@@ -256,6 +256,23 @@ latest two valid observations. The emitted `events` are already
256
256
  Hachure-evidence-shaped; a consumer or product lifts them into a Hachure
257
257
  `TrustBundle` with `@kontourai/surface`'s `TrustBundleBuilder` when it wants
258
258
  Surface projection — lookout itself authors nothing in the trust layer.
259
+
260
+ ### Verified proposal head witnesses
261
+
262
+ The concrete filesystem observation store additionally exposes
263
+ `readVerifiedHead(sourceId, limits)` and `compareHeadWitness(witness, limits)`.
264
+ The first returns an authenticated observation id and snapshot reference with an
265
+ opaque, versioned witness; the second reads only bounded head metadata and
266
+ returns `matches`, `changed`, `missing`, `unavailable`, `corrupt`, or
267
+ `unsupported`. It never reads proposal bytes during comparison.
268
+
269
+ A witness is an as-of fence, not a freshness guarantee: it binds the source,
270
+ current head, store scope, pointer, and filesystem metadata observed at capture.
271
+ It detects ordinary older writers without requiring them to write a sidecar.
272
+ Restarting the same store preserves a witness; copying or restoring the store
273
+ can invalidate it. It does not prove proposal-body bit-rot or provide an atomic
274
+ transaction across stores. Limits are finite hard caps on enumeration, pointer,
275
+ and authenticated-record reads; callers may only narrow the defaults.
259
276
  Store paths refuse symbolic links. An existing source lock is not automatically
260
277
  broken: after confirming no writer is active, an operator may remove an
261
278
  abandoned `.lock` file and retry.
@@ -23,7 +23,7 @@ export type { KeyedMultisetFacts, KeyedMultisetOptions, StructuralComparison, St
23
23
  export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
24
24
  export type { FieldChangedEvent, FieldChangeKind, NewEntityAppearedEvent, ProposalDiffEvent, ProposalEvidence, ProposalIdentity, ProposalOccurrencePair, ProposalSetDiff, ProposalSetDiffInput, ProposalSetFacts, ProposalSetObservation, ProvenanceChangeFact, } from "./proposal-diff.js";
25
25
  export { createObservationStore } from "./observation-store.js";
26
- export type { CreateObservationStoreOptions, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalObservationRecordInput, StoredProposalObservationV1 } from "./observation-store.js";
26
+ export type { CreateObservationStoreOptions, HeadWitnessComparison, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalHeadWitnessV1, ProposalObservationRecordInput, StoredProposalObservationV1, VerifiedHeadLimits, VerifiedHeadObservationStore, VerifiedHeadRead } from "./observation-store.js";
27
27
  export { createDriftEmitter } from "./drift-emission.js";
28
28
  export type { BaselineEstablishedFact, CreateDriftEmitterOptions, DriftEmitter, DriftError, DriftErrorKind, DriftFact, DriftResult, DriftSuccess, EmitDriftInput } from "./drift-emission.js";
29
29
  export { checkSchemaCoverage } from "./coverage.js";
@@ -39,6 +39,42 @@ export interface ObservationStore {
39
39
  loadLatest(sourceId: string): Promise<ObservationStoreResult<StoredProposalObservationV1 | null>>;
40
40
  commit(input: ProposalObservationRecordInput, expectedPriorId: string | null): Promise<ObservationStoreResult<StoredProposalObservationV1>>;
41
41
  }
42
+ /** Finite caller-controlled ceilings for a verified proposal-head read. */
43
+ export interface VerifiedHeadLimits {
44
+ readonly maxEntries?: number;
45
+ readonly maxIndexBytes?: number;
46
+ readonly maxPointerBytes?: number;
47
+ readonly maxRecordBytes?: number;
48
+ }
49
+ export interface ProposalHeadWitnessV1 {
50
+ readonly kind: "lookout.proposal-head-witness/v1";
51
+ readonly version: 1;
52
+ readonly sourceId: string;
53
+ readonly observationId: string;
54
+ /** Opaque binding of this store scope and its bounded metadata-as-of state. */
55
+ readonly token: string;
56
+ }
57
+ export type VerifiedHeadRead = {
58
+ readonly kind: "verified";
59
+ readonly sourceId: string;
60
+ readonly observationId: string;
61
+ readonly snapshotRef: string;
62
+ readonly witness: ProposalHeadWitnessV1;
63
+ } | {
64
+ readonly kind: "missing" | "unavailable" | "corrupt" | "unsupported";
65
+ };
66
+ export type HeadWitnessComparison = {
67
+ readonly kind: "matches";
68
+ readonly sourceId: string;
69
+ readonly observationId: string;
70
+ } | {
71
+ readonly kind: "changed" | "missing" | "unavailable" | "corrupt" | "unsupported";
72
+ };
73
+ /** Additive concrete-store capability. Generic ObservationStore implementations need not provide it. */
74
+ export interface VerifiedHeadObservationStore {
75
+ readVerifiedHead(sourceId: string, limits?: VerifiedHeadLimits): Promise<VerifiedHeadRead>;
76
+ compareHeadWitness(witness: ProposalHeadWitnessV1, limits?: VerifiedHeadLimits): Promise<HeadWitnessComparison>;
77
+ }
42
78
  export interface ObservationStoreFaults {
43
79
  readonly beforeSerialize?: () => void;
44
80
  readonly beforeTempWrite?: (kind: "record" | "pointer") => void;
@@ -47,9 +83,13 @@ export interface ObservationStoreFaults {
47
83
  readonly beforePointerRename?: () => void;
48
84
  readonly beforeDirectorySync?: (kind: "record" | "pointer") => void;
49
85
  readonly beforePrune?: () => void;
86
+ /** Test-only fence hooks; production callers do not supply them. */
87
+ readonly beforeHeadStrongLoad?: () => void;
88
+ readonly beforeHeadRecordRead?: () => void;
89
+ readonly beforeHeadAfterFence?: () => void;
50
90
  }
51
91
  export interface CreateObservationStoreOptions {
52
92
  readonly root?: string;
53
93
  readonly faults?: ObservationStoreFaults;
54
94
  }
55
- export declare function createObservationStore(options?: CreateObservationStoreOptions): ObservationStore;
95
+ export declare function createObservationStore(options?: CreateObservationStoreOptions): ObservationStore & VerifiedHeadObservationStore;
@@ -1,6 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lstat, mkdir, open, readFile, readdir, rename, rm, unlink } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, unlink } from "node:fs/promises";
3
4
  import path from "node:path";
5
+ import { types } from "node:util";
6
+ const VERIFIED_HEAD_MAX = Object.freeze({ maxEntries: 8, maxIndexBytes: 8 * 1024, maxPointerBytes: 8 * 1024, maxRecordBytes: 1024 * 1024 });
4
7
  function sourceKey(sourceId) {
5
8
  return `${encodeURIComponent(sourceId).replaceAll("%", "_").slice(0, 48)}-${createHash("sha256").update(sourceId).digest("hex").slice(0, 16)}`;
6
9
  }
@@ -13,6 +16,200 @@ function stable(value) {
13
16
  }
14
17
  function canonical(value) { return `${JSON.stringify(stable(value))}\n`; }
15
18
  function digest(body) { return createHash("sha256").update(canonical(body)).digest("hex"); }
19
+ function resolveHeadLimits(limits) {
20
+ try {
21
+ if (limits !== undefined && (!limits || typeof limits !== "object" || types.isProxy(limits) || Array.isArray(limits)))
22
+ return null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ let supplied;
28
+ try {
29
+ const descriptors = limits === undefined ? {} : Object.getOwnPropertyDescriptors(limits);
30
+ if (Object.keys(descriptors).some((key) => !Object.hasOwn(VERIFIED_HEAD_MAX, key) || descriptors[key]?.get || descriptors[key]?.set))
31
+ return null;
32
+ supplied = Object.fromEntries(Object.entries(descriptors).map(([key, descriptor]) => [key, descriptor.value]));
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ const resolved = { ...VERIFIED_HEAD_MAX };
38
+ for (const key of Object.keys(VERIFIED_HEAD_MAX)) {
39
+ const value = supplied[key];
40
+ if (value === undefined)
41
+ continue;
42
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > VERIFIED_HEAD_MAX[key])
43
+ return null;
44
+ resolved[key] = value;
45
+ }
46
+ return resolved;
47
+ }
48
+ async function lstatHead(file) { return lstat(file, { bigint: true }); }
49
+ function identity(stats) {
50
+ return { dev: String(stats.dev), ino: String(stats.ino), mode: String(stats.mode), size: String(stats.size), ctimeNs: String(stats.ctimeNs), mtimeNs: String(stats.mtimeNs) };
51
+ }
52
+ function sameIdentity(left, right) { return canonical(left) === canonical(right); }
53
+ function headToken(rootRealpath, sourceId, metadata) {
54
+ return createHash("sha256").update(canonical({ kind: "lookout.proposal-head-witness/v1", version: 1, rootRealpath, sourceId, metadata })).digest("hex");
55
+ }
56
+ function errno(cause) { return cause?.code; }
57
+ function boundedSourceId(sourceId) {
58
+ return typeof sourceId === "string" && sourceId.length > 0 && sourceId.length <= 256 && Buffer.byteLength(sourceId) <= 512;
59
+ }
60
+ async function boundedText(file, maximum) {
61
+ let handle;
62
+ try {
63
+ const before = await lstatHead(file);
64
+ if (before.isSymbolicLink() || !before.isFile())
65
+ return { kind: "corrupt" };
66
+ if (before.size > BigInt(maximum))
67
+ return { kind: "unavailable" };
68
+ handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
69
+ const opened = await handle.stat({ bigint: true });
70
+ if (!opened.isFile() || !sameIdentity(identity(before), identity(opened)) || opened.size > BigInt(maximum))
71
+ return { kind: "unavailable" };
72
+ const buffer = Buffer.alloc(Number(opened.size) + 1);
73
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
74
+ const after = await handle.stat({ bigint: true });
75
+ if (!sameIdentity(identity(opened), identity(after)) || bytesRead !== Number(opened.size) || bytesRead > maximum)
76
+ return { kind: "unavailable" };
77
+ return { kind: "ok", text: buffer.subarray(0, bytesRead).toString("utf8"), identity: identity(after) };
78
+ }
79
+ catch (cause) {
80
+ return errno(cause) === "ENOENT" ? { kind: "unavailable" } : { kind: "unavailable" };
81
+ }
82
+ finally {
83
+ await handle?.close().catch(() => undefined);
84
+ }
85
+ }
86
+ async function inspectHead(root, sourceId, limits) {
87
+ if (!boundedSourceId(sourceId))
88
+ return { kind: "unsupported" };
89
+ let key;
90
+ try {
91
+ key = sourceKey(sourceId);
92
+ }
93
+ catch {
94
+ return { kind: "unsupported" };
95
+ }
96
+ let rootStats;
97
+ try {
98
+ rootStats = await lstatHead(root);
99
+ }
100
+ catch (cause) {
101
+ return errno(cause) === "ENOENT" ? { kind: "missing" } : { kind: "unavailable" };
102
+ }
103
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory())
104
+ return { kind: "corrupt" };
105
+ let rootRealpath;
106
+ try {
107
+ rootRealpath = await realpath(root);
108
+ }
109
+ catch {
110
+ return { kind: "unavailable" };
111
+ }
112
+ const dir = path.join(root, key);
113
+ let sourceStats;
114
+ try {
115
+ sourceStats = await lstatHead(dir);
116
+ }
117
+ catch (cause) {
118
+ return errno(cause) === "ENOENT" ? { kind: "missing" } : { kind: "unavailable" };
119
+ }
120
+ if (sourceStats.isSymbolicLink() || !sourceStats.isDirectory())
121
+ return { kind: "corrupt" };
122
+ const names = [];
123
+ let indexBytes = 0;
124
+ try {
125
+ const directory = await opendir(dir, { bufferSize: 1 });
126
+ try {
127
+ for await (const entry of directory) {
128
+ if (names.length >= limits.maxEntries)
129
+ return { kind: "unavailable" };
130
+ indexBytes += Buffer.byteLength(entry.name);
131
+ if (indexBytes > limits.maxIndexBytes)
132
+ return { kind: "unavailable" };
133
+ if (entry.name === ".lock" || entry.name.includes(".tmp-"))
134
+ return { kind: "unavailable" };
135
+ if (entry.name !== "latest.json" && !/^[a-f0-9]{64}\.json$/.test(entry.name))
136
+ return { kind: "corrupt" };
137
+ const entryStats = await lstatHead(path.join(dir, entry.name));
138
+ if (entryStats.isSymbolicLink() || !entryStats.isFile())
139
+ return { kind: "corrupt" };
140
+ names.push(entry.name);
141
+ }
142
+ }
143
+ finally {
144
+ await directory.close().catch(() => undefined);
145
+ }
146
+ }
147
+ catch {
148
+ return { kind: "unavailable" };
149
+ }
150
+ names.sort();
151
+ if (!names.includes("latest.json"))
152
+ return names.length === 0 ? { kind: "missing" } : { kind: "corrupt" };
153
+ const pointerRead = await boundedText(path.join(dir, "latest.json"), limits.maxPointerBytes);
154
+ if (pointerRead.kind !== "ok")
155
+ return pointerRead;
156
+ let pointer;
157
+ try {
158
+ pointer = JSON.parse(pointerRead.text);
159
+ }
160
+ catch {
161
+ return { kind: "corrupt" };
162
+ }
163
+ if (!pointer || typeof pointer !== "object" || Array.isArray(pointer) || pointer.version !== 1 || pointer.sourceId !== sourceId || typeof pointer.observationId !== "string" || !/^[a-f0-9]{64}$/.test(pointer.observationId))
164
+ return { kind: "corrupt" };
165
+ const recordName = `${pointer.observationId}.json`;
166
+ if (!names.includes(recordName))
167
+ return { kind: "corrupt" };
168
+ let recordStats;
169
+ try {
170
+ recordStats = await lstatHead(path.join(dir, recordName));
171
+ }
172
+ catch {
173
+ return { kind: "unavailable" };
174
+ }
175
+ if (recordStats.isSymbolicLink() || !recordStats.isFile())
176
+ return { kind: "corrupt" };
177
+ if (recordStats.size > BigInt(limits.maxRecordBytes))
178
+ return { kind: "unavailable" };
179
+ let finalRoot;
180
+ let finalSource;
181
+ try {
182
+ finalRoot = await lstatHead(root);
183
+ finalSource = await lstatHead(dir);
184
+ }
185
+ catch {
186
+ return { kind: "unavailable" };
187
+ }
188
+ if (!sameIdentity(identity(rootStats), identity(finalRoot)) || !sameIdentity(identity(sourceStats), identity(finalSource)))
189
+ return { kind: "unavailable" };
190
+ return { kind: "ready", metadata: { root: identity(rootStats), rootRealpath, source: identity(sourceStats), names, pointer: pointerRead.text, pointerIdentity: pointerRead.identity, observationId: pointer.observationId, record: identity(recordStats) } };
191
+ }
192
+ function snapshotWitness(value) {
193
+ try {
194
+ if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value))
195
+ return { kind: "corrupt" };
196
+ const descriptors = Object.getOwnPropertyDescriptors(value);
197
+ const keys = ["kind", "version", "sourceId", "observationId", "token"];
198
+ if (Object.keys(descriptors).length !== keys.length || keys.some((key) => !Object.hasOwn(descriptors, key) || descriptors[key]?.get || descriptors[key]?.set))
199
+ return { kind: "corrupt" };
200
+ const witness = Object.fromEntries(keys.map((key) => [key, descriptors[key].value]));
201
+ if (witness.kind !== "lookout.proposal-head-witness/v1" || typeof witness.version !== "number")
202
+ return { kind: "unsupported" };
203
+ if (witness.version !== 1)
204
+ return { kind: "unsupported" };
205
+ if (!boundedSourceId(witness.sourceId) || typeof witness.observationId !== "string" || !/^[a-f0-9]{64}$/.test(witness.observationId) || typeof witness.token !== "string" || !/^[a-f0-9]{64}$/.test(witness.token))
206
+ return { kind: "corrupt" };
207
+ return { kind: "ok", witness: { kind: witness.kind, version: witness.version, sourceId: witness.sourceId, observationId: witness.observationId, token: witness.token } };
208
+ }
209
+ catch {
210
+ return { kind: "corrupt" };
211
+ }
212
+ }
16
213
  function buildRecord(input) {
17
214
  try {
18
215
  const { observation, check } = input;
@@ -109,7 +306,7 @@ async function atomicWrite(file, bytes, kind, faults) {
109
306
  }
110
307
  }
111
308
  export function createObservationStore(options = {}) {
112
- const root = options.root ?? path.join(process.cwd(), ".kontourai", "lookout", "observations");
309
+ const root = path.resolve(options.root ?? path.join(process.cwd(), ".kontourai", "lookout", "observations"));
113
310
  async function loadLatest(sourceId) {
114
311
  try {
115
312
  const dir = path.join(root, sourceKey(sourceId));
@@ -237,5 +434,53 @@ export function createObservationStore(options = {}) {
237
434
  await rm(lockPath, { force: true }).catch(() => undefined);
238
435
  }
239
436
  }
240
- return { loadLatest, commit };
437
+ async function readVerifiedHead(sourceId, suppliedLimits) {
438
+ const limits = resolveHeadLimits(suppliedLimits);
439
+ if (limits === null)
440
+ return { kind: "unsupported" };
441
+ const before = await inspectHead(root, sourceId, limits);
442
+ if (before.kind !== "ready")
443
+ return before;
444
+ // This is the only body read in the witness API. It reuses the concrete store's
445
+ // authenticated record validator and is bracketed by metadata fences below.
446
+ options.faults?.beforeHeadStrongLoad?.();
447
+ options.faults?.beforeHeadRecordRead?.();
448
+ const record = await boundedText(path.join(root, sourceKey(sourceId), `${before.metadata.observationId}.json`), limits.maxRecordBytes);
449
+ if (record.kind !== "ok")
450
+ return record;
451
+ let loaded;
452
+ try {
453
+ loaded = validate(JSON.parse(record.text), sourceId);
454
+ }
455
+ catch {
456
+ return { kind: "corrupt" };
457
+ }
458
+ if (!loaded.ok || loaded.value.observationId !== before.metadata.observationId)
459
+ return { kind: "corrupt" };
460
+ options.faults?.beforeHeadAfterFence?.();
461
+ const after = await inspectHead(root, sourceId, limits);
462
+ if (after.kind !== "ready")
463
+ return after;
464
+ if (canonical(before.metadata) !== canonical(after.metadata))
465
+ return { kind: "unavailable" };
466
+ const token = headToken(before.metadata.rootRealpath, sourceId, before.metadata);
467
+ return { kind: "verified", sourceId, observationId: loaded.value.observationId, snapshotRef: loaded.value.snapshotRef, witness: { kind: "lookout.proposal-head-witness/v1", version: 1, sourceId, observationId: loaded.value.observationId, token } };
468
+ }
469
+ async function compareHeadWitness(witness, suppliedLimits) {
470
+ const captured = snapshotWitness(witness);
471
+ if (captured.kind !== "ok")
472
+ return captured;
473
+ const limits = resolveHeadLimits(suppliedLimits);
474
+ if (limits === null)
475
+ return { kind: "unsupported" };
476
+ const inspected = await inspectHead(root, captured.witness.sourceId, limits);
477
+ if (inspected.kind !== "ready")
478
+ return inspected;
479
+ if (inspected.metadata.observationId !== captured.witness.observationId)
480
+ return { kind: "changed" };
481
+ return headToken(inspected.metadata.rootRealpath, captured.witness.sourceId, inspected.metadata) === captured.witness.token
482
+ ? { kind: "matches", sourceId: captured.witness.sourceId, observationId: captured.witness.observationId }
483
+ : { kind: "changed" };
484
+ }
485
+ return { loadLatest, commit, readVerifiedHead, compareHeadWitness };
241
486
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/lookout",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "A small source registry and drift-check runner built on Forage snapshots.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",