auto-model-router 0.20.0 → 0.21.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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.20.0",
10
+ "version": "0.21.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.20.0",
17
+ "version": "0.21.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -762,9 +762,10 @@ virtual profile it picked. Every routed response carries
762
762
 
763
763
  The ledger records every dispatch: model decided and served, tier, provider,
764
764
  tokens (including cached), reported cost, time to first token, total latency,
765
- escalation signal, error, and the agentdox context scope the turn carried (the
765
+ escalation signal, error, the agentdox context scope the turn carried (the
766
766
  project it belongs to; NULL for a turn that carried none, and for every row
767
- written before v0.19.0). Three views aggregate it, all from the same
767
+ written before v0.19.0) and how many strings redaction removed from the request
768
+ (NULL when redaction was off, and for every row written before v0.21.0). Three views aggregate it, all from the same
768
769
  `buildUsageReport` in `src/cost/report.ts`:
769
770
 
770
771
  - `/router report` in omp — a fullscreen hub with the `/models` look: views
@@ -802,6 +803,8 @@ written before v0.19.0). Three views aggregate it, all from the same
802
803
  a comma-separated set of ids, for a group).
803
804
  - `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
804
805
  applies the once-a-day gate and returns `due: false` when nothing is due).
806
+ - `POST /v1/router/prune` — applies `ledger.retentionDays` now and answers
807
+ `{ deleted, oldestKeptMs, retentionDays }`. See [Data governance](#data-governance).
805
808
 
806
809
  What it shows, for the window:
807
810
 
@@ -1160,7 +1163,28 @@ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
1160
1163
  | `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
1161
1164
  | `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
1162
1165
  | `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
