@kontourai/lookout 0.5.0 → 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.
@@ -13,6 +13,8 @@ export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-
13
13
  export type { ResolveLookoutSnapshotOptions } from "./snapshot-store.js";
14
14
  export { admitProposalObservation } from "./observation-admission.js";
15
15
  export type { AdmitProposalObservationInput, AdmittedProposalObservation, AdmittedSnapshotIdentity, ObservationAdmissionError, ObservationAdmissionErrorKind, ObservationAdmissionResult } from "./observation-admission.js";
16
+ export { admitSourceCapture, admitSourceCheck } from "./source-admission.js";
17
+ export type { AdmittedSourceCapture, AdmittedSourceCheck, SourceAdmissionError, SourceAdmissionResult } from "./source-admission.js";
16
18
  export type { SnapshotSourceRefResolution } from "@kontourai/forage/fetch";
17
19
  export { canonicalValueKey } from "./canonical-value.js";
18
20
  export type { CanonicalValueKey, CanonicalValueFormatVersion, CanonicalValueResult, DiffKernelError, DiffKernelErrorKind, DiffResult, IdentityResult, } from "./canonical-value.js";
@@ -21,7 +23,7 @@ export type { KeyedMultisetFacts, KeyedMultisetOptions, StructuralComparison, St
21
23
  export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
22
24
  export type { FieldChangedEvent, FieldChangeKind, NewEntityAppearedEvent, ProposalDiffEvent, ProposalEvidence, ProposalIdentity, ProposalOccurrencePair, ProposalSetDiff, ProposalSetDiffInput, ProposalSetFacts, ProposalSetObservation, ProvenanceChangeFact, } from "./proposal-diff.js";
23
25
  export { createObservationStore } from "./observation-store.js";
24
- 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";
25
27
  export { createDriftEmitter } from "./drift-emission.js";
26
28
  export type { BaselineEstablishedFact, CreateDriftEmitterOptions, DriftEmitter, DriftError, DriftErrorKind, DriftFact, DriftResult, DriftSuccess, EmitDriftInput } from "./drift-emission.js";
27
29
  export { checkSchemaCoverage } from "./coverage.js";
package/dist/src/index.js CHANGED
@@ -5,6 +5,7 @@ export { loadRegistry, LookoutRegistry, parseRegistry, RegistryValidationError,
5
5
  export { inMemorySourceStore } from "./source-store.js";
6
6
  export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
7
7
  export { admitProposalObservation } from "./observation-admission.js";
8
+ export { admitSourceCapture, admitSourceCheck } from "./source-admission.js";
8
9
  export { canonicalValueKey } from "./canonical-value.js";
9
10
  export { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
10
11
  export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
@@ -30,6 +30,15 @@ export type ObservationAdmissionResult = {
30
30
  readonly ok: false;
31
31
  readonly error: ObservationAdmissionError;
32
32
  };
33
+ /** A stable exact-reader capability, captured before asynchronous admission begins. */
34
+ export declare function captureExactSnapshotReader(store: SnapshotStore): SnapshotStore | null;
35
+ export type SnapshotSourceBinding = "url-binding" | "redirect-binding";
36
+ /**
37
+ * Bind an authenticated snapshot to a registry source without exposing its
38
+ * body. Historical direct captures can predate a registry URL change, but a
39
+ * legacy reference cannot authenticate any redirect capture.
40
+ */
41
+ export declare function snapshotSourceBinding(registeredUrl: string, finalUrl: string, redirects: readonly string[] | undefined, integrity: "snapshot-envelope" | "body-and-identity", current: boolean): SnapshotSourceBinding | null;
33
42
  export interface AdmitProposalObservationInput {
34
43
  readonly source: LookoutSource;
35
44
  readonly current: ProposalSetObservation;
@@ -45,3 +54,6 @@ export interface AdmitProposalObservationInput {
45
54
  * snapshot bodies nor reads/writes the observation store.
46
55
  */
47
56
  export declare function admitProposalObservation(input: AdmitProposalObservationInput): Promise<ObservationAdmissionResult>;
57
+ export declare function normalizedHttpUrl(value: string): string | null;
58
+ /** Forage records visited redirect URLs before the final URL. */
59
+ export declare function validRedirectChain(redirects: readonly string[], finalUrl: string): boolean;
@@ -1,4 +1,32 @@
1
1
  import { resolveLookoutSnapshot } from "./snapshot-store.js";
2
+ /** A stable exact-reader capability, captured before asynchronous admission begins. */
3
+ export function captureExactSnapshotReader(store) {
4
+ try {
5
+ if (!store || typeof store !== "object" || typeof store.findExact !== "function")
6
+ return null;
7
+ const findExact = store.findExact.bind(store);
8
+ return { findExact };
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ /**
15
+ * Bind an authenticated snapshot to a registry source without exposing its
16
+ * body. Historical direct captures can predate a registry URL change, but a
17
+ * legacy reference cannot authenticate any redirect capture.
18
+ */
19
+ export function snapshotSourceBinding(registeredUrl, finalUrl, redirects, integrity, current) {
20
+ const registered = normalizedHttpUrl(registeredUrl);
21
+ const final = normalizedHttpUrl(finalUrl);
22
+ if (registered === null || final === null)
23
+ return "url-binding";
24
+ if (!redirects?.length)
25
+ return !current || final === registered ? null : "url-binding";
26
+ if (integrity !== "snapshot-envelope" || !validRedirectChain(redirects, finalUrl))
27
+ return "redirect-binding";
28
+ return !current || normalizedHttpUrl(redirects[0]) === registered ? null : "redirect-binding";
29
+ }
2
30
  /**
3
31
  * Authenticate proposal-observation snapshot references before they can be
4
32
  * diffed or committed. It is deliberately metadata-only: it neither exposes
@@ -72,13 +100,14 @@ function captureInvocation(input) {
72
100
  if (!input || typeof input !== "object")
73
101
  return null;
74
102
  const image = structuredClone({ source: input.source, current: input.current, check: input.check, prior: input.prior });
75
- return { ...image, snapshotStore: input.snapshotStore };
103
+ const snapshotStore = captureExactSnapshotReader(input.snapshotStore);
104
+ return snapshotStore === null ? null : { ...image, snapshotStore };
76
105
  }
77
106
  catch {
78
107
  return null;
79
108
  }
80
109
  }
81
- function normalizedHttpUrl(value) {
110
+ export function normalizedHttpUrl(value) {
82
111
  try {
83
112
  const url = new URL(value);
84
113
  if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password)
@@ -91,19 +120,15 @@ function normalizedHttpUrl(value) {
91
120
  }
92
121
  }
93
122
  function bindCurrentSnapshot(finalUrl, redirects, integrity, registeredUrl) {
94
- const normalizedFinal = normalizedHttpUrl(finalUrl);
95
- if (normalizedFinal === null)
96
- return failure("insufficient-binding", "url-binding", "Snapshot URL is not an admissible HTTP URL");
97
- if (!redirects?.length)
98
- return normalizedFinal === registeredUrl ? null : failure("insufficient-binding", "url-binding", "Snapshot URL is not bound to the registered source");
99
- if (integrity !== "snapshot-envelope")
100
- return failure("insufficient-binding", "redirect-binding", "Legacy snapshot references cannot authenticate redirect captures");
101
- if (normalizedHttpUrl(redirects[0]) !== registeredUrl || !validRedirectChain(redirects, finalUrl))
123
+ const binding = snapshotSourceBinding(registeredUrl, finalUrl, redirects, integrity, true);
124
+ if (binding === "url-binding")
125
+ return failure("insufficient-binding", "url-binding", "Snapshot URL is not bound to the registered source");
126
+ if (binding === "redirect-binding")
102
127
  return failure("insufficient-binding", "redirect-binding", "Snapshot redirect capture is not admissibly bound");
103
128
  return null;
104
129
  }
105
130
  /** Forage records visited redirect URLs before the final URL. */
106
- function validRedirectChain(redirects, finalUrl) {
131
+ export function validRedirectChain(redirects, finalUrl) {
107
132
  const urls = [...redirects, finalUrl].map(normalizedHttpUrl);
108
133
  if (urls.some((url) => url === null))
109
134
  return false;
@@ -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
  }
@@ -0,0 +1,37 @@
1
+ import type { SnapshotStore } from "@kontourai/forage";
2
+ import type { CheckResult } from "./check-result.js";
3
+ import { type AdmittedSnapshotIdentity } from "./observation-admission.js";
4
+ import { type LookoutSource } from "./registry.js";
5
+ export type SourceAdmissionError = {
6
+ readonly kind: "invalid-input" | "unresolved" | "insufficient-binding";
7
+ readonly message: string;
8
+ };
9
+ export type SourceAdmissionResult<T> = {
10
+ readonly ok: true;
11
+ readonly value: T;
12
+ } | {
13
+ readonly ok: false;
14
+ readonly error: SourceAdmissionError;
15
+ };
16
+ export interface AdmittedSourceCapture {
17
+ readonly capture: AdmittedSnapshotIdentity;
18
+ }
19
+ export interface AdmittedSourceCheck {
20
+ readonly prior: AdmittedSnapshotIdentity | null;
21
+ readonly current: AdmittedSnapshotIdentity;
22
+ readonly checkedAt: string;
23
+ readonly resultKind: Exclude<CheckResult["kind"], "error">;
24
+ }
25
+ /** Metadata-only capture admission; it neither fetches nor persists. */
26
+ export declare function admitSourceCapture(input: {
27
+ source: LookoutSource;
28
+ snapshotRef: string;
29
+ snapshotStore: SnapshotStore;
30
+ }): Promise<SourceAdmissionResult<AdmittedSourceCapture>>;
31
+ /** Admit a genuine CheckRunner outcome; this validates relationships, not HTTP provenance. */
32
+ export declare function admitSourceCheck(input: {
33
+ source: LookoutSource;
34
+ check: CheckResult;
35
+ expectedPriorSnapshotRef: string | null;
36
+ snapshotStore: SnapshotStore;
37
+ }): Promise<SourceAdmissionResult<AdmittedSourceCheck>>;
@@ -0,0 +1,138 @@
1
+ import { parseSnapshotSourceRef } from "@kontourai/forage/fetch";
2
+ import { captureExactSnapshotReader, normalizedHttpUrl, snapshotSourceBinding, } from "./observation-admission.js";
3
+ import { parseRegistry } from "./registry.js";
4
+ import { resolveLookoutSnapshot } from "./snapshot-store.js";
5
+ /** Metadata-only capture admission; it neither fetches nor persists. */
6
+ export async function admitSourceCapture(input) {
7
+ const captured = captureCapture(input);
8
+ if (!captured || !validSource(captured.source) || !canonicalRef(captured.snapshotRef))
9
+ return fail("invalid-input", "Capture admission input is malformed");
10
+ const value = await resolve(captured.source, captured.snapshotRef, captured.snapshotStore, true);
11
+ return value.ok ? { ok: true, value: { capture: value.value } } : value;
12
+ }
13
+ /** Admit a genuine CheckRunner outcome; this validates relationships, not HTTP provenance. */
14
+ export async function admitSourceCheck(input) {
15
+ const captured = captureCheck(input);
16
+ if (!captured || !validSource(captured.source) || !validCheck(captured.source, captured.check))
17
+ return fail("invalid-input", "Check admission input is malformed");
18
+ const check = captured.check;
19
+ if (check.kind === "error")
20
+ return fail("invalid-input", "An error result has no successful capture");
21
+ const refs = refsFor(check, captured.expectedPriorSnapshotRef);
22
+ // Validate every durable reference before the first exact lookup: admission
23
+ // never lets a malformed historical anchor cause partial capability I/O.
24
+ if (!refs || !canonicalRef(refs.current) || (refs.prior !== null && !canonicalRef(refs.prior)))
25
+ return fail("invalid-input", "Check references do not match the expected baseline");
26
+ const current = await resolve(captured.source, refs.current, captured.snapshotStore, true);
27
+ if (!current.ok)
28
+ return current;
29
+ const prior = refs.prior === null ? null : await resolve(captured.source, refs.prior, captured.snapshotStore, false);
30
+ if (prior !== null && !prior.ok)
31
+ return prior;
32
+ if (check.kind === "unchanged-hash" && prior !== null && prior.ok && (prior.value.bodyHash !== current.value.bodyHash || prior.value.url !== current.value.url))
33
+ return fail("insufficient-binding", "Same-hash result does not bind one resource capture");
34
+ if (check.kind === "changed" && check.changeBasis === "hash" && prior !== null && prior.ok && prior.value.bodyHash === current.value.bodyHash && prior.value.url === current.value.url)
35
+ return fail("insufficient-binding", "Changed result does not bind a changed capture");
36
+ return { ok: true, value: { prior: prior?.ok ? prior.value : null, current: current.value, checkedAt: check.checkedAt, resultKind: check.kind } };
37
+ }
38
+ function refsFor(check, expected) {
39
+ if (check.kind === "unchanged-304")
40
+ return expected !== null && check.snapshotRef === expected ? { prior: expected, current: check.snapshotRef } : null;
41
+ if (check.kind === "unchanged-hash")
42
+ return check.priorSnapshotRef === expected ? { prior: expected, current: check.currentSnapshotRef } : null;
43
+ return check.changeBasis === "initial"
44
+ ? expected === null ? { prior: null, current: check.currentSnapshotRef } : null
45
+ : check.priorSnapshotRef === expected && expected !== null ? { prior: expected, current: check.currentSnapshotRef } : null;
46
+ }
47
+ function validSource(source) {
48
+ try {
49
+ if (!source || typeof source !== "object" || normalizedHttpUrl(source.url) === null)
50
+ return false;
51
+ parseRegistry({ version: 1, sources: [source] });
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ function validCheck(source, check) {
59
+ if (!check || typeof check !== "object" || Array.isArray(check))
60
+ return false;
61
+ const common = () => check.sourceId === source.id && check.sourceUrl === source.url && validTimestamp(check.checkedAt) && validWarnings(check.warnings);
62
+ if (check.kind === "unchanged-304")
63
+ return closed(check, ["sourceId", "sourceUrl", "checkedAt", "warnings", "kind", "snapshotRef"]) && common() && typeof check.snapshotRef === "string";
64
+ if (check.kind === "unchanged-hash")
65
+ return closed(check, ["sourceId", "sourceUrl", "checkedAt", "warnings", "kind", "priorSnapshotRef", "currentSnapshotRef"]) && common() && typeof check.priorSnapshotRef === "string" && typeof check.currentSnapshotRef === "string";
66
+ if (check.kind === "changed")
67
+ return closed(check, ["sourceId", "sourceUrl", "checkedAt", "warnings", "kind", "priorSnapshotRef", "currentSnapshotRef", "changeBasis"]) && common() && typeof check.currentSnapshotRef === "string" && (check.changeBasis === "initial" || check.changeBasis === "hash") && (check.changeBasis === "initial" ? check.priorSnapshotRef === null : typeof check.priorSnapshotRef === "string");
68
+ return false;
69
+ }
70
+ function closed(value, keys) {
71
+ if (Object.getPrototypeOf(value) !== Object.prototype)
72
+ return false;
73
+ const actual = Object.keys(value);
74
+ return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
75
+ }
76
+ function validWarnings(value) {
77
+ if (!Array.isArray(value))
78
+ return false;
79
+ const keys = Object.keys(value);
80
+ if (keys.length !== value.length)
81
+ return false;
82
+ for (let index = 0; index < value.length; index++) {
83
+ if (!Object.hasOwn(value, index) || typeof value[index] !== "string")
84
+ return false;
85
+ }
86
+ return true;
87
+ }
88
+ function validTimestamp(value) {
89
+ if (typeof value !== "string")
90
+ return false;
91
+ const instant = new Date(value);
92
+ return !Number.isNaN(instant.valueOf()) && instant.toISOString() === value;
93
+ }
94
+ function canonicalRef(value) {
95
+ if (typeof value !== "string")
96
+ return false;
97
+ const parsed = parseSnapshotSourceRef(value);
98
+ if (!parsed || !/^[a-f0-9]{64}$/.test(parsed.bodyHash) || (parsed.snapshotDigest !== undefined && !/^[a-f0-9]{64}$/.test(parsed.snapshotDigest)))
99
+ return false;
100
+ const query = new URLSearchParams({ url: parsed.url, sha256: parsed.bodyHash, fetchedAt: parsed.fetchedAt });
101
+ if (parsed.snapshotDigest !== undefined)
102
+ query.set("snapshotSha256", parsed.snapshotDigest);
103
+ return value === `forage-snapshot:${encodeURIComponent(parsed.sourceId)}?${query.toString()}`;
104
+ }
105
+ function captureCapture(input) {
106
+ try {
107
+ if (!input || typeof input !== "object")
108
+ return null;
109
+ const snapshotStore = captureExactSnapshotReader(input.snapshotStore);
110
+ return snapshotStore === null ? null : { ...structuredClone({ source: input.source, snapshotRef: input.snapshotRef }), snapshotStore };
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ function captureCheck(input) {
117
+ try {
118
+ if (!input || typeof input !== "object")
119
+ return null;
120
+ const snapshotStore = captureExactSnapshotReader(input.snapshotStore);
121
+ return snapshotStore === null ? null : { ...structuredClone({ source: input.source, check: input.check, expectedPriorSnapshotRef: input.expectedPriorSnapshotRef }), snapshotStore };
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ async function resolve(source, ref, store, current) {
128
+ const resolved = await resolveLookoutSnapshot(ref, { store });
129
+ if (!resolved.ok || resolved.snapshot.sourceId !== source.id)
130
+ return fail("unresolved", "Snapshot reference could not be admitted");
131
+ const binding = snapshotSourceBinding(source.url, resolved.snapshot.url, resolved.snapshot.redirects, resolved.integrity, current);
132
+ if (binding !== null)
133
+ return fail("insufficient-binding", binding === "redirect-binding" ? "Snapshot redirect capture is not admissibly bound" : "Snapshot URL is not bound to the registered source");
134
+ return { ok: true, value: { sourceId: source.id, snapshotRef: ref, url: resolved.snapshot.url, bodyHash: resolved.snapshot.bodyHash, fetchedAt: resolved.snapshot.fetchedAt, ...(resolved.reference.snapshotDigest ? { snapshotDigest: resolved.reference.snapshotDigest } : {}), integrity: resolved.integrity } };
135
+ }
136
+ function fail(kind, message) {
137
+ return { ok: false, error: { kind, message } };
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/lookout",
3
- "version": "0.5.0",
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",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@kontourai/datum": "0.7.0",
50
- "@kontourai/forage": "0.6.0",
50
+ "@kontourai/forage": "0.6.1",
51
51
  "@kontourai/traverse": "0.25.1"
52
52
  },
53
53
  "devDependencies": {