@kontourai/lookout 0.2.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 +94 -17
- package/dist/src/check-runner.js +24 -1
- package/dist/src/cli.d.ts +2 -2
- package/dist/src/cli.js +10 -10
- package/dist/src/coverage.d.ts +58 -0
- package/dist/src/coverage.js +39 -0
- package/dist/src/{survey-emission.d.ts → drift-emission.d.ts} +16 -19
- package/dist/src/drift-emission.js +71 -0
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +2 -1
- package/package.json +2 -2
- package/dist/src/survey-emission.js +0 -138
package/README.md
CHANGED
|
@@ -1,14 +1,59 @@
|
|
|
1
1
|
# @kontourai/lookout
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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-
|
|
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-
|
|
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 `
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
package/dist/src/check-runner.js
CHANGED
|
@@ -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 }, { ...
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
128
|
+
return "emit-drift requires a source id";
|
|
129
129
|
if (!observationPath)
|
|
130
|
-
return "emit-
|
|
131
|
-
return { command: "emit-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
21
|
+
export interface DriftSuccess {
|
|
23
22
|
readonly sourceId: string;
|
|
24
23
|
readonly events: readonly ProposalDiffEvent[];
|
|
25
|
-
readonly facts: readonly
|
|
26
|
-
|
|
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
|
|
31
|
-
export interface
|
|
32
|
-
readonly kind:
|
|
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
|
|
36
|
+
export type DriftResult = {
|
|
37
37
|
readonly ok: true;
|
|
38
|
-
readonly value:
|
|
38
|
+
readonly value: DriftSuccess;
|
|
39
39
|
} | {
|
|
40
40
|
readonly ok: false;
|
|
41
|
-
readonly error:
|
|
41
|
+
readonly error: DriftError;
|
|
42
42
|
};
|
|
43
|
-
export interface
|
|
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
|
|
50
|
-
emit(input:
|
|
49
|
+
export interface DriftEmitter<E> {
|
|
50
|
+
emit(input: EmitDriftInput<E>): Promise<DriftResult>;
|
|
51
51
|
}
|
|
52
|
-
export interface
|
|
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
|
|
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
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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 {
|
|
20
|
-
export type { BaselineEstablishedFact,
|
|
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 {
|
|
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.
|
|
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/
|
|
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
|
-
}
|