brink-ir 0.0.17

Intermediate representations for inkle's ink narrative scripting language
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
//! Generated table of written diagnostic explanations (#3169).
//!
//! The prose lives in `docs/diagnostics/Exxx.md` — one file per code, all
//! 189 of them — under a `## Explanation` heading. Only the ones actually
//! WRITTEN appear here; a code whose Explanation section is empty is absent,
//! so [`DiagnosticCode::explanation`] returns `None` rather than handing a
//! caller an empty string to test for.
//!
//! Embedded rather than `include_str!`-ed because the docs live outside this
//! crate's package directory, and embedded rather than read at runtime
//! because the wasm build has no filesystem. `explanations_match_the_docs`
//! in `diagnostics.rs` re-reads the files and fails if this drifts — that
//! test runs in the workspace, where `docs/` exists.
//!
//! To regenerate: see that test's failure message.

use super::DiagnosticCode;

pub(super) const EXPLANATIONS: &[(DiagnosticCode, &str)] = &[
    (
        DiagnosticCode::E014,
        r"A `~` logic line that lowers to no statement at all: not a `~ temp`
declaration, not an assignment, not a `return`, not a `~ { … }` block, not
an `await`, and not even a bare expression — just `~` followed by nothing
the grammar recognizes as the start of one. The line contributes nothing to
the compiled program, so the compiler flags it rather than silently
dropping it.

`E014` also covers a handful of unrelated **malformed** partial parses that
happen to share the code — fourteen other raise sites in total, across
three files: `~ temp` and `~ x =` (target/value) shapes with a missing
name or value in `hir::lower::content::logic_line`, the `~ { … }`
block-statement mirrors (`TempDecl`/`Assignment`/`ForStmt`) in
`hir::lower::content::logic_block`, and the native surface's own
`let`/`assign`/`for` mirrors missing their name, place, or value in
`hir::lower_native::control_flow`. Those are error-recovery diagnostics
over a real, if broken, construct — not an empty line — and the auto-fix
below is careful to tell the two apart.",
    ),
    (
        DiagnosticCode::E031,
        r"`brink_analyzer::resolve::check_arity` compares an ordinary call site's
supplied argument count against the resolved target's declared parameter
count. This is `Warning`-tier: the mismatched program still compiles and
runs — the call site's excess or missing arguments are a mechanical
problem the compiler can point at, not one that blocks the build.
`E176` is this diagnostic's sibling for a divert/tunnel/thread-start call
shape rather than an ordinary call.",
    ),
    (
        DiagnosticCode::E035,
        r#"`brink-analyzer::manifest`'s symbol-declaration pass warns whenever an
author declares a `VAR`, `CONST`, `EXTERNAL`, or knot (including a
`=== function NAME(...) ===` tunnel-as-function) whose name collides with
one of two reserved-name sets: the classic uppercase ink intrinsics
(`is_builtin_function` — `RANDOM`, `FLOOR`, `TURNS_SINCE`, …) or the T1b
stdlib slice-1 lowercase free functions (`is_t1b_stdlib_name` — `len`,
`push`, `insert`, `remove`, …, brink-dialect only). The bare `none` Option
literal rides the same warning for the same reason, though it is not a
function name at all. This is the ruled posture, stated in
`docs/t1b-surface-spec.md` §5 and `docs/stdlib-spec.md` item 3: prelude and
builtin names are **shadowable, not reserved** — an author declaration of
the same name is legal, and E035 exists only to flag that it is happening,
not to block it. (The one exception is the protocol registry's `display` /
`compare` / `next` — those three names are a **hard compile error, `E113`**
to redeclare, not an E035 warning, and only under the brink dialect; see
"What does NOT fire" below and `docs/stdlib-spec.md` item 6, F6.)

The warning fires once, at the shadowing symbol's own **declaration site**,
in the "merge manifests" step of analysis (`docs/compiler-spec.md` Pass 3) —
independent of whether the symbol is ever referenced or called anywhere in
the program. `SymbolKind::List` and `SymbolKind::ListItem` are **not** in
the warned-kind set. That is not a gap: see "What does NOT fire" below for
why a list item colliding with a stdlib verb name has nothing to warn
about.

Until PR #2859 (issue #2856), this rule was **documented but not actually
implemented**: `resolve_variable`/`resolve_function` (and, independently,
`lir::lower::expr::lower_call`) checked the reserved-name sets *before*
consulting real declared symbols, so a `VAR RANDOM` was silently ignored in
favor of the real `RANDOM()` at every read, and a knot `=== function
FLOOR(x) ===` was silently ignored in favor of the real `FLOOR()` at its own
call site — a clean compile with the E035 warning present, and the wrong
program running underneath it. PR #2859 reordered both layers so a real
resolved declaration wins first, with builtin recognition demoted to a
fallback consulted only once resolution has already failed — the "rules
attach to resolved definitions, never names" ruling (`docs/decision-log.md`,
2026-07-12). This page documents the rule as it now actually behaves."#,
    ),
    (
        DiagnosticCode::E061,
        r#"A bare struct-name annotation is checked against the same referrer-scoped `ImportScope`/import-visibility rules an ordinary reference is, so a struct declared in a module this file has not imported does not count as "recognized" even though the name exists elsewhere in the project."#,
    ),
    (
        DiagnosticCode::E063,
        r#"Two independent producers reach this page, gated by the same `TypePolicy::Strict` check and reported through the same code, but otherwise unrelated:

**1. Annotation vs. body-inference mismatch** (`brink_analyzer::annotations::mismatches`, TM-2's "annotation = firewall"). For each named knot and stitch, this compares its own declared param/return type annotations against the body-inferred types `infer_project` derives independently, and reports a disagreement — `Unknown`/`Conflicted` body types never disagree (an unconstrained or genuinely-conflicting body offers nothing an annotation could contradict), and `unify(annotation, body) == annotation` covers the one legal directional coercion (`int` body, `float` annotation).

**2. Calls through a value** (`brink_analyzer::strict::check_value_calls`, docs/t1c-spec.md §4/§8). For the same named-knot/stitch set, this walks every recorded [`ValueCallFact`] — a call site whose callee is a `temp`/param rather than a knot, external, or annotated function value — and reports the ones whose *known* (non-`Unknown`, non-`Conflicted`) type disagrees with being called: a concrete non-function type (`NotCallable`), the wrong argument count (`ArityMismatch`/`OverBind`), or a mismatched argument type (`ArgMismatch`). A callee whose type escapes inference as `Unknown` or `Conflicted` is reported too, but as **`E065`**/**`E066`** respectively, not E063 — see "What does NOT fire".

Both producers are wired inside `strict::check`, which `strict_diagnostics` (`crates/internal/brink-analyzer/src/lib.rs`) invokes only `if opts.type_policy() == TypePolicy::Strict`; under `Gradual` the whole block — inference included — is skipped, so neither producer runs at all, confirmed empirically:

| Effective policy | Annotation mismatch (`hp: string` vs. int-inferred body) |
|---|---|
| `Brink`, `types` unset (→ `Strict`, `resolve_type_policy`) | `E063`, `Severity::Error` |
| `Brink`, `types = gradual` | compiles clean — no diagnostic |
| `Brink`, `types = strict` | `E063`, `Severity::Error` |

`resolve_type_policy` (`strict.rs`) is why an unset `types` under `Dialect::Brink` defaults to `Strict` while the same unset `types` under `Dialect::StrictInk` defaults to `Gradual` — "brink dialect" and "strict typing" read as independent axes but a bare `dialect = brink` project is running `Strict` by default unless `types = gradual` is set explicitly. `StrictInk` can never reach `Strict` at all: `types = strict` under `dialect = strict-ink` is a project-level config error, `E064`, reported instead of running `strict::check` (TM-2's annotation syntax is brink-extension syntax, so strict typing requires the brink dialect).

`effective_severity` (`strict.rs`) additionally hard-codes E063 to `Severity::Error` whenever `types == Strict` — but since both producers on this page already require `types == Strict` to run at all, in practice every E063 diagnostic either of them reports is `Error`; `DiagnosticCode::E063`'s own declared base severity (`Warning`) never actually surfaces for these two producers as currently wired."#,
    ),
    (
        DiagnosticCode::E082,
        r#"A block-scoped `temp` — one declared inside a `~ { … }` multi-line logic block, or inside the block a `while`/`for`/`if` opens (`docs/t1b-surface-spec.md` §2) — is visible only for the rest of *that* block. Once the block's closing `}` (or, for `for`/`while`/`if`, the end of the construct it desugars into) is reached, the name goes out of scope, exactly like a local variable leaving a `{ … }` scope in a C-family language. This is a **brink-extension (T1b) construct**: it exists under `--dialect brink` (or `[project] dialect = "brink"` in `brink.toml`); under the default `strict-ink` dialect, `~ { … }` itself is rejected first, by [E051](E051.md), so E082 can never fire there.

The confusing part is that the declaration is still sitting right there in the source, a few lines up — nothing about the shape of the code signals that it stopped applying. `lower_path` (the by-value/`ref`-argument read path) and `lower_call` (the call-position path, added by #2848 for issue #2837) both resolve a bare name to a `Temp` symbol first via `LowerCtx::temp_slot`, which only consults currently-*open* block scopes. When a name was *at some point* declared with `declare_block_local` (i.e. it really is a block-scoped temp, not a classic one) but has nothing open for it right now, that is unambiguous: the block it belonged to already closed, and this diagnostic fires — naming the block-close, not silently falling through.

This mechanism is shared LIR lowering, reached from both source surfaces: ink's own `~ { … }` block syntax, and the native `.brink` surface's own code-ground logic blocks, which lower through the identical scope-tracking pass (see the native lowering's own cross-reference to this exact E082 arm in `hir::lower_native::body::mark_split_logic_block_scopes`'s doc comment, guarding a `> text` prose-line split against attributing a later read to the wrong block).

**Plain classic temps behave differently.** A *classic* `temp` — one declared directly in a knot/stitch body, not inside a nested block — used before its own declaring statement is a forward reference on the flow graph, not a lexical-scope defect: it lives in the same call frame, so since issue #3362 it resolves to that frame's own slot (`temp_slot_raw`) and is reported as [E193](E193.md), a `[lints]`-overridable warning, while the runtime reads the still-unset slot as ink's missing-variable default. (Until #3362 it emitted a hashed `GetGlobal`/`RefGlobal` id — matching how the converter's own hashing works — with no compile diagnostic at all, which failed at link with `unresolved global`.) A block-scoped temp read after its block closes gets the opposite treatment on purpose — it is unambiguously a real defect, and one that was never expressible in inklecate at all, so it is refused at compile time instead of deferred to a runtime fault (the #680 root cause this diagnostic replaced)."#,
    ),
    (
        DiagnosticCode::E092,
        r"`brink-analyzer::manifest::insert_symbol`'s `effective_visibility` applies
declaration-flips-default (`docs/modules-spec.md` §4): a declared module
(`#@module(name)` present) defaults `Private`; an undeclared stem-module
defaults `Public`. An explicit `#@private`/`#@public` override that names
exactly that default changes nothing — the effective visibility is the same
either way — so it warns rather than silently doing nothing.",
    ),
    (
        DiagnosticCode::E156,
        r#"Brink lambdas capture **by value, always** (RULED 2026-07-19, `docs/decision-log.md` "Lambdas ruled: Rust pipes under the RustScript north star"): there is no `move` keyword because move semantics are the only mode, and there are no reference captures in v1.

A captured binding inside the lambda is therefore the lambda's own *snapshot* of the outer binding. Writing to it can never be observed by the enclosing scope — a snapshot write is always a lost write. Rather than let that surface as a silent runtime no-op, the ruling makes it a compile error, which kills the closure-mutation confusion structurally.

The check is lexical: it fires when the assignment's target name is bound outside the lambda (an enclosing `fn`/`flow` parameter, an enclosing `let`, a `for` binding, an `as` binding, or an enclosing lambda's parameter). Assignments to the lambda's own parameters and to `let` bindings declared inside the lambda are ordinary local writes and are not flagged. Assignments to a **global** (a module-level `var` cell) are not captures at all — a global is a durable cell reached by name, not a snapshotted binding — and are likewise not flagged."#,
    ),
    (
        DiagnosticCode::E157,
        r#"A save's visit/turn counts key on a scope's compiled id. A **named** scope (a knot, stitch, or a choice/gather carrying an author `(label)`) hashes its id from that name — stable no matter what else in the project changes. An **anonymous** scope (an unlabeled once-only choice's target, or a sequence's wrapper container) hashes its id from *position* instead: inserting or removing a sibling construct earlier in the same **weave block** shifts every later positional counter, and with it every later anonymous id. (Counters are block-local, so an edit in one choice's body never renumbers a sibling's body — the exposure is bounded to the construct's own block. A `(label)` goes further: it anchors its entire subtree, so everything inside a labeled choice or block is independent of sibling edits anywhere.)

Issue #1674 measured this exposure and found it bounded: globals are keyed by name, so only visit/turn counts are exposed to the *runtime*, and an anonymous container's count is unreadable by author expressions (there is no way to write `READ_COUNT` against something with no name). (`docs/decision-log.md`'s 2026-07-27 "CORRECTION to the R1 entry" widens this: anonymous scopes also carry translation units, so intl is exposed too — this lint and `LoadReport::anonymous_states_dropped` still only cover the visit/turn-count half.) The fallout, when a patch shifts an anonymous id, is exactly two shapes:

- a once-only choice may **reappear**, as if never chosen;
- a sequence may **restart** from its first branch.

`brink_runtime::save::load_state` reports this after the fact through `LoadReport::anonymous_states_dropped` (a saved anonymous visit/turn count that no longer resolves). This lint is the *before*: a compile-time nudge to name the construct so the exposure never happens in the first place.

Naming is the fix ruled proportionate for choices — a labeled choice (`* (label) …`) resolves its identity by name, not position, is immune to this drift, and anchors everything inside its body along with it. A sequence has no label syntax of its own; the mitigation is structural — place it inside a `(label)`ed choice or block (whose anchor insulates it), or in its own small, stably-named stitch, so nothing can renumber it.

This lint is **off/info by default** (RULED: a single-shot project that never patches its content should not be nagged) and tier-able through `[lints]` like any other diagnostic code — a team doing live-ops or shipping user-generated content can raise it:

```toml
[lints]
E157 = "warn"   # or "deny", "hint" — any LintLevel
```

Only constructs that genuinely carry durable state are flagged:

- a `+` (sticky/repeatable) choice is never flagged — it has no "already chosen" state to begin with;
- a fallback (`else`) choice is never flagged;
- a single-branch, non-`once` sequence is never flagged — its computed branch index is always `0` regardless of visit count, so despite the alternation syntax it is genuinely stateless."#,
    ),
    (
        DiagnosticCode::E158,
        r#"Lambda lifting (issue #1709) turns `|params| body` into a synthesized, callable function. Before it can do that, it walks the body once to find every **free** name — a name the body reads that is not one of the lambda's own params or an inner binding — because each free local becomes a capture, snapshotted into the closure at the point the lambda value is created.

A free name that is not a local of the enclosing frame at all (a module-level `var`, a knot or function name) is left alone: it resolves the same way from inside the lifted function as it did outside, so no capture is needed. But a free name the analyzer *did* resolve as a `Temp`/`Param` of the enclosing frame is, by construction, a local — and if lifting still cannot find a slot for it, that is not "not a capture", it is a capture the pass cannot perform.

The one shape that happens in practice is recursion:

```brink,fires(E158)
fn a() {
  let f = |x| {
    if x <= 0 { return 0; }
    return f(x - 1) + 1;
  };
  return f(3);
}
```

`f`'s initializer (the lambda itself) is scanned for captures *before* the enclosing `let f = …` finishes binding `f` — so `f` has no temp slot yet when the lambda body's `f(x - 1)` call is scanned, even though the analyzer resolves that same `f` as a real local. Falling through silently here would leave call lowering to target the `let`'s own `DefinitionId` as though it were a callable function — not a compile error, but a program that compiles clean and faults at runtime when `f` is called from inside itself.

Recursive lambdas are not supported in this slice. `E158` refuses the program at compile time instead of shipping that miscompile."#,
    ),
    (
        DiagnosticCode::E164,
        r#"Inline markup is **freeform by default** (`docs/prose-dialect-spec.md` §4.2): an unrecognized `<tag>` is never a parse error, and a project that declares no vocabulary is never diagnosed. The host capability manifest is what *tightens* this. Once its `markup` section declares at least one span kind, every span in the project is checked against that vocabulary and an undeclared tag reports `E164`.

The vocabulary is **host-authored** and lives alongside `externals` in the host capability manifest (`docs/host-capability-manifest.md`), by §3.4's authorship test: a text-effect plugin can generate its tag declarations the same way bindings generate externals. Element conventions are project-authored and live elsewhere — they are a different surface.

`E164` is a `Warning` by default, so its severity is configurable: `[lints] E164 = "deny"` makes a declared vocabulary binding, and `@[allow(E164)]` / `// brink-disable E164` turn it off for one declaration or one line."#,
    ),
    (
        DiagnosticCode::E165,
        r#"The per-kind counterpart of [`E164`](E164.md). When the host capability manifest's `markup` section declares a span kind, it also declares the attribute names that kind accepts; an attribute outside that set reports `E165`.

Gated the same way `E164` is: it fires only for a span whose *name* the manifest does declare. An undeclared tag reports `E164` alone rather than cascading one report per attribute, and a project that declares no vocabulary at all is never diagnosed (markup is freeform by default — `docs/prose-dialect-spec.md` §4.2).

Attribute *values* are not checked. Span attribute values are static text by construction, so there is no type or domain to check them against — only the attribute name is part of the declared vocabulary.

`E165` is a `Warning` by default, so its severity is configurable through `[lints]`, `@[allow(E165)]`, and `// brink-disable E165`, exactly like `E164`.

`E165` ranges against the exact `name="value"` attribute, not the whole enclosing span (issue #1829) — so a span carrying several undeclared attributes gets one squiggle per attribute rather than several identical whole-span squiggles."#,
    ),
    (
        DiagnosticCode::E166,
        r#"`@[element(args = "…", block)]` declares that the annotated handler captures the run of content following its matched line into a `content` param (`docs/decision-log.md`, 2026-07-31, "Conventions are annotated handlers"). `block` widens the same capture contract [`E160`](E160.md) already enforces for `args`' named captures: the declaration must actually have somewhere for the captured run to bind, checked statically rather than deferred to dispatch. A `block` annotation with nothing to bind the captured run to is a defect in the declaration itself, not a per-call-site concern.

`E166` fires in two cases:

- the annotated declaration has no parameter whose type annotation is `content`, or that parameter is not the *last* parameter;
- the `content`-typed parameter's name collides with one of `args`' own named capture groups (a capture and the block receiver cannot be the same param).

`@[element(…, block)]` only declares the capture contract — it does not by itself implement the terminator search (a blank line or any element-level line) that would collect the block's content run and bind it to the receiver. The plain `!name` dispatch rewrite (matching a line and calling the handler by name) shipped in issue #2004; the `block` capture's own trailing-receiver binding — a `block`-declared handler's dispatch has no capture to bind the collected run from — remains issue #1839's scope. Until #1839 lands, a well-formed `block` declaration parses and validates, and a bare `!name` line dispatches to it, but the `content`-typed receiver parameter itself is never populated from a captured block.

**`content` is a resolvable annotation type (issue #1846).** A `block`-flagged declaration's qualifying trailing parameter must be *written* as `content` for this check (a shallow text match on the raw `TypeExpr`, per [`hir::lower_native::annotation`]'s own doc), and `content` is now in `brink_analyzer::annotations::is_known_leaf`'s vocabulary — the same declaration's `content`-typed parameter no longer raises `E061` under `dialect = brink` (the dialect brink-lsp and brink-web resolve from `brink.toml`). So the Fix example below parses and validates as a well-formed `block` declaration (no `E166`) and now compiles cleanly end-to-end at the declaration-surface level. It is still **not usable end-to-end** in the block-capture sense: the `block` receiver binding — running the terminator search, collecting the captured run, and populating the `content` param — remains issue #1839's scope (see above), not delivered here."#,
    ),
    (
        DiagnosticCode::E167,
        r#"Issue #1838 and the 2026-07-31 ruling ("Conventions are annotated handlers", `docs/decision-log.md`) collapsed the declarative element table into an annotation surface. Issue #2164's 2026-08-03 ruling then split that surface in two: `@[convention(claims = "…", order = N)]` for pattern-claiming, `@[element(args = "…")]` for `!name`-dispatched, self-announcing handlers. A handler spelled with `claims = "…"` claims prose lines that announce nothing — a scene heading, a transition — and the compiler rewrites each claimed line into a single call on the handler.

Because the rewrite has no other source of arguments, the compiler checks the binding contract in both directions at the declaration:

- `E160` — a named capture that matches no parameter (it could never bind anything);
- `E167` — a parameter that no named capture matches (the rewrite could never supply it).

Reporting at the declaration rather than at a claimed line is deliberate: the defect is in the pattern/signature pair, and it would otherwise surface as a confusing error on whichever prose line happened to be claimed first."#,
    ),
    (
        DiagnosticCode::E168,
        r#"Issue #1838 rewrote every prose line a claiming handler's pattern matches into exactly one call. When a file declares more than one claiming handler, and more than one of them could match the same line, something has to decide which handler wins. Issue #2164's 2026-08-03 ruling (`docs/decision-log.md`, "`order` is REQUIRED on `@[convention]`…") makes that decision **total, explicit, and authored**: `try_claim` (`crates/internal/brink-ir/src/hir/lower_native/element.rs`) tries each handler in ascending `order` and dispatches to the first pattern that matches. This retired the interim issue #1848 declaration-order rule — a claiming `fn`'s textual position in the file has no bearing on precedence any more.

`E168` catches the narrowest, fully provable instance of two patterns competing for the same line: **identical patterns**. Identical patterns match identical inputs, so wherever both handlers are eligible to claim a line, the lower-`order` one wins first.

That is not quite the same as "the higher-`order` twin can never claim anything", though. `try_claim` excludes a handler from claiming lines that live inside its **own** declaration (a handler's own body is not claimable by itself — the staging rule). That exclusion does not extend to a higher-`order`, byte-identical twin: the twin is exactly the handler that *can* claim a line inside the lower-`order` one's own body, precisely because the lower-`order` one is barred from claiming there. So `E168` runs after the whole file has been lowered and only fires when the higher-`order` twin produced **zero** actual claims — a twin that won even one claim (necessarily somewhere inside the lower-`order` twin's own body) is live and is not diagnosed.

**What this does not catch.** Two *different* patterns whose matched-line sets merely overlap — one a strict subset of the other, an alternation sharing a branch, two competing prefixes — are the more common and more valuable case to flag ("pattern power proportional to auditability", `docs/prose-dialect-spec.md` §3.5b). That case is now covered by `E170` (issue #1859), which proves subsumption from a set of witness strings generated from the higher-`order` pattern's structure rather than requiring byte-identical text."#,
    ),
    (
        DiagnosticCode::E169,
        r#"The 2026-07-31 §9.1 ruling ("Conventions are annotated handlers", `docs/decision-log.md`) settled an asymmetry between the two ways a handler can be reached (item 4):

> **Pattern-claiming is confined to ONE module** — the conventions module named in `brink.toml`. `!name`-dispatched handlers stay legal anywhere precisely because they self-announce.

A `!name`-dispatched line spells the handler it calls right there at the call site — a reader sees exactly what runs. A *claiming* pattern (`claims = "…"`) works the other way: it silently reinterprets ordinary prose that happens to match its regex as a call, with nothing at the call site marking that a rewrite happened. That asymmetry is only safe if every claiming handler lives in one file a reader (or reviewer) already knows to open — scattered across the project, "did this line get claimed, and by what" stops being answerable by inspection.

Issue #1838 built the dispatch mechanism itself and issue #1847 closed a related silent-drop (a claiming `fn` nested inside a `module { … }` block). Both landed the *placement* half of the asymmetry: `E112` fires when a `claims` annotation sits somewhere other than a top-level `fn`. **This code is the *module* half**: even a validly-placed top-level claiming `fn` is misplaced if it isn't declared in the file `brink.toml` names. Issue #2164's 2026-08-03 ruling later split the annotation surface into `@[convention(…)]` (claiming) and `@[element(…)]` (`!name`-dispatched) — this code's confinement rule stays with `@[convention]`, the claiming half, unchanged.

⚠ **Issue #2180 renamed the config key** from `[project] elements` to `[project] conventions`: the key predates the `@[element]`/`@[convention]` split above and, post-split, named a module of the *latter*, not the former — a misnomer once the split landed. `brink-project-config` still accepts `elements` as a deprecated alias (it sets the same value, but emits a `ConfigWarning` naming the rename) for a deprecation window rather than hard-breaking every existing project's `brink.toml`.

⚠ **Issue #2289 (2026-08-05 ruling) corrected a defect in this confinement rule that survived unnoticed since #1844 landed**: confinement restricted *where a handler may be declared*, but nothing made the configured module's handlers actually claim prose in any *other* file — a correctly-declared conventions module claimed nothing outside its own file. That is now fixed (see `hir::lower_native::element`'s "Cross-file claiming reach" module doc): the configured module's handlers claim across the WHOLE PROJECT. The confinement rule this code enforces is what makes that coherent — see the maintainer's own framing in the decision log: *"it's never file local. you configure conventions for a project, that's why they're conventions and not 'local patterns.'"*"#,
    ),
    (
        DiagnosticCode::E170,
        r#"This extends `E168` (byte-identical patterns) to cover the more common and more valuable case: two *different* patterns whose matched-line sets overlap.

When a file declares more than one claiming handler, and more than one of them could match the same line, something has to decide which handler wins. Issue #2164's 2026-08-03 ruling (`docs/decision-log.md`, "`order` is REQUIRED on `@[convention]`…") makes that decision **total, explicit, and authored**: `try_claim` (`crates/internal/brink-ir/src/hir/lower_native/element.rs`) tries each handler in ascending `order` and dispatches to the first pattern that matches. This retired the interim issue #1848 declaration-order rule — a claiming `fn`'s textual position in the file has no bearing on precedence any more.

`E170` catches overlapping (but non-identical) patterns where the higher-`order` handler provably can never win a claim on its own — because every string the higher-`order` pattern can match, the lower-`order` pattern also matches, so the lower-`order` one always wins first under order-sorted, first-match-wins dispatch. Mere overlap (some string both patterns match, but each also matches strings the other doesn't) is *not* enough to flag — a higher-`order` pattern that is genuinely more specific in a way that also matches different lines is live, not dead code.

The detection uses a sound-but-incomplete heuristic: generating a set of candidate strings from the higher-`order` pattern's structure (recursing into named capture groups, expanding every alternation branch, picking a representative character for classes and repetitions) and checking that the lower-`order` pattern accepts **every** one of them. If it does, the higher-`order` pattern's language is provably subsumed by the lower-`order` one. If the higher-`order` pattern contains a construct the generator doesn't know how to expand, no witnesses are produced and nothing is flagged — a false negative (missing a real subsumption) is safer than a false positive (incorrectly flagging patterns that never actually overlap).

The higher-`order` handler is only flagged if it produced **zero** actual claims — if it actually won even one claim (necessarily for a line the lower-`order` pattern couldn't match, or where the lower-`order` pattern was barred by the staging rule), it is live and is not diagnosed.

**What this does not catch.** Subsumption so subtle that no witness set proves it — complex alternations, look-ahead assertions, or patterns where subsumption depends on interactions the generator can't expand — are not detected.

Each higher-`order` handler is reported **at most once**, against the first (lowest-`order`) handler it provably subsumes — a handler subsumed by two or more lower-`order` handlers is not re-reported once per subsuming handler."#,
    ),
    (
        DiagnosticCode::E171,
        r#"`hir::lower_native::element::try_claim` rewrites a claimed prose line into exactly one call, and every argument of that call comes from a named capture. The rewrite binds each capture as a plain `Expr::String` literal, **unconditionally** — regardless of what type the receiving parameter declares:

```brink,fires(E171)
@[convention(claims = "^Take (?<n>\\d+)$", order = 10)]
fn take(n: int) {
  inventory_add(n)
}
```

`n` is declared `int`, but `try_claim` always passes it as a string. Left unchecked, this is **silent today** — nothing checks a direct call's arguments against the callee's declared parameter types yet. That generic check (`E063` for this shape) is exactly what open issue #1864 asks to build; until it lands, a mismatched claiming handler like the one above compiles with zero diagnostics and just receives the wrong value at runtime.

Numeric capture coercion is `docs/prose-dialect-spec.md` §3.5b's own **Deferred** list — the underlying gap is ruled-deferred, not itself a defect.

`E171` is reported at the **declaration**, the same static-defect-in-the-declaration posture `E160`/`E166`/`E167` already take for the rest of the capture contract — pointing at the mismatched parameter's own type annotation, not the whole `@[convention(…)]` line or a claimed line's whole text. A handler that fails this check is never registered as a claiming handler at all (like a handler that fails `E160`/`E166`/`E167`), so no line is ever rewritten to a call with an argument that could never match its declared type.

An **untyped** parameter (no `: type` annotation) is unaffected — it takes whatever the rewrite gives it, exactly as before this check existed.

### Why `content` is exempt

`content` might look like the same case as `int` — a capture can no more produce a `FragmentRef` than it can produce an integer, and binding a `content`-typed parameter to an actual captured value is the *same* Deferred list's own item, separate from numeric coercion (`docs/prose-dialect-spec.md` §3.5b, issue #1846/#1838/#1839). But `content` is exempted because it is the spec-ruled capture annotation form (§3.5b, issue #1846/#1839): the spec's own worked example (`fn radio(chan: string, text: content)`) and the `tests/tier1-native/annotations-element` golden fixture both declare a captured `content` parameter today, and both compile clean. Flagging it here would turn an already-shipped, spec-ruled pattern into a fresh hard error for no compiler-observable reason.

Every *other* declared type (`int`, `float`, `bool`, a struct name, a generic, a `fn` type) has no such precedent and no such rescue — those are this diagnostic's actual target."#,
    ),
    (
        DiagnosticCode::E172,
        r#"`#@…` is not its own grammar production in either dialect. It is an ordinary tag — `HASH` followed by free text — and only ink's HIR lowerer (`hir::lower::directive::parse_directive_tag`) gives a leading `@` special, compile-time-consumed meaning: it strips the directive name, matches it against a fixed set (`private`, `public`, `was`, `module`, `local`, `effects`), and erases the tag from the compiled output before anything reaches the runtime.

`hir::lower_native` never had a matching check. `#` is already the runtime-tag sigil in native content position — that is exactly *why* `#@…` parses as a tag rather than a directive on the native surface too, not a gap in the grammar. But nothing downstream of the parser treated a leading `@` as meaningful, so before this diagnostic existed, `#@was("old_name")` in a `.brink` file compiled clean and shipped `@was("old_name")` as a literal tag on the compiled story — no error, no warning, and the mistake surfaced (if at all) as mysterious tag content at runtime rather than as a compile-time failure. That silence is worse than a plain no-op: an author porting a file from ink, or splitting time between the two dialects, has no signal that the line did nothing they intended.

`E172` closes that gap: `hir::lower_native::body::lower_tag` checks every tag's text for a leading `@` and raises this code, naming the fix.

**Four outcomes, by directive name:**

- `was` and `effects` are real ink directive names (`hir::lower::directive::parse_directive_tag`'s recognized set) that each have a real native counterpart — the `@[…]` annotation channel (`hir::lower_native::annotation`) recognizes `@[was("old::path")]` and `@[effects(…)]`. The message names the matching annotation spelling directly.
- `module`, `public`, `private`, and `local` are real ink directive names with no native equivalent today — native has no per-declaration visibility or locality syntax yet, and file identity is established structurally, not through a tag directive. The message says so plainly instead of inventing a spelling that doesn't exist.
- `allow` is **not** an ink directive name at all — ink's own directive recognizer only knows the six names above, so `#@allow` is an unknown directive there too. It gets its own wording: native's `@[allow(…)]` annotation is the diagnostic-suppression channel, but that is unrelated to this tag, which has no directive meaning in either dialect.
- any other name is unrecognized by both dialects. The message only says the tag has the *shape* of a directive (a leading `@`) — it never asserts ink would recognize that specific name, since a project may deliberately use its own `@`-led runtime tag convention (the issue's own caution, e.g. `#@narrator`).

**`Warning` by default, not `Error`.** A literal `@`-led tag can be a deliberate runtime convention for a host that wants one — the compiler cannot tell "this author meant an ink directive" from "this project really does tag lines with `@`" just from the text. So the diagnostic is `[lints]`-configurable and suppressible at the source with `@[allow(E172)]`, the same posture `E132`/`E168`/`E170` take for other directive-adjacent, non-fatal misuses. A project that wants the literal tag keeps it; a project that meant the ink directive gets pointed at the fix."#,
    ),
    (
        DiagnosticCode::E173,
        r#"The host capability manifest's `markup` section (`docs/host-capability-manifest.md` § "Markup vocabulary") declares each span kind's accepted attributes. Until issue #1997, that section was an *allow*-list only: `attrs` named which attributes a kind accepts, and an attribute outside that set reported [`E165`](E165.md) — but a declared attribute that was simply *absent* from a span went entirely undiagnosed. There was no way to say "this attribute is mandatory."

Issue #1997 (ruling `#1780`'s gap 1) adds a `required` flag to each declared attribute. A span whose kind is declared, and which omits one of that kind's `required` attributes, reports `E173`.

Gated the same way [`E164`](E164.md)/`E165` are:

- It only ever fires for a span whose *name* the manifest does declare — an undeclared tag reports `E164` alone, with no `E173` alongside it (there is no declared attribute set to be missing *from*).
- It only fires for the subset of a kind's attributes actually marked `required`; a kind with none required never raises this for any span of that kind.
- One diagnostic per missing attribute, not one combined message — a span missing several required attributes gets one `E173` per name, mirroring `E165`'s one-per-attribute posture.

`E173` stays ranged against the whole span, unlike `E165` (issue #1829) — a *missing* attribute has no `name="value"` node in source to point at, so there is nothing narrower to range against.

Attribute *values* are still never checked — this code is about presence, not typing. Span attribute values stay static text by construction (`SyntaxKind::SPAN_ATTR_VALUE`); the manifest's `attrs` schema widened to a per-attribute record to make room for a future value type (`ManifestSpanKind`'s own doc), but that is schema headroom only — no attribute value is parsed, resolved, or checked against anything by this code or by `required`.

`E173` is a `Warning` by default, so its severity is configurable through `[lints]`, `@[allow(E173)]`, and `// brink-disable E173`, exactly like `E164`/`E165`."#,
    ),
    (
        DiagnosticCode::E174,
        r#"Ruled 2026-08-01 (issue #1994, closing #1932): for a lambda specifically, a written annotation **governs** that slot's resulting type — it is not merely a fallback consulted when the body-derived type comes back `Unknown`. If the body's own independent derivation resolves to something concrete and it disagrees with the annotation, that is an error raised immediately at the lambda's own declaration, not a deferred surprise at whatever calls the lambda later.

This is deliberately different from a top-level `fn`/`flow`, where a written annotation is only the `Unknown`-fallback overlay and a disagreement is reported as the gradual/advisory `E063` (`docs/typed-mode-spec.md` §2's "annotation = firewall" precedence rule, and its lambda counterpart recorded alongside it). The two are ruled to differ on purpose: a lambda is typically small and locally scoped, and is more likely annotated specifically to pin down what its body should mean — so a wrong body should not be able to silently override a correct annotation the way it can for a `fn`.

An unannotated param or return is unaffected: it still exports whatever its body derives, exactly as before (issue #1910).

This check runs only under `types = strict` (`docs/typed-mode-spec.md`), the same policy gate every other TM-3 type-mismatch diagnostic uses. Unlike `E063`, it is `Error` by default, not `Warning` — it is not `[lints]`-downgradable the way the advisory codes are."#,
    ),
    (
        DiagnosticCode::E175,
        "This code no longer fires. It documented `register`'s placement rule while that intrinsic existed; the intrinsic (and the mechanism it served) has since been deleted.",
    ),
    (
        DiagnosticCode::E176,
        r#"A knot or stitch that declares parameters (`=== accuse(who) ===` in ink, `flow accuse(who) { … }` in native) is diverted to exactly like a function call: the call site must supply one argument per declared parameter. `E176` is `E031`'s sibling for this shape — `E031` is "function call argument count mismatch" and is scoped to ordinary calls (`f(args)`); `E176` covers the divert/tunnel/thread-start call shape instead, so the two can be told apart and suppressed independently.

This diagnostic was previously unreachable for a divert on either dialect, and unreachable for a native `-> knot(args)` site at all until recently:

- Before PR #2150 (issue #2136), native's `-> knot(args)` call-args syntax hard-failed with `E129` ("parses but has no HIR lowering yet") — the argument list never reached HIR at all, so no argument-checking pass could see it.
- Even after that fix wired `DivertTarget::args` for real, the arity check still could not fire: `brink_ir::symbols::project`'s divert-reference projection always recorded `arg_count: None` for a divert, regardless of how many arguments the divert actually supplied. `brink_analyzer::resolve::check_arity` (the mechanism behind `E031`) only runs when a reference's `arg_count` is `Some`, so a divert's arity was never checked on ink either, not just native. Issue #2156 closed both gaps: the reference now carries `Some(target.args.len())`, and a dedicated `E176` check runs whenever that resolves to a `Knot`, `Stitch`, or `Label`.

`E176` deliberately does **not** fire when a divert resolves through a `Variable` or a divert-typed local parameter (`=== knot(-> return_to) ===`, then `-> return_to`) — see "Advanced: sending divert targets as parameters" in the ink documentation. Those are stored/forwarded divert-target values: the variable or parameter itself carries no declared parameter row, so there is nothing meaningful to check arity against at that indirection site."#,
    ),
    (
        DiagnosticCode::E178,
        r#"Issue #2164 (`docs/decision-log.md` 2026-08-03, "`order` is REQUIRED on `@[convention]`, and duplicates within a module are a compile error") split the old `@[element(claims = "…")]` spelling into its own `@[convention(…)]` annotation, and made `order` a **required** property of it, not an optional one:

> `order` is a REQUIRED property of `@[convention]`, not an optional one... there is no "default when `order` is absent", because it can never be absent.

A pattern-claiming handler competes for prose lines it did not announce — unlike a `!name`-dispatched `@[element(…)]` handler, which self-announces and therefore needs no precedence at all. When more than one claiming handler in a module could match the same line, something has to decide which one is tried first. Before this ruling that "something" was implicit: a claiming `fn`'s own textual position in the file. That interim rule (issue #1848) is retired — precedence is now **total, explicit, and authored** on the declaration itself, via `order = N`, and the compiler never falls back to declaration position, file order, or any other inferred tie-break to decide.

Because there is no default, a `@[convention]` written without `order` is not silently assigned "declared-order" precedence the way it would have been before this ruling — it is a compile error instead, so an author is never surprised by a claiming handler's place in the resolution order they never actually chose.

`@[element(args = "…")]` (the `!name`-dispatched, self-announcing form) is unaffected: it takes no `order` at all, since a handler that names itself never competes for a line, and declaring one there is simply an unrecognized clause (`E159`), not this code."#,
    ),
    (
        DiagnosticCode::E179,
        r#"`order` (issue #2164, `docs/decision-log.md` 2026-08-03) makes a claiming handler's precedence "total, explicit, and authored" — every `@[convention]` in a module names a bare integer, and the walk tries lower-`order` handlers before higher ones. That only works if `order` actually totally orders the module's handlers. Two declarations sharing one value would leave their relative precedence undefined again — exactly the ambiguity `order` exists to remove — so the ruling closes that gap by rejecting it outright rather than resolving it:

> `order` is REQUIRED, and duplicates are a compile error... there is no tie-breaking rule, because ties are rejected rather than resolved.

Unlike most duplicate-declaration diagnostics (which report only the second occurrence, the "one wins" posture `E048` takes for a repeated directive), `E179` is reported against **both** conflicting declarations — the duplicate-*definition* posture, since neither declaration is more "the real one" than the other: an author who opens either `fn` sees the conflict, not just the one that happened to be declared later.

This check is scoped to handlers declared **in one file** — the same "declared IN THIS FILE" ground truth `E168`'s duplicate-pattern check and `HirFile::claim_handlers` already use. A handler injected from the project's conventions module into some OTHER file's dispatch table (issue #2289's cross-file claiming reach) is not compared against that file's own `order` values here — not because it lacks a real `order` (it carries one, read straight off its own declaration, unlike the deleted #1863 injection seam this replaces), but because it was not declared in that file: two declarations sharing an `order` inside ONE module is what this check means, and an injected handler is not a second declaration, just the same one being used elsewhere."#,
    ),
    (
        DiagnosticCode::E180,
        r#"`attach` (issue #2178, split from #2164's 2026-08-03 design-backport comment) declares the **schema** a claiming handler attaches to the run its claimed line produces: a plain `struct` name, naming which keys are attached and their types. `docs/decision-log.md`'s 2026-08-03 entry ("The element output model") states the governing split plainly:

> The attachment schema is a STRUCT — do not invent a DSL... Declared (projection, editor-readable): pattern, `order`, mode, `kind`, which keys are attached and their types. Computed (handler body, compiler-only): emitted text, normalized values, side effects... A `struct` is already declarative, statically known, serialized, and understood by compiler + editor + host — so the projection carries a type and no new declarative sub-language exists.

That split only holds if the handler's declared return type actually **is** the struct `attach` names — otherwise the schema a tool reads off the annotation and the value the handler could ever actually return would disagree, and the projection would describe an output the compiler could never produce. `E180` closes that gap: the annotation is checked against the declaration's own `: type` clause, the same way `E166`'s `block` check and `E171`'s captured-parameter check are checked against the declaration itself, never against the handler's runtime behavior.

This is a **declaration-surface** check, like every other check in `hir::lower_native::annotation`: it compares `attach`'s name against the return type's own bare name (`TypeExpr::Named`), and never resolves whether a struct of that name is actually *declared* anywhere in the project — that is real name resolution's job, out of scope for this code (the same posture `E171`'s own doc explains for a captured parameter's declared type)."#,
    ),
    (
        DiagnosticCode::E181,
        r#"`brink_ir::lir::lower::structs::build_shape_table` walks every file's declared `STRUCT`s and resolves each one's own `DefinitionId` via `decls::lookup_global(index, file_id, name, SymbolKind::Struct)`, using the struct's own declaring file as referrer. Because the symbol index always keeps an entry for `(file_id, name)` when a struct genuinely declares itself in that file, this lookup almost always hits `lookup_global`'s exact-file arm and never fails.

It can fail in one narrow case: `brink-analyzer` already dropped this HIR declaration's own symbol entry as a true intra-module duplicate (`E023` — the same declared module as an earlier same-name declaration elsewhere), so no symbol carries `(file_id, name)` any more. Ordinarily `lookup_global`'s unscoped fallback then rescues the surviving sibling's id instead — that rescue is exactly what lets `build_shape_table`'s own `by_def`-keyed dedup recognize "this is a true intra-module duplicate, not a fresh shape" and skip it a second time. But that fallback itself excludes any candidate declared in a mounted `std…` module (issue #2197's std-visibility carve-out). If **every** surviving same-name candidate happens to be std-declared, the fallback comes back empty too, `lookup_global` returns `None`, and — before this diagnostic existed — the struct was silently dropped from both the shape table and the seeded name table, shifting every subsequent `ShapeId`/`NameId` and the bytecode built from them with no diagnostic at all.

`E181` is the non-suppressible backstop that makes that drop loud instead, the same defense-in-depth posture as `E060`/`E073`: it should never fire from an ordinary compile, and reaching it means the invariant `build_shape_table`'s dedup logic depends on (a self-declaring struct always resolves against its own file, or against a non-std surviving duplicate) has been violated.

Reachable **today**, not only from a future multi-file std mount: the standard library already declares `struct Cue` and `struct Parenthetical` (`std/conventions/screenplay.brink`), and `symbol_index_query` builds the shared symbol index from every registered file regardless of the compilation closure — so the mounted std declaration sits in the index even for an ink entry whose LIR closure never reaches the std file itself. An ordinary project need not declare a `#@module` at all: if the project's own file and the std file don't coexist under M-2d (either isn't module-qualified, or the project isn't `Dialect::Brink`, or it isn't all-native), a same-named project `STRUCT` collides with std's as an ordinary same-module duplicate, and it is the *project's* declaration that gets dropped whenever its own file sorts after the std key in `FileId`-mint order (a project file named `story.ink`, `world.ink`, or `types.ink` reliably does, since `"std/…"` sorts first). It would *also* become reachable the multi-file-std way this doc previously described, if a future std mount ever gains two files sharing a declared module and duplicating a struct name.

`build_struct_shape_data` (the `NameId`-free, `Eq`-cutoff twin of `build_shape_table` that `brink-db`'s `struct_shape_data_query` memoizes for per-knot chunk lowering) performs the textually identical lookup over the same inputs and does not raise this diagnostic itself — see its own doc comment for why that is a deliberate, documented ruling rather than a second silent drop: every real compile computes both functions in the same salsa revision, over the same symbol index and the same files' `STRUCT` declarations, so the same drop condition always raises `E181` from `build_shape_table`'s side in that same compile."#,
    ),
    (
        DiagnosticCode::E182,
        r#"A `@[convention]` handler competes for lines it never announced — it claims prose by pattern, not by the author writing `!name` at the call site (issue #2179, `docs/decision-log.md` 2026-08-06 "No-world-reads fence: analyzer effect-row check; unclassified externals are diagnosed"). That only holds together if classification is a **pure function of the text**: if a handler's claim depended on live game state, the editor could never display which handler would fire, the claiming projection could never be cached, and explain-match tooling would depend on a save file existing. So the rule is narrow and absolute: a handler may call pure (in-project) functions and `Effect`/`Presentation`-kind externals ("commands" — a state-changing or client-only call, neither of which the handler *reads back*), but it may **never** call an `EXTERNAL` classified `Query` (a world read), transitively.

`ExternalKind` (`brink_ir::host_manifest::ExternalKind`) is the classification vocabulary: `Query`, `Effect`, `Presentation`, or `Plain` (the default, meaning unclassified). Before this issue `ExternalKind` was advisory tooling metadata only; this check makes it **load-bearing** — an `EXTERNAL` with neither an inline `@kind` doc tag nor a matching registered host-manifest entry stays `Plain`, and a `Plain` external reached from a handler is diagnosed exactly like a proven `Query` one. "Unprovable is not passable": the compiler has no way to know a `Plain` external doesn't read world state, so it does not assume the best case.

This is checked over the handler's **transitive** call closure, not just its own direct calls: `handler() -> helper() -> get_health()` is diagnosed even though `handler` never calls `get_health` itself — the diagnostic is anchored at the real offending call site (inside `helper`'s own body here), which may be in a different definition, and a different file, than the handler's own declaration."#,
    ),
    (
        DiagnosticCode::E183,
        r#"`lower_call` (`brink-ir::lir::lower::expr`) turns a resolved call-site symbol into LIR. Most resolved kinds have an explicit lowering (`External` → `CallExternal`, `List` → a list-conversion builtin, `Variable`/`Constant` → `CallVariable`, `Knot` → `Call` — ink allows any knot as a function via tunnels, so no other kind needs its own arm to be *legitimately* callable). Every other kind reaching this point is not callable, and this code refuses it with a diagnostic instead of falling through a catch-all that would emit `lir::ExprKind::Call` against whatever id happens to be resolved there.

That catch-all used to exist with no check at all (issue #2837). It is exactly the mechanism that let a resolution bug in PR #2836's first attempt ship as a silent miscompile: the program compiled clean — 7,941 tests, the oracle ratchet, and clippy all green — and then faulted at runtime with `UnresolvedDefinition(ListItem(..))`. The reason a resolution mistake became a *runtime* fault instead of a *compile* error was this unguarded catch-all, not the specific resolution bug that first exposed it (that one was fixed separately, in `brink-analyzer::resolve::resolve_function`). This code is the backstop: whatever puts a non-callable symbol at a call position in the future, the compiler refuses it here rather than shipping it.

`resolve_function` cannot legitimately hand back `Stitch`, `Label`, or `Struct` for a real call site today, and a bare `ListItem` only ever comes back for a `#fn(target)` literal site (`arg_count: None`), which never reaches `lower_call` at all — those four kinds are a defensive backstop against a future resolution regression. `Param`/`Temp` are different: they are reachable from ordinary author source today, whenever `LowerCtx::temp_slot` has nothing open for the name at the call site — the normal shape of a genuine forward reference, not a `temp_slot` bug (see "When this fires" below)."#,
    ),
    (
        DiagnosticCode::E184,
        r#"`brink_ir::lir::lower::decls::collect_globals` (for `CONST`/`VAR`) and `collect_externals` (for `EXTERNAL`) each resolve a declaration's own `DefinitionId` via `decls::lookup_global(index, file_id, name, kind)`, using the declaration's own declaring file as referrer. Because the symbol index always keeps an entry for `(file_id, name, kind)` when a declaration genuinely declares itself in that file, this lookup almost always hits `lookup_global`'s exact-file arm and never fails.

It can fail in one narrow case: `brink-analyzer` already dropped this HIR declaration's own symbol entry as a true intra-module duplicate (`E023` — the same declared module as an earlier same-name/same-kind declaration elsewhere), so no symbol carries `(file_id, name, kind)` any more. Ordinarily `lookup_global`'s unscoped fallback then rescues the surviving sibling's id instead. But that fallback itself excludes any candidate declared in a mounted `std…` module (issue #2197's std-visibility carve-out). If **every** surviving same-name/same-kind candidate happens to be std-declared, the fallback comes back empty too, `lookup_global` returns `None`, and — before this diagnostic existed — the declaration was silently dropped from `PreludeDecls` (no `lir::GlobalDef` for a `CONST`/`VAR`, no `lir::ExternalDef` for an `EXTERNAL`) with no diagnostic at all.

`E184` is the non-suppressible backstop that makes that drop loud instead, the same defense-in-depth posture as `E060`/`E073`/`E181` (this diagnostic's own `STRUCT` twin, issue #2240): it should never fire from an ordinary compile, and reaching it means the invariant these self-declaration lookups depend on (a self-declaring symbol always resolves against its own file, or against a non-std surviving duplicate) has been violated.

Reachable **today** for `EXTERNAL`, the same way issue #2240 found `E181` reachable for `STRUCT`: the standard library declares `extern scene_entered(title, slug)` (`std/conventions/screenplay.brink`), and `symbol_index_query` builds the shared symbol index from every registered file regardless of the compilation closure — so the mounted std declaration sits in the index even for an ink entry whose LIR closure never reaches the std file itself. An ordinary project need not declare a `#@module`, and — unlike `STRUCT` — `EXTERNAL` needs no `dialect` override to parse at all: a plain `EXTERNAL scene_entered(...)` in a `.ink` file collides with std's own `extern scene_entered` as an ordinary same-module duplicate (neither side is module-qualified, so M-2d cross-declared-module coexistence never applies), and it is the *project's* declaration that gets dropped whenever its own file sorts after the std key in `FileId`-mint order (a project file named `story.ink` reliably does, since `"std/…"` sorts first).

`std` declares no `CONST`/`VAR` today, so the `CONST`/`VAR` call sites stay reachable only in principle — the same status `E181` itself carried before its own reachable `EXTERNAL`-shaped case was found here. A future std module adding a `CONST`/`VAR` would make them reachable the same way."#,
    ),
    (
        DiagnosticCode::E185,
        r#"Issue #1900 (PR #1939) added strict-mode type checking for a plain dotted-assignment target's *value* against the field's declared type — `check_declared_field_assign_target` records a candidate fact for `~ p.x = expr`, later walked by `structs::check_field_assign_mismatch` against the receiver's declared shape to compare `expr`'s type with `x`'s declared type (`E063` on a mismatch). That check is deliberately silent when the field name itself doesn't resolve on the shape — "Unknown never disagrees" is its posture, by design, for the *type-mismatch* comparison.

But that left a real gap: nothing checked whether the field name *exists* on the shape at all. `ref_projection::check_strict`'s `E098` covers an unknown segment only in `ref`-argument position (`ref npc.bogus`, handed to a call); the construction-literal path already had this check (`structs::check`'s `E070`, `docs/typed-mode-spec.md` §6) but only for `Point#{bogus: 1}`-shaped literals. A plain assignment to an unknown field had no equivalent, and compiled clean under `types = strict` with zero diagnostics.

`E185` closes that gap: `structs::check_field_assign_mismatch` (the same function `E063` comes from) now reports it the moment the walk resolves the receiver's shape but the shape declares no field by the name being assigned."#,
    ),
    (
        DiagnosticCode::E186,
        r#"`try_claim` (`brink_ir::hir::lower_native::element`) dispatches a claimed line to exactly one of two shapes, chosen by an `if is_block { .. } else if is_attach { .. }`: **wrap mode** (`block`, issue #1839) captures the following run into the handler's own trailing `content`-typed parameter, and **attach mode** (`attach = StructName`, issue #2178) captures the following run as block-level metadata, merging the handler's returned struct fields into `OutputLine.element.data` for every line in that run (issue #2108, `docs/decision-log.md` 2026-08-03 "The element output model").

Before this code existed, nothing checked whether a single handler declared both. `parse_convention_clauses`/`convention_annotation` (`annotation.rs`) parsed and stored `attach` regardless of `block`, and `try_claim`'s `if`/`else if` always took the `block` arm when both were set — `attach` was accepted syntax that silently did nothing: no event, no data merge, no error, no warning, no hint. Issue #2264 names this exactly the shape house rule 9 ("flag silent data drops") calls always-a-bug-until-proven-otherwise.

This code is a deliberate refusal to define combined semantics, not an oversight of one: "wrap AND attach" — does the wrapped call's own return value *also* attach to the run it wraps? does it attach to itself? — is an open design question with no ruling and no test pinning any answer. Rather than invent one, `parse_convention` rejects the co-occurrence outright, the same "never a partial `ConventionAnnotation`" posture [E159](E159.md)/[E166](E166.md)/[E167](E167.md)/[E178](E178.md)/[E180](E180.md) already take — a handler declaring both is never registered as a claiming handler at all.

Reachable through both element shapes `try_claim` dispatches: an ordinary block-form claim (`@NAME` on its own line) and the compact-cue desugar (`@NAME: text`, issue #2079) — both route through the same function, so a compact-cue-claiming handler declaring both clauses hits this same check."#,
    ),
    (
        DiagnosticCode::E187,
        r#"Issue #2201: before this code existed, `lir::lower::stmts::lower_assign_target` — the shared choke point most write shapes resolve their root through — treated `SymbolKind::Constant` identically to `SymbolKind::Variable`: it handed back an ordinary writable `AssignTarget::Global`, with no distinction at all between the two symbol kinds. A story that reassigned a declared `CONST` compiled clean, with zero diagnostics anywhere in the pipeline, and the mutated value was observable in the story's own output.

This is broader than issue #2122's earlier finding for `as`-binding immutability: that issue's fix (E148) covered only `lower_field_mutator`/`lower_single_level_field_write`'s two field-write shapes. `CONST` reassignment needed the fix at every choke point that resolves a `Global` write root — seven shapes in total, enumerated in [E148](E148.md)'s own doc for the `as`-binding case and mirrored exactly here for `CONST`:

- `lower_assign_target` itself — plain/compound assignment, a postfix's bare-target conversion, an indexed-assignment root (via `lower_indexed_assignment`, which resolves its flattened root through this same function), the `pop`/`heap_pop` mutator intrinsics' lvalue argument, `lower_bare_mutator`'s root (the bare-variable fast path for the entire `MutatorKind` family — `push`/`insert`/`remove`/`remove_at`, not just `pop`/`heap_pop`), and `lower_lvalue_container_chain`'s root (the indexed-lvalue mutator path, e.g. `push(grid[y], v)`) — all of which call this same function for their root.
- `lower_single_level_field_write`/`lower_field_mutator` — a single-level struct-field write/mutator (`c.field = v`, `push(c.items, v)`) resolves its root `SymbolInfo` independently of `lower_assign_target` (the caller has already split a two-segment path into head/field before either function runs), so each needs its own call to the shared check.
- `lower_ref_path_call_arg`/`lower_ref_projection_arg` — passing a `CONST` by `ref` (bare or as a projection root) hands the callee a raw pointer to the storage cell without ever routing through assignment lowering at all.

All five are raised through the same shared helper, `lir::lower::stmts::reject_const_write` — the `CONST` analog of `reject_as_binding_write` (E148's own helper) — called individually from each choke point above, exactly the same "no single call site sees every write shape" reasoning E148's helper already establishes.

### Posture: a lowering refusal, not an analyzer diagnostic

This is deliberately placed at LIR lowering (the same posture as [E074](E074.md)/[E148](E148.md)), not as a `brink-analyzer` diagnostic (the posture [E185](E185.md) takes, which does surface through both editor analysis roads). The write-channel enumeration above already lives entirely inside `lir::lower` — it is the layer that resolves every one of these shapes to a `Global` root today. Re-implementing the same enumeration inside the analyzer, as a separate HIR-level walk, would risk exactly the channel-undercounting drift that made this issue's own premise true in the first place (issue #2122 named only two of these seven channels; the rest went unnoticed until this issue's audit). Putting the check at the layer that already catches every channel once, rather than duplicating that catalogue at a second layer, is the fix that can't silently regress to catching only *some* of the shapes again.

One consequence of this posture: like E074/E148, E187 fires during a real compile (`brink compile`, or any pipeline that runs LIR lowering) — it does **not** currently appear in either editor analysis road's live Problems panel (`ProjectDb`'s db-direct road or `IdeSnapshot::analyze`'s off-db road), since neither road runs LIR lowering. This matches E074/E148's existing, unchanged posture; it is not a regression introduced by this code.

### Applies to both surfaces

This check applies identically to `.ink` and `.brink` source — it is not native-gated. It mirrors ink's own compile-time rejection (see the Summary above), and `SymbolKind::Constant` is resolved the same way by name resolution for both frontends by the time LIR lowering runs, so the same `reject_const_write` call fires for either dialect."#,
    ),
    (
        DiagnosticCode::E188,
        r#"Issue #1865 (filed from wave retro on #1846/PR #1861's review): `annotations::resolve` matches a fixed set of literal names — the scalar leaves, plus `content` (issue #1846's capture-contract leaf), plus the NS-A8 tower kinds — **before** it ever consults `names.structs`. That ordering is deliberate, and this issue does not change it: `resolve`'s own doc already calls it out — "Checked after the fixed scalar-keyword set so a struct can never shadow `int`/`float`/etc. (those names aren't legal `STRUCT` identifiers by convention, but this ordering is the unambiguous choice regardless)" — and the tower-kind arm carries the matching comment: "checked before the struct lookup, so a STRUCT can never shadow a tower type name (the same ordering that keeps `int`/`float` unshadowable)".

The consequence nothing diagnosed before this code existed: a project that declares, say, `STRUCT content { … }` silently changes what every `content`-typed annotation means. `VAR v: content = ...` still compiles — it just always resolves to the builtin `Ty::Content`, never to the user's struct — with no diagnostic anywhere, in either direction (not at the struct declaration, not at any annotation site).

`E188` closes that gap at the declaration site: `annotations::check_reserved_type_names` walks every declared `STRUCT` and flags one whose name is in the same reserved set `resolve`'s `Named` arm checks first.

### What this code deliberately does NOT cover, and why (verified, not assumed)

- **The generic heads** (`List`/`Array`/`Map`/`Option`/`Weighted`/`Handle`). These names are special-cased only inside `TypeExpr::Generic`'s own dispatch (`Array<T>`, with angle brackets) — a *bare* `Named` reference to a struct sharing one of those names (`f: Array`, no `<...>`) still falls through to the ordinary `names.structs.contains(name)` arm and resolves to the struct correctly. There is no real collision for `resolve` to have, so `E188` never fires for a `STRUCT Array = #{...}`-shaped declaration. (Structs are never generic, so there is no way to write `Array<T>` meaning "a struct named Array parameterized by T" in the first place — the collision the generic-head special case exists to arbitrate simply cannot arise for a struct.)
- **`void`**. Unlike the scalar leaves, `resolve`'s `Named` arm has no explicit `"void"` case at all — an unmatched name falls straight through to the struct-lookup arm. A `STRUCT void = #{...}` therefore resolves fine through a bare annotation; `E188` never fires for it.
- **Declared `LIST` names or registered `Handle<K>` kinds.** `names.lists`/`names.handles` are only ever consulted inside `List<L>`/`Handle<K>`'s own generic-argument position, never against a bare `Named` annotation — a different namespace from `names.structs` entirely, with nothing to collide."#,
    ),
    (
        DiagnosticCode::E193,
        r#"A classic `~ temp` belongs to its knot's **call frame**, not to a lexical
block. Every read anywhere in that frame — the knot body, any choice branch,
the gather, any of the knot's stitches — resolves to the same slot. What
resolution alone cannot say is whether the declaring statement has *run* by
the time a given read executes.

`brink_analyzer::temp_dominance` answers that structurally, over the HIR
block tree, with no control-flow graph:

> a `~ temp` declaration `D` sitting directly in block `B` dominates exactly
> those reads that lie inside `B`'s own subtree and start at or after `D`'s
> end.

Reaching any point in `B`'s subtree past `D` means executing `B`'s
statements in order through `D` first, so nesting below `D` — a choice set,
a conditional, a labeled gather — is still behind it. Everything else in the
region is a different block's subtree, which is what makes both of the
ruled shapes fall out of one rule:

1. a sibling choice branch declares it, another one reads it;
2. a gather is reached from a branch that did not declare it;
3. the read is written textually ahead of the declaration.

Each of a knot's root body and every one of its stitch bodies is checked as
its own independent region: a `~ temp` declared in one is never looked up
for a read in another. (A fourth shape this page used to enumerate — a
stitch reading a temp declared at its knot's root — turned out not to be a
dominance question at all: it fires unconditionally, dominance aside, and
inklecate rejects the identical program outright rather than warning on it.
The 2026-09-01 follow-up ruling on #3373 moved it into its own compat-deny
code, [`E194`](E194.md).) The rule deliberately does not model diverts
within a region either — a divert that re-enters a gather inside the same
block *after* the declaration ran is not a defect and is not reported.

**Why it is a warning and not an error.** The C# reference runtime prints
`RUNTIME WARNING: Variable not found: 'n'. Using default value of 0 (false).
This can happen with temporary variables if the declaration hasn't yet been
hit.` and keeps playing, which is why the pattern reaches authors as "it
works fine in Inky". Brink now plays it the same way — `Opcode::GetTemp`
reads an unset slot as `0` and raises a `brink_runtime::RuntimeWarning`
through the same channel — so the ink-compat floor stays honest, and this
diagnostic is the half that arrives before the author ever presses play.
(RULED 2026-09-01, option C on issue #3354; `docs/compiler-spec.md` "Temp
scope and definite assignment" and `docs/runtime-spec.md` "Uninitialized temp
reads".)

**What does not fire.** A knot or stitch *parameter* is bound at call time,
so a name that is also a parameter of its enclosing definition is never
reported — matching `lir::lower::temps::alloc_temps`, which gives the
parameter the slot and lets a same-named `~ temp` write through it. A plain
assignment target (`~ n = 1`) is a write, not a read. A `temp` declared
inside a `~ { … }` block is [`E082`](E082.md)'s subject — a lexical-scope
defect, not a definite-assignment one. Reads inside a lambda body are skipped
because the lambda's own parameters shadow the enclosing frame."#,
    ),
    (
        DiagnosticCode::E194,
        r#"`brink_ir::lir::lower::temps::alloc_temps` walks a knot's own body plus
every one of its stitch bodies before lowering begins and gives each `~
temp` name one slot in that shared frame — so, mechanically, nothing stops
a stitch from reading a name only the knot's root declares. The program
compiles and plays.

Ink's own compiler does not extend a knot's `~ temp` visibility into its
stitches at all. A stitch is, for `~ temp` purposes, a separate scope from
its knot's root content — referencing the knot's temp from inside a stitch
is `Unresolved variable` in inklecate, full stop, independent of whether
the divert that entered the stitch happened to run the declaration first:

```ink,fires(E194)
-> k
=== k ===
~ temp n = 7
-> s
= s
Stitch sees {n}.
-> END
```

By default this does not compile at all (`E194` is `Error`-tier); once
downgraded (see "Fixing it" below) it plays `Stitch sees 7.` in brink,
while inklecate rejects it outright — the declaration having already run
when the divert reaches `s` makes no difference to ink's compiler. That is
what separates this from
[`E193`](E193.md): `E193` is a genuine dominance question (did the
declaring statement run *on this path* before the read?) that the runtime
resolves the same way ink's runtime does, by substituting a default and
warning. This is not a runtime question at all — ink's compiler refuses the
reference regardless of the runtime path, so there is no runtime fallback
to lean on the way `E193`'s has.

**Why compat-deny, not a plain warning.** `docs/compiler-spec.md`
"Compat-deny diagnostics" (issue #3373, RULED 2026-09-01) names the tier:
"inklecate rejects this; brink can run it; you must opt in." Defaulting to
`Error` matches inklecate's own hard rejection, so an ink-compat project
sees the same wall Inky would show it. What makes the tier different from
an ordinary hard error is the admission invariant: brink genuinely produces
a *working* program once a project opts in, so the code stays
`[lints]`-overridable rather than staying a permanent, non-negotiable
error — the ruling's own words: "we should allow it to be turned off if the
user wants, it's annoying."

**What fires: reads AND plain writes.** A plain assignment (`~ n = 9`) in a
stitch to a name only the knot's root declares fires exactly like a read —
assigning still has to *resolve* `n` to a slot before it can store into it,
and inklecate rejects that resolution too, just with a different message
(`Variable could not be found to assign to: 'n'` rather than `Unresolved
variable: n`):

```ink,fires(E194)
-> k
=== k ===
~ temp n = 7
-> s
= s
~ n = 9
Knot temp is now assigned.
-> END
```

**What does not fire.** A stitch parameter of the same name is bound at
call time and is never reported. A stitch that declares its *own* `~ temp`
of the same name shadows the knot's for that stitch's reads and writes
entirely — that is [`E193`](E193.md)'s question (does the stitch's own
declaration dominate its own reads?), not this one. A read or write inside
the knot's own root body, or inside another stitch that itself declares the
name, is untouched by this check. A compound assignment (`~ n += 1`) or
`~ n++`/`~ n--` reads the name before writing it back, so it is reported as
a read, not a write — the message still names the right operation because
`ReadCollector` (shared with `E193`) only discounts a plain `Set` target as
"not a read", never a compound one."#,
    ),
    (
        DiagnosticCode::E195,
        r#"The check runs once per choice line, during HIR lowering
(`hir::lower::choice::LowerChoice::lower_choice`), and looks at exactly the
evidence inklecate's own parser looks at: the choice's own line, not
whatever is nested underneath it. It fires only when **all** of the
following hold:

- no divert on the choice's own line — `* ->` counts as having one, even
  though the divert has no target; only a line with no `->` token at all
  counts as "no divert",
- no tag directly on the choice line (`* #tag`) — matching inklecate, which
  does not warn on a tag-only choice either,
- and no real text in any of the three same-line content regions ink's
  grammar gives a choice (`text[bracket]inner`) — including an *explicit but
  empty* `[]`, which still parses to a zero-width content node, not to
  nothing.

**A `(label)` or `{condition}` guard does not exempt a choice from this
check.** The reference's own `emptyContent` computation
(`startContent`/`innerContent`/`optionOnlyContent`) has no such carve-out,
and measurement against inklecate confirms it fires anyway: both `* (opt)`
and `VAR x = true` / `* {x}`, each followed by a blank line, still emit
"Choice is completely empty…" — see the fires examples below.

Nested content *underneath* the choice line — the block that plays after the
choice is selected — is never consulted. `* []` followed by an indented
paragraph still fires: inklecate's own check works the same way, since the
nested block is parsed as a separate weave continuation, after the single
line `Choice()` has already decided whether to warn.

**Why the check lives in lowering, not in a later analyzer pass over the
built `hir::Choice`** (contrast [E034](E034.md), which runs entirely over
already-lowered `Choice` values): an explicit-but-empty divert (`* ->`) and
no divert at all (`* []`) are indistinguishable once lowered — both leave no
`Stmt::Divert` in the choice's `body.stmts`, since a target-less divert
carries no target to lower into one. Whether a `->` token was written at
all is evidence that exists only on the AST, at the point `lower_choice`
already has it in hand, so the check runs there instead of being
reconstructed later from a shape that has already thrown the distinction
away.

**Ink surface only.** This is not wired into the native `{? … }` surface's
own `lower_choice`. inklecate is an ink-only tool, so ink is the surface
this diagnostic's parity claim is actually about — but the deeper reason is
that the same rule would be actively wrong for native: native choices
routinely put their only divert *inside* the choice's braced body
(`{? * { -> knot } }`), which this check's same-line-only evidence does not
see, so wiring it in as written would warn on completely ordinary native
code. Native already has its own, unambiguous slot for "no visible option"
— `else { … }` — which lowers with `is_fallback: true` and needs no warning
about being empty; it is supposed to be."#,
    ),
    (
        DiagnosticCode::E110,
        r"`#@effects(…)` was the original tag-channel spelling of a knot/stitch's effects assertion. The `@[effects(…)]` annotation is the final NS-A2 form (`docs/stdlib-spec.md` §9.2, ruled 2026-07-18), and the two spellings are **not** interchangeable text: `#@effects(…)` keeps the legacy **colon** argument grammar (`reads: gold, hp`) frozen forever, while `@[effects(…)]` uses the amended **paren-clause** grammar (`reads(gold, hp)`, 2026-07-19). The tag spelling still parses — nothing about the assertion's meaning changes — but every new definition should use the annotation spelling, and this warning is how an existing `#@effects(…)` site is found.",
    ),
];