@gmickel/gno 1.16.0 → 1.17.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.
Files changed (46) hide show
  1. package/README.md +20 -16
  2. package/assets/skill/SKILL.md +8 -5
  3. package/package.json +1 -1
  4. package/src/app/context-agent-projection.ts +303 -0
  5. package/src/app/context-format.ts +249 -0
  6. package/src/app/context-runtime-contract.ts +325 -0
  7. package/src/app/context-runtime-input.ts +362 -0
  8. package/src/app/context-runtime-types.ts +65 -0
  9. package/src/app/context-runtime.ts +170 -0
  10. package/src/app/context-surface.ts +145 -0
  11. package/src/cli/commands/context-build.ts +149 -0
  12. package/src/cli/commands/context-verify.ts +90 -0
  13. package/src/cli/options.ts +4 -0
  14. package/src/cli/program.ts +178 -0
  15. package/src/core/context-budget.ts +461 -0
  16. package/src/core/context-capsule-index-schema.ts +15 -0
  17. package/src/core/context-capsule-retrieval-schema.ts +81 -0
  18. package/src/core/context-capsule-schema.ts +473 -0
  19. package/src/core/context-capsule-validation.ts +416 -0
  20. package/src/core/context-capsule-verification.ts +218 -0
  21. package/src/core/context-capsule.ts +439 -0
  22. package/src/core/context-compiler.ts +513 -0
  23. package/src/core/context-evidence-metadata.ts +33 -0
  24. package/src/core/context-evidence.ts +495 -0
  25. package/src/core/context-facets.ts +163 -0
  26. package/src/core/context-guidance.ts +69 -0
  27. package/src/core/context-scope.ts +32 -0
  28. package/src/core/context-verifier-canonical.ts +90 -0
  29. package/src/core/context-verifier-input.ts +66 -0
  30. package/src/core/context-verifier.ts +447 -0
  31. package/src/core/sections.ts +63 -0
  32. package/src/mcp/server.ts +10 -4
  33. package/src/mcp/tools/context.ts +229 -0
  34. package/src/mcp/tools/index.ts +27 -0
  35. package/src/pipeline/chunk-lookup.ts +33 -0
  36. package/src/pipeline/hybrid.ts +79 -57
  37. package/src/pipeline/types.ts +14 -0
  38. package/src/sdk/client.ts +68 -6
  39. package/src/sdk/index.ts +21 -0
  40. package/src/sdk/types.ts +24 -0
  41. package/src/serve/background-runtime.ts +1 -0
  42. package/src/serve/context-capsule.ts +136 -0
  43. package/src/serve/context.ts +10 -1
  44. package/src/serve/routes/api.ts +2 -0
  45. package/src/serve/server.ts +23 -0
  46. package/src/store/sqlite/adapter.ts +38 -20
