@kontourai/lookout 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,59 @@
1
1
  # @kontourai/lookout
2
2
 
3
- A small **source registry** and **non-throwing drift-check runner** built on
4
- [Traverse](https://github.com/kontourai/traverse) snapshots. Lookout answers one
5
- question per registered source, cheaply and honestly: *did this source change
6
- since we last looked?*
3
+ **Cheap, honest drift detection for content you re-check over time — "did this source change since we last looked?"**
7
4
 
8
- It composes Traverse for fetching and snapshot storage, provides deterministic
9
- proposal diffing, and authors provenance-bearing Survey inputs. It does **not**
10
- extract fields, project to Surface, review claims, notify, crawl, or schedule.
11
- Operational failures are returned as typed results.
5
+ A small **source registry** and **non-throwing drift-check runner** built on
6
+ [Traverse](https://github.com/kontourai/traverse) snapshots. It composes Traverse
7
+ for fetching and snapshot storage, provides deterministic proposal diffing, and
8
+ emits neutral, typed drift — its own vocabulary, in its own dependency-free
9
+ package. It does **not** extract fields, author trust-layer records, project to
10
+ Surface, review claims, notify, crawl, or schedule. Operational failures are
11
+ returned as typed results.
12
+
13
+ ## Why it's different
14
+
15
+ The naive way to answer "did it change?" is to re-crawl the source and diff the
16
+ bytes. That's expensive (full re-download every time), noisy (a changed ad or
17
+ timestamp reads as "changed"), and fragile (a thrown error mid-run looks the same
18
+ as "no change"). Lookout is the opposite on all three:
19
+
20
+ | | Re-crawl + byte-diff | `lookout` |
21
+ |---|---|---|
22
+ | Cost | full re-download every check | **conditional `304`** — often no download at all |
23
+ | Signal | raw bytes (ads/timestamps = "changed") | **proposal-identity diff** — "a *new entity appeared*" vs "a byte moved" |
24
+ | Honesty | a crash or false-`304` silently reads as "unchanged" | **typed results, never throws**; hardened against false-`304` (the [traverse#49](https://github.com/kontourai/traverse/issues/49) validator-scoping pin) |
25
+ | Output | your problem to shape | **neutral, typed drift** — events already Hachure-evidence-shaped, ready for a consumer to lift into a trust bundle |
26
+
27
+ So the point isn't "diffing" — it's *cheap + honest + semantic + review-ready*
28
+ change detection, so a periodic re-check surfaces **only the real delta** (this
29
+ provider is new, that listing changed) instead of re-reviewing everything.
30
+
31
+ ## Where it sits
32
+
33
+ One layer in a four-verb stack; each repo owns one verb and is usable alone:
34
+
35
+ - **[forage](https://github.com/kontourai/forage)** — CRAWL: fetch / frontier / SSRF-safe egress / snapshots
36
+ - **[traverse](https://github.com/kontourai/traverse)** — EXTRACT: content + schema → reviewable proposals
37
+ - **lookout** — CHANGE: did this registered source drift since last look?
38
+ - **survey** — the SHAPE: what reviewed truth looks like (claims / review / resolution)
39
+
40
+ Lookout composes the fetch/snapshot layer (today Traverse's `/fetch`; re-points at
41
+ `forage` as its single-source fetch surface lands) for cheap `304`-aware re-checks,
42
+ and Traverse's `ExtractionProposal` identity for the semantic diff. Dependency
43
+ arrows point only downward — no cycles. Lookout itself depends on nothing in the
44
+ trust layer: its events are already Hachure-evidence-shaped (`snapshotRef` /
45
+ `locator` / `excerpt` / `fieldPath`), so a consumer or product lifts them into a
46
+ Hachure `TrustBundle` via `@kontourai/surface`'s `TrustBundleBuilder` — the same
47
+ pattern Traverse uses to match Survey's shape without importing Survey.
48
+
49
+ **SSRF-safe egress.** Registered source URLs are operator- / aggregator-supplied
50
+ and not fully trusted, so lookout routes its default fetch transport through
51
+ [forage](https://github.com/kontourai/forage)'s SSRF-pinned guarded fetch
52
+ (`@kontourai/forage/egress`). A registered source pointing at a private,
53
+ link-local, loopback, or cloud-metadata host (e.g. `169.254.169.254`) is refused
54
+ before any connection — a drift check can never be turned into an SSRF vector.
55
+ A caller that injects its own `fetchSource` or `fetchOptions.fetch` (e.g. tests)
56
+ owns its transport and opts out of the default guard.
12
57
 
13
58
  ## Requirements
14
59
 
@@ -89,12 +134,39 @@ default at `<cwd>/.kontourai/lookout/snapshots` (override with the CLI
89
134
  `--snapshot-root` flag or by injecting a store in library use). Lookout adds no
90
135
  custom filenames or retention.
91
136
 
137
+ ## Schema coverage
138
+
139
+ Drift isn't only "the bytes changed" — a source can reformat so that a field
140
+ your schema *declares* silently stops being produced. The byte/proposal diffs
141
+ above are temporal (they need a prior and say nothing on a first look), so they
142
+ can't catch this. `checkSchemaCoverage` is the static complement:
143
+
144
+ ```ts
145
+ import { checkSchemaCoverage } from "@kontourai/lookout";
146
+
147
+ const { covered, gaps } = checkSchemaCoverage(source.targetSchema, proposals);
148
+ // covered: declared paths at least one proposal produced (schema order)
149
+ // gaps: declared paths that produced none, each with its `required` flag
150
+ for (const gap of gaps) {
151
+ // escalate a missing required field harder than a missing optional one
152
+ }
153
+ ```
154
+
155
+ It reduces one declared `TargetFieldSchema[]` and one produced
156
+ `ExtractionProposal[]` set into `{ covered, gaps }` by exact
157
+ `proposal.fieldPath === field.path`. It is pure and total — no prior, no
158
+ network, no throw — and answers on the very first observation. It measures the
159
+ produced set you pass (it runs no post-verification filtering itself), reports
160
+ only against the *declared* surface (an undeclared proposal path is neither
161
+ covered nor a gap), and preserves schema order. Escalation — warning, review
162
+ item, or hard failure — is the consumer's call.
163
+
92
164
  ## CLI
93
165
 
94
166
  ```
95
167
  lookout check <id> [--registry <path>] [--snapshot-root <path>]
96
168
  lookout check --all [--registry <path>] [--snapshot-root <path>]
97
- lookout emit-survey <id> --observation <path|-> [--registry <path>] [--observation-root <path>]
169
+ lookout emit-drift <id> --observation <path|-> [--registry <path>] [--observation-root <path>]
98
170
  ```
99
171
 
100
172
  - Emits **exactly one compact JSON object per checked source, per stdout line**
@@ -110,14 +182,18 @@ lookout emit-survey <id> --observation <path|-> [--registry <path>] [--observati
110
182
  This exit/output contract is what makes `lookout check --all` safe to drive from
111
183
  an external scheduler.
112
184
 
113
- `emit-survey` is a separate composable command: its input is a JSON object with
185
+ `emit-drift` is a separate composable command: its input is a JSON object with
114
186
  `observation` (a `ProposalSetObservation`) and its matching `check` anchor.
115
187
  Extraction is supplied by the caller. The first successful input commits a
116
- baseline fact and returns `surveyInput: null`; a genuine later change returns
117
- one unreviewed SurveyInput batch. State defaults to
118
- `.kontourai/lookout/observations`, uses immutable digest-addressed records and
119
- an atomic per-source pointer, and retains the latest two valid observations.
120
- Consumers pass the batch to Survey when they want Surface projection.
188
+ baseline fact and returns `priorObservationId: null` with empty `events`; a
189
+ genuine later change returns non-empty `events` and `facts` plus a
190
+ `priorObservationId` pointing at the prior observation it was diffed against.
191
+ State defaults to `.kontourai/lookout/observations`, uses immutable
192
+ digest-addressed records and an atomic per-source pointer, and retains the
193
+ latest two valid observations. The emitted `events` are already
194
+ Hachure-evidence-shaped; a consumer or product lifts them into a Hachure
195
+ `TrustBundle` with `@kontourai/surface`'s `TrustBundleBuilder` when it wants
196
+ Surface projection — lookout itself authors nothing in the trust layer.
121
197
  Store paths refuse symbolic links. An existing source lock is not automatically
122
198
  broken: after confirming no writer is active, an operator may remove an
123
199
  abandoned `.lock` file and retry.
@@ -137,8 +213,9 @@ const results = await runner.checkAll(registry.list());
137
213
  ```
138
214
 
139
215
  `createCheckRunner` accepts injected seams — `store`, `fetchSource` (defaults to
140
- Traverse), `fetchOptions`, and `clock` — so checks run with no live network or
141
- timers in tests.
216
+ Traverse's fetcher over forage's SSRF-guarded egress), `fetchOptions`, and
217
+ `clock` — so checks run with no live network or timers in tests. Injecting either
218
+ `fetchSource` or `fetchOptions.fetch` overrides the default guarded transport.
142
219
 
143
220
  ## Non-goals
144
221
 
@@ -1,7 +1,30 @@
1
1
  import { buildSnapshotSourceRef, fetchSource as traverseFetchSource, } from "@kontourai/traverse/fetch";
2
+ import { createGuardedFetch } from "@kontourai/forage/egress";
3
+ /**
4
+ * The default egress transport for lookout's registered-source fetches: forage's
5
+ * SSRF-pinned guarded fetch. Registered source URLs are operator- / aggregator-
6
+ * supplied and not fully trusted, so a source pointing at a private, link-local,
7
+ * loopback, or cloud-metadata host is refused before any connection — a drift
8
+ * check can never be turned into an SSRF vector. Only wired when lookout uses the
9
+ * default traverse fetcher and the caller injected no `fetch`; a caller that
10
+ * supplies its own `fetchSource` or `fetchOptions.fetch` (e.g. tests) owns the
11
+ * transport. Created lazily and cached so the guard is built once, not per check.
12
+ */
13
+ let cachedGuardedFetch;
14
+ function defaultGuardedFetch() {
15
+ cachedGuardedFetch ??= createGuardedFetch();
16
+ return cachedGuardedFetch;
17
+ }
2
18
  export function createCheckRunner(options) {
19
+ const usingDefaultFetcher = options.fetchSource === undefined;
3
20
  const fetchImpl = options.fetchSource ?? traverseFetchSource;
4
21
  const clock = options.clock ?? (() => new Date().toISOString());
22
+ // Guard the default traverse fetcher's egress with forage. A caller-supplied
23
+ // fetcher or `fetchOptions.fetch` owns its own transport and is left as-is.
24
+ const fetchOptions = { ...options.fetchOptions };
25
+ if (usingDefaultFetcher && fetchOptions.fetch === undefined) {
26
+ fetchOptions.fetch = defaultGuardedFetch();
27
+ }
5
28
  async function check(source) {
6
29
  const common = () => ({
7
30
  sourceId: source.id,
@@ -18,7 +41,7 @@ export function createCheckRunner(options) {
18
41
  }
19
42
  let fetched;
20
43
  try {
21
- fetched = await fetchImpl({ id: source.id, url: source.url, revalidate: true }, { ...options.fetchOptions, store: options.store });
44
+ fetched = await fetchImpl({ id: source.id, url: source.url, revalidate: true }, { ...fetchOptions, store: options.store });
22
45
  }
23
46
  catch (error) {
24
47
  return lookoutError(common(), "unexpected", error);
package/dist/src/cli.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Writable } from "node:stream";
2
2
  import { type CheckRunner } from "./check-runner.js";
3
3
  import { type LookoutRegistry } from "./registry.js";
4
- import { type EmissionResult } from "./survey-emission.js";
4
+ import { type DriftResult } from "./drift-emission.js";
5
5
  export interface RunCliOptions {
6
6
  argv?: string[];
7
7
  stdout?: Pick<Writable, "write">;
@@ -9,6 +9,6 @@ export interface RunCliOptions {
9
9
  loadRegistry?: (path?: string) => Promise<LookoutRegistry>;
10
10
  runner?: CheckRunner;
11
11
  readObservation?: (path: string) => Promise<unknown>;
12
- emitSurvey?: (sourceId: string, value: unknown, registry: LookoutRegistry, observationRoot?: string) => Promise<EmissionResult>;
12
+ emitDrift?: (sourceId: string, value: unknown, registry: LookoutRegistry, observationRoot?: string) => Promise<DriftResult>;
13
13
  }
14
14
  export declare function runCli(options?: RunCliOptions): Promise<number>;
package/dist/src/cli.js CHANGED
@@ -3,7 +3,7 @@ import { createCheckRunner } from "./check-runner.js";
3
3
  import { loadRegistry } from "./registry.js";
4
4
  import { createLookoutSnapshotStore } from "./snapshot-store.js";
5
5
  import { createObservationStore } from "./observation-store.js";
6
- import { createSurveyEmitter } from "./survey-emission.js";
6
+ import { createDriftEmitter } from "./drift-emission.js";
7
7
  export async function runCli(options = {}) {
8
8
  const argv = options.argv ?? process.argv.slice(2);
9
9
  const stdout = options.stdout ?? process.stdout;
@@ -21,7 +21,7 @@ export async function runCli(options = {}) {
21
21
  stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
22
22
  return 1;
23
23
  }
24
- if (parsed.command === "emit-survey") {
24
+ if (parsed.command === "emit-drift") {
25
25
  const source = registry.get(parsed.id);
26
26
  if (!source) {
27
27
  stderr.write(`Unknown source id: ${parsed.id}\n`);
@@ -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.emitSurvey ?? emitSurvey)(parsed.id, value, registry, parsed.observationRoot);
38
+ const result = await (options.emitDrift ?? emitDrift)(parsed.id, value, registry, parsed.observationRoot);
39
39
  if (!result.ok) {
40
40
  stderr.write(`${result.error.kind}: ${result.error.message}\n`);
41
41
  return 1;
@@ -61,10 +61,10 @@ export async function runCli(options = {}) {
61
61
  return 0;
62
62
  }
63
63
  function parseArgs(argv) {
64
- if (argv[0] === "emit-survey")
64
+ if (argv[0] === "emit-drift")
65
65
  return parseEmitArgs(argv);
66
66
  if (argv[0] !== "check")
67
- return "Usage: lookout check <id>|--all [--registry path] [--snapshot-root path]\n lookout emit-survey <id> --observation <path|-> [--registry path] [--observation-root path]";
67
+ return "Usage: lookout check <id>|--all [--registry path] [--snapshot-root path]\n lookout emit-drift <id> --observation <path|-> [--registry path] [--observation-root path]";
68
68
  const parsed = { command: "check", all: false };
69
69
  for (let index = 1; index < argv.length; index += 1) {
70
70
  const arg = argv[index];
@@ -125,10 +125,10 @@ function parseEmitArgs(argv) {
125
125
  id = arg;
126
126
  }
127
127
  if (!id)
128
- return "emit-survey requires a source id";
128
+ return "emit-drift requires a source id";
129
129
  if (!observationPath)
130
- return "emit-survey requires --observation <path|->";
131
- return { command: "emit-survey", id, observationPath, registryPath, observationRoot };
130
+ return "emit-drift requires --observation <path|->";
131
+ return { command: "emit-drift", id, observationPath, registryPath, observationRoot };
132
132
  }
133
133
  async function readObservation(file) {
134
134
  const maxBytes = 1024 * 1024;
@@ -164,10 +164,10 @@ function cliEntities(observation) {
164
164
  }
165
165
  return [...grouped].map(([key, proposals]) => ({ key, proposals }));
166
166
  }
167
- async function emitSurvey(sourceId, value, registry, observationRoot) {
167
+ async function emitDrift(sourceId, value, registry, observationRoot) {
168
168
  if (!value || typeof value !== "object" || Array.isArray(value))
169
169
  return { ok: false, error: { kind: "invalid-input", message: "Observation document must be an object" } };
170
170
  const document = value;
171
171
  const source = registry.get(sourceId);
172
- return createSurveyEmitter({ store: createObservationStore({ root: observationRoot }) }).emit({ source, current: document.observation, check: document.check, callbacks: { selectEntities: cliEntities, entityIdentity: (entity) => entity.key, proposalsFor: (entity) => entity.proposals, fieldIdentity: (_entity, proposal) => proposal.fieldPath } });
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 } });
173
173
  }
@@ -0,0 +1,58 @@
1
+ import type { ExtractionProposal, TargetFieldSchema } from "@kontourai/traverse";
2
+ /**
3
+ * A declared schema field that the supplied observation did not cover: the
4
+ * extractor produced **zero** proposals whose `fieldPath` equals this field's
5
+ * declared `path`. `required` mirrors the schema field's own `required` flag
6
+ * (absent → `false`) so a consumer can escalate a missing required field harder
7
+ * than a missing optional one.
8
+ *
9
+ * The gap keys the declared field under `fieldPath` (not `path`) deliberately:
10
+ * it is the same string as the schema field's `path`, but named to line up with
11
+ * `ExtractionProposal.fieldPath` so a consumer can correlate a gap directly
12
+ * against the proposals it was measured over. `covered` needs no such wrapper —
13
+ * it carries neither the `required` flag nor a correlation need — so it stays a
14
+ * bare `string[]` of declared paths.
15
+ */
16
+ export interface SchemaCoverageGap {
17
+ readonly fieldPath: string;
18
+ readonly required: boolean;
19
+ }
20
+ /**
21
+ * The result of checking one extraction observation against the declared
22
+ * schema. `covered` lists, in schema order, every declared `path` that at least
23
+ * one proposal produced; `gaps` lists, in schema order, every declared `path`
24
+ * that produced none. The two are disjoint and together cover the declared
25
+ * schema. `gaps` never mentions a proposal fieldPath that is absent from the
26
+ * schema — this is a coverage check of the *declared* surface, not an
27
+ * unexpected-field detector.
28
+ */
29
+ export interface SchemaCoverageResult {
30
+ readonly covered: readonly string[];
31
+ readonly gaps: readonly SchemaCoverageGap[];
32
+ }
33
+ /**
34
+ * Static schema-coverage check: which declared schema fields did this single
35
+ * extraction observation fail to produce at all?
36
+ *
37
+ * This is the *static*, first-observation-capable complement to the temporal
38
+ * proposal diff (`diffProposalSets`). The diff answers "did the produced
39
+ * proposals change since we last looked?" and, by construction, says nothing on
40
+ * a first observation. Coverage answers a question that has an answer on the
41
+ * very first look and needs no prior: "did the source drift — reformat, rename a
42
+ * heading, reorder a table — such that a field the schema *declares* silently
43
+ * stopped being produced?". A declared field with no proposal is exactly that
44
+ * signal: a layout-drift regression that would otherwise surface only as a
45
+ * silent gap in the output.
46
+ *
47
+ * Membership is by exact `proposal.fieldPath === field.path`. Matching against
48
+ * the produced-proposal set (not any post-verification survivor set) is
49
+ * deliberate: a field the extractor *did* produce but a downstream step later
50
+ * dropped is explained by that step's own diagnostic and is not a coverage gap
51
+ * — the extractor covered it. Callers that want coverage measured against a
52
+ * post-verification set simply pass that set as `proposals`.
53
+ *
54
+ * Pure and total: no injected callbacks, no network, no throw. Declared fields
55
+ * are evaluated in schema order; a schema that repeats a `path` reports it once
56
+ * per occurrence (the schema, not this check, owns declared-field uniqueness).
57
+ */
58
+ export declare function checkSchemaCoverage(schema: readonly TargetFieldSchema[], proposals: readonly ExtractionProposal[]): SchemaCoverageResult;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Static schema-coverage check: which declared schema fields did this single
3
+ * extraction observation fail to produce at all?
4
+ *
5
+ * This is the *static*, first-observation-capable complement to the temporal
6
+ * proposal diff (`diffProposalSets`). The diff answers "did the produced
7
+ * proposals change since we last looked?" and, by construction, says nothing on
8
+ * a first observation. Coverage answers a question that has an answer on the
9
+ * very first look and needs no prior: "did the source drift — reformat, rename a
10
+ * heading, reorder a table — such that a field the schema *declares* silently
11
+ * stopped being produced?". A declared field with no proposal is exactly that
12
+ * signal: a layout-drift regression that would otherwise surface only as a
13
+ * silent gap in the output.
14
+ *
15
+ * Membership is by exact `proposal.fieldPath === field.path`. Matching against
16
+ * the produced-proposal set (not any post-verification survivor set) is
17
+ * deliberate: a field the extractor *did* produce but a downstream step later
18
+ * dropped is explained by that step's own diagnostic and is not a coverage gap
19
+ * — the extractor covered it. Callers that want coverage measured against a
20
+ * post-verification set simply pass that set as `proposals`.
21
+ *
22
+ * Pure and total: no injected callbacks, no network, no throw. Declared fields
23
+ * are evaluated in schema order; a schema that repeats a `path` reports it once
24
+ * per occurrence (the schema, not this check, owns declared-field uniqueness).
25
+ */
26
+ export function checkSchemaCoverage(schema, proposals) {
27
+ const producedPaths = new Set(proposals.map((proposal) => proposal.fieldPath));
28
+ const covered = [];
29
+ const gaps = [];
30
+ for (const field of schema) {
31
+ if (producedPaths.has(field.path)) {
32
+ covered.push(field.path);
33
+ }
34
+ else {
35
+ gaps.push({ fieldPath: field.path, required: field.required === true });
36
+ }
37
+ }
38
+ return { covered, gaps };
39
+ }
@@ -1,4 +1,3 @@
1
- import { type SurveyInput } from "@kontourai/survey";
2
1
  import type { LookoutSource } from "./registry.js";
3
2
  import { type ProposalDiffEvent, type ProposalSetDiff, type ProposalSetDiffInput, type ProposalSetFacts, type ProposalSetObservation } from "./proposal-diff.js";
4
3
  import type { ObservationCheckAnchor, ObservationStore, StoredProposalObservationV1 } from "./observation-store.js";
@@ -11,7 +10,7 @@ export interface BaselineEstablishedFact {
11
10
  readonly resolution: "observation";
12
11
  readonly proposalCount: number;
13
12
  }
14
- export type EmissionFact = BaselineEstablishedFact | {
13
+ export type DriftFact = BaselineEstablishedFact | {
15
14
  readonly kind: "proposal-set-facts";
16
15
  readonly priorSnapshotRef: string;
17
16
  readonly currentSnapshotRef: string;
@@ -19,37 +18,38 @@ export type EmissionFact = BaselineEstablishedFact | {
19
18
  readonly resolution: "observation";
20
19
  readonly value: ProposalSetFacts;
21
20
  };
22
- export interface EmissionSuccess {
21
+ export interface DriftSuccess {
23
22
  readonly sourceId: string;
24
23
  readonly events: readonly ProposalDiffEvent[];
25
- readonly facts: readonly EmissionFact[];
26
- readonly surveyInput: SurveyInput | null;
24
+ readonly facts: readonly DriftFact[];
25
+ /** The prior observation this drift was diffed against, or null on a first-ever (baseline) observation. */
26
+ readonly priorObservationId: string | null;
27
27
  readonly committedObservation: StoredProposalObservationV1;
28
28
  readonly warnings: readonly string[];
29
29
  }
30
- export type EmissionErrorKind = "invalid-input" | "prior-state-error" | "diff-error" | "authoring-error" | "persistence-error";
31
- export interface EmissionError {
32
- readonly kind: EmissionErrorKind;
30
+ export type DriftErrorKind = "invalid-input" | "prior-state-error" | "diff-error" | "persistence-error" | "serialization-error" | "unexpected";
31
+ export interface DriftError {
32
+ readonly kind: DriftErrorKind;
33
33
  readonly message: string;
34
34
  readonly cause?: unknown;
35
35
  }
36
- export type EmissionResult = {
36
+ export type DriftResult = {
37
37
  readonly ok: true;
38
- readonly value: EmissionSuccess;
38
+ readonly value: DriftSuccess;
39
39
  } | {
40
40
  readonly ok: false;
41
- readonly error: EmissionError;
41
+ readonly error: DriftError;
42
42
  };
43
- export interface EmitSurveyInput<E> {
43
+ export interface EmitDriftInput<E> {
44
44
  readonly source: LookoutSource;
45
45
  readonly current: ProposalSetObservation;
46
46
  readonly check: ObservationCheckAnchor;
47
47
  readonly callbacks: Omit<ProposalSetDiffInput<E>, "prior" | "current">;
48
48
  }
49
- export interface SurveyEmitter<E> {
50
- emit(input: EmitSurveyInput<E>): Promise<EmissionResult>;
49
+ export interface DriftEmitter<E> {
50
+ emit(input: EmitDriftInput<E>): Promise<DriftResult>;
51
51
  }
52
- export interface CreateSurveyEmitterOptions<E> {
52
+ export interface CreateDriftEmitterOptions<E> {
53
53
  readonly store: ObservationStore;
54
54
  readonly now?: () => string;
55
55
  readonly diff?: (input: ProposalSetDiffInput<E>) => {
@@ -61,8 +61,5 @@ export interface CreateSurveyEmitterOptions<E> {
61
61
  readonly message: string;
62
62
  };
63
63
  };
64
- /** Test seam. Production callers must leave this as observation. */
65
- readonly resolution?: "observation";
66
- readonly transformSurveyInput?: (input: SurveyInput) => SurveyInput;
67
64
  }
68
- export declare function createSurveyEmitter<E>(options: CreateSurveyEmitterOptions<E>): SurveyEmitter<E>;
65
+ export declare function createDriftEmitter<E>(options: CreateDriftEmitterOptions<E>): DriftEmitter<E>;
@@ -0,0 +1,71 @@
1
+ import { diffProposalSets } from "./proposal-diff.js";
2
+ function stableJson(value) {
3
+ if (Array.isArray(value))
4
+ return `[${value.map(stableJson).join(",")}]`;
5
+ if (value && typeof value === "object")
6
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
7
+ return JSON.stringify(value) ?? "undefined";
8
+ }
9
+ function normalizeDiff(value) {
10
+ const sorted = (items) => [...items].sort((a, b) => stableJson(a).localeCompare(stableJson(b)));
11
+ return {
12
+ events: sorted(value.events),
13
+ facts: {
14
+ retainedProposalOccurrences: sorted(value.facts.retainedProposalOccurrences),
15
+ addedProposalOccurrences: sorted(value.facts.addedProposalOccurrences),
16
+ removedProposalOccurrences: sorted(value.facts.removedProposalOccurrences),
17
+ provenanceChanges: sorted(value.facts.provenanceChanges),
18
+ removedEntities: [...value.facts.removedEntities].sort(),
19
+ },
20
+ };
21
+ }
22
+ export function createDriftEmitter(options) {
23
+ const now = options.now ?? (() => new Date().toISOString());
24
+ const diff = options.diff ?? diffProposalSets;
25
+ return {
26
+ async emit(input) {
27
+ try {
28
+ if (!input.source || input.source.id !== input.current?.sourceId || input.check?.currentSnapshotRef !== input.current?.snapshotRef) {
29
+ return { ok: false, error: { kind: "invalid-input", message: "Registry source, observation, and check anchor must agree" } };
30
+ }
31
+ const loaded = await options.store.loadLatest(input.source.id);
32
+ if (!loaded.ok)
33
+ return { ok: false, error: { kind: "prior-state-error", message: loaded.error.message, cause: loaded.error } };
34
+ const recordedAt = now();
35
+ const priorObservationId = loaded.value?.observationId ?? null;
36
+ let events = [];
37
+ let facts;
38
+ if (loaded.value === null) {
39
+ 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 }];
40
+ }
41
+ else {
42
+ let derived;
43
+ try {
44
+ derived = diff({ prior: { sourceId: loaded.value.sourceId, snapshotRef: loaded.value.snapshotRef, observedAt: loaded.value.observedAt, proposals: loaded.value.proposals }, current: input.current, ...input.callbacks });
45
+ }
46
+ catch (cause) {
47
+ return { ok: false, error: { kind: "diff-error", message: "Proposal diff threw", cause } };
48
+ }
49
+ if (!derived.ok)
50
+ return { ok: false, error: { kind: "diff-error", message: derived.error.message, cause: derived.error } };
51
+ const normalized = normalizeDiff(derived.value);
52
+ events = normalized.events;
53
+ facts = [{ kind: "proposal-set-facts", priorSnapshotRef: loaded.value.snapshotRef, currentSnapshotRef: input.current.snapshotRef, origin: input.source.kind, resolution: "observation", value: normalized.facts }];
54
+ }
55
+ try {
56
+ JSON.stringify({ events, facts });
57
+ }
58
+ catch (cause) {
59
+ return { ok: false, error: { kind: "serialization-error", message: "Drift result is not serializable", cause } };
60
+ }
61
+ const committed = await options.store.commit({ observation: input.current, recordedAt, check: input.check }, loaded.value?.observationId ?? null);
62
+ if (!committed.ok)
63
+ return { ok: false, error: { kind: "persistence-error", message: committed.error.message, cause: committed.error } };
64
+ return { ok: true, value: { sourceId: input.source.id, events, facts, priorObservationId, committedObservation: committed.value, warnings: committed.warnings ?? [] } };
65
+ }
66
+ catch (cause) {
67
+ return { ok: false, error: { kind: "unexpected", message: "Drift emission failed", cause } };
68
+ }
69
+ },
70
+ };
71
+ }
@@ -16,5 +16,7 @@ export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js
16
16
  export type { FieldChangedEvent, FieldChangeKind, NewEntityAppearedEvent, ProposalDiffEvent, ProposalEvidence, ProposalIdentity, ProposalOccurrencePair, ProposalSetDiff, ProposalSetDiffInput, ProposalSetFacts, ProposalSetObservation, ProvenanceChangeFact, } from "./proposal-diff.js";
17
17
  export { createObservationStore } from "./observation-store.js";
18
18
  export type { CreateObservationStoreOptions, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalObservationRecordInput, StoredProposalObservationV1 } from "./observation-store.js";
19
- export { createSurveyEmitter } from "./survey-emission.js";
20
- export type { BaselineEstablishedFact, CreateSurveyEmitterOptions, EmissionError, EmissionErrorKind, EmissionFact, EmissionResult, EmissionSuccess, EmitSurveyInput, SurveyEmitter } from "./survey-emission.js";
19
+ export { createDriftEmitter } from "./drift-emission.js";
20
+ export type { BaselineEstablishedFact, CreateDriftEmitterOptions, DriftEmitter, DriftError, DriftErrorKind, DriftFact, DriftResult, DriftSuccess, EmitDriftInput } from "./drift-emission.js";
21
+ export { checkSchemaCoverage } from "./coverage.js";
22
+ export type { SchemaCoverageGap, SchemaCoverageResult } from "./coverage.js";
package/dist/src/index.js CHANGED
@@ -7,4 +7,5 @@ export { canonicalValueKey } from "./canonical-value.js";
7
7
  export { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
8
8
  export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
9
9
  export { createObservationStore } from "./observation-store.js";
10
- export { createSurveyEmitter } from "./survey-emission.js";
10
+ export { createDriftEmitter } from "./drift-emission.js";
11
+ export { checkSchemaCoverage } from "./coverage.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/lookout",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A small source registry and drift-check runner built on Traverse snapshots.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@kontourai/datum": "0.3.0",
47
- "@kontourai/survey": "1.7.0",
47
+ "@kontourai/forage": "0.2.0",
48
48
  "@kontourai/traverse": "0.14.1"
49
49
  },
50
50
  "devDependencies": {
@@ -1,138 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { SurveyInputBuilder } from "@kontourai/survey";
3
- import { diffProposalSets } from "./proposal-diff.js";
4
- function id(...parts) { return createHash("sha256").update(JSON.stringify(parts)).digest("hex"); }
5
- function eventValue(event) {
6
- return event.kind === "new-entity-appeared"
7
- ? { kind: event.kind, entityKey: event.entityKey, currentValues: event.current.map((item) => ({ fieldKey: item.fieldKey, value: item.value })) }
8
- : { kind: event.kind, changeKind: event.changeKind, entityKey: event.entityKey, fieldKey: event.fieldKey, ...(event.prior ? { priorValue: event.prior.value } : {}), ...(event.current ? { currentValue: event.current.value } : {}) };
9
- }
10
- function observationFor(source, prior, current, event, generatedAt, index) {
11
- const eventId = id(source.id, prior.observationId, current.snapshotRef, event.kind, event.entityKey, event.kind === "field-changed" ? event.fieldKey : "", index);
12
- const evidence = event.kind === "new-entity-appeared" ? event.current : [event.prior, event.current].filter((item) => item !== undefined);
13
- const primary = event.kind === "new-entity-appeared" ? event.current[0] : event.current ?? event.prior;
14
- const metadata = {
15
- sourceId: source.id, eventKind: event.kind, priorObservationId: prior.observationId,
16
- priorSnapshotRef: prior.snapshotRef, currentSnapshotRef: current.snapshotRef,
17
- evidence: evidence.map((item) => ({ snapshotRef: item.snapshotRef, locator: item.provenance.locator, excerpt: item.provenance.excerpt, extractor: item.extractor, fieldPath: item.fieldPath })),
18
- ...(event.kind === "field-changed" ? { changeKind: event.changeKind } : {}),
19
- };
20
- return {
21
- id: `lookout-${eventId}`,
22
- rawSource: { kind: source.kind, resolution: "observation", sourceRef: current.snapshotRef, observedAt: current.observedAt, locatorScheme: source.kind === "web-page" ? "html" : "structured-field", metadata },
23
- extraction: { target: `lookout:${event.entityKey}:${event.kind === "field-changed" ? event.fieldKey : "appearance"}`, value: eventValue(event), confidence: primary?.confidence, locator: primary?.provenance.locator, excerpt: primary?.provenance.excerpt, extractor: primary?.extractor ?? "lookout:proposal-diff", extractedAt: generatedAt, metadata },
24
- candidateSet: { status: "needs-review", metadata },
25
- claim: { subjectType: "lookout.observed-entity", subjectId: event.entityKey, facet: "lookout.source-change", claimType: event.kind === "new-entity-appeared" ? "lookout.new-entity-appeared" : "lookout.field-changed", fieldOrBehavior: event.kind === "field-changed" ? event.fieldKey : event.entityKey, value: eventValue(event), status: "proposed", impactLevel: "low", collectedBy: "@kontourai/lookout", createdAt: generatedAt, evidenceMethod: "observation", metadata },
26
- };
27
- }
28
- function containsAuthorizing(value) {
29
- if (Array.isArray(value))
30
- return value.some(containsAuthorizing);
31
- if (!value || typeof value !== "object")
32
- return false;
33
- return Object.entries(value).some(([key, item]) => key === "authorizing" || containsAuthorizing(item));
34
- }
35
- function validateSurveyInput(input, source, current, prior, events) {
36
- if (input.reviewOutcomes.length !== 0 || containsAuthorizing(input))
37
- return false;
38
- if (input.rawSources.length !== events.length || input.claims.length !== events.length)
39
- return false;
40
- for (const raw of input.rawSources)
41
- if (raw.kind !== source.kind || raw.resolution !== "observation" || raw.sourceRef !== current.snapshotRef)
42
- return false;
43
- for (let index = 0; index < events.length; index += 1) {
44
- const claim = input.claims[index];
45
- const event = events[index];
46
- if (!claim || !event || claim.status !== "proposed" || claim.metadata?.priorSnapshotRef !== prior.snapshotRef || claim.metadata?.currentSnapshotRef !== current.snapshotRef)
47
- return false;
48
- const evidence = claim.metadata.evidence;
49
- if (!Array.isArray(evidence))
50
- return false;
51
- if (event.kind === "new-entity-appeared" && !event.current.every((side) => evidence.some((item) => item && typeof item === "object" && item.snapshotRef === side.snapshotRef)))
52
- return false;
53
- if (event.kind === "field-changed") {
54
- if (event.prior && !evidence.some((item) => item && typeof item === "object" && item.snapshotRef === event.prior.snapshotRef))
55
- return false;
56
- if (event.current && !evidence.some((item) => item && typeof item === "object" && item.snapshotRef === event.current.snapshotRef))
57
- return false;
58
- if (!event.current && !input.rawSources[index] || input.rawSources[index]?.sourceRef !== current.snapshotRef)
59
- return false;
60
- }
61
- }
62
- return true;
63
- }
64
- function stableJson(value) {
65
- if (Array.isArray(value))
66
- return `[${value.map(stableJson).join(",")}]`;
67
- if (value && typeof value === "object")
68
- return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
69
- return JSON.stringify(value) ?? "undefined";
70
- }
71
- function normalizeDiff(value) {
72
- const sorted = (items) => [...items].sort((a, b) => stableJson(a).localeCompare(stableJson(b)));
73
- return { events: sorted(value.events), facts: { retainedProposalOccurrences: sorted(value.facts.retainedProposalOccurrences), addedProposalOccurrences: sorted(value.facts.addedProposalOccurrences), removedProposalOccurrences: sorted(value.facts.removedProposalOccurrences), provenanceChanges: sorted(value.facts.provenanceChanges), removedEntities: [...value.facts.removedEntities].sort() } };
74
- }
75
- export function createSurveyEmitter(options) {
76
- const now = options.now ?? (() => new Date().toISOString());
77
- const diff = options.diff ?? diffProposalSets;
78
- return { async emit(input) {
79
- try {
80
- if (options.resolution !== undefined && options.resolution !== "observation")
81
- return { ok: false, error: { kind: "invalid-input", message: "Lookout may author only observation resolution" } };
82
- if (!input.source || input.source.id !== input.current?.sourceId || input.check?.currentSnapshotRef !== input.current?.snapshotRef)
83
- return { ok: false, error: { kind: "invalid-input", message: "Registry source, observation, and check anchor must agree" } };
84
- const loaded = await options.store.loadLatest(input.source.id);
85
- if (!loaded.ok)
86
- return { ok: false, error: { kind: "prior-state-error", message: loaded.error.message, cause: loaded.error } };
87
- const recordedAt = now();
88
- let events = [];
89
- let facts;
90
- let surveyInput = null;
91
- if (loaded.value === null) {
92
- 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 }];
93
- }
94
- else {
95
- let derived;
96
- try {
97
- derived = diff({ prior: { sourceId: loaded.value.sourceId, snapshotRef: loaded.value.snapshotRef, observedAt: loaded.value.observedAt, proposals: loaded.value.proposals }, current: input.current, ...input.callbacks });
98
- }
99
- catch (cause) {
100
- return { ok: false, error: { kind: "diff-error", message: "Proposal diff threw", cause } };
101
- }
102
- if (!derived.ok)
103
- return { ok: false, error: { kind: "diff-error", message: derived.error.message, cause: derived.error } };
104
- const normalized = normalizeDiff(derived.value);
105
- events = normalized.events;
106
- facts = [{ kind: "proposal-set-facts", priorSnapshotRef: loaded.value.snapshotRef, currentSnapshotRef: input.current.snapshotRef, origin: input.source.kind, resolution: "observation", value: normalized.facts }];
107
- if (events.length > 0) {
108
- try {
109
- const builder = new SurveyInputBuilder({ source: "@kontourai/lookout", generatedAt: recordedAt });
110
- builder.addObservations(events.map((event, index) => observationFor(input.source, loaded.value, input.current, event, recordedAt, index)));
111
- surveyInput = builder.build();
112
- if (options.transformSurveyInput)
113
- surveyInput = options.transformSurveyInput(surveyInput);
114
- JSON.stringify(surveyInput);
115
- if (!validateSurveyInput(surveyInput, input.source, input.current, loaded.value, events))
116
- return { ok: false, error: { kind: "authoring-error", message: "SurveyInput violated observation-only provenance" } };
117
- }
118
- catch (cause) {
119
- return { ok: false, error: { kind: "authoring-error", message: "Could not author SurveyInput", cause } };
120
- }
121
- }
122
- }
123
- try {
124
- JSON.stringify({ events, facts, surveyInput });
125
- }
126
- catch (cause) {
127
- return { ok: false, error: { kind: "authoring-error", message: "Emission result is not serializable", cause } };
128
- }
129
- const committed = await options.store.commit({ observation: input.current, recordedAt, check: input.check }, loaded.value?.observationId ?? null);
130
- if (!committed.ok)
131
- return { ok: false, error: { kind: "persistence-error", message: committed.error.message, cause: committed.error } };
132
- return { ok: true, value: { sourceId: input.source.id, events, facts, surveyInput, committedObservation: committed.value, warnings: committed.warnings ?? [] } };
133
- }
134
- catch (cause) {
135
- return { ok: false, error: { kind: "authoring-error", message: "Emission failed", cause } };
136
- }
137
- } };
138
- }