1163
- | `retentionDays` | `365` | Delete ledger rows older than this, checked hourly; `0` keeps everything. The ledger grows about 2.5 MB a day under steady use. Freed pages are reused, so the file stops growing rather than shrinking. |
1166
+ | `retentionDays` | `null` | Delete ledger rows — and the feedback keyed to them — older than this many days, checked at most hourly; `null` (the default) and `0` keep everything. The ledger grows about 2.5 MB a day under steady use. See [Data governance](#data-governance). |
1167
+
1168
+ ### `redaction` — keep configured strings out of every request
1169
+
1170
+ | Key | Default | Meaning |
1171
+ | --- | --- | --- |
1172
+ | `enabled` | `false` | Apply the rules to every outgoing request. Off means the router never touches the prompt. |
1173
+ | `rules` | `[]` | `{ name, pattern, replacement? }` entries. `pattern` is a regular-expression source compiled once at load under a guard (see [Data governance](#data-governance)); `replacement` defaults to `[redacted:<name>]`. At most 64 rules. |
1174
+ | `scanTools` | `false` | Also scan tool-call arguments and tool results, not just message text. |
1175
+
1176
+ ```yaml
1177
+ # ~/.auto-model-router/config.yml
1178
+ redaction:
1179
+ enabled: true
1180
+ scanTools: true
1181
+ rules:
1182
+ - name: api key
1183
+ pattern: "sk-live-[A-Za-z0-9]{16,}"
1184
+ - name: customer id
1185
+ pattern: "(?:acct|customer)-\\d{6}"
1186
+ replacement: "<customer>"
1187
+ ```
1164
1188
 
1165
1189
  ### Top-level
1166
1190
 
@@ -1372,6 +1396,92 @@ ignored rather than failing the turn. The decision trail records what the
1372
1396
  policy changed (`policy: …`), and `GET /v1/router/catalog?policy=…` shows what a
1373
1397
  policy admits, model by model, without routing a turn.
1374
1398
 
1399
+ ## Data governance
1400
+
1401
+ Two things an operator with a compliance obligation needs from a router: that
1402
+ certain strings never reach a provider, and that they can say how long a record
1403
+ of the traffic is kept. Both are off by default — the router does not touch a
1404
+ prompt or delete a row unless it is told to. Design notes:
1405
+ [`docs/data-governance.md`](docs/data-governance.md).
1406
+
1407
+ ### Redaction, before a provider sees the prompt
1408
+
1409
+ With `redaction.enabled`, every rule is applied to the outgoing request
1410
+ **immediately after it is rendered and before anything can dispatch it**. That
1411
+ is one place, deliberately: all three front ends (chat completions, Responses,
1412
+ Anthropic Messages) normalise to the same request shape, and every upstream
1413
+ client — OpenRouter, Ollama, a named OpenAI/Anthropic/vLLM upstream — renders
1414
+ its own protocol from it. A provider added later is covered without being told.
1415
+
1416
+ Message text is always scanned, including the system prompt and the injected
1417
+ agentdox block. With `scanTools`, tool-call arguments and tool results are too
1418
+ — which is where the bytes are (a turn's prompt is mostly file content), and
1419
+ also where the secret an agent just read from disk actually is. Tool names, ids
1420
+ and schemas are never rewritten: they are the harness's vocabulary, and editing
1421
+ one would break the call/result pairing the model needs. A match becomes
1422
+ `[redacted:<name>]`, or the rule's own `replacement`. Nothing else about the
1423
+ turn changes — same routing, same cache breakpoints, same bytes otherwise.
1424
+
1425
+ **The pattern guard.** A rule is configuration meeting text from a user, run
1426
+ against megabytes of tool output on every turn, which is exactly the shape that
1427
+ backtracks. So a pattern is compiled ONCE at load and these are refused, with
1428
+ the reason and the rule's name, rather than trusted:
1429
+
1430
+ | Refused | Why |
1431
+ | --- | --- |
1432
+ | A nested unbounded quantifier — `(a+)+`, `(\d{2,})*` | The classic catastrophic shape: the ways to split one input across both quantifiers grow exponentially with its length. A **bounded** outer quantifier is fine, so `(?:\d{1,3}\.){3}\d{1,3}` still loads. |
1433
+ | An alternation under an unbounded quantifier — `(?:a\|a)*` | The other one: branches that can match the same input many ways. An alternation not under one (`(?:acct\|customer)-\d{6}`) is fine. |
1434
+ | A backreference — `\1`, `\k<name>` | Takes the pattern outside the regular languages, where no bound on matching time exists at all. |
1435
+ | A pattern that matches the empty string | It would replace at every position, turning the prompt into replacement text. |
1436
+ | A pattern over 512 characters, or a set over 64 rules | A rule describes the shape of a secret. Every real one is short. |
1437
+ | A pattern that does not compile | Reported with the engine's own message. |
1438
+
1439
+ Patterns compile with the `u` flag where possible — it rejects sloppy escapes
1440
+ at load rather than letting them mean something else, and makes matching work
1441
+ on code points — falling back to no flag for a legacy pattern that only `u`
1442
+ rejects, so an operator's working rule does not break on an upgrade. A refused
1443
+ rule is a **startup error**, not a warning: a rule the operator believes is
1444
+ removing something must never be a rule the router quietly skipped. The same
1445
+ guard is exported as `validateRedactionPattern`, so a front door can reject a
1446
+ rule while an operator is typing it, with the message the router would use.
1447
+
1448
+ **The evidence, without the secret.** Every ledger row carries `redactions`,
1449
+ the number of strings removed from that turn's request — a count and nothing
1450
+ else, because the matched text is precisely what must not exist outside the
1451
+ client. Nothing logs a match at any level. `GET /v1/router/report` totals it as
1452
+ `redactions` with `redactedTurns` beside it, so a front door can show "N turns
1453
+ had something removed", and the rendered report prints the same line. A row
1454
+ written before v0.21.0, or a turn with redaction off, stores NULL, which is a
1455
+ different fact from `0` (the rules ran and matched nothing).
1456
+
1457
+ ### Ledger retention
1458
+
1459
+ `ledger.retentionDays` says how long turns are kept. `null` — the default — and
1460
+ `0` keep everything; a number deletes ledger rows older than that many days,
1461
+ along with the user verdicts (`/router good|bad`) keyed to them and the Ollama
1462
+ meter samples that calibrate them. Keeping is the default because deleting is
1463
+ the direction that cannot be undone, and how long a record of what people asked
1464
+ a model lives is an operator's decision, not a default's.
1465
+
1466
+ The prune runs on the router's own housekeeping schedule, **at most once an
1467
+ hour** however often it is asked, and once shortly after boot so a lowered
1468
+ window applies without waiting. Freed pages are handed back to the filesystem
1469
+ where the engine can (a ledger created at v0.21.0 or later is
1470
+ `auto_vacuum=INCREMENTAL`; an older file reuses them instead), and the WAL is
1471
+ folded back so the space is real on disk.
1472
+
1473
+ ```
1474
+ POST /v1/router/prune → { "deleted": 12043, "oldestKeptMs": 1782720000000, "retentionDays": 365 }
1475
+ ```
1476
+
1477
+ `oldestKeptMs` is the timestamp of the oldest row still in the ledger — how far
1478
+ back it now goes, which is what the question was actually about; `null` when it
1479
+ is empty. The route exists because a front door of the team edition holds a
1480
+ **read-only** handle on the ledger file by design and must never delete from it
1481
+ itself: it triggers the router's own prune and reads the counts back. It obeys
1482
+ the same hourly floor, so calling it in a loop is harmless, and it needs
1483
+ `server.apiKey` like every other route.
1484
+
1375
1485
  ## Using a remote router
1376
1486
 
1377
1487
  `auto-model-router connect --url <router> --key <key>` points this machine at a router
@@ -0,0 +1,210 @@
1
+ # Data governance: redaction and ledger retention
2
+
3
+ Design notes for the two controls an operator with a compliance obligation
4
+ needs from the router — keeping strings out of every request, and saying how
5
+ long the record of a turn is kept. The README section
6
+ ([Data governance](../README.md#data-governance)) is the operator's view; this
7
+ is where the decisions and their reasons live.
8
+
9
+ Both ship **off**. A router that has not been told otherwise does not touch a
10
+ prompt and does not delete a row.
11
+
12
+ ---
13
+
14
+ ## 1. Redaction
15
+
16
+ ### Where it sits, and why there
17
+
18
+ ```
19
+ wire in core wire out
20
+ ─────────────────────────────────────────────────────────────────────────────
21
+ POST /v1/chat/completions ─┐
22
+ POST /v1/responses ────────┼─► parseChatRequest ─► route ─► renderUpstreamBody
23
+ POST /v1/messages ─────────┘ (NormRequest) │
24
+
25
+ ┌──── REDACTION ────┐
26
+ │ redactUpstreamBody│
27
+ └─────────┬─────────┘
28
+
29
+ upstream.dispatch
30
+ ┌──────────────┼──────────────┐
31
+ OpenRouter Ollama named upstream
32
+ (openai/anthropic/vllm)
33
+ ```
34
+
35
+ `src/server/turn.ts` calls `redactUpstreamBody` between
36
+ `req.renderUpstreamBody(...)` and `upstream.dispatch({ body, ... })`. That is
37
+ the only choke point that covers everything, and it covers it by construction:
38
+
39
+ - **Every wire in** ends up in the same shape. The Responses wire
40
+ (`responsesToChatBody`) and the Anthropic Messages wire (`messagesToChatBody`)
41
+ both translate into the chat-completions body before `parseChatRequest` sees
42
+ it, and `renderUpstreamBody` has exactly one implementation
43
+ (`src/wire/openai/request.ts`).
44
+ - **Every provider out** renders its own protocol *from* that body —
45
+ `src/upstream/openrouter.ts`, `ollama.ts`, `anthropic.ts`, `compat.ts`. A
46
+ provider added later inherits the guard without being told about it.
47
+
48
+ Redacting the `NormRequest` instead would not work: `NormMessage.text` is a
49
+ lossy concatenation used only for classification, and nothing is ever
50
+ dispatched from it. Redacting inside each upstream client would work and would
51
+ be wrong — it is four places today and five tomorrow.
52
+
53
+ Ordering inside the turn also matters. Redaction runs *after* compaction edits,
54
+ the agentdox context block and the cache breakpoints have been applied, so the
55
+ injected project context is scanned too and a rewritten text part keeps its
56
+ `cache_control` marker.
57
+
58
+ ### What is scanned
59
+
60
+ | Field | Scanned |
61
+ | --- | --- |
62
+ | `messages[].content` (string, and `type: "text"` parts) for every non-tool role | always |
63
+ | `messages[].content` for `role: "tool"` (tool results) | `scanTools` |
64
+ | `messages[].tool_calls[].function.arguments` | `scanTools` |
65
+ | tool names, tool-call ids, `tools[]` schemas, `model` | never |
66
+
67
+ Tool results and arguments are gated because they are most of a turn's prompt
68
+ bytes, so scanning them is most of the CPU — and, for an operator who cares
69
+ about a secret in a file the agent just read, most of the point. Names and ids
70
+ are never touched because rewriting one breaks the call/result pairing the
71
+ model needs; a model slug is the router's own vocabulary, not conversation
72
+ content.
73
+
74
+ An `image_url` part is skipped: it carries a data URI that no redaction rule
75
+ can meaningfully read and that every rule would be slow over.
76
+
77
+ ### The pattern guard
78
+
79
+ `src/config/redaction.ts`. A rule is configuration meeting text from a user,
80
+ which is the exact shape that backtracks — a pattern an operator wrote once,
81
+ run against megabytes of tool output on every turn of every conversation. A
82
+ redaction rule that hangs a request is worse than no rule at all, so the
83
+ pattern is compiled once at load and these are refused:
84
+
85
+ 1. **A nested unbounded quantifier.** `(a+)+`, `(\d{2,})*`, `([a-z]*)+`. The
86
+ number of ways to split one input across two unbounded quantifiers grows
87
+ exponentially with its length, so a single non-matching tool result can pin
88
+ a core for minutes. Detected by walking the source once, tracking group
89
+ spans (honouring escapes and character classes) and checking the body of any
90
+ group that carries `*`, `+` or `{n,}`. A **bounded** outer quantifier is
91
+ fine, which is why `(?:\d{1,3}\.){3}\d{1,3}` — the shape real rules use —
92
+ still loads.
93
+ 2. **An alternation under an unbounded quantifier.** `(?:a|a)*`. The other
94
+ textbook exponential shape. An alternation that is not under one is fine.
95
+ 3. **A backreference** (`\1`, `\k<name>`). It takes the pattern outside the
96
+ regular languages, so no bound on matching time exists for it at all.
97
+ 4. **A pattern that matches the empty string.** It would replace at every
98
+ position and turn the prompt into replacement text.
99
+ 5. **A pattern over 512 characters, or a set over 64 rules.** A rule describes
100
+ the shape of a secret; every real one is short and literal.
101
+ 6. **A pattern that does not compile**, reported with the engine's message.
102
+
103
+ Compilation prefers the `u` flag: it rejects sloppy escapes and malformed
104
+ quantifiers at load rather than letting them silently mean something else, and
105
+ it makes matching operate on code points, so a rule cannot be defeated by an
106
+ astral character splitting a surrogate pair. A pattern that only `u` rejects
107
+ (an unescaped `{`, an octal escape) falls back to no flag — an operator's
108
+ working rule must not break on an upgrade — so `u` is a preference, not a
109
+ requirement.
110
+
111
+ Refusal happens in two places, deliberately. The config schema
112
+ (`configInputSchema`) rejects a file rule with the path and reason, so
113
+ `config.yml` fails at load; `startServer` compiles the rule set again before
114
+ the listener exists, which is where an embedder's programmatic overrides — they
115
+ never pass through the schema — are caught. Either way a bad rule is an error,
116
+ never a warning: a rule the operator believes is removing something must never
117
+ be a rule the router quietly skipped.
118
+
119
+ Compiled rules are memoised on the rules' own text (`redactionRulesFor`), not
120
+ on the config object's identity, because hot reload and `reconfigure` mutate
121
+ the live config **in place** — a reference check would miss an edit. So an
122
+ edited rule set compiles once more and an unedited one is a map lookup on the
123
+ turn path.
124
+
125
+ ### The evidence
126
+
127
+ `LedgerEntry.redactions` → `ledger.redactions INTEGER`, schema **v19**, added
128
+ the way `scope` was at v18: a guarded `ALTER TABLE ... ADD COLUMN` on open plus
129
+ a `USER_VERSION` bump, so an old ledger opens and gains the column with its
130
+ rows NULL. Three states, all meaningful:
131
+
132
+ | Value | Means |
133
+ | --- | --- |
134
+ | `NULL` | redaction was off for this turn (or the row predates v19) |
135
+ | `0` | the rules ran and matched nothing |
136
+ | `n` | `n` strings were removed from this turn's request |
137
+
138
+ `buildUsageReport` totals it as `redactions` with `redactedTurns`
139
+ (`COUNT(redactions > 0)`) beside it, and the rendered report prints one line
140
+ when there is something to say. Nothing anywhere records the matched text: the
141
+ turn logs a debug line with a count, and the startup line lists rule *names*
142
+ only. A redaction log that quotes the secret is just a second copy of the
143
+ secret.
144
+
145
+ ---
146
+
147
+ ## 2. Ledger retention
148
+
149
+ ### The window
150
+
151
+ `ledger.retentionDays: number | null`. `null` is the default and `0` means the
152
+ same thing: keep everything. Keeping is the default because deleting is the
153
+ direction that cannot be undone, and because how long a record of what people
154
+ asked a model lives is a decision an operator makes for their deployment, not
155
+ one a library default should make for them. (Before v0.21.0 the default was
156
+ 365 days.)
157
+
158
+ ### What goes
159
+
160
+ `createLedger(...).prune(retentionDays, nowMs)` deletes, in this order:
161
+
162
+ 1. `feedback` rows — the user verdicts from `/router good|bad` — matched by
163
+ *both* their own age and the ledger rows about to go
164
+ (`ledger_id IN (SELECT id FROM ledger WHERE created_at_ms < ?)`), so
165
+ verdicts orphaned by a prune from an older version are swept up too. First,
166
+ because the subquery needs the rows that are about to be deleted.
167
+ 2. `ollama_meter_samples` past the cutoff. They only calibrate the ledger's own
168
+ Ollama estimate, so they age out with the rows they calibrate.
169
+ 3. `ledger` rows past the cutoff.
170
+
171
+ Then, when anything was deleted, `PRAGMA incremental_vacuum` hands freed pages
172
+ back to the filesystem and `PRAGMA wal_checkpoint(TRUNCATE)` folds the WAL so
173
+ the space is real on disk. Both are best-effort inside a `try`: a ledger that
174
+ could not shrink is a far smaller problem than a prune that throws.
175
+ `openDb` sets `PRAGMA auto_vacuum = INCREMENTAL` before the journal mode, which
176
+ SQLite honours only for a **new** database — an existing ledger keeps its mode
177
+ and reuses freed pages instead of releasing them, which is the pre-v0.21.0
178
+ behaviour and is fine.
179
+
180
+ `PruneResult` is `{ deleted, oldestKeptMs }`. `oldestKeptMs` is
181
+ `MIN(created_at_ms)` over what remains (null when the ledger is empty) — the
182
+ honest answer to "how far back does this ledger go now", which is what the
183
+ question was actually about, and it is reported even when nothing was deleted.
184
+
185
+ ### The schedule, and the route
186
+
187
+ `createRetentionRunner` (`src/cost/retention.ts`) owns the once-an-hour floor.
188
+ Three callers share it and none of them can bypass it:
189
+
190
+ - the server's one-minute housekeeping timer, which calls `maybeRun()`;
191
+ - a `setTimeout` five seconds after boot, so a lowered window applies without
192
+ waiting out an hour (the first call is always due);
193
+ - `POST /v1/router/prune`, which calls `runNow()` — always doing the work,
194
+ because the caller wants the counts, and marking the schedule satisfied for
195
+ the next hour, because a prune that just ran is a prune that just ran.
196
+
197
+ The window is read through a function rather than captured, so a hot reload or
198
+ an embedder's `reconfigure` changes it without restarting anything.
199
+
200
+ The route exists for one reason: a front door of the team edition holds a
201
+ **read-only** handle on the ledger file by design and must never delete from
202
+ it. It asks the router, and gets back:
203
+
204
+ ```json
205
+ { "deleted": 12043, "oldestKeptMs": 1782720000000, "retentionDays": 365 }
206
+ ```
207
+
208
+ It is guarded by `server.apiKey` like every other route, and it is safe to call
209
+ in a loop: the second call inside the hour still returns the counts, having
210
+ found nothing left to delete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -362,7 +362,17 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
362
362
  { path: "ledger.fallbackBlend.inputPerMtok", label: "Fallback blend input $/Mtok", kind: "number", min: 0 },
363
363
  { path: "ledger.fallbackBlend.outputPerMtok", label: "Fallback blend output $/Mtok", kind: "number", min: 0 },
364
364
  { path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
365
- { path: "ledger.retentionDays", label: "Ledger retention", kind: "number", min: 0, hint: "days; 0 keeps everything" },
365
+ { path: "ledger.retentionDays", label: "Ledger retention", kind: "number", min: 0, optional: true, hint: "days; 0 or cleared keeps everything" },
366
+ ],
367
+ },
368
+ {
369
+ title: "Redaction",
370
+ fields: [
371
+ { path: "redaction.enabled", label: "Remove configured strings from outgoing requests", kind: "boolean" },
372
+ { path: "redaction.scanTools", label: "Also scan tool arguments and tool results", kind: "boolean" },
373
+ // `redaction.rules` is a list of name/pattern records, edited in the
374
+ // config file (or by a front door) rather than one prompt at a time —
375
+ // the same treatment `profiles` and `upstreams` get.
366
376
  ],
367
377
  },
368
378
  {
@@ -385,8 +385,14 @@ export const DEFAULT_CONFIG: RouterConfig = {
385
385
  // so early cost reporting never underreports.
386
386
  fallbackBlend: { inputPerMtok: 1.5, outputPerMtok: 7.5 },
387
387
  conversationTtlMs: 7 * 24 * 60 * 60 * 1000,
388
- retentionDays: 365,
388
+ // Keep everything until an operator says otherwise: how long a record of
389
+ // what people asked a model lives is their decision, and deleting is the
390
+ // direction that cannot be undone. `POST /v1/router/prune` and the hourly
391
+ // scheduler both do nothing while this is null.
392
+ retentionDays: null,
389
393
  },
394
+ // Off, with no rules: redaction only ever removes what an operator names.
395
+ redaction: { enabled: false, rules: [], scanTools: false },
390
396
  // On by default: an absolute floor that no available model meets is how the
391
397
  // router ends up serving every turn from the cheapest tier.
392
398
  adaptiveTierFloors: true,
@@ -95,8 +95,9 @@ export interface WatchConfigOptions {
95
95
  * Config paths captured at construction, so a file edit cannot reach the
96
96
  * running process: the socket, the upstream clients, the agentdox bridge, the
97
97
  * ledger file. Everything else, including `ollama.costBias`,
98
- * `ollama.biasUntilUsage`, `server.subagentProfile` and `ledger.retentionDays`,
99
- * is read at call time and hot-reloads. A bare block name pins the whole
98
+ * `ollama.biasUntilUsage`, `server.subagentProfile`, `ledger.retentionDays` and
99
+ * the whole `redaction` block (rules recompile when their text changes), is
100
+ * read at call time and hot-reloads. A bare block name pins the whole
100
101
  * block; `block.key` pins one key and lets its siblings through.
101
102
  */
102
103
  /**