@kontourai/lookout 0.4.0 → 0.5.1

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
@@ -228,7 +228,7 @@ item, or hard failure — is the consumer's call.
228
228
  ```
229
229
  lookout check <id> [--registry <path>] [--snapshot-root <path>]
230
230
  lookout check --all [--registry <path>] [--snapshot-root <path>]
231
- lookout emit-drift <id> --observation <path|-> [--registry <path>] [--observation-root <path>]
231
+ lookout emit-drift <id> --observation <path|-> [--registry <path>] [--observation-root <path>] [--snapshot-root <path>]
232
232
  ```
233
233
 
234
234
  - Emits **exactly one compact JSON object per checked source, per stdout line**
@@ -260,6 +260,12 @@ Store paths refuse symbolic links. An existing source lock is not automatically
260
260
  broken: after confirming no writer is active, an operator may remove an
261
261
  abandoned `.lock` file and retry.
262
262
 
263
+ Before an observation can be diffed or committed, `emit-drift` resolves its
264
+ current and selected-prior Forage snapshot references against the configured
265
+ snapshot store. This is an explicit capability (`--snapshot-root` defaults to
266
+ `.kontourai/lookout/snapshots`); missing, corrupt, malformed, or insufficiently
267
+ bound captures produce no facts and no continuity commit.
268
+
263
269
  ## Library
264
270
 