@@ -0,0 +1,69 @@
1
+ import type { SearchResult } from "../pipeline/types";
2
+ import type { ContextRow } from "../store/types";
3
+
4
+ import { decorateUriForIndex } from "../app/constants";
5
+ import { contextCapsuleContextIdentity } from "./context-capsule-validation";
6
+ import {
7
+ contextIdentityFromUri,
8
+ resolveContextSnapshot,
9
+ } from "./context-resolver";
10
+
11
+ export interface ContextConfiguredGuidance {
12
+ contextId: string;
13
+ scopeType: "global" | "collection" | "prefix";
14
+ scopeKey: string;
15
+ text: string;
16
+ }
17
+
18
+ const compareCodeUnits = (left: string, right: string): number => {
19
+ if (left < right) return -1;
20
+ if (left > right) return 1;
21
+ return 0;
22
+ };
23
+
24
+ /** A docid is only a short source-hash prefix and cannot identify a document. */
25
+ export const contextGuidanceResultIdentity = (
26
+ result: Pick<SearchResult, "docid" | "uri">
27
+ ): string => JSON.stringify([result.uri, result.docid]);
28
+
29
+ export const resolveContextGuidance = (
30
+ contextSnapshot: ContextRow[],
31
+ results: SearchResult[],
32
+ indexName: string
33
+ ): {
34
+ contexts: ContextConfiguredGuidance[];
35
+ idsByResultIdentity: Map<string, string[]>;
36
+ } => {
37
+ const byId = new Map<string, ContextConfiguredGuidance>();
38
+ const idsByResultIdentity = new Map<string, string[]>();
39
+ for (const result of results) {
40
+ const ids: string[] = [];
41
+ const identity = contextIdentityFromUri(result.uri);
42
+ const resolved = identity
43
+ ? resolveContextSnapshot(contextSnapshot, identity)
44
+ : undefined;
45
+ for (const provenance of resolved?.provenance ?? []) {
46
+ const guidance = {
47
+ scopeType: provenance.scopeType,
48
+ scopeKey:
49
+ provenance.scopeType === "prefix"
50
+ ? decorateUriForIndex(provenance.normalizedScopeKey, indexName)
51
+ : provenance.normalizedScopeKey,
52
+ text: provenance.text,
53
+ };
54
+ const contextId = contextCapsuleContextIdentity(guidance);
55
+ byId.set(contextId, { contextId, ...guidance });
56
+ ids.push(contextId);
57
+ }
58
+ idsByResultIdentity.set(
59
+ contextGuidanceResultIdentity(result),
60
+ [...new Set(ids)].sort(compareCodeUnits)
61
+ );
62
+ }
63
+ return {
64
+ contexts: [...byId.values()].sort((left, right) =>
65
+ compareCodeUnits(left.contextId, right.contextId)
66
+ ),
67
+ idsByResultIdentity,
68
+ };
69
+ };
@@ -0,0 +1,32 @@
1
+ /** Canonical URI scope matching shared by Context planning stages. */
2
+
3
+ import { DEFAULT_INDEX_NAME, parseUri } from "../app/constants";
4
+ import { canonicalizeIndexName } from "../app/index-name";
5
+
6
+ export const isContextUriInScope = (
7
+ uri: string,
8
+ indexName: string,
9
+ collections: string[],
10
+ prefixValue: string | null
11
+ ): boolean => {
12
+ const value = parseUri(uri);
13
+ if (
14
+ !value ||
15
+ canonicalizeIndexName(value.indexName ?? DEFAULT_INDEX_NAME) !==
16
+ indexName ||
17
+ (collections.length > 0 && !collections.includes(value.collection))
18
+ ) {
19
+ return false;
20
+ }
21
+ if (prefixValue === null) return true;
22
+ const prefix = parseUri(prefixValue);
23
+ return Boolean(
24
+ prefix &&
25
+ value.collection === prefix.collection &&
26
+ canonicalizeIndexName(prefix.indexName ?? DEFAULT_INDEX_NAME) ===
27
+ indexName &&
28
+ (prefix.path === "" ||
29
+ value.path === prefix.path ||
30
+ value.path.startsWith(`${prefix.path}/`))
31
+ );
32
+ };
@@ -0,0 +1,90 @@
1
+ /** Stable JSON and raw text checks used by Capsule verification guards. */
2
+
3
+ const compareCodeUnits = (left: string, right: string): number =>
4
+ left < right ? -1 : left > right ? 1 : 0;
5
+
6
+ const canonicalizeJsonValue = (value: unknown): unknown => {
7
+ if (Array.isArray(value)) return value.map(canonicalizeJsonValue);
8
+ if (value !== null && typeof value === "object") {
9
+ const sorted: Record<string, unknown> = {};
10
+ for (const key of Object.keys(value).sort(compareCodeUnits)) {
11
+ const child = (value as Record<string, unknown>)[key];
12
+ if (child === undefined) {
13
+ throw new Error(`Canonical JSON rejects undefined at ${key}`);
14
+ }
15
+ sorted[key] = canonicalizeJsonValue(child);
16
+ }
17
+ return sorted;
18
+ }
19
+ if (typeof value === "number" && !Number.isFinite(value)) {
20
+ throw new Error("Canonical JSON rejects non-finite numbers");
21
+ }
22
+ return value;
23
+ };
24
+
25
+ export const canonicalVerifierJson = (value: unknown): string =>
26
+ JSON.stringify(canonicalizeJsonValue(value));
27
+
28
+ const isNoncanonicalNormalizedText = (value: unknown): boolean => {
29
+ if (typeof value === "string") {
30
+ return value.includes("\r") || value !== value.normalize("NFC");
31
+ }
32
+ return false;
33
+ };
34
+
35
+ const containsNoncanonicalText = (value: unknown): boolean =>
36
+ Array.isArray(value) && value.some(isNoncanonicalNormalizedText);
37
+
38
+ const recordOf = (value: unknown): Record<string, unknown> =>
39
+ value !== null && typeof value === "object"
40
+ ? (value as Record<string, unknown>)
41
+ : {};
42
+
43
+ /** Mirrors only the text fields normalized by normalizePayload. */
44
+ export const hasNoncanonicalVerifierText = (value: unknown): boolean => {
45
+ const capsule = recordOf(value);
46
+ const scope = recordOf(capsule.scope);
47
+ const retrieval = recordOf(capsule.retrieval);
48
+ const guidance = recordOf(capsule.guidance);
49
+ const coverage = recordOf(capsule.coverage);
50
+ const evidence = Array.isArray(capsule.evidence) ? capsule.evidence : [];
51
+ const configuredContexts = Array.isArray(guidance.configuredContexts)
52
+ ? guidance.configuredContexts
53
+ : [];
54
+ const coveredFacets = Array.isArray(coverage.coveredFacets)
55
+ ? coverage.coveredFacets
56
+ : [];
57
+
58
+ return (
59
+ isNoncanonicalNormalizedText(capsule.goal) ||
60
+ isNoncanonicalNormalizedText(capsule.query) ||
61
+ isNoncanonicalNormalizedText(scope.uriPrefix) ||
62
+ containsNoncanonicalText(scope.collections) ||
63
+ containsNoncanonicalText(scope.tagsAll) ||
64
+ containsNoncanonicalText(scope.tagsAny) ||
65
+ containsNoncanonicalText(scope.categories) ||
66
+ containsNoncanonicalText(retrieval.facets) ||
67
+ containsNoncanonicalText(retrieval.queryVariants) ||
68
+ evidence.some((item) => {
69
+ const record = recordOf(item);
70
+ return (
71
+ isNoncanonicalNormalizedText(record.title) ||
72
+ isNoncanonicalNormalizedText(record.heading) ||
73
+ containsNoncanonicalText(record.contextIds) ||
74
+ containsNoncanonicalText(record.facets)
75
+ );
76
+ }) ||
77
+ configuredContexts.some((item) => {
78
+ const record = recordOf(item);
79
+ return (
80
+ isNoncanonicalNormalizedText(record.scopeKey) ||
81
+ isNoncanonicalNormalizedText(record.text)
82
+ );
83
+ }) ||
84
+ containsNoncanonicalText(coverage.requestedFacets) ||
85
+ coveredFacets.some((item) =>
86
+ isNoncanonicalNormalizedText(recordOf(item).facet)
87
+ ) ||
88
+ containsNoncanonicalText(coverage.unresolvedFacets)
89
+ );
90
+ };
@@ -0,0 +1,66 @@
1
+ /** Canonical verification input preflight; performs no store I/O. */
2
+
3
+ import type { ContextCapsuleV1 } from "./context-capsule";
4
+
5
+ import {
6
+ canonicalContextCapsuleJson,
7
+ ContextCapsuleContractError,
8
+ parseContextCapsuleV1,
9
+ type ContextCapsuleCreateOptions,
10
+ } from "./context-capsule";
11
+ import {
12
+ canonicalVerifierJson,
13
+ hasNoncanonicalVerifierText,
14
+ } from "./context-verifier-canonical";
15
+
16
+ export interface ContextVerifierTokenAuthority {
17
+ countTokens?: ContextCapsuleCreateOptions["countTokens"];
18
+ tokenizerFingerprint?: string | null;
19
+ }
20
+
21
+ export const rawCanonicalContextJson = (input: unknown): string => {
22
+ try {
23
+ return canonicalVerifierJson(input);
24
+ } catch (cause) {
25
+ throw new ContextCapsuleContractError(
26
+ "invalid_input",
27
+ "Context Capsule input must be canonical JSON",
28
+ { cause }
29
+ );
30
+ }
31
+ };
32
+
33
+ export const parseCanonicalContextCapsuleForVerification = (
34
+ input: unknown,
35
+ authority: ContextVerifierTokenAuthority = {}
36
+ ): ContextCapsuleV1 => {
37
+ const rawInputBefore = rawCanonicalContextJson(input);
38
+ if (hasNoncanonicalVerifierText(input)) {
39
+ throw new ContextCapsuleContractError(
40
+ "invalid_input",
41
+ "Context Capsule input must already use NFC text and LF line endings"
42
+ );
43
+ }
44
+ let capsule = parseContextCapsuleV1(input);
45
+ if (capsule.budget.estimator === "active_tokenizer") {
46
+ if (
47
+ authority.countTokens === undefined ||
48
+ authority.tokenizerFingerprint !== capsule.budget.tokenizerFingerprint
49
+ ) {
50
+ throw new ContextCapsuleContractError(
51
+ "tokenizer_unavailable",
52
+ "The Capsule's active tokenizer and matching fingerprint are required for verification"
53
+ );
54
+ }
55
+ capsule = parseContextCapsuleV1(input, {
56
+ countTokens: authority.countTokens,
57
+ });
58
+ }
59
+ if (rawInputBefore !== canonicalContextCapsuleJson(capsule)) {
60
+ throw new ContextCapsuleContractError(
61
+ "invalid_input",
62
+ "Context Capsule input must already use its canonical semantic representation"
63
+ );
64
+ }
65
+ return capsule;
66
+ };
@@ -0,0 +1,447 @@
1
+ /** Deterministic, non-mutating verification for saved Context Capsules. */
2
+
3
+ import type {
4
+ ChunkRow,
5
+ DocumentRow,
6
+ StorePort,
7
+ StoreResult,
8
+ } from "../store/types";
9
+ import type { ContextCapsuleV1 } from "./context-capsule";
10
+ import type { ContextCapsuleVerification } from "./context-capsule-verification";
11
+
12
+ import { decorateUriForIndex, deriveDocid } from "../app/constants";
13
+ import { chunkMatchesCanonicalContent } from "../pipeline/chunk-lookup";
14
+ import { type ContextCapsuleCreateOptions } from "./context-capsule";
15
+ import { sha256Text } from "./context-capsule-validation";
16
+ import {
17
+ CONTEXT_CAPSULE_FINGERPRINT_DRIFT_REASONS,
18
+ contextCapsuleVerificationSchema,
19
+ } from "./context-capsule-verification";
20
+ import {
21
+ captureContextEvidenceSnapshot,
22
+ type ContextEvidenceSnapshot,
23
+ } from "./context-evidence";
24
+ import { canonicalVerifierJson } from "./context-verifier-canonical";
25
+ import {
26
+ parseCanonicalContextCapsuleForVerification,
27
+ rawCanonicalContextJson,
28
+ } from "./context-verifier-input";
29
+ import { extractInclusiveLines } from "./sections";
30
+
31
+ type ContextVerifierStore = Pick<
32
+ StorePort,
33
+ | "getActivationIndexSnapshot"
34
+ | "getChunksBatch"
35
+ | "getCollections"
36
+ | "getContexts"
37
+ | "getDocumentsByDocids"
38
+ > &
39
+ Required<Pick<StorePort, "getContentBatch">>;
40
+
41
+ export interface ContextVerifierFingerprints {
42
+ config: string;
43
+ retrieval: string;
44
+ embeddingModel: string | null;
45
+ rerankModel: string | null;
46
+ tokenizer: string | null;
47
+ }
48
+
49
+ export interface ContextVerifierDeps {
50
+ store: ContextVerifierStore;
51
+ currentFingerprints: ContextVerifierFingerprints;
52
+ resolveCurrentRanks?: (
53
+ capsule: ContextCapsuleV1
54
+ ) => Promise<ReadonlyMap<string, number>>;
55
+ countTokens?: ContextCapsuleCreateOptions["countTokens"];
56
+ tokenizerFingerprint?: string | null;
57
+ }
58
+
59
+ export type ContextVerifierErrorCode =
60
+ | "capsule_mutated_during_verify"
61
+ | "chunk_load_failed"
62
+ | "content_load_failed"
63
+ | "context_changed_during_verify"
64
+ | "document_load_failed"
65
+ | "index_changed_during_verify";
66
+
67
+ export class ContextVerifierError extends Error {
68
+ readonly code: ContextVerifierErrorCode;
69
+
70
+ constructor(
71
+ code: ContextVerifierErrorCode,
72
+ message: string,
73
+ cause?: unknown
74
+ ) {
75
+ super(message, cause === undefined ? undefined : { cause });
76
+ this.name = "ContextVerifierError";
77
+ this.code = code;
78
+ }
79
+ }
80
+
81
+ const unwrapStore = <T>(
82
+ result: StoreResult<T>,
83
+ code: ContextVerifierErrorCode,
84
+ operation: string
85
+ ): T => {
86
+ if (result.ok) return result.value;
87
+ throw new ContextVerifierError(
88
+ code,
89
+ `${operation}: ${result.error.message}`,
90
+ result.error.cause
91
+ );
92
+ };
93
+
94
+ const matchingDocument = (
95
+ documents: readonly DocumentRow[],
96
+ uri: string,
97
+ sourceHash: string,
98
+ mirrorHash: string,
99
+ indexName: string
100
+ ): DocumentRow | null => {
101
+ const matches = documents.filter(
102
+ (document) =>
103
+ document.active &&
104
+ document.docid === deriveDocid(sourceHash) &&
105
+ document.sourceHash === sourceHash &&
106
+ document.mirrorHash === mirrorHash &&
107
+ decorateUriForIndex(document.uri, indexName) === uri
108
+ );
109
+ return matches.length === 1 ? (matches[0] ?? null) : null;
110
+ };
111
+
112
+ const rawInclusiveLines = (
113
+ content: string,
114
+ startLine: number,
115
+ endLine: number
116
+ ): string => {
117
+ const lines = content.split("\n");
118
+ if (startLine > lines.length) return "";
119
+ return lines.slice(startLine - 1, Math.min(endLine, lines.length)).join("\n");
120
+ };
121
+
122
+ type Evidence = ContextCapsuleV1["evidence"][number];
123
+ type EvidenceReceipt = ContextCapsuleVerification["evidence"][number];
124
+
125
+ interface LoadedEvidence {
126
+ contentByHash: Map<string, string>;
127
+ chunksByHash: Map<string, ChunkRow[]>;
128
+ documents: DocumentRow[];
129
+ snapshot: ContextEvidenceSnapshot;
130
+ }
131
+
132
+ const missingReceipt = (evidence: Evidence): EvidenceReceipt => ({
133
+ evidenceId: evidence.evidenceId,
134
+ uri: evidence.uri,
135
+ contentStatus: "missing",
136
+ contentCode: "source_missing",
137
+ rankingStatus: "unavailable",
138
+ rankingCode: "ranking_unavailable",
139
+ currentSourceHash: null,
140
+ currentMirrorHash: null,
141
+ currentPassageHash: null,
142
+ currentRetrievalRank: null,
143
+ });
144
+
145
+ const missingMirrorReceipt = (
146
+ evidence: Evidence,
147
+ sourceHash: string,
148
+ mirrorHash: string | null
149
+ ): EvidenceReceipt => ({
150
+ evidenceId: evidence.evidenceId,
151
+ uri: evidence.uri,
152
+ contentStatus: "missing",
153
+ contentCode: "mirror_missing",
154
+ rankingStatus: "unavailable",
155
+ rankingCode: "ranking_unavailable",
156
+ currentSourceHash: sourceHash,
157
+ currentMirrorHash: mirrorHash,
158
+ currentPassageHash: null,
159
+ currentRetrievalRank: null,
160
+ });
161
+
162
+ const verifyContent = (
163
+ evidence: Evidence,
164
+ loaded: LoadedEvidence,
165
+ indexName: string
166
+ ): EvidenceReceipt => {
167
+ const identities = loaded.snapshot.documents.filter(
168
+ (document) => document.uri === evidence.uri
169
+ );
170
+ const identity = identities.length === 1 ? identities[0] : undefined;
171
+ if (!identity) return missingReceipt(evidence);
172
+ if (!identity.mirrorHash) {
173
+ return missingMirrorReceipt(evidence, identity.sourceHash, null);
174
+ }
175
+ const document = matchingDocument(
176
+ loaded.documents,
177
+ evidence.uri,
178
+ identity.sourceHash,
179
+ identity.mirrorHash,
180
+ indexName
181
+ );
182
+ const currentSourceHash = document?.sourceHash ?? identity.sourceHash;
183
+ const registeredMirrorHash = document?.mirrorHash ?? identity.mirrorHash;
184
+ const content = loaded.contentByHash.get(registeredMirrorHash);
185
+ if (content === undefined) {
186
+ return missingMirrorReceipt(
187
+ evidence,
188
+ currentSourceHash,
189
+ registeredMirrorHash
190
+ );
191
+ }
192
+
193
+ const currentMirrorHash = sha256Text(content);
194
+ const exactPassage = extractInclusiveLines(
195
+ content,
196
+ evidence.startLine,
197
+ evidence.endLine
198
+ );
199
+ const currentPassageHash = sha256Text(
200
+ exactPassage ??
201
+ rawInclusiveLines(content, evidence.startLine, evidence.endLine)
202
+ );
203
+ const chunks = loaded.chunksByHash.get(registeredMirrorHash);
204
+ const chunk = chunks?.find(
205
+ (candidate) =>
206
+ candidate.mirrorHash === registeredMirrorHash &&
207
+ candidate.startLine === evidence.startLine &&
208
+ candidate.endLine === evidence.endLine &&
209
+ chunkMatchesCanonicalContent(candidate, content)
210
+ );
211
+ const chunkValid = chunk !== undefined;
212
+
213
+ let contentCode: EvidenceReceipt["contentCode"] = "verified_unchanged";
214
+ if (currentSourceHash !== evidence.sourceHash) {
215
+ contentCode = "source_stale";
216
+ } else if (
217
+ currentMirrorHash !== registeredMirrorHash ||
218
+ content.includes("\r")
219
+ ) {
220
+ contentCode = "mirror_corrupt";
221
+ } else if (registeredMirrorHash !== evidence.mirrorHash) {
222
+ contentCode = "mirror_stale";
223
+ } else if (
224
+ exactPassage === null ||
225
+ currentPassageHash !== evidence.passageHash
226
+ ) {
227
+ contentCode = "passage_stale";
228
+ } else if (!chunks || chunks.length === 0) {
229
+ contentCode = "chunk_missing";
230
+ } else if (!chunkValid) {
231
+ contentCode = "chunk_corrupt";
232
+ }
233
+
234
+ return {
235
+ evidenceId: evidence.evidenceId,
236
+ uri: evidence.uri,
237
+ contentStatus: contentCode === "verified_unchanged" ? "unchanged" : "stale",
238
+ contentCode,
239
+ rankingStatus: "unavailable",
240
+ rankingCode: "ranking_unavailable",
241
+ currentSourceHash,
242
+ currentMirrorHash,
243
+ currentPassageHash,
244
+ currentRetrievalRank: null,
245
+ };
246
+ };
247
+
248
+ const applyRanking = (
249
+ receipt: EvidenceReceipt,
250
+ evidence: Evidence,
251
+ currentRanks: ReadonlyMap<string, number> | null
252
+ ): EvidenceReceipt => {
253
+ if (receipt.contentStatus !== "unchanged") return receipt;
254
+ const rank = currentRanks?.get(evidence.evidenceId);
255
+ if (!Number.isSafeInteger(rank) || (rank ?? 0) < 1) return receipt;
256
+ const reranked = rank !== evidence.retrievalRank;
257
+ return {
258
+ ...receipt,
259
+ rankingStatus: reranked ? "reranked" : "unchanged",
260
+ rankingCode: reranked ? "ranking_changed" : "ranking_unchanged",
261
+ currentRetrievalRank: rank ?? null,
262
+ };
263
+ };
264
+
265
+ const aggregateReceipt = (
266
+ capsule: ContextCapsuleV1,
267
+ indexFingerprint: string,
268
+ currentFingerprints: ContextVerifierFingerprints,
269
+ evidence: EvidenceReceipt[]
270
+ ): ContextCapsuleVerification => {
271
+ const contentStatus = evidence.some(
272
+ (item) => item.contentStatus === "missing"
273
+ )
274
+ ? "missing"
275
+ : evidence.some((item) => item.contentStatus === "stale")
276
+ ? "stale"
277
+ : "unchanged";
278
+ const rankingStatus = evidence.some(
279
+ (item) => item.rankingStatus === "unavailable"
280
+ )
281
+ ? "unavailable"
282
+ : evidence.some((item) => item.rankingStatus === "reranked")
283
+ ? "reranked"
284
+ : "unchanged";
285
+ const reasons = fingerprintReasons(
286
+ capsule,
287
+ currentFingerprints,
288
+ indexFingerprint
289
+ );
290
+ return contextCapsuleVerificationSchema.parse({
291
+ schemaVersion: capsule.schemaVersion,
292
+ coordinateSpace: capsule.coordinateSpace,
293
+ capsuleId: capsule.capsuleId,
294
+ operationStatus: "completed",
295
+ contentStatus,
296
+ contentCode:
297
+ contentStatus === "missing"
298
+ ? "content_missing"
299
+ : contentStatus === "stale"
300
+ ? "content_stale"
301
+ : "verified_unchanged",
302
+ rankingStatus,
303
+ rankingCode:
304
+ rankingStatus === "unavailable"
305
+ ? "ranking_unavailable"
306
+ : rankingStatus === "reranked"
307
+ ? "ranking_changed"
308
+ : "ranking_unchanged",
309
+ currentFingerprints: {
310
+ ...currentFingerprints,
311
+ index: indexFingerprint,
312
+ },
313
+ fingerprintStatus: reasons.length === 0 ? "unchanged" : "drifted",
314
+ fingerprintReasons: reasons,
315
+ indexSnapshot: {
316
+ before: indexFingerprint,
317
+ after: indexFingerprint,
318
+ stable: true,
319
+ },
320
+ evidence,
321
+ });
322
+ };
323
+
324
+ const fingerprintReasons = (
325
+ capsule: ContextCapsuleV1,
326
+ current: ContextVerifierFingerprints,
327
+ indexFingerprint: string
328
+ ): (typeof CONTEXT_CAPSULE_FINGERPRINT_DRIFT_REASONS)[number][] => {
329
+ const changed = {
330
+ config_changed: current.config !== capsule.fingerprints.config,
331
+ retrieval_changed: current.retrieval !== capsule.fingerprints.retrieval,
332
+ embedding_model_changed:
333
+ current.embeddingModel !== capsule.fingerprints.embeddingModel,
334
+ rerank_model_changed:
335
+ current.rerankModel !== capsule.fingerprints.rerankModel,
336
+ tokenizer_changed: current.tokenizer !== capsule.fingerprints.tokenizer,
337
+ index_changed: indexFingerprint !== capsule.retrieval.indexSnapshot.after,
338
+ } satisfies Record<
339
+ (typeof CONTEXT_CAPSULE_FINGERPRINT_DRIFT_REASONS)[number],
340
+ boolean
341
+ >;
342
+ return CONTEXT_CAPSULE_FINGERPRINT_DRIFT_REASONS.filter(
343
+ (reason) => changed[reason]
344
+ );
345
+ };
346
+
347
+ export { parseCanonicalContextCapsuleForVerification } from "./context-verifier-input";
348
+
349
+ /** Verify a Capsule without rebuilding it or mutating caller-owned input. */
350
+ export const verifyContextCapsule = async (
351
+ input: unknown,
352
+ deps: ContextVerifierDeps
353
+ ): Promise<ContextCapsuleVerification> => {
354
+ const rawInputBefore = rawCanonicalContextJson(input);
355
+ const capsule = parseCanonicalContextCapsuleForVerification(input, deps);
356
+ const before = await captureContextEvidenceSnapshot(
357
+ deps.store,
358
+ capsule.scope.indexName,
359
+ capsule.scope.collections
360
+ );
361
+ const evidenceUris = new Set(capsule.evidence.map((item) => item.uri));
362
+ const referencedDocuments = before.documents.filter((document) =>
363
+ evidenceUris.has(document.uri)
364
+ );
365
+ const docids = [
366
+ ...new Set(
367
+ referencedDocuments.map((document) => deriveDocid(document.sourceHash))
368
+ ),
369
+ ];
370
+ const mirrorHashes = [
371
+ ...new Set(
372
+ referencedDocuments.flatMap((document) =>
373
+ document.mirrorHash === null ? [] : [document.mirrorHash]
374
+ )
375
+ ),
376
+ ];
377
+ const [documentResult, contentResult, chunkResult, rankResult] =
378
+ await Promise.all([
379
+ deps.store.getDocumentsByDocids(docids, { activeOnly: true }),
380
+ deps.store.getContentBatch(mirrorHashes),
381
+ deps.store.getChunksBatch(mirrorHashes),
382
+ deps.resolveCurrentRanks?.(structuredClone(capsule)).catch(() => null) ??
383
+ Promise.resolve(null),
384
+ ]);
385
+ const loaded: LoadedEvidence = {
386
+ snapshot: before,
387
+ documents: unwrapStore(
388
+ documentResult,
389
+ "document_load_failed",
390
+ "Failed to batch-load verification documents"
391
+ ),
392
+ contentByHash: unwrapStore(
393
+ contentResult,
394
+ "content_load_failed",
395
+ "Failed to batch-load verification mirrors"
396
+ ),
397
+ chunksByHash: unwrapStore(
398
+ chunkResult,
399
+ "chunk_load_failed",
400
+ "Failed to batch-load verification chunks"
401
+ ),
402
+ };
403
+ const after = await captureContextEvidenceSnapshot(
404
+ deps.store,
405
+ capsule.scope.indexName,
406
+ capsule.scope.collections
407
+ );
408
+ if (before.indexFingerprint !== after.indexFingerprint) {
409
+ throw new ContextVerifierError(
410
+ "index_changed_during_verify",
411
+ "Index changed while Context Capsule verification was running"
412
+ );
413
+ }
414
+ if (before.contextFingerprint !== after.contextFingerprint) {
415
+ throw new ContextVerifierError(
416
+ "context_changed_during_verify",
417
+ "Configured contexts changed while Context Capsule verification was running"
418
+ );
419
+ }
420
+ const rawInputAfter = rawCanonicalContextJson(input);
421
+ if (rawInputBefore !== rawInputAfter) {
422
+ throw new ContextVerifierError(
423
+ "capsule_mutated_during_verify",
424
+ "Context Capsule input changed while verification was running"
425
+ );
426
+ }
427
+
428
+ const evidence = capsule.evidence.map((item) =>
429
+ applyRanking(
430
+ verifyContent(item, loaded, capsule.scope.indexName),
431
+ item,
432
+ rankResult
433
+ )
434
+ );
435
+ return aggregateReceipt(
436
+ capsule,
437
+ before.indexFingerprint,
438
+ deps.currentFingerprints,
439
+ evidence
440
+ );
441
+ };
442
+
443
+ /** Canonical JSON projection for cross-surface receipt parity. */
444
+ export const canonicalContextCapsuleVerificationJson = (
445
+ input: unknown
446
+ ): string =>
447
+ canonicalVerifierJson(contextCapsuleVerificationSchema.parse(input));