Files
ue-toolchain/plugins/ue-design-skills/skills/ue-gameplay-messaging/references/failure-modes.md
T
MagentaDolphin ecd87ac96d feat(skills): ship ue-design-skills bundle, licensing and delivery gate
Phase 0 of the handoff plan, as a marketplace rather than a flat skills/
directory. Content moved out of the LyraResearch archive and depersonalised:
addresses stay in the archive, recipes ship.

- plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the
  six required fields; catalog.json as the harness-neutral source of truth and
  .claude-plugin/ as one adapter over it.
- _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a
  declared file cannot silently miss the line rules.
- ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split
  licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata).
- LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as
  the material the licence decision grew from.

Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their
fixtures with a clean baseline and 2 root files reaching the line rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:48:55 +07:00

647 lines
28 KiB
Markdown

# Failure modes: gameplay messaging
Seventeen ways a tag-addressed message bus stops delivering, over-delivers, or
delivers into a void, without reporting anything useful.
The shared property of this list: **a bus converts a compile-time dependency into
a runtime one, and then does not check it.** The publisher compiles whether or not
anyone listens; the listener compiles whether or not anyone publishes. Every
entry below is a consequence of that trade, and the observable is almost always
the same — nothing happens, or something happens twice.
Recipes use `rg`. Two conventions worth stating once, because both cost real time
during the audit that produced this file:
- **Restrict searches to source globs.** An unrestricted search of a built tree
matches compiled debug symbols, which are not code. In the measured reference a
search for a suspected dead field returned four hits; three were `.pdb` files
and the source truth was one. A count that includes build output is not a count
of your code.
- **Most recipes are a pair of searches.** The defect class here is "the thing is
present and the thing that would give it meaning is absent", and one search can
only show the first half.
---
## Delivery correctness
### MB-01 - Listener removed using the wrong channel key
**Mechanism.** A hierarchical broadcast walks from the sent channel up through its
parents. Inside that walk, code that removes a listener addresses the removal by
the *original broadcast channel* rather than the ancestor tag currently being
visited. Handle IDs are allocated per channel, so the pair (channel, ID) is the
only unique identity.
**Why it is silent.** On the first iteration of the walk the two variables hold
the same value, so exact-match listeners — which is nearly all of them in most
projects — are removed correctly. The bug requires a listener registered on a
parent with partial matching *and* an expired payload type, simultaneously. Both
conditions are rare, and neither is an error on its own.
**Why the obvious check misses it.** The line reads correctly in isolation:
removing by "channel" is obviously right, and the variable is named `Channel`.
The defect is that the enclosing loop rebinds the meaning of "the channel we are
talking about" on every iteration, and the correct variable is three lines up in
the `for` header. Worse, the error-log line a few lines below uses the loop
variable correctly — so the file contains both the right and the wrong idiom, and
the wrong one is the one that mutates state.
**Symptom.** Two outcomes, both bad and neither loud. Either the stale listener is
never removed and a warning is emitted on every subsequent broadcast forever, or —
worse — an unrelated listener that happens to hold the same channel-local ID in
the broadcast channel's bucket is removed instead, and its owner silently stops
receiving messages.
**Detect.** Compare the loop variable against the key used for removal:
```bash
rg -n -A12 "for\s*\(.*RequestDirectParent" --glob "*.cpp" . \
| rg "UnregisterListenerInternal|Remove\("
```
Read the output next to the `for` header. If the removal uses the loop's starting
value rather than the loop variable, that is this defect. In the measured
reference the walk iterated over an ancestor tag while removal passed the
broadcast channel.
**Guardrail.** When handle IDs are channel-local, make the removal API take the
pair and nothing else, and assert inside it that the pair was found. A removal
that silently finds nothing is how this defect stays invisible.
---
### MB-02 - Covariance in C++, equality in Blueprint
**Mechanism.** The C++ delivery path accepts a payload whose struct derives from
the listener's expected struct. The Blueprint async listener compares types with
strict equality.
**Why it is silent.** Both behaviours are individually reasonable and both are
implemented correctly. A Blueprint listener that does not match simply does not
fire, and not firing is the normal state of a listener most of the time.
**Why the obvious check misses it.** The two implementations live in different
files, written for different consumers, and each is correct against its own local
reasoning. There is no shared test matrix, because the two APIs are usually tested
by different people — the C++ one by whoever wrote the bus, the Blueprint one by
whoever first used the node. Nothing in either file mentions the other.
**Symptom.** A Blueprint listener subscribed to a base struct never fires for
derived payloads, while a C++ listener on the same base receives them. The
designer reports "the node never fires", which everyone investigates as a wrong
tag — the one hypothesis that is cheap to test and wrong.
**Detect.** Find both type gates and compare the operators:
```bash
rg -n "IsChildOf" --glob "*.cpp" . # covariant gate
rg -n "StructType\s*==|== \w*StructType" --glob "*.cpp" . # equality gate
```
Two different operators guarding the same conceptual check, in the same plugin,
is the finding. In the measured reference the C++ path used covariance and the
async Blueprint path used equality, on the same registration.
**Guardrail.** Pick one contract, write it down, and run one test matrix through
both APIs. If they must differ, the difference belongs in the node's tooltip, not
only in the source.
---
### MB-03 - Type check disabled by an empty type pin
**Mechanism.** Registering with a null expected struct type sets a flag that
disables the type check entirely, so the listener receives any payload. The path
is documented as internal — and is reachable from a Blueprint node whose payload
type pin was left empty.
**Why it is silent.** Nothing fails. The listener receives messages, possibly
several unrelated kinds, and the Blueprint graph downstream either uses the
payload or does not. There is no log, because from the bus's point of view the
listener asked for exactly this.
**Why the obvious check misses it.** The comment on the branch says "for internal
use", and reviewers believe comments about intent. Confirming reachability means
tracing a Blueprint pin's default value into a factory function into a private
registration call — three hops across two modules, none of which look
interesting.
**Symptom.** In Blueprint, usually nothing worse than a payload extraction that
returns false. In C++, the same path combined with a templated callback would
reinterpret unrelated bytes as the expected struct, which is undefined behaviour
with no diagnostic.
**Detect.** Find the escape hatch and then find who can reach it:
```bash
rg -n "bHadValidType|StructType\s*==\s*nullptr" --glob "*.cpp" --glob "*.h" .
rg -n -B4 "RegisterListenerInternal\(" --glob "*.cpp" . | rg -i "Get\(\)|nullptr"
```
Any caller that can pass a null type is a caller that can disable the check.
**Guardrail.** If a code path must exist for internal reasons, make it
inaccessible rather than merely undocumented — a private overload, a passkey
type, or a compile-time gate. Reject an unresolved wildcard payload at Blueprint
compile time.
---
### MB-04 - Payload pointer retained after the callback returns
**Mechanism.** The bus passes a pointer to the sender's stack object rather than
copying. A listener stores that pointer.
**Why it is silent.** The memory is still mapped and, for a short while, still
holds the right bytes. Reading it immediately after the broadcast usually works.
The failure requires the stack to be reused, which depends on what runs next.
**Why the obvious check misses it.** The callback signature hands you a reference,
and references are normally safe to keep. Nothing in the type expresses "valid
until this function returns". The one place the constraint is written down is the
bus's own documentation, which the person writing the listener has no reason to
open.
**Symptom.** Corrupted payload fields read at a later tick — values that are
plausible, occasionally correct, and change with unrelated code. This is one of
the few entries in this file that produces a crash, and the crash is far from the
cause.
**Detect.** Find listeners that store rather than consume:
```bash
rg -n -A8 "void .*\(FGameplayTag\s+\w+,\s*const\s+F\w+&\s*\w+\)" --glob "*.cpp" . \
| rg "=\s*&|Ptr\s*=|AddRaw|CopyRef"
```
Any assignment of the payload's address into a member is the finding. A copy of
the payload by value is fine.
**Guardrail.** State the lifetime in the callback's own documentation, and copy at
the boundary. Where the bus crosses into a scripting layer, copy by value — the
measured reference does exactly this for its Blueprint path, and nulls the stored
pointer immediately after the delegate returns.
---
## Ownership and lifetime
### MB-05 - The convenient overload cannot express the match type
**Mechanism.** Three registration overloads exist. The one that binds a UObject
member weakly — the safest and by far the most used — delegates to the raw form
without forwarding the match type, so it is always exact.
**Why it is silent.** Exact matching is the right default, so every listener
registered this way behaves correctly. The feature that is unavailable is one
nobody is currently trying to use, and its absence produces no error because you
cannot pass the argument at all.
**Why the obvious check misses it.** The feature is fully implemented, documented
in the enum, exposed in the Blueprint node, and covered by the broadcast walk. A
review that asks "do we support hierarchical matching?" finds all of that and
answers yes. The gap is one unforwarded default argument in a header.
**Symptom.** A project ships a complete hierarchical matching implementation that
nothing in its own C++ ever uses, and nobody notices, because using it requires
switching to a less convenient overload for reasons that are never stated.
**Detect.** Count the feature's declaration sites against its use sites, and
exclude the implementation itself:
```bash
rg -n "PartialMatch" --glob "*.h" --glob "*.cpp" . # declared
rg -n "PartialMatch" --glob "*.cpp" . | rg -v "MessageRouter|MessageSubsystem"
```
An empty second result with a rich first result is the finding. In the measured
reference the game code used partial matching zero times; the only similarly
named symbols elsewhere belonged to two unrelated subsystems that had copied the
pattern.
**Guardrail.** Every overload of a registration API forwards every option, or the
option does not exist. If an overload deliberately restricts, name it so —
`RegisterExactListener` — rather than silently dropping an argument.
---
### MB-06 - Parameter struct with no bound callback returns an invalid handle silently
**Mechanism.** The options-struct registration form checks that a callback is
bound and, if not, returns a default-constructed invalid handle without
registering and without logging.
**Why it is silent.** The caller receives a handle-shaped value. Storing it,
passing it around and eventually unregistering it all work. The only difference
is that nothing was ever registered.
**Why the obvious check misses it.** The return type is not optional and not an
error code; it is a handle, and handles look like success. Checking validity
requires knowing that this overload can fail, which is stated nowhere at the call
site.
**Symptom.** A listener that never receives anything, in code that reads as fully
wired. Because unregistration of an invalid handle is also silent, no part of the
lifecycle complains.
**Detect.** Find registrations whose returned handle is never validity-checked:
```bash
rg -n "RegisterListener\(" --glob "*.cpp" . -A3 | rg -v "IsValid|ensure|check"
```
**Guardrail.** Return an explicit failure, or log at warning level, or assert in
development builds. A silent failure path on a registration API is a listener
that cannot be debugged.
---
### MB-07 - Async listener outlives its owner until the next message
**Mechanism.** An async listen action detects its owner's death by noticing that
its dynamic delegate is no longer bound — which it can only do *while handling a
message*. Until the next message arrives, the action and its registration stay
alive.
**Why it is silent.** The cleanup does eventually happen, and it happens before
anything observable goes wrong. On a busy channel the window is a frame.
**Why the obvious check misses it.** There *is* cleanup, and reading it shows
correct logic. The gap is a timing property — cleanup is driven by traffic rather
than by the owner's destruction — and timing properties are invisible in a code
review that asks "is cleanup implemented?".
**Symptom.** On a quiet channel, listener records accumulate for destroyed
owners. On a shutdown path, the last message of a session may be delivered into
an object graph that is already tearing down.
**Detect.** Find cleanup that is triggered by message handling rather than by
destruction:
```bash
rg -n -B6 "SetReadyToDestroy" --glob "*.cpp" . | rg -i "IsBound|HandleMessage"
rg -n -i "//\s*@?TODO.*(proactive|cleanup)" --glob "*.cpp" .
```
The second command matters: in the measured reference the authors had recorded
exactly this limitation as a TODO with a tracker reference, which is the
strongest possible confirmation that a finding is real and known.
**Guardrail.** Tie the async action's lifetime to its owner's destruction
directly, not to the arrival of the next message.
---
### MB-08 - Listener map is not cleared on map travel
**Mechanism.** A GameInstance-scoped bus is cleared only at GameInstance
teardown. Listeners registered by objects belonging to a world that has been
travelled away from remain in the map.
**Why it is silent.** Weak binding means the dead objects are never called. The
records are inert: they cost memory and a little iteration time, and produce no
wrong behaviour.
**Why the obvious check misses it.** Deinitialisation exists and is correct for
the scope it was written for. "Does the bus clean up?" is answered yes. The
question that finds this is narrower — *at which lifecycle event?* — and the
answer is one that only matters in a project that travels between maps, which a
single-map test never does.
**Symptom.** Listener counts that grow monotonically across a session of map
changes. Diagnosis is difficult because the leak is bounded by session length and
the objects themselves are correctly garbage collected.
**Detect.** Find the scope and the reset point, and compare them:
```bash
rg -n "public UGameInstanceSubsystem|public UWorldSubsystem" --glob "*.h" .
rg -n -A4 "::Deinitialize\(\)" --glob "*.cpp" . | rg "Reset\(|Empty\(|Clear\("
```
A GameInstance scope whose only reset is in `Deinitialize` leaks across travel by
construction. Confirm at runtime: log the listener count, travel, log again.
**Guardrail.** Either scope the bus to the world, or hook map change and drop
records whose weak owner has expired. A weak callback is not an unsubscription.
---
### MB-09 - Teardown calls an accessor that asserts
**Mechanism.** The static accessor resolves the world with an assert-on-failure
mode and then asserts on both the world and the subsystem. Teardown code calls it
to unregister.
**Why it is silent.** In every ordinary shutdown the world is still present when
components tear down, so the asserts pass. The failure needs an unusual teardown
order — a subsystem destroyed first, PIE cancelled mid-initialisation, a level
unloaded during travel.
**Why the obvious check misses it.** Unregistering in `EndPlay` is the correct
pattern and is what a reviewer is looking for. That the *accessor* is the
strictest of the several available ways to reach the subsystem is a property of a
different file.
**Symptom.** An assert during shutdown, in a build configuration and teardown
order that nobody can reproduce on demand, at a call site that is doing the right
thing.
**Detect.** Find teardown paths that use the asserting accessor:
```bash
rg -n -B2 -A2 "::EndPlay|NativeDestruct|BeginDestroy" --glob "*.cpp" . \
| rg "Subsystem::Get\(|::Get\(this\)"
rg -n -A6 "static .*& Get\(" --glob "*.h" . | rg "Assert|check\("
```
The pairing is the finding: an asserting accessor called from a destruction path.
In the measured reference this pattern appeared in three separate consumer
classes, all of which could have unregistered through the handle instead.
**Guardrail.** Unregister through the handle, which resolves the subsystem weakly
and no-ops when it is gone. Provide a non-asserting accessor and use it in every
teardown path.
---
## Contract and vocabulary
### MB-10 - The channel string is the contract, declared in several places
**Mechanism.** Publisher and each subscriber declare their own file-local tag
constant for the same channel string. The engine deduplicates by string, so it
works.
**Why it is silent.** It is correct. Every site resolves to the same tag, the join
succeeds, and the arrangement has a real benefit: modules do not depend on each
other's headers.
**Why the obvious check misses it.** Each declaration is locally idiomatic and
each file reads well. Nothing links them. A rename in one file compiles, links,
and passes review — and the reviewer of that file has no way to see the other
three.
**Symptom.** Editing one declaration silently detaches one participant from the
channel. This is the symmetric-typo failure from tag governance, arriving through
a different door: correctness means all sites agree, not that any one is right.
**Detect.** Count independent declarations per channel string:
```bash
rg -o -N 'UE_DEFINE_GAMEPLAY_TAG[_A-Z]*\([^,]+,\s*"([^"]+)"' -r '$1' \
--glob "*.cpp" . | sort | uniq -c | sort -rn | head
```
Any string with a count above one is a contract with several owners. In the
measured reference one elimination channel was declared independently at four
sites — the publisher and three separate processors in a feature plugin.
**Guardrail.** Shared channels live in one header owned by the publisher. Keep
file-local declarations only for channels that never leave their file.
---
### MB-11 - Listener order assumed without a contract
**Mechanism.** Nothing guarantees callback order, and removal by swap-with-last
actively reorders the array as a side effect of unrelated unsubscriptions.
**Why it is silent.** An order exists on every run and is stable as long as
nothing unsubscribes. Code that depends on it works, and keeps working, until an
unrelated feature adds a listener or removes one.
**Why the obvious check misses it.** The dependency is never written down —
that is what makes it an assumption. No search finds "this listener must run
first"; the knowledge lives in the fact that it currently does.
**Symptom.** Ordering-dependent behaviour that changes when an unrelated system
subscribes, in a different module, in a different release.
**Detect.** Find the removal strategy and the documented guarantee, and check for
listeners that mutate shared state:
```bash
rg -n "RemoveAtSwap|RemoveSwap" --glob "*.cpp" .
rg -n -i "order.*not guaranteed|call order" --glob "*.h" .
```
If the second command finds an explicit warning from the bus authors, the
guarantee does not exist and any dependence on order is yours to remove.
**Guardrail.** If ordering matters, it is not a bus concern — sequence the work in
one owner and broadcast the outcome. Never introduce priority through
registration order.
---
### MB-12 - Reentrancy with no depth limit
**Mechanism.** Broadcasting from inside a listener callback is legal and
supported. Nothing bounds the recursion.
**Why it is silent.** It is a legitimate and useful pattern — an aggregator that
consumes one channel and publishes a derived fact on another does exactly this.
The dangerous case differs only in that the graph of channels contains a cycle.
**Why the obvious check misses it.** Each listener is individually correct and
each broadcast is individually justified. The cycle exists in the graph formed by
several modules, which no file shows, and which a feature plugin can complete by
adding one processor.
**Symptom.** Stack overflow, or a hang, with a call stack that is one repeating
pattern thousands of frames deep and no obvious origin.
**Detect.** Map the graph: which listeners publish, and on which channels?
```bash
rg -n -B10 "BroadcastMessage\(" --glob "*.cpp" . \
| rg -B2 "OnMessageReceived|::Handle\w*Message"
```
Every hit is an edge from an input channel to an output channel. Draw them and
look for a cycle. In the measured reference this pattern was present and
deliberate — a processor consuming eliminations and publishing assists — with no
cycle, which is the correct state and worth confirming rather than assuming.
**Guardrail.** Keep a broadcast-depth counter in development builds and assert
past a small bound. Document, per processor, which channels it reads and writes.
---
## Scope and misuse
### MB-13 - A message used where state is required
**Mechanism.** A fact is published as an event only. A listener that subscribes
after the event has no way to learn it.
**Why it is silent.** Every listener that existed at publication time is correct.
Subscription usually happens during initialisation, before anything interesting
has been published, so the whole system is correct for the entire development
period.
**Why the obvious check misses it.** Testing subscribes early by construction —
you start the game, the widget is created, then things happen. Reproducing
requires a listener created *after* the fact, which means mid-match widget
creation, a late-joining client, or a feature activated at runtime.
**Symptom.** A widget created mid-match shows a default value forever. A late
joiner's UI is empty while everyone else's is correct.
**Detect.** For each channel, ask whether any subscriber can be created late:
```bash
rg -n "RegisterListener\(" --glob "*.cpp" . -B6 | rg -i "NativeConstruct|BeginPlay|OnActivated"
```
Widget construction and feature activation are the late-creation paths. For each,
apply the test from the skill: if a listener subscribing one second late must know
the current value, the fact is state and the bus is the wrong carrier.
**Guardrail.** Replicate or store the state; use the message purely as a change
notification. On subscription, read the current value directly from its owner —
which requires that the value have an owner, and that is the real design output.
---
### MB-14 - A command disguised as an event
**Mechanism.** A message is published to make something happen, rather than to
report that it has happened.
**Why it is silent.** With exactly one listener it behaves identically to a
function call. It works, and it looks decoupled.
**Why the obvious check misses it.** The mechanism is indistinguishable from
correct use — same API, same payload shape. Only the *name* reveals it, and names
in the imperative mood ("give", "apply", "spawn") pass review because they
describe what the sender wants.
**Symptom.** Zero listeners means the command is silently dropped with no failure
result. Two listeners means it happens twice. Neither is reported, because a bus
has no concept of a required handler.
**Detect.** Grep the channel vocabulary for the imperative mood:
```bash
rg -o -N '"([A-Za-z]+\.[A-Za-z.]*Message[A-Za-z.]*)"' -r '$1' --glob "*.cpp" . \
| sort -u | rg -i "^\w+\.(Add|Give|Set|Apply|Spawn|Request|Please|Do)"
```
A channel named for an action rather than for a completed fact is the finding.
Past-tense names — "equipped", "eliminated", "changed" — are the correct shape.
**Guardrail.** Commands go to one accountable handler that can fail and say so.
Events report facts in the past tense and tolerate zero listeners.
---
### MB-15 - Broadcast treated as replication
**Mechanism.** A message is published on the server and expected to reach
clients. The bus is local to one process and has no networking at all.
**Why it is silent.** In a single-process test — the editor's default multiplayer
mode — the host and its client share one process and, depending on the bus's
scope, may share one instance. The message appears to cross the network.
**Why the obvious check misses it.** Everything about the code reads as
transport: a channel, a payload, publish and subscribe. The absence of networking
is a property of a module's dependency list, which is not where anyone looks when
reasoning about a message flow.
**Symptom.** A feature that works in the editor and does nothing on a dedicated
server. Alternatively, a listen server processes both the server-side publication
and the client-side rebroadcast and everything happens twice.
**Detect.** Prove the absence, then find who assumed otherwise:
```bash
rg -n "UFUNCTION\([^)]*(Server|Client|NetMulticast)|NetSerialize|Replicated" \
--glob "*.cpp" --glob "*.h" <bus-module-path>/ # expect: nothing
rg -n -B4 "BroadcastMessage\(" --glob "*.cpp" . | rg "HasAuthority|WITH_SERVER_CODE|NM_"
```
The second command finds the correct pattern as well as the incorrect one: a
net-mode check before a rebroadcast is the guard that prevents double processing
on a listen server. Its absence next to a rebroadcast is the finding.
**Guardrail.** Replicate state or send an RPC; rebroadcast locally on arrival;
guard the rebroadcast with a net-mode check so a listen server does not process
both paths.
---
## Cost
### MB-16 - Per-broadcast copy of the listener array
**Mechanism.** For mutation safety the listener array of each non-empty bucket is
copied before iteration. The copied element type holds a callable object, whose
copy may allocate.
**Why it is silent.** It is correct, and correctness was the reason for it. The
cost is proportional to broadcast frequency times listener count times tag depth,
and all three are small early in a project.
**Why the obvious check misses it.** The line is a defensive copy with a comment
explaining why it is needed, which is exactly what a reviewer wants to see. The
cost is invisible unless you know that the element type is expensive to copy —
which requires reading a different struct in a different header.
**Symptom.** Allocator churn proportional to message traffic, appearing in
profiles as generic allocation cost rather than as bus cost. Discovered when a
high-frequency channel — damage in a shooter — is added late.
**Detect.** Find the copy and the depth walk, then measure:
```bash
rg -n -B2 "TArray<\w*ListenerData>\s*\w+\(" --glob "*.cpp" .
rg -n "RequestDirectParent" --glob "*.cpp" .
```
Both present means cost per broadcast scales with tag depth even when no
hierarchical listener exists. Confirm with an allocation profile while
broadcasting on the highest-frequency channel.
**Guardrail.** Copy handles rather than callables, or iterate an index with a
generation counter, or skip the ancestor walk when the bucket count for parent
tags is zero. Measure before optimising — this is a real cost, not a large one.
---
### MB-17 - A declared field nobody uses, and the search that hides it
**Mechanism.** A member is declared in a public struct, never written and never
read. It survives because removing it feels risky.
**Why it is silent.** An unused field costs a few bytes and nothing else. It
serialises, copies and compiles like any other member.
**Why the obvious check misses it.** This entry is here less for the dead field
than for the search that fails to prove it dead. A naive recursive search of a
built project matches compiled debug symbols, so a field with exactly one source
occurrence returns several hits and reads as "in use". The instrument reports the
build output as if it were code.
**Symptom.** Dead API surface that persists across refactors, plus — more
expensively — a general loss of trust in the searches used to make deletion
decisions.
**Detect.** Search source only, and say so explicitly:
```bash
F='StateClearedHandle'
rg -n "\b$F\b" --glob "*.h" --glob "*.cpp" . # source truth
rg -n "\b$F\b" . # unfiltered, for contrast
```
In the measured reference the filtered search returned exactly one line — the
declaration — while the unfiltered one returned four, three of them debug-symbol
files. Always compare the two before concluding either "dead" or "in use".
**Guardrail.** Restrict every deletion-decision search by file type. Delete dead
fields in the same change that proves them dead, and record the search you ran.