265
271
  ```ts
package/dist/src/cli.d.ts CHANGED
@@ -9,6 +9,6 @@ export interface RunCliOptions {
9
9
  loadRegistry?(path?: string): Promise<SourceStore>;
10
10
  runner?: CheckRunner;
11
11
  readObservation?: (path: string) => Promise<unknown>;
12
- emitDrift?(sourceId: string, value: unknown, store: SourceStore, observationRoot?: string): Promise<DriftResult>;
12
+ emitDrift?(sourceId: string, value: unknown, store: SourceStore, observationRoot?: string, snapshotRoot?: string): Promise<DriftResult>;
13
13
  }
14
14
  export declare function runCli(options?: RunCliOptions): Promise<number>;
package/dist/src/cli.js CHANGED
@@ -35,7 +35,7 @@ export async function runCli(options = {}) {
35
35
  stderr.write(`Could not read observation: ${error instanceof Error ? error.message : String(error)}\n`);
36
36
  return 1;
37
37
  }
38
- const result = await (options.emitDrift ?? emitDrift)(parsed.id, value, store, parsed.observationRoot);
38
+ const result = await (options.emitDrift ?? emitDrift)(parsed.id, value, store, parsed.observationRoot, parsed.snapshotRoot);
39
39
  if (!result.ok) {
40
40
  stderr.write(`${result.error.kind}: ${result.error.message}\n`);
41
41
  return 1;
@@ -104,9 +104,10 @@ function parseEmitArgs(argv) {
104
104
  let observationPath;
105
105
  let registryPath;
106
106
  let observationRoot;
107
+ let snapshotRoot;
107
108
  for (let index = 1; index < argv.length; index += 1) {
108
109
  const arg = argv[index];
109
- if (arg === "--registry" || arg === "--observation" || arg === "--observation-root") {
110
+ if (arg === "--registry" || arg === "--observation" || arg === "--observation-root" || arg === "--snapshot-root") {
110
111
  const value = argv[++index];
111
112
  if (!value || (value.startsWith("--") && value !== "-"))
112
113
  return `${arg} requires a path`;
@@ -114,8 +115,10 @@ function parseEmitArgs(argv) {
114
115
  registryPath = value;
115
116
  else if (arg === "--observation")
116
117
  observationPath = value;
117
- else
118
+ else if (arg === "--observation-root")
118
119
  observationRoot = value;
120
+ else
121
+ snapshotRoot = value;
119
122
  }
120
123
  else if (arg.startsWith("--"))
121
124
  return `Unknown option: ${arg}`;
@@ -128,7 +131,7 @@ function parseEmitArgs(argv) {
128
131
  return "emit-drift requires a source id";
129
132
  if (!observationPath)
130
133
  return "emit-drift requires --observation <path|->";
131
- return { command: "emit-drift", id, observationPath, registryPath, observationRoot };
134
+ return { command: "emit-drift", id, observationPath, registryPath, observationRoot, snapshotRoot };
132
135
  }
133
136
  async function readObservation(file) {
134
137
  const maxBytes = 1024 * 1024;
@@ -164,10 +167,10 @@ function cliEntities(observation) {
164
167
  }
165
168
  return [...grouped].map(([key, proposals]) => ({ key, proposals }));
166
169
  }
167
- async function emitDrift(sourceId, value, store, observationRoot) {
170
+ async function emitDrift(sourceId, value, store, observationRoot, snapshotRoot) {
168
171
  if (!value || typeof value !== "object" || Array.isArray(value))
169
172
  return { ok: false, error: { kind: "invalid-input", message: "Observation document must be an object" } };
170
173
  const document = value;
171
174
  const source = (await store.get(sourceId));
172
- return createDriftEmitter({ 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 } });
175
+ return createDriftEmitter({ store: createObservationStore({ root: observationRoot }), snapshotStore: createLookoutSnapshotStore(snapshotRoot) }).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
176
  }
@@ -1,6 +1,7 @@
1
1
  import type { LookoutSource } from "./registry.js";
2
2
  import { type ProposalDiffEvent, type ProposalSetDiff, type ProposalSetDiffInput, type ProposalSetFacts, type ProposalSetObservation } from "./proposal-diff.js";
3
3
  import type { ObservationCheckAnchor, ObservationStore, StoredProposalObservationV1 } from "./observation-store.js";
4
+ import type { SnapshotStore } from "@kontourai/forage";
4
5
  export interface BaselineEstablishedFact {
5
6
  readonly kind: "baseline-established";
6
7
  readonly sourceId: string;
@@ -51,6 +52,8 @@ export interface DriftEmitter<E> {
51
52
  }
52
53
  export interface CreateDriftEmitterOptions<E> {
53
54
  readonly store: ObservationStore;
55
+ /** Required explicit capability for authenticating durable snapshot references. */
56
+ readonly snapshotStore: SnapshotStore;
54
57
  readonly now?: () => string;
55
58
  readonly diff?: (input: ProposalSetDiffInput<E>) => {
56
59
  readonly ok: true;
@@ -1,4 +1,5 @@
1
1
  import { diffProposalSets } from "./proposal-diff.js";
2
+ import { admitProposalObservation } from "./observation-admission.js";
2
3
  function stableJson(value) {
3
4
  if (Array.isArray(value))
4
5
  return `[${value.map(stableJson).join(",")}]`;
@@ -27,23 +28,40 @@ export function createDriftEmitter(options) {
27
28
  return {
28
29
  async emit(input) {
29
30
  try {
30
- if (!input.source || input.source.id !== input.current?.sourceId || input.check?.currentSnapshotRef !== input.current?.snapshotRef) {
31
+ // Copy the complete caller image before the first await. Callers retain
32
+ // their objects, so resolving a snapshot must not create a time window
33
+ // in which a mutated ref/proposal is later committed under an admitted one.
34
+ const invocation = captureInvocation(input);
35
+ if (!invocation || !invocation.source || invocation.source.id !== invocation.current?.sourceId || invocation.check?.currentSnapshotRef !== invocation.current?.snapshotRef) {
31
36
  return { ok: false, error: { kind: "invalid-input", message: "Registry source, observation, and check anchor must agree" } };
32
37
  }
33
- const loaded = await options.store.loadLatest(input.source.id);
38
+ // Admit the current reference before continuity I/O. In particular,
39
+ // Forage rejects a malformed digest before findExact/loadLatest runs.
40
+ const currentAdmission = await admitProposalObservation({ source: invocation.source, current: invocation.current, check: invocation.check, prior: null, snapshotStore: options.snapshotStore });
41
+ if (!currentAdmission.ok)
42
+ return { ok: false, error: { kind: "invalid-input", message: "Current observation could not be admitted" } };
43
+ const loaded = await options.store.loadLatest(invocation.source.id);
34
44
  if (!loaded.ok)
35
45
  return { ok: false, error: { kind: "prior-state-error", message: loaded.error.message, cause: loaded.error } };
46
+ const prior = loaded.value === null ? null : capture(loaded.value);
47
+ if (loaded.value !== null && prior === null)
48
+ return { ok: false, error: { kind: "prior-state-error", message: "Prior observation could not be captured" } };
49
+ const admission = await admitProposalObservation({ source: invocation.source, current: invocation.current, check: invocation.check, prior, snapshotStore: options.snapshotStore });
50
+ if (!admission.ok)
51
+ return { ok: false, error: { kind: admission.error.kind === "prior-unresolved" ? "prior-state-error" : "invalid-input", message: "Observation could not be admitted" } };
36
52
  const recordedAt = now();
37
- const priorObservationId = loaded.value?.observationId ?? null;
53
+ const priorObservationId = prior?.observationId ?? null;
38
54
  let events = [];
39
55
  let facts;
40
- if (loaded.value === null) {
41
- facts = [{ kind: "baseline-established", sourceId: input.source.id, snapshotRef: input.current.snapshotRef, observedAt: input.current.observedAt, origin: input.source.kind, resolution: "observation", proposalCount: input.current.proposals.length }];
56
+ if (prior === null) {
57
+ facts = [{ kind: "baseline-established", sourceId: invocation.source.id, snapshotRef: invocation.current.snapshotRef, observedAt: invocation.current.observedAt, origin: invocation.source.kind, resolution: "observation", proposalCount: invocation.current.proposals.length }];
42
58
  }
43
59
  else {
44
60
  let derived;
45
61
  try {
46
- derived = diff({ prior: { sourceId: loaded.value.sourceId, snapshotRef: loaded.value.snapshotRef, observedAt: loaded.value.observedAt, proposals: loaded.value.proposals }, current: input.current, ...input.callbacks });
62
+ // Diff callbacks receive their own clone, never the image used for
63
+ // durable commit below.
64
+ derived = diff({ prior: { sourceId: prior.sourceId, snapshotRef: prior.snapshotRef, observedAt: prior.observedAt, proposals: capture(prior.proposals) ?? [] }, current: capture(invocation.current), ...invocation.callbacks });
47
65
  }
48
66
  catch (cause) {
49
67
  return { ok: false, error: { kind: "diff-error", message: "Proposal diff threw", cause } };
@@ -52,7 +70,7 @@ export function createDriftEmitter(options) {
52
70
  return { ok: false, error: { kind: "diff-error", message: derived.error.message, cause: derived.error } };
53
71
  const normalized = normalizeDiff(derived.value);
54
72
  events = normalized.events;
55
- facts = [{ kind: "proposal-set-facts", priorSnapshotRef: loaded.value.snapshotRef, currentSnapshotRef: input.current.snapshotRef, origin: input.source.kind, resolution: "observation", value: normalized.facts }];
73
+ facts = [{ kind: "proposal-set-facts", priorSnapshotRef: prior.snapshotRef, currentSnapshotRef: invocation.current.snapshotRef, origin: invocation.source.kind, resolution: "observation", value: normalized.facts }];
56
74
  }
57
75
  try {
58
76
  JSON.stringify({ events, facts });
@@ -60,10 +78,10 @@ export function createDriftEmitter(options) {
60
78
  catch (cause) {
61
79
  return { ok: false, error: { kind: "serialization-error", message: "Drift result is not serializable", cause } };
62
80
  }
63
- const committed = await options.store.commit({ observation: input.current, recordedAt, check: input.check }, loaded.value?.observationId ?? null);
81
+ const committed = await options.store.commit({ observation: invocation.current, recordedAt, check: invocation.check }, prior?.observationId ?? null);
64
82
  if (!committed.ok)
65
83
  return { ok: false, error: { kind: "persistence-error", message: committed.error.message, cause: committed.error } };
66
- return { ok: true, value: { sourceId: input.source.id, events, facts, priorObservationId, committedObservation: committed.value, warnings: committed.warnings ?? [] } };
84
+ return { ok: true, value: { sourceId: invocation.source.id, events, facts, priorObservationId, committedObservation: committed.value, warnings: committed.warnings ?? [] } };
67
85
  }
68
86
  catch (cause) {
69
87
  return { ok: false, error: { kind: "unexpected", message: "Drift emission failed", cause } };
@@ -71,3 +89,22 @@ export function createDriftEmitter(options) {
71
89
  },
72
90
  };
73
91
  }
92
+ function capture(value) { try {
93
+ return structuredClone(value);
94
+ }
95
+ catch {
96
+ return null;
97
+ } }
98
+ function captureInvocation(input) {
99
+ try {
100
+ if (!input || typeof input !== "object")
101
+ return null;
102
+ const image = capture({ source: input.source, current: input.current, check: input.check });
103
+ if (image === null || !input.callbacks || typeof input.callbacks !== "object")
104
+ return null;
105
+ return { ...image, callbacks: { ...input.callbacks } };
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
@@ -11,6 +11,10 @@ export type { SourceStore } from "./source-store.js";
11
11
  export type { ExtractableLookoutSource, LookoutRegistryDocument, LookoutSource, LookoutSourceKind, RenderPolicy, StructuredFileFormat, StructuredFileLookoutSource, } from "./registry.js";
12
12
  export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
13
13
  export type { ResolveLookoutSnapshotOptions } from "./snapshot-store.js";
14
+ export { admitProposalObservation } from "./observation-admission.js";
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";
14
18
  export type { SnapshotSourceRefResolution } from "@kontourai/forage/fetch";
15
19
  export { canonicalValueKey } from "./canonical-value.js";
16
20
  export type { CanonicalValueKey, CanonicalValueFormatVersion, CanonicalValueResult, DiffKernelError, DiffKernelErrorKind, DiffResult, IdentityResult, } from "./canonical-value.js";
package/dist/src/index.js CHANGED
@@ -4,6 +4,8 @@ export { defaultProviderResolver } from "./provider-resolution.js";
4
4
  export { loadRegistry, LookoutRegistry, parseRegistry, RegistryValidationError, } from "./registry.js";
5
5
  export { inMemorySourceStore } from "./source-store.js";
6
6
  export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
7
+ export { admitProposalObservation } from "./observation-admission.js";
8
+ export { admitSourceCapture, admitSourceCheck } from "./source-admission.js";
7
9
  export { canonicalValueKey } from "./canonical-value.js";
8
10
  export { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
9
11
  export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
@@ -0,0 +1,59 @@
1
+ import type { SnapshotStore } from "@kontourai/forage";
2
+ import type { LookoutSource } from "./registry.js";
3
+ import type { StoredProposalObservationV1, ObservationCheckAnchor } from "./observation-store.js";
4
+ import type { ProposalSetObservation } from "./proposal-diff.js";
5
+ /** A resolved snapshot identity suitable for durable observation metadata. */
6
+ export interface AdmittedSnapshotIdentity {
7
+ readonly sourceId: string;
8
+ readonly snapshotRef: string;
9
+ readonly url: string;
10
+ readonly bodyHash: string;
11
+ readonly fetchedAt: string;
12
+ /** Present only for Forage envelope-integrity references; never synthesized for legacy refs. */
13
+ readonly snapshotDigest?: string;
14
+ readonly integrity: "snapshot-envelope" | "body-and-identity";
15
+ }
16
+ export interface AdmittedProposalObservation {
17
+ readonly current: AdmittedSnapshotIdentity;
18
+ readonly prior: AdmittedSnapshotIdentity | null;
19
+ }
20
+ export type ObservationAdmissionErrorKind = "invalid-input" | "current-unresolved" | "prior-unresolved" | "insufficient-binding";
21
+ export interface ObservationAdmissionError {
22
+ readonly kind: ObservationAdmissionErrorKind;
23
+ readonly classification: "invalid-reference" | "not-found" | "mismatch" | "corrupt" | "store" | "source-identity" | "url-binding" | "redirect-binding";
24
+ readonly message: string;
25
+ }
26
+ export type ObservationAdmissionResult = {
27
+ readonly ok: true;
28
+ readonly value: AdmittedProposalObservation;
29
+ } | {
30
+ readonly ok: false;
31
+ readonly error: ObservationAdmissionError;
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;
42
+ export interface AdmitProposalObservationInput {
43
+ readonly source: LookoutSource;
44
+ readonly current: ProposalSetObservation;
45
+ readonly check: ObservationCheckAnchor;
46
+ /** The already-selected observation-store record; admission never selects or persists continuity. */
47
+ readonly prior: StoredProposalObservationV1 | null;
48
+ /** Explicit capability: admission never chooses a snapshot root or storage implementation. */
49
+ readonly snapshotStore: SnapshotStore;
50
+ }
51
+ /**
52
+ * Authenticate proposal-observation snapshot references before they can be
53
+ * diffed or committed. It is deliberately metadata-only: it neither exposes
54
+ * snapshot bodies nor reads/writes the observation store.
55
+ */
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;
@@ -0,0 +1,144 @@
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
+ }
30
+ /**
31
+ * Authenticate proposal-observation snapshot references before they can be
32
+ * diffed or committed. It is deliberately metadata-only: it neither exposes
33
+ * snapshot bodies nor reads/writes the observation store.
34
+ */
35
+ export async function admitProposalObservation(input) {
36
+ try {
37
+ // This is a public asynchronous boundary. Capture every caller-owned
38
+ // value before admission starts I/O so a caller cannot splice a later
39
+ // reference, source identity, or anchor into the authenticated result.
40
+ const invocation = captureInvocation(input);
41
+ if (invocation === null)
42
+ return failure("invalid-input", "source-identity", "Observation admission input is malformed");
43
+ return await admit(invocation);
44
+ }
45
+ catch {
46
+ return failure("invalid-input", "source-identity", "Observation admission input is malformed");
47
+ }
48
+ }
49
+ async function admit(invocation) {
50
+ if (!invocation || typeof invocation !== "object" || !invocation.source || typeof invocation.source !== "object" || !invocation.current || typeof invocation.current !== "object" || !invocation.check || typeof invocation.check !== "object" || !invocation.snapshotStore || typeof invocation.snapshotStore !== "object" ||
51
+ typeof invocation.source.id !== "string" || typeof invocation.source.url !== "string" || typeof invocation.current.sourceId !== "string" || typeof invocation.current.snapshotRef !== "string" || typeof invocation.check.currentSnapshotRef !== "string" ||
52
+ invocation.source.id !== invocation.current.sourceId || invocation.check.currentSnapshotRef !== invocation.current.snapshotRef) {
53
+ return failure("invalid-input", "source-identity", "Registry source, observation, and check anchor must agree");
54
+ }
55
+ const registeredUrl = normalizedHttpUrl(invocation.source.url);
56
+ if (registeredUrl === null)
57
+ return failure("invalid-input", "url-binding", "Registered source URL is not an admissible HTTP URL");
58
+ // resolveLookoutSnapshot delegates canonical reference validation to Forage;
59
+ // its resolver validates before calling findExact, so malformed refs perform
60
+ // no snapshot-store I/O.
61
+ const current = await resolveLookoutSnapshot(invocation.current.snapshotRef, { store: invocation.snapshotStore });
62
+ if (!current.ok)
63
+ return resolutionFailure("current", current.error.kind);
64
+ if (current.snapshot.sourceId !== invocation.source.id)
65
+ return failure("invalid-input", "source-identity", "Current snapshot identity does not match the registered source");
66
+ const currentBinding = bindCurrentSnapshot(current.snapshot.url, current.snapshot.redirects, current.integrity, registeredUrl);
67
+ if (currentBinding !== null)
68
+ return currentBinding;
69
+ if (invocation.prior === null)
70
+ return { ok: true, value: { current: identity(invocation.current.snapshotRef, current), prior: null } };
71
+ if (!invocation.prior || typeof invocation.prior !== "object" || typeof invocation.prior.sourceId !== "string" || typeof invocation.prior.snapshotRef !== "string" || !invocation.prior.check || typeof invocation.prior.check !== "object" || typeof invocation.prior.check.currentSnapshotRef !== "string" ||
72
+ invocation.prior.sourceId !== invocation.source.id || invocation.prior.check.currentSnapshotRef !== invocation.prior.snapshotRef) {
73
+ return failure("invalid-input", "source-identity", "Prior observation identity is not valid for the registered source");
74
+ }
75
+ const prior = await resolveLookoutSnapshot(invocation.prior.snapshotRef, { store: invocation.snapshotStore });
76
+ if (!prior.ok)
77
+ return resolutionFailure("prior", prior.error.kind);
78
+ if (prior.snapshot.sourceId !== invocation.source.id)
79
+ return failure("invalid-input", "source-identity", "Prior snapshot identity does not match the registered source");
80
+ // A historical capture may legitimately precede a registry URL change. It
81
+ // still needs durable identity authentication, but is not rebound to today’s URL.
82
+ if (prior.snapshot.redirects?.length && prior.integrity !== "snapshot-envelope") {
83
+ return failure("insufficient-binding", "redirect-binding", "Legacy snapshot references cannot authenticate redirect captures");
84
+ }
85
+ if (prior.snapshot.redirects?.length && !validRedirectChain(prior.snapshot.redirects, prior.snapshot.url)) {
86
+ return failure("insufficient-binding", "redirect-binding", "Snapshot redirect capture is not admissibly bound");
87
+ }
88
+ return { ok: true, value: { current: identity(invocation.current.snapshotRef, current), prior: identity(invocation.prior.snapshotRef, prior) } };
89
+ }
90
+ function identity(snapshotRef, resolved) {
91
+ return { sourceId: resolved.snapshot.sourceId, snapshotRef, url: resolved.snapshot.url, bodyHash: resolved.snapshot.bodyHash, fetchedAt: resolved.snapshot.fetchedAt, ...(resolved.reference.snapshotDigest === undefined ? {} : { snapshotDigest: resolved.reference.snapshotDigest }), integrity: resolved.integrity };
92
+ }
93
+ function resolutionFailure(side, kind) {
94
+ const classification = { "invalid-reference": "invalid-reference", "snapshot-not-found": "not-found", "snapshot-mismatch": "mismatch", "snapshot-corrupt": "corrupt", "snapshot-store-error": "store" }[kind];
95
+ return failure(side === "current" ? "current-unresolved" : "prior-unresolved", classification, `${side === "current" ? "Current" : "Prior"} snapshot reference could not be admitted`);
96
+ }
97
+ function failure(kind, classification, message) { return { ok: false, error: { kind, classification, message } }; }
98
+ function captureInvocation(input) {
99
+ try {
100
+ if (!input || typeof input !== "object")
101
+ return null;
102
+ const image = structuredClone({ source: input.source, current: input.current, check: input.check, prior: input.prior });
103
+ const snapshotStore = captureExactSnapshotReader(input.snapshotStore);
104
+ return snapshotStore === null ? null : { ...image, snapshotStore };
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ export function normalizedHttpUrl(value) {
111
+ try {
112
+ const url = new URL(value);
113
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password)
114
+ return null;
115
+ url.hash = "";
116
+ return url.href;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ function bindCurrentSnapshot(finalUrl, redirects, integrity, registeredUrl) {
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")
127
+ return failure("insufficient-binding", "redirect-binding", "Snapshot redirect capture is not admissibly bound");
128
+ return null;
129
+ }
130
+ /** Forage records visited redirect URLs before the final URL. */
131
+ export function validRedirectChain(redirects, finalUrl) {
132
+ const urls = [...redirects, finalUrl].map(normalizedHttpUrl);
133
+ if (urls.some((url) => url === null))
134
+ return false;
135
+ const concrete = urls;
136
+ const parsed = concrete.map((url) => new URL(url));
137
+ const host = parsed[0].host;
138
+ for (let index = 0; index < parsed.length; index += 1) {
139
+ const url = parsed[index];
140
+ if (url.host !== host || (index > 0 && parsed[index - 1].protocol === "https:" && url.protocol !== "https:"))
141
+ return false;
142
+ }
143
+ return true;
144
+ }
@@ -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.4.0",
3
+ "version": "0.5.1",
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": {