@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 +7 -1
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +9 -6
- package/dist/src/drift-emission.d.ts +3 -0
- package/dist/src/drift-emission.js +46 -9
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +2 -0
- package/dist/src/observation-admission.d.ts +59 -0
- package/dist/src/observation-admission.js +144 -0
- package/dist/src/source-admission.d.ts +37 -0
- package/dist/src/source-admission.js +138 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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 =
|
|
53
|
+
const priorObservationId = prior?.observationId ?? null;
|
|
38
54
|
let events = [];
|
|
39
55
|
let facts;
|
|
40
|
-
if (
|
|
41
|
-
facts = [{ kind: "baseline-established", sourceId:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
50
|
+
"@kontourai/forage": "0.6.1",
|
|
51
51
|
"@kontourai/traverse": "0.25.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|