@runbooks/search 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.
package/dist/log.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Zero-result queries, logged without PII (§17, M-03).
3
+ *
4
+ * An empty result is a request for content: somebody arrived with a real problem and the
5
+ * catalog did not have it, which is worth more than a search that matched. So the fact
6
+ * that it happened is kept — and nothing else is.
7
+ *
8
+ * The rule is structural rather than a redaction step. A log line is built from a fixed
9
+ * set of fields, none of which can carry a query string or anything about the client, so
10
+ * there is no path by which one arrives and later has to be scrubbed. "We redact before
11
+ * storing" is a promise made by whoever already has the data.
12
+ */
13
+ import { type SearchQuery } from "./search.js";
14
+ export interface ZeroResultLine {
15
+ /** The day. Not the moment: a timestamp series is a movement log of one organization. */
16
+ readonly date: string;
17
+ /** Which facets were used, not what they were set to for free-text ones. */
18
+ readonly facets: readonly string[];
19
+ /** How many words the symptom had. A length is not a query. */
20
+ readonly symptom_words: number;
21
+ /** The controlled values, which are drawn from a published vocabulary and identify nobody. */
22
+ readonly target?: string;
23
+ readonly risk_max?: string;
24
+ readonly profile?: string;
25
+ readonly trust_min: string;
26
+ }
27
+ /**
28
+ * What may be recorded about a query that found nothing.
29
+ *
30
+ * The symptom text never appears — not hashed, not truncated. A hashed query is a query
31
+ * for anybody holding a candidate list, and an ops search reads like an incident report:
32
+ * a hostname, a customer name, a table nobody else has.
33
+ */
34
+ export declare function zeroResultLine(query: SearchQuery, today: string): ZeroResultLine;
35
+ /**
36
+ * The aggregate a backlog is built from: which facet combinations keep coming back empty.
37
+ *
38
+ * Counts only, and only above a floor — a combination seen once is one organization's
39
+ * afternoon, and publishing it would be publishing them.
40
+ */
41
+ export declare const REPORTABLE_FLOOR = 5;
42
+ export declare function gaps(lines: readonly ZeroResultLine[], floor?: number): {
43
+ readonly facets: string;
44
+ readonly count: number;
45
+ }[];
package/dist/log.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Zero-result queries, logged without PII (§17, M-03).
3
+ *
4
+ * An empty result is a request for content: somebody arrived with a real problem and the
5
+ * catalog did not have it, which is worth more than a search that matched. So the fact
6
+ * that it happened is kept — and nothing else is.
7
+ *
8
+ * The rule is structural rather than a redaction step. A log line is built from a fixed
9
+ * set of fields, none of which can carry a query string or anything about the client, so
10
+ * there is no path by which one arrives and later has to be scrubbed. "We redact before
11
+ * storing" is a promise made by whoever already has the data.
12
+ */
13
+ import { DEFAULT_TRUST_MIN } from "./search.js";
14
+ /**
15
+ * What may be recorded about a query that found nothing.
16
+ *
17
+ * The symptom text never appears — not hashed, not truncated. A hashed query is a query
18
+ * for anybody holding a candidate list, and an ops search reads like an incident report:
19
+ * a hostname, a customer name, a table nobody else has.
20
+ */
21
+ export function zeroResultLine(query, today) {
22
+ const facets = [
23
+ query.symptom ? "symptom" : undefined,
24
+ query.target ? "target" : undefined,
25
+ query.risk_max ? "risk_max" : undefined,
26
+ query.execution ? "execution" : undefined,
27
+ query.profile ? "profile" : undefined,
28
+ query.available ? "available" : undefined,
29
+ query.trust_min ? "trust_min" : undefined,
30
+ ].filter((facet) => facet !== undefined);
31
+ return {
32
+ date: today,
33
+ facets,
34
+ symptom_words: query.symptom ? query.symptom.trim().split(/\s+/).filter(Boolean).length : 0,
35
+ ...(query.target ? { target: query.target } : {}),
36
+ ...(query.risk_max ? { risk_max: query.risk_max } : {}),
37
+ ...(query.profile ? { profile: query.profile } : {}),
38
+ trust_min: query.trust_min ?? DEFAULT_TRUST_MIN,
39
+ };
40
+ }
41
+ /**
42
+ * The aggregate a backlog is built from: which facet combinations keep coming back empty.
43
+ *
44
+ * Counts only, and only above a floor — a combination seen once is one organization's
45
+ * afternoon, and publishing it would be publishing them.
46
+ */
47
+ export const REPORTABLE_FLOOR = 5;
48
+ export function gaps(lines, floor = REPORTABLE_FLOOR) {
49
+ const tally = new Map();
50
+ for (const line of lines) {
51
+ const key = [...line.facets].sort().join("+") || "none";
52
+ tally.set(key, (tally.get(key) ?? 0) + 1);
53
+ }
54
+ return [...tally.entries()]
55
+ .filter(([, count]) => count >= floor)
56
+ .map(([facets, count]) => ({ facets, count }))
57
+ .sort((a, b) => b.count - a.count);
58
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `/v1/search` (M-03, §12, §17).
3
+ *
4
+ * §17 defines three intents, and the third — "given the MCP servers I have connected,
5
+ * what can I actually execute" — is the agent's primary path. It is a first-class query
6
+ * here rather than a filter bolted onto text search, because the two questions have
7
+ * different right answers: a text search wants the best match, and an agent wants the
8
+ * set it can run without stopping halfway.
9
+ */
10
+ import { type Bm25Index, type Synonyms } from "./bm25.js";
11
+ export type Trust = "T0" | "T1" | "T2" | "T3" | "T4";
12
+ export type Risk = "read-only" | "reversible-write" | "destructive" | "irreversible";
13
+ export interface IndexedRecord {
14
+ readonly ref: string;
15
+ readonly title: string;
16
+ readonly description: string;
17
+ readonly targets: readonly string[];
18
+ readonly capabilities: readonly string[];
19
+ readonly trust: Trust;
20
+ readonly risk: Risk;
21
+ readonly profile: string;
22
+ readonly execution?: string;
23
+ /** The topic this record answers, when one has been authored (I-05). */
24
+ readonly topic?: string;
25
+ }
26
+ export interface SearchQuery {
27
+ readonly symptom?: string;
28
+ readonly target?: string;
29
+ readonly risk_max?: Risk;
30
+ readonly execution?: string;
31
+ /** Defaults to T2 (§8, §17). Below it is an explicit act, never an omission. */
32
+ readonly trust_min?: Trust;
33
+ readonly profile?: string;
34
+ /**
35
+ * What the caller can actually run. A record is returned only if **every** capability
36
+ * it declares is in this set — see `executableWith`.
37
+ */
38
+ readonly available?: readonly string[];
39
+ readonly limit?: number;
40
+ readonly cursor?: string;
41
+ }
42
+ export interface Hit {
43
+ readonly ref: string;
44
+ readonly score: number;
45
+ /** Set when this hit stands for a topic rather than a record (§17, G5). */
46
+ readonly topic?: string;
47
+ }
48
+ export interface SearchResult {
49
+ readonly hits: readonly Hit[];
50
+ readonly total: number;
51
+ /**
52
+ * Where to continue: an opaque cursor naming the last hit on this page and the query
53
+ * it came from (./cursor.ts). Not an offset — the result set is rebuilt per request,
54
+ * and an offset taken against one build skips whatever moved across the boundary in
55
+ * the next.
56
+ */
57
+ readonly next_cursor?: string;
58
+ /** What the query was resolved as, including the defaults it did not state. */
59
+ readonly applied: {
60
+ readonly trust_min: Trust;
61
+ readonly capability_semantics: "all-required-available";
62
+ };
63
+ }
64
+ export declare const DEFAULT_TRUST_MIN: Trust;
65
+ export declare const DEFAULT_LIMIT = 25;
66
+ /**
67
+ * Whether a caller can run this record, given what they have.
68
+ *
69
+ * **Every capability the record declares must be available.** The other reading — return
70
+ * anything that shares one capability — is a security footgun: it hands an agent a
71
+ * procedure it can start and cannot finish, and a procedure abandoned halfway has already
72
+ * done part of the work. Part of a destructive procedure is a state nobody designed.
73
+ *
74
+ * This is why the semantics are in the response as well as in the contract: a caller who
75
+ * assumed the other reading finds out from the answer rather than from an incident.
76
+ */
77
+ export declare function executableWith(record: Pick<IndexedRecord, "capabilities">, available: readonly string[]): boolean;
78
+ export interface SearchOptions {
79
+ /** Prebuilt, so the same ranking runs in a browser and in a Lambda. */
80
+ readonly index?: Bm25Index;
81
+ /**
82
+ * Words this catalog treats as one, from S-09's vocabulary.
83
+ *
84
+ * Data rather than a dependency, because this library is what the browser runs. Without
85
+ * it a reader who typed `kubernetes` found nothing while the record was indexed under
86
+ * `k8s` — and the clustering that groups records into topics had folded the two
87
+ * together all along, through the same vocabulary this table is built from.
88
+ */
89
+ readonly synonyms?: Synonyms;
90
+ /**
91
+ * The vector half of §17's hybrid. Absent offline, and absent is not a failure: BM25
92
+ * alone finds fewer things, and finding fewer things is recoverable in a way that
93
+ * inventing a ranking is not.
94
+ */
95
+ readonly semantic?: (query: string, refs: readonly string[]) => readonly {
96
+ ref: string;
97
+ score: number;
98
+ }[];
99
+ }
100
+ /**
101
+ * Run a query.
102
+ *
103
+ * Filters first, then ranking, then grouping, then the page. Filters first because
104
+ * `trust_min` is a safety default rather than a preference: a record that should not be
105
+ * in the answer must not be ranked into it and then trimmed, or the trimming becomes the
106
+ * thing that has to be right.
107
+ */
108
+ export declare function runSearch(records: readonly IndexedRecord[], query: SearchQuery, options?: SearchOptions): SearchResult;
package/dist/search.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `/v1/search` (M-03, §12, §17).
3
+ *
4
+ * §17 defines three intents, and the third — "given the MCP servers I have connected,
5
+ * what can I actually execute" — is the agent's primary path. It is a first-class query
6
+ * here rather than a filter bolted onto text search, because the two questions have
7
+ * different right answers: a text search wants the best match, and an agent wants the
8
+ * set it can run without stopping halfway.
9
+ */
10
+ import { buildIndex, search as bm25 } from "./bm25.js";
11
+ import { encodeCursor, readCursor } from "./cursor.js";
12
+ const TRUST_ORDER = ["T0", "T1", "T2", "T3", "T4"];
13
+ const RISK_ORDER = ["read-only", "reversible-write", "destructive", "irreversible"];
14
+ export const DEFAULT_TRUST_MIN = "T2";
15
+ export const DEFAULT_LIMIT = 25;
16
+ /**
17
+ * Whether a caller can run this record, given what they have.
18
+ *
19
+ * **Every capability the record declares must be available.** The other reading — return
20
+ * anything that shares one capability — is a security footgun: it hands an agent a
21
+ * procedure it can start and cannot finish, and a procedure abandoned halfway has already
22
+ * done part of the work. Part of a destructive procedure is a state nobody designed.
23
+ *
24
+ * This is why the semantics are in the response as well as in the contract: a caller who
25
+ * assumed the other reading finds out from the answer rather than from an incident.
26
+ */
27
+ export function executableWith(record, available) {
28
+ const have = new Set(available);
29
+ return record.capabilities.every((capability) => have.has(capability));
30
+ }
31
+ function atLeast(level, minimum) {
32
+ return TRUST_ORDER.indexOf(level) >= TRUST_ORDER.indexOf(minimum);
33
+ }
34
+ function atMost(risk, maximum) {
35
+ return RISK_ORDER.indexOf(risk) <= RISK_ORDER.indexOf(maximum);
36
+ }
37
+ /**
38
+ * Run a query.
39
+ *
40
+ * Filters first, then ranking, then grouping, then the page. Filters first because
41
+ * `trust_min` is a safety default rather than a preference: a record that should not be
42
+ * in the answer must not be ranked into it and then trimmed, or the trimming becomes the
43
+ * thing that has to be right.
44
+ */
45
+ export function runSearch(records, query, options = {}) {
46
+ const trustMin = query.trust_min ?? DEFAULT_TRUST_MIN;
47
+ const eligible = records.filter((record) => {
48
+ if (!atLeast(record.trust, trustMin))
49
+ return false;
50
+ if (query.target && !record.targets.includes(query.target))
51
+ return false;
52
+ if (query.risk_max && !atMost(record.risk, query.risk_max))
53
+ return false;
54
+ if (query.execution && record.execution !== query.execution)
55
+ return false;
56
+ if (query.profile && record.profile !== query.profile)
57
+ return false;
58
+ if (query.available && !executableWith(record, query.available))
59
+ return false;
60
+ return true;
61
+ });
62
+ let ranked;
63
+ if (query.symptom) {
64
+ const index = options.index ??
65
+ buildIndex(eligible.map((r) => ({ ref: r.ref, text: `${r.title} ${r.description} ${r.targets.join(" ")}` })), options.synonyms ?? {});
66
+ const allowed = new Set(eligible.map((r) => r.ref));
67
+ const lexical = bm25(index, query.symptom).filter((hit) => allowed.has(hit.ref));
68
+ const semantic = options.semantic?.(query.symptom, [...allowed]) ?? [];
69
+ const merged = new Map(lexical.map((hit) => [hit.ref, hit.score]));
70
+ for (const hit of semantic)
71
+ merged.set(hit.ref, (merged.get(hit.ref) ?? 0) + hit.score);
72
+ ranked = [...merged.entries()]
73
+ .map(([ref, score]) => ({ ref, score }))
74
+ .sort((a, b) => (b.score === a.score ? (a.ref < b.ref ? -1 : 1) : b.score - a.score));
75
+ }
76
+ else {
77
+ // No symptom: the facets are the query, and the order is stable rather than clever.
78
+ ranked = [...eligible].sort((a, b) => (a.ref < b.ref ? -1 : 1)).map((r) => ({ ref: r.ref, score: 0 }));
79
+ }
80
+ // §17 and G5: one line per topic where a topic exists, so variants do not compete.
81
+ const byRef = new Map(records.map((r) => [r.ref, r]));
82
+ const seenTopics = new Set();
83
+ const grouped = [];
84
+ for (const hit of ranked) {
85
+ const topic = byRef.get(hit.ref)?.topic;
86
+ if (!topic) {
87
+ grouped.push(hit);
88
+ continue;
89
+ }
90
+ if (seenTopics.has(topic))
91
+ continue;
92
+ seenTopics.add(topic);
93
+ grouped.push({ ...hit, topic });
94
+ }
95
+ const limit = query.limit ?? DEFAULT_LIMIT;
96
+ // A cursor resumes after a ref. An unreadable one starts from the beginning here; the
97
+ // endpoint refuses it outright, because a caller who sent one deserves to be told
98
+ // rather than handed page 1 believing it is page 3.
99
+ const read = query.cursor ? readCursor(query.cursor, query) : undefined;
100
+ const start = read?.ok === true ? grouped.findIndex((hit) => hit.ref === read.cursor.after) + 1 : 0;
101
+ const page = grouped.slice(start, start + limit);
102
+ const last = page[page.length - 1];
103
+ return {
104
+ hits: page,
105
+ total: grouped.length,
106
+ ...(start + page.length < grouped.length && last
107
+ ? { next_cursor: encodeCursor(last.ref, query) }
108
+ : {}),
109
+ applied: { trust_min: trustMin, capability_semantics: "all-required-available" },
110
+ };
111
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,253 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildIndex, search as bm25, terms } from "./bm25.js";
3
+ import { runSearch, executableWith, DEFAULT_TRUST_MIN, DEFAULT_LIMIT, } from "./search.js";
4
+ import { zeroResultLine, gaps, REPORTABLE_FLOOR } from "./log.js";
5
+ import { matchStack, excluded } from "./stack.js";
6
+ const records = [
7
+ {
8
+ ref: "std/kafka-lag", title: "Kafka consumer group stuck", targets: ["kafka"],
9
+ description: "Offsets stop advancing and lag grows on a consumer group.",
10
+ capabilities: ["cli:kafka-consumer-groups", "cli:kubectl"], trust: "T2",
11
+ risk: "reversible-write", profile: "P1", execution: "human-with-agent",
12
+ },
13
+ {
14
+ ref: "std/drain-node", title: "Drain a NotReady node", targets: ["k8s"],
15
+ description: "A node is stuck NotReady and its pods are not rescheduled.",
16
+ capabilities: ["cli:kubectl"], trust: "T2", risk: "destructive", profile: "P1",
17
+ execution: "human-with-agent",
18
+ },
19
+ {
20
+ ref: "std/disk-pressure", title: "Disk pressure triage", targets: ["linux"],
21
+ description: "A filesystem is filling up and something has to be reclaimed.",
22
+ capabilities: ["cli:df", "cli:du"], trust: "T2", risk: "read-only", profile: "P1",
23
+ execution: "human-only",
24
+ },
25
+ {
26
+ ref: "acme/unreviewed", title: "Kafka lag, unreviewed", targets: ["kafka"],
27
+ description: "An unreviewed procedure about consumer lag.",
28
+ capabilities: ["cli:kafka-consumer-groups"], trust: "T1", risk: "read-only", profile: "P0",
29
+ },
30
+ ];
31
+ /** §8 and §17: the default answer holds nothing below T2, and that is not an omission. */
32
+ describe("the default response is T2 and above", () => {
33
+ it("leaves an unreviewed record out without being asked", () => {
34
+ const result = runSearch(records, { symptom: "kafka lag" });
35
+ expect(result.hits.map((h) => h.ref)).not.toContain("acme/unreviewed");
36
+ });
37
+ it("says which default it applied, so a caller is not guessing", () => {
38
+ expect(runSearch(records, {}).applied.trust_min).toBe(DEFAULT_TRUST_MIN);
39
+ });
40
+ it("includes it only when the caller asks for it explicitly", () => {
41
+ const result = runSearch(records, { symptom: "kafka lag", trust_min: "T1" });
42
+ expect(result.hits.map((h) => h.ref)).toContain("acme/unreviewed");
43
+ });
44
+ it("filters before ranking, so nothing below the floor is ranked and then trimmed", () => {
45
+ // With a limit of one, an unreviewed record that outranked the rest would still be
46
+ // absent — because it was never a candidate.
47
+ const result = runSearch(records, { symptom: "unreviewed procedure lag", limit: 1 });
48
+ expect(result.hits.map((h) => h.ref)).not.toContain("acme/unreviewed");
49
+ });
50
+ });
51
+ /**
52
+ * The footgun the criterion names: "anything sharing one capability" hands an agent a
53
+ * procedure it can start and cannot finish, and part of a destructive procedure is a
54
+ * state nobody designed.
55
+ */
56
+ describe("capability search returns what the caller can actually run", () => {
57
+ it("requires every declared capability, not any of them", () => {
58
+ const result = runSearch(records, { available: ["cli:kubectl"] });
59
+ expect(result.hits.map((h) => h.ref)).toEqual(["std/drain-node"]);
60
+ });
61
+ it("returns a record once its whole set is available", () => {
62
+ const result = runSearch(records, { available: ["cli:kubectl", "cli:kafka-consumer-groups"] });
63
+ expect(result.hits.map((h) => h.ref)).toContain("std/kafka-lag");
64
+ });
65
+ it("says the semantics in the response, not only in the contract", () => {
66
+ expect(runSearch(records, { available: [] }).applied.capability_semantics)
67
+ .toBe("all-required-available");
68
+ });
69
+ it("returns nothing rather than something partial when nothing is offered", () => {
70
+ expect(runSearch(records, { available: [] }).hits).toEqual([]);
71
+ });
72
+ it.each([
73
+ [["cli:df"], []],
74
+ [["cli:df", "cli:du"], ["std/disk-pressure"]],
75
+ ])("with %o returns %o", (available, expected) => {
76
+ expect(runSearch(records, { available }).hits.map((h) => h.ref)).toEqual(expected);
77
+ });
78
+ it("is the same predicate a caller can apply themselves", () => {
79
+ expect(executableWith({ capabilities: ["a", "b"] }, ["a"])).toBe(false);
80
+ expect(executableWith({ capabilities: ["a", "b"] }, ["a", "b", "c"])).toBe(true);
81
+ expect(executableWith({ capabilities: [] }, [])).toBe(true);
82
+ });
83
+ });
84
+ describe("facets narrow without ranking", () => {
85
+ it.each([
86
+ [{ target: "kafka" }, ["std/kafka-lag"]],
87
+ [{ risk_max: "read-only" }, ["std/disk-pressure"]],
88
+ [{ execution: "human-only" }, ["std/disk-pressure"]],
89
+ [{ profile: "P0" }, []],
90
+ ])("%o", (query, expected) => {
91
+ expect(runSearch(records, query).hits.map((h) => h.ref)).toEqual(expected);
92
+ });
93
+ it("orders a facet-only answer stably rather than cleverly", () => {
94
+ const once = runSearch(records, {}).hits.map((h) => h.ref);
95
+ expect(runSearch(records, {}).hits.map((h) => h.ref)).toEqual(once);
96
+ expect([...once].sort()).toEqual(once);
97
+ });
98
+ });
99
+ /** A ranking somebody cannot reproduce is one they have to trust (§19). */
100
+ describe("symptom search is ordinary BM25", () => {
101
+ it("puts the record about the symptom first", () => {
102
+ const result = runSearch(records, { symptom: "consumer group offsets" });
103
+ expect(result.hits[0].ref).toBe("std/kafka-lag");
104
+ });
105
+ it("finds nothing for a symptom nothing is about", () => {
106
+ expect(runSearch(records, { symptom: "quantum tunnelling" }).hits).toEqual([]);
107
+ });
108
+ it("is deterministic, including its tie-breaks", () => {
109
+ const index = buildIndex([
110
+ { ref: "b", text: "kafka lag" },
111
+ { ref: "a", text: "kafka lag" },
112
+ ]);
113
+ expect(bm25(index, "kafka").map((h) => h.ref)).toEqual(["a", "b"]);
114
+ });
115
+ it("tokenizes the way a reader would expect", () => {
116
+ expect(terms("Kafka consumer-group, stuck!")).toEqual(["kafka", "consumer", "group", "stuck"]);
117
+ });
118
+ });
119
+ /** §17, G5: variants of one procedure are one answer, not three competing ones. */
120
+ describe("results are topics where topics exist", () => {
121
+ const withTopic = [
122
+ { ...records[0], ref: "std/kafka-a", topic: "consumer-stuck" },
123
+ { ...records[0], ref: "std/kafka-b", topic: "consumer-stuck" },
124
+ records[2],
125
+ ];
126
+ it("collapses variants into one hit", () => {
127
+ const result = runSearch(withTopic, { symptom: "consumer group" });
128
+ expect(result.hits.filter((h) => h.topic === "consumer-stuck")).toHaveLength(1);
129
+ });
130
+ it("marks the hit as standing for a topic", () => {
131
+ const hit = runSearch(withTopic, { symptom: "consumer group" }).hits[0];
132
+ expect(hit.topic).toBe("consumer-stuck");
133
+ });
134
+ it("leaves a record with no topic as itself", () => {
135
+ const result = runSearch(withTopic, { symptom: "filesystem filling" });
136
+ expect(result.hits[0].topic).toBeUndefined();
137
+ });
138
+ });
139
+ describe("pagination is consistent with the rest of /v1", () => {
140
+ const many = Array.from({ length: 60 }, (_, i) => ({ ...records[2], ref: `std/r${String(i).padStart(2, "0")}` }));
141
+ it("pages at a size nobody has to think about", () => {
142
+ expect(runSearch(many, {}).hits).toHaveLength(DEFAULT_LIMIT);
143
+ });
144
+ it("names where to continue rather than an offset to trust", () => {
145
+ const first = runSearch(many, {});
146
+ expect(first.next_cursor).toBeDefined();
147
+ const second = runSearch(many, { cursor: first.next_cursor });
148
+ expect(second.hits[0].ref).not.toBe(first.hits[0].ref);
149
+ });
150
+ it("stops naming one at the end", () => {
151
+ expect(runSearch(many, { limit: 100 }).next_cursor).toBeUndefined();
152
+ });
153
+ it("reports the total behind the page", () => {
154
+ expect(runSearch(many, {}).total).toBe(60);
155
+ });
156
+ });
157
+ /**
158
+ * An ops search reads like an incident report: a hostname, a customer name, a table
159
+ * nobody else has. The query text never enters the log — not hashed, not truncated.
160
+ */
161
+ describe("a zero-result query is logged without the query", () => {
162
+ const query = { symptom: "postgres on db-3.acme.internal is out of wal space", target: "postgres" };
163
+ const line = zeroResultLine(query, "2026-09-21");
164
+ it("keeps no trace of the text", () => {
165
+ const serialized = JSON.stringify(line);
166
+ for (const fragment of ["db-3", "acme", "wal", "postgres on"]) {
167
+ expect(serialized).not.toContain(fragment);
168
+ }
169
+ });
170
+ it("records that a symptom was searched, and how long it was", () => {
171
+ expect(line.facets).toContain("symptom");
172
+ expect(line.symptom_words).toBe(8);
173
+ });
174
+ it("keeps the controlled values, which come from a published vocabulary", () => {
175
+ expect(line.target).toBe("postgres");
176
+ });
177
+ it("records the day, not the moment", () => {
178
+ expect(line.date).toBe("2026-09-21");
179
+ expect(JSON.stringify(line)).not.toMatch(/T\d{2}:\d{2}/);
180
+ });
181
+ it("carries nothing about who asked", () => {
182
+ expect(Object.keys(line).sort()).toEqual(["date", "facets", "symptom_words", "target", "trust_min"].sort());
183
+ });
184
+ it("publishes a gap only once several people have hit it", () => {
185
+ const once = Array.from({ length: REPORTABLE_FLOOR - 1 }, () => line);
186
+ expect(gaps(once)).toEqual([]);
187
+ expect(gaps([...once, line])[0].count).toBe(REPORTABLE_FLOOR);
188
+ });
189
+ });
190
+ /**
191
+ * §10.1: killing the Lambda degrades to the client-side index. It is the same function
192
+ * over the same artifact, which is what makes that true rather than hoped for.
193
+ */
194
+ describe("the endpoint and the browser run the same search", () => {
195
+ it("gives the same answer with a prebuilt index as without one", () => {
196
+ const index = buildIndex(records.map((r) => ({ ref: r.ref, text: `${r.title} ${r.description} ${r.targets.join(" ")}` })));
197
+ const inBrowser = runSearch(records, { symptom: "node pods rescheduled" });
198
+ const inLambda = runSearch(records, { symptom: "node pods rescheduled" }, { index });
199
+ expect(inLambda.hits.map((h) => h.ref)).toEqual(inBrowser.hits.map((h) => h.ref));
200
+ });
201
+ it("works with no semantic half at all, finding fewer things rather than inventing an order", () => {
202
+ const withoutVectors = runSearch(records, { symptom: "offsets" });
203
+ expect(withoutVectors.hits.length).toBeGreaterThan(0);
204
+ });
205
+ it("merges the semantic half when there is one", () => {
206
+ const semantic = (_q, refs) => refs.includes("std/disk-pressure") ? [{ ref: "std/disk-pressure", score: 99 }] : [];
207
+ const result = runSearch(records, { symptom: "offsets" }, { semantic });
208
+ expect(result.hits[0].ref).toBe("std/disk-pressure");
209
+ });
210
+ });
211
+ /**
212
+ * The floor the matcher applies when nobody set one (§8, §17).
213
+ *
214
+ * `trust_min` "defaults to T2 … below it is an explicit act, never an omission" — true of
215
+ * `runSearch` and, until 2026-09-05, false of `matchStack`, which applied no floor at all
216
+ * unless a caller asked for one. Nothing here asserted it in the direction that mattered,
217
+ * so the two halves of one rule were free to disagree, and did.
218
+ *
219
+ * Found by building a record that lands at T1 and running the matcher over the real index:
220
+ * a reader saying "linux, cli:df" was shown two procedures, one of them below the level
221
+ * `/v1/index.json` excludes, `/catalog` does not ship and no hub counts.
222
+ */
223
+ describe("what a stack is shown when nobody set a trust floor", () => {
224
+ const matchable = (ref, trust) => ({
225
+ ref,
226
+ title: ref,
227
+ description: "",
228
+ targets: ["linux"],
229
+ capabilities: ["cli:df"],
230
+ risk: "read-only",
231
+ trust,
232
+ profile: "P1",
233
+ execution: "agent-autonomous",
234
+ });
235
+ const records = [matchable("std/audited", "T2"), matchable("std/not-audited", "T1")];
236
+ const stack = { targets: { linux: undefined }, capabilities: ["cli:df"] };
237
+ it("shows the audited one and not the other", () => {
238
+ expect(matchStack(records, stack).map((m) => m.record.ref)).toEqual(["std/audited"]);
239
+ });
240
+ it("says why the other is missing rather than dropping it silently", () => {
241
+ const why = excluded(records, stack).find((e) => e.ref === "std/not-audited")?.why ?? "";
242
+ expect(why).toContain("T1");
243
+ expect(why, "a reader cannot tell it exists, or how to see it").toMatch(/default|floor/i);
244
+ });
245
+ it("shows it when the reader lowers the floor, which is the explicit act", () => {
246
+ const opened = matchStack(records, { ...stack, trustMin: "T0" });
247
+ expect(opened.map((m) => m.record.ref)).toEqual(["std/audited", "std/not-audited"]);
248
+ });
249
+ it("is the same default the search contract applies", () => {
250
+ expect(runSearch(records, {}).applied.trust_min).toBe(DEFAULT_TRUST_MIN);
251
+ expect(matchStack(records, stack).every((m) => m.record.trust === DEFAULT_TRUST_MIN)).toBe(true);
252
+ });
253
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Stack matching (W-20, §17, §13.3).
3
+ *
4
+ * §17's third entry point taken to its conclusion: a reader says what they have — targets
5
+ * and versions, the capabilities their client offers, how much risk they will accept, what
6
+ * supervision they enforce — and the catalog answers what applies to *them*.
7
+ *
8
+ * The privacy shape is the constraint that decides the design. **A declared stack is an
9
+ * infrastructure fingerprint**: postgres 14.3 and kubernetes 1.29 and a private MCP server
10
+ * names an organization more precisely than most of what §14 refuses to collect in a run
11
+ * report. So the declaration never travels. This module is a pure function over the
12
+ * published index, run in the reader's browser, and there is nothing here that could send
13
+ * one anywhere — no client, no logger, no callback.
14
+ *
15
+ * The matching itself is deliberately unclever. A range that cannot be parsed does not
16
+ * match rather than matching hopefully: telling somebody a procedure applies to their
17
+ * cluster when nobody checked is worse than telling them nothing.
18
+ */
19
+ import { type IndexedRecord, type Risk, type Trust } from "./search.js";
20
+ export type RuntimeProfile = "R0" | "R1" | "R2";
21
+ export interface Stack {
22
+ /** `{ postgres: "14.3", kubernetes: "1.29" }` — versions optional per target. */
23
+ readonly targets: Readonly<Record<string, string | undefined>>;
24
+ /** What this client can actually invoke. A record needing more is not applicable. */
25
+ readonly capabilities: readonly string[];
26
+ /** The most dangerous thing this reader will be shown. */
27
+ readonly riskCeiling?: Risk;
28
+ /** What the client enforces (§13.3). A record demanding more is refused, not ranked low. */
29
+ readonly runtimeProfile?: RuntimeProfile;
30
+ readonly execution?: string;
31
+ /**
32
+ * The trust floor. **Omitting it means T2**, the same default `runSearch` applies.
33
+ *
34
+ * It was optional in the sense of absent: unset meant no floor at all, so a stack page
35
+ * offered a T1 record beside a T2 one and the contract's own sentence about it — *below
36
+ * it is an explicit act, never an omission* — was true of the endpoint and false of the
37
+ * matcher. Measured with a record built for it: a reader saying "linux, cli:df" was
38
+ * shown two procedures and one of them was T1, a record `/v1/index.json` excludes,
39
+ * `/catalog` does not even ship, and no hub counts.
40
+ *
41
+ * Pass `"T0"` to mean no floor. That is the explicit act.
42
+ */
43
+ readonly trustMin?: Trust;
44
+ }
45
+ /** What the index must carry for a record to be matchable against a stack. */
46
+ export interface Matchable extends IndexedRecord {
47
+ readonly applies_to?: Readonly<Record<string, string>>;
48
+ readonly min_runtime_profile?: RuntimeProfile;
49
+ }
50
+ export type Applicability = {
51
+ readonly applies: true;
52
+ readonly matchedTargets: readonly string[];
53
+ } | {
54
+ readonly applies: false;
55
+ readonly why: string;
56
+ };
57
+ /**
58
+ * Does this version satisfy this range?
59
+ *
60
+ * A deliberately small subset of what a version range can be: comparators over dotted
61
+ * numbers, joined by spaces meaning "and". Anything else is **unparseable, and unparseable
62
+ * does not match** — a range this cannot read is a question nobody answered, and answering
63
+ * it optimistically puts a procedure in front of somebody whose cluster it was never
64
+ * checked against.
65
+ */
66
+ export declare function satisfies(version: string, range: string): boolean | undefined;
67
+ /**
68
+ * Whether a record applies to a stack, and when it does not, why.
69
+ *
70
+ * The refusals are named because "no results" is the least useful thing a catalog can say
71
+ * to somebody who has just described their environment: a reader who is told *their client
72
+ * only enforces R0* knows what to change, and a reader shown an empty list concludes the
73
+ * catalog is empty.
74
+ */
75
+ export declare function applicability(record: Matchable, stack: Stack): Applicability;
76
+ export interface Match {
77
+ readonly record: Matchable;
78
+ readonly matchedTargets: readonly string[];
79
+ }
80
+ /**
81
+ * What applies, ranked by applicability and then by trust.
82
+ *
83
+ * Applicability first because relevance is what was asked for; trust second because
84
+ * between two procedures that both fit, the one somebody has verified is the better
85
+ * answer. Ties break on the ref, so two builds of the same catalog agree.
86
+ */
87
+ export declare function matchStack(records: readonly Matchable[], stack: Stack): Match[];
88
+ /** Everything that did not apply, with the reason: an empty list explains nothing. */
89
+ export declare function excluded(records: readonly Matchable[], stack: Stack): {
90
+ ref: string;
91
+ why: string;
92
+ }[];