@isparling/engram-cli 0.1.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.
@@ -0,0 +1,54 @@
1
+ // Content hashing used to detect record changes across preview, approval, and
2
+ // commit.
3
+ //
4
+ // Binds an `approval_required` result to the exact MUTATION the caller saw,
5
+ // not merely to the record it started from.
6
+ //
7
+ // Hashing the record alone is insufficient and was a real defect: it detects
8
+ // that the record changed under the caller, but it does not detect that the
9
+ // caller approved a DIFFERENT mutation. With a record-only hash, previewing
10
+ // candidate A against an unchanged record and then approving candidate B
11
+ // with A's hash commits B — a diff the caller never saw. The submission date
12
+ // has the same effect, since it changes the plan without touching the record.
13
+ //
14
+ // `hashPlan` therefore covers both endpoints of the diff: the exact bytes
15
+ // read and the exact bytes that would be written.
16
+
17
+ import { createHash } from "node:crypto";
18
+
19
+ export function hashRecordText(text: string): string {
20
+ return createHash("sha256").update(text, "utf8").digest("hex");
21
+ }
22
+
23
+ /**
24
+ * Hashes the complete mutation: where it lands, the record as read, and the
25
+ * text that would replace it. Any change to any of the three — a different
26
+ * destination, a modified record, a different candidate, or a submission date
27
+ * that alters the result — produces a different hash, so an approval cannot
28
+ * carry across to another mutation.
29
+ *
30
+ * The destination is included because before/after text alone is not a unique
31
+ * mutation: two spaces holding byte-identical records make the same textual
32
+ * transition, so a hash previewed in one space would otherwise approve the
33
+ * commit in another after a binding change.
34
+ *
35
+ * The record is hashed as raw bytes rather than decoded text. Decoding first
36
+ * maps every invalid UTF-8 sequence to U+FFFD, so distinct byte-level edits
37
+ * would collide and an intervening change could commit instead of being
38
+ * refused as stale.
39
+ *
40
+ * Components are length-prefixed so no triple can collide with a different
41
+ * triple through concatenation.
42
+ */
43
+ export function hashPlan(recordPath: string, recordBytes: Buffer, afterText: string): string {
44
+ const after = Buffer.from(afterText, "utf8");
45
+ const destination = Buffer.from(recordPath, "utf8");
46
+ return createHash("sha256")
47
+ .update(`${destination.length}\n`)
48
+ .update(destination)
49
+ .update(`\n${recordBytes.length}\n`)
50
+ .update(recordBytes)
51
+ .update(`\n${after.length}\n`)
52
+ .update(after)
53
+ .digest("hex");
54
+ }
@@ -0,0 +1,13 @@
1
+ // Shared deep-freeze. Two call sites (knowledgeRetrieval.ts and
2
+ // presentation.ts) used to carry verbatim copies of this without a seen-set,
3
+ // so a self-referential object graph would recurse forever. Not reachable
4
+ // from parseKnowledgeRecord output today, but worth one implementation with
5
+ // cycle protection before anything else starts feeding it.
6
+ export function deepFreeze<T>(value: T, seen: WeakSet<object> = new WeakSet()): T {
7
+ if (typeof value !== "object" || value === null) return value;
8
+ if (seen.has(value)) return value;
9
+ seen.add(value);
10
+ if (!Object.isFrozen(value)) Object.freeze(value);
11
+ for (const child of Object.values(value)) deepFreeze(child, seen);
12
+ return value;
13
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Structural diff helpers for comparing knowledge records before they are
2
+ // written.
3
+ //
4
+ // Small hand-written line-based diff (classic LCS dynamic program). Record
5
+ // files are tiny (tens of lines), so the O(n*m) table is not a concern.
6
+ // Renders the complete before/after text as a unified-style diff — this is
7
+ // the "render the complete diff" step, used both to gate approval and to
8
+ // show the reviewer exactly what a write will do.
9
+
10
+ import { requireDefined } from "./types.ts";
11
+
12
+ export type DiffOp = { type: "equal" | "add" | "remove"; line: string };
13
+
14
+ /** Reads the LCS table at [i][j]. Every call site keeps i, j within
15
+ * [0, n] / [0, m] by construction, so an out-of-range read here would
16
+ * indicate a bug in the loop bounds, not a legitimate "no value" case —
17
+ * hence the thrown default instead of a silent `?? 0`. */
18
+ function lcsAt(table: number[][], i: number, j: number): number {
19
+ const row = requireDefined(table[i], `lcs table row ${i} out of range`);
20
+ return requireDefined(row[j], `lcs table cell [${i}][${j}] out of range`);
21
+ }
22
+
23
+ function lineAt(lines: string[], i: number): string {
24
+ return requireDefined(lines[i], `line index ${i} out of range`);
25
+ }
26
+
27
+ export function diffLines(before: string[], after: string[]): DiffOp[] {
28
+ const n = before.length;
29
+ const m = after.length;
30
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
31
+
32
+ for (let i = n - 1; i >= 0; i--) {
33
+ for (let j = m - 1; j >= 0; j--) {
34
+ const row = requireDefined(lcs[i], `lcs table row ${i} out of range`);
35
+ if (before[i] === after[j]) {
36
+ row[j] = lcsAt(lcs, i + 1, j + 1) + 1;
37
+ } else {
38
+ row[j] = Math.max(lcsAt(lcs, i + 1, j), lcsAt(lcs, i, j + 1));
39
+ }
40
+ }
41
+ }
42
+
43
+ const ops: DiffOp[] = [];
44
+ let i = 0;
45
+ let j = 0;
46
+ while (i < n && j < m) {
47
+ if (before[i] === after[j]) {
48
+ ops.push({ type: "equal", line: lineAt(before, i) });
49
+ i++;
50
+ j++;
51
+ } else if (lcsAt(lcs, i + 1, j) >= lcsAt(lcs, i, j + 1)) {
52
+ ops.push({ type: "remove", line: lineAt(before, i) });
53
+ i++;
54
+ } else {
55
+ ops.push({ type: "add", line: lineAt(after, j) });
56
+ j++;
57
+ }
58
+ }
59
+ while (i < n) {
60
+ ops.push({ type: "remove", line: lineAt(before, i) });
61
+ i++;
62
+ }
63
+ while (j < m) {
64
+ ops.push({ type: "add", line: lineAt(after, j) });
65
+ j++;
66
+ }
67
+ return ops;
68
+ }
69
+
70
+ export function renderUnifiedDiff(recordId: string, beforeText: string, afterText: string): string {
71
+ const before = beforeText.split("\n");
72
+ const after = afterText.split("\n");
73
+ const ops = diffLines(before, after);
74
+
75
+ const lines: string[] = [`--- ${recordId} (before)`, `+++ ${recordId} (after)`];
76
+ for (const op of ops) {
77
+ const prefix = op.type === "equal" ? " " : op.type === "add" ? "+" : "-";
78
+ lines.push(`${prefix}${op.line}`);
79
+ }
80
+ return lines.join("\n");
81
+ }
@@ -0,0 +1,128 @@
1
+ import { guardedRetrieveInActiveSpace, receiptFor } from "./guardedRetrievalInternal.ts";
2
+ import type { RetrievalReceipt } from "./knowledgeRetrieval.ts";
3
+ import type { SpawnFn } from "./qmdRunner.ts";
4
+ import { resolveActiveSpace, type ActiveSpace } from "./spaceRegistry.ts";
5
+ import type { EnvLike, Result } from "./types.ts";
6
+ import type {
7
+ KnowledgeError,
8
+ KnowledgeRecord,
9
+ PresentationPack,
10
+ } from "./knowledgeTypes.ts";
11
+
12
+ export type GuardedRetrievalRequest = {
13
+ query: string;
14
+ audienceId: string;
15
+ pack: PresentationPack;
16
+ viewId?: string;
17
+ requestedSourceClasses?: readonly string[];
18
+ };
19
+
20
+ // A space-scoped view's retrieval: enumerates every eligible record under
21
+ // the active space's records root instead of running a ranked qmd search.
22
+ // No `query` field — an enumerating view accepts no caller-supplied query
23
+ // at all (see presentation.ts's `query_not_scoped` refusal).
24
+ export type GuardedEnumerationRequest = {
25
+ audienceId: string;
26
+ pack: PresentationPack;
27
+ viewId?: string;
28
+ requestedSourceClasses?: readonly string[];
29
+ };
30
+
31
+ export type GuardedRetrievalRecord = {
32
+ record: KnowledgeRecord;
33
+ relativePath: string;
34
+ sourceUri: string;
35
+ sourceClasses: string[];
36
+ score?: number;
37
+ };
38
+
39
+ export type GuardedRetrievalHit = {
40
+ schema_version: 0;
41
+ status: "hit";
42
+ records: GuardedRetrievalRecord[];
43
+ receipt: RetrievalReceipt;
44
+ };
45
+
46
+ export type GuardedRetrievalMiss = {
47
+ schema_version: 0;
48
+ status: "miss";
49
+ records: [];
50
+ receipt: RetrievalReceipt;
51
+ };
52
+
53
+ export type GuardedRetrievalFailure = {
54
+ schema_version: 0;
55
+ status: "failed";
56
+ errors: KnowledgeError[];
57
+ receipt: RetrievalReceipt;
58
+ };
59
+
60
+ export type GuardedRetrievalOutcome = GuardedRetrievalHit | GuardedRetrievalMiss | GuardedRetrievalFailure;
61
+
62
+ export type GuardedRetrievalOptions = {
63
+ env?: EnvLike;
64
+ spawnFn?: SpawnFn;
65
+ };
66
+
67
+ // A receipt for the "no active space" failure: guardedRetrieve cannot name
68
+ // a space it never resolved, so this is a distinct type rather than a
69
+ // fabricated ActiveSpace fed through the ordinary receipt shape.
70
+ export type UnresolvedRetrievalReceipt = Omit<RetrievalReceipt, "activeSpace"> & { activeSpace: null };
71
+
72
+ export type GuardedRetrievalUnresolvedFailure = {
73
+ schema_version: 0;
74
+ status: "failed";
75
+ errors: KnowledgeError[];
76
+ receipt: UnresolvedRetrievalReceipt;
77
+ };
78
+
79
+ function isObject(value: unknown): value is Record<string, unknown> {
80
+ return typeof value === "object" && value !== null && !Array.isArray(value);
81
+ }
82
+
83
+ // Mirrors the defensiveness already applied to `request.query` below: this
84
+ // runs before any pack validation, so `request.pack` cannot be trusted to
85
+ // match its declared type at runtime.
86
+ function allowedSourceClassesOf(pack: unknown): readonly string[] {
87
+ if (!isObject(pack)) return [];
88
+ const policy = pack.retrievalPolicy;
89
+ if (!isObject(policy)) return [];
90
+ const allowed = policy.allowedSourceClasses;
91
+ if (!Array.isArray(allowed)) return [];
92
+ return allowed.filter((entry): entry is string => typeof entry === "string");
93
+ }
94
+
95
+ export async function guardedRetrieve(
96
+ request: GuardedRetrievalRequest,
97
+ options: GuardedRetrievalOptions = {},
98
+ ): Promise<GuardedRetrievalOutcome | GuardedRetrievalUnresolvedFailure> {
99
+ const env = options.env ?? process.env;
100
+ const activeResult: Result<ActiveSpace> = await resolveActiveSpace(env);
101
+ if (!activeResult.ok) {
102
+ return {
103
+ schema_version: 0,
104
+ status: "failed",
105
+ errors: activeResult.errors.map((message) => ({ kind: "retrieval", code: "active_space_unresolved", message })),
106
+ receipt: receiptFor(
107
+ null,
108
+ typeof request.query === "string" ? request.query : null,
109
+ "search",
110
+ request.requestedSourceClasses === undefined ? [] : request.requestedSourceClasses,
111
+ allowedSourceClassesOf(request.pack),
112
+ ),
113
+ };
114
+ }
115
+ return guardedRetrieveInActiveSpace(activeResult.value, request, options);
116
+ }
117
+
118
+ export function isGuardedRetrievalHit(result: GuardedRetrievalOutcome): result is GuardedRetrievalHit {
119
+ return result.status === "hit";
120
+ }
121
+
122
+ export function isGuardedRetrievalMiss(result: GuardedRetrievalOutcome): result is GuardedRetrievalMiss {
123
+ return result.status === "miss";
124
+ }
125
+
126
+ export function isGuardedRetrievalFailure(result: GuardedRetrievalOutcome): result is GuardedRetrievalFailure {
127
+ return result.status === "failed";
128
+ }
@@ -0,0 +1,321 @@
1
+ import {
2
+ retrieveEnumeratedRecords,
3
+ retrieveGuardedRecords,
4
+ type GuardedRetrievalFilter,
5
+ type RetrievalOutcome,
6
+ type RetrievalReceipt,
7
+ } from "./knowledgeRetrieval.ts";
8
+ import type { ActiveSpace } from "./spaceRegistry.ts";
9
+ import type {
10
+ GuardedEnumerationRequest,
11
+ GuardedRetrievalFailure,
12
+ GuardedRetrievalOptions,
13
+ GuardedRetrievalOutcome,
14
+ GuardedRetrievalRecord,
15
+ GuardedRetrievalRequest,
16
+ UnresolvedRetrievalReceipt,
17
+ } from "./guardedRetrieval.ts";
18
+ import type { KnowledgeError, PresentationPack } from "./knowledgeTypes.ts";
19
+
20
+ function retrievalError(code: string, message: string, field?: string): KnowledgeError {
21
+ return field === undefined
22
+ ? { kind: "retrieval", code, message }
23
+ : { kind: "retrieval", code, field, message };
24
+ }
25
+
26
+ export function receiptFor(
27
+ active: ActiveSpace,
28
+ query: string | null,
29
+ scope: "search" | "space",
30
+ requestedSourceClasses: readonly string[],
31
+ allowedSourceClasses: readonly string[],
32
+ ): RetrievalReceipt;
33
+ export function receiptFor(
34
+ active: null,
35
+ query: string | null,
36
+ scope: "search" | "space",
37
+ requestedSourceClasses: readonly string[],
38
+ allowedSourceClasses: readonly string[],
39
+ ): UnresolvedRetrievalReceipt;
40
+ export function receiptFor(
41
+ active: ActiveSpace | null,
42
+ query: string | null,
43
+ scope: "search" | "space",
44
+ requestedSourceClasses: readonly string[],
45
+ allowedSourceClasses: readonly string[],
46
+ ): RetrievalReceipt | UnresolvedRetrievalReceipt {
47
+ return {
48
+ schemaVersion: 0,
49
+ scope,
50
+ query,
51
+ activeSpace: active === null ? null : active.spaceId,
52
+ collection: active === null ? "" : active.qmdCollectionName,
53
+ requestedSourceClasses: [...requestedSourceClasses],
54
+ allowedSourceClasses: [...allowedSourceClasses],
55
+ kind: "miss",
56
+ locatorUris: [],
57
+ recordIds: [],
58
+ exposedResults: [],
59
+ // No filtering — let alone ranking — has run yet at any of this
60
+ // function's call sites (pack validation, installation, query shape,
61
+ // audience lookup, source-class authorization, or query-strategy
62
+ // failures, plus the "no active space" case handled by guardedRetrieve
63
+ // before any of the above even runs), so there is no threshold to report
64
+ // and no withholding to disclose.
65
+ relevanceThreshold: null,
66
+ withheld: { audienceId: null, count: 0 },
67
+ };
68
+ }
69
+
70
+ function failure(
71
+ active: ActiveSpace,
72
+ query: string | null,
73
+ scope: "search" | "space",
74
+ requestedSourceClasses: readonly string[],
75
+ allowedSourceClasses: readonly string[],
76
+ errors: KnowledgeError[],
77
+ ): GuardedRetrievalFailure {
78
+ return {
79
+ schema_version: 0,
80
+ status: "failed",
81
+ errors,
82
+ receipt: receiptFor(active, query, scope, requestedSourceClasses, allowedSourceClasses),
83
+ };
84
+ }
85
+
86
+ function activePackInstalled(active: ActiveSpace, pack: PresentationPack): boolean {
87
+ return active.packs.some((installed) => installed.id === pack.id && installed.version === pack.version);
88
+ }
89
+
90
+ function uniqueStrings(values: readonly string[]): string[] {
91
+ return [...new Set(values)];
92
+ }
93
+
94
+ function isObject(value: unknown): value is Record<string, unknown> {
95
+ return typeof value === "object" && value !== null && !Array.isArray(value);
96
+ }
97
+
98
+ function stringArray(value: unknown): string[] | undefined {
99
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? [...value] : undefined;
100
+ }
101
+
102
+ function functionValue(value: unknown): Function | undefined {
103
+ return typeof value === "function" ? value : undefined;
104
+ }
105
+
106
+ type PolicySnapshot = {
107
+ allowedSourceClasses: string[];
108
+ queryStrategy: PresentationPack["retrievalPolicy"]["queryStrategy"];
109
+ classifySource: PresentationPack["retrievalPolicy"]["classifySource"];
110
+ relevanceThreshold: number | null;
111
+ isEligible: PresentationPack["retrievalPolicy"]["isEligible"];
112
+ includePresentations: unknown;
113
+ };
114
+
115
+ function snapshotPolicy(pack: unknown): { ok: true; value: PolicySnapshot } | { ok: false; allowedSourceClasses: string[]; errors: KnowledgeError[] } {
116
+ if (!isObject(pack) || !isObject(pack.retrievalPolicy)) {
117
+ return { ok: false, allowedSourceClasses: [], errors: [retrievalError("policy_shape_invalid", "retrieval policy must be an object", "pack")] };
118
+ }
119
+ const policy = pack.retrievalPolicy;
120
+ const allowedSourceClasses = stringArray(policy.allowedSourceClasses);
121
+ if (allowedSourceClasses === undefined) {
122
+ return { ok: false, allowedSourceClasses: [], errors: [retrievalError("policy_shape_invalid", "retrieval policy allowedSourceClasses must be an array of strings", "pack")] };
123
+ }
124
+ const queryStrategy = functionValue(policy.queryStrategy);
125
+ const classifySource = functionValue(policy.classifySource);
126
+ const isEligible = functionValue(policy.isEligible);
127
+ if (queryStrategy === undefined || classifySource === undefined || isEligible === undefined) {
128
+ return { ok: false, allowedSourceClasses, errors: [retrievalError("policy_shape_invalid", "retrieval policy callbacks must be functions", "pack")] };
129
+ }
130
+ if (policy.includePresentations !== false) {
131
+ return { ok: true, value: {
132
+ allowedSourceClasses,
133
+ queryStrategy: (input) => Reflect.apply(queryStrategy, undefined, [input]),
134
+ classifySource: (source) => Reflect.apply(classifySource, undefined, [source]),
135
+ relevanceThreshold: typeof policy.relevanceThreshold === "number" || policy.relevanceThreshold === null ? policy.relevanceThreshold : Number.NaN,
136
+ isEligible: (record) => Reflect.apply(isEligible, undefined, [record]),
137
+ includePresentations: policy.includePresentations,
138
+ } };
139
+ }
140
+ return { ok: true, value: {
141
+ allowedSourceClasses,
142
+ queryStrategy: (input) => Reflect.apply(queryStrategy, undefined, [input]),
143
+ classifySource: (source) => Reflect.apply(classifySource, undefined, [source]),
144
+ relevanceThreshold: typeof policy.relevanceThreshold === "number" || policy.relevanceThreshold === null ? policy.relevanceThreshold : Number.NaN,
145
+ isEligible: (record) => Reflect.apply(isEligible, undefined, [record]),
146
+ includePresentations: false,
147
+ } };
148
+ }
149
+
150
+ function validatePolicy(policy: PolicySnapshot): KnowledgeError[] {
151
+ const errors: KnowledgeError[] = [];
152
+ if (policy.includePresentations !== false) {
153
+ errors.push(retrievalError("policy_presentations_included", "retrieval policy must exclude presentation artifacts"));
154
+ }
155
+ if (policy.allowedSourceClasses.length === 0) {
156
+ errors.push(retrievalError("policy_source_classes_empty", "retrieval policy must declare at least one allowed source class"));
157
+ }
158
+ if (
159
+ policy.relevanceThreshold !== null &&
160
+ (!Number.isFinite(policy.relevanceThreshold) || policy.relevanceThreshold < 0)
161
+ ) {
162
+ errors.push(retrievalError("policy_relevance_threshold_invalid", "retrieval policy relevance threshold must be null or a non-negative finite number"));
163
+ }
164
+ return errors;
165
+ }
166
+
167
+ // "search" runs the pack's declared query strategy over a ranked qmd search;
168
+ // "space" enumerates every Markdown record under the active space's records
169
+ // root and runs no qmd process. Everything in `performRetrieval` below this
170
+ // point — policy validation, pack installation, audience lookup, source-class
171
+ // authorization — applies identically to both; only query construction and
172
+ // the final retrieval call differ, so neither mode can silently skip a guard
173
+ // the other one runs.
174
+ type RetrievalMode =
175
+ | { kind: "search"; query: string }
176
+ | { kind: "space" };
177
+
178
+ async function performRetrieval(
179
+ active: ActiveSpace,
180
+ mode: RetrievalMode,
181
+ request: GuardedEnumerationRequest,
182
+ options: GuardedRetrievalOptions,
183
+ ): Promise<GuardedRetrievalOutcome> {
184
+ const policySnapshot = snapshotPolicy(request.pack);
185
+ const baseQuery: string | null = mode.kind === "search" ? mode.query : null;
186
+ const scope = mode.kind;
187
+ const initialAllowedSourceClasses = policySnapshot.ok ? policySnapshot.value.allowedSourceClasses : policySnapshot.allowedSourceClasses;
188
+ const requestedSourceClasses = request.requestedSourceClasses === undefined
189
+ ? [...initialAllowedSourceClasses]
190
+ : uniqueStrings(request.requestedSourceClasses);
191
+ if (!policySnapshot.ok) {
192
+ return failure(active, baseQuery, scope, requestedSourceClasses, policySnapshot.allowedSourceClasses, policySnapshot.errors);
193
+ }
194
+ const policy = policySnapshot.value;
195
+
196
+ const packErrors = validatePolicy(policy);
197
+ if (packErrors.length > 0) {
198
+ return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, packErrors);
199
+ }
200
+ if (!activePackInstalled(active, request.pack)) {
201
+ return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
202
+ "pack_not_installed",
203
+ `presentation pack ${request.pack.id}@${request.pack.version} is not installed in the active space`,
204
+ "pack",
205
+ )]);
206
+ }
207
+ if (mode.kind === "search" && (typeof mode.query !== "string" || mode.query.trim().length === 0 || mode.query.includes("\u0000"))) {
208
+ return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
209
+ "query_invalid",
210
+ "retrieval query must be a non-empty string without NUL bytes",
211
+ "query",
212
+ )]);
213
+ }
214
+ const audience = request.pack.audiences.find((candidate) => candidate.id === request.audienceId);
215
+ if (audience === undefined) {
216
+ return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
217
+ "audience_unknown",
218
+ `audience ${request.audienceId} is not configured by the presentation pack`,
219
+ "audience",
220
+ )]);
221
+ }
222
+ const audienceSnapshot = Object.freeze({ id: audience.id, authorize: audience.authorize });
223
+ const disallowedRequestedClass = requestedSourceClasses.find(
224
+ (sourceClass) => !policy.allowedSourceClasses.includes(sourceClass),
225
+ );
226
+ if (disallowedRequestedClass !== undefined) {
227
+ return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
228
+ "source_class_not_allowed",
229
+ `requested source class ${disallowedRequestedClass} is not allowed by the retrieval policy`,
230
+ "requestedSourceClasses",
231
+ )]);
232
+ }
233
+ if (requestedSourceClasses.length === 0) {
234
+ return {
235
+ schema_version: 0,
236
+ status: "miss",
237
+ records: [],
238
+ receipt: receiptFor(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses),
239
+ };
240
+ }
241
+
242
+ const filter: GuardedRetrievalFilter = {
243
+ audienceId: audienceSnapshot.id,
244
+ requestedSourceClasses,
245
+ allowedSourceClasses: policy.allowedSourceClasses,
246
+ includePresentations: false,
247
+ relevanceThreshold: policy.relevanceThreshold,
248
+ classifySource: policy.classifySource,
249
+ isEligible: policy.isEligible,
250
+ authorize: audienceSnapshot.authorize,
251
+ };
252
+
253
+ let retrieval: RetrievalOutcome;
254
+ if (mode.kind === "search") {
255
+ let query: string;
256
+ try {
257
+ query = policy.queryStrategy({
258
+ query: mode.query,
259
+ ...(request.viewId === undefined ? {} : { viewId: request.viewId }),
260
+ requestedSourceClasses,
261
+ });
262
+ } catch (error) {
263
+ return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
264
+ "query_strategy_failed",
265
+ `retrieval query strategy failed: ${error instanceof Error ? error.message : String(error)}`,
266
+ )]);
267
+ }
268
+ if (typeof query !== "string" || query.trim().length === 0 || query.includes("\u0000")) {
269
+ return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
270
+ "query_strategy_invalid",
271
+ "retrieval query strategy returned an empty or unsafe query",
272
+ )]);
273
+ }
274
+ retrieval = await retrieveGuardedRecords(active, query, filter, options.spawnFn);
275
+ } else {
276
+ retrieval = await retrieveEnumeratedRecords(active, filter);
277
+ }
278
+
279
+ if (retrieval.kind === "failure") return { schema_version: 0, status: "failed", errors: retrieval.errors, receipt: retrieval.receipt };
280
+ if (retrieval.kind === "miss") return { schema_version: 0, status: "miss", records: [], receipt: retrieval.receipt };
281
+
282
+ const records: GuardedRetrievalRecord[] = [];
283
+ for (const related of retrieval.records) {
284
+ const exposed = retrieval.receipt.exposedResults.find((candidate) => candidate.recordId === related.record.id);
285
+ if (exposed === undefined) {
286
+ return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
287
+ "receipt_incomplete",
288
+ "retrieval receipt did not contain a reference for every exposed record",
289
+ )]);
290
+ }
291
+ records.push({
292
+ record: related.record,
293
+ relativePath: related.relativePath,
294
+ sourceUri: related.sourceUri,
295
+ sourceClasses: [...exposed.sourceClasses],
296
+ ...(exposed.score === undefined ? {} : { score: exposed.score }),
297
+ });
298
+ }
299
+ return { schema_version: 0, status: "hit", records, receipt: retrieval.receipt };
300
+ }
301
+
302
+ export async function guardedRetrieveInActiveSpace(
303
+ active: ActiveSpace,
304
+ request: GuardedRetrievalRequest,
305
+ options: GuardedRetrievalOptions = {},
306
+ ): Promise<GuardedRetrievalOutcome> {
307
+ return performRetrieval(active, { kind: "search", query: request.query }, request, options);
308
+ }
309
+
310
+ // The space-scoped counterpart used only by `renderPresentation` when a
311
+ // view's scope is "space": no query is ever constructed or validated, and
312
+ // the final retrieval call enumerates the records root instead of running a
313
+ // qmd search. Every other guard above is shared, unchanged, with
314
+ // `guardedRetrieveInActiveSpace`.
315
+ export async function guardedEnumerateInActiveSpace(
316
+ active: ActiveSpace,
317
+ request: GuardedEnumerationRequest,
318
+ options: GuardedRetrievalOptions = {},
319
+ ): Promise<GuardedRetrievalOutcome> {
320
+ return performRetrieval(active, { kind: "space" }, request, options);
321
+ }