Data Model: Harden the Dead-Symbol Gate

No domain/persisted entities. The "model" here is the gate's internal AST data structures and the per-symbol disposition set. Recorded for implementation precision.

Gate data structures (existing — reuse, don't rebuild)

StructureSourceShapeUse by new detectors
path_to_tree_walk_modules (L951–977)dict[Path, ast.Module] — whole-src AST corpus, parsed onceThe detectors ast.walk these cached trees (zero new I/O)
path_to_dotted_walk_modulesdict[Path, str]Attribute a found reference back to its containing module
decls_walk_modulesdict[str, frozenset[str]] (module → __all__)The symbols under test
per_symbol_imports_by_target (L980–1007)dict[str, set[str]] (target module → imported names)Widen this with non-import edges so _symbol_has_caller's re-export rules apply
star_targets_imports_by_targetset[str]Existing star-import short-circuit
submodule_index_submodule_index (L1051)dict[str, list[str]] (prefix → submodules)Existing parent/submodule re-export rules

New structure: per-tree import-alias map (the anchor for C-001)

alias_map: dict[str, str] per file — local name → resolved dotted module — built from ast.Import (alias.asname or alias.name) and ast.ImportFrom with asname (from pkg import mod as mpkg.mod). This is the no-false-negative anchor: a <name>.<attr> or getattr(<name>, "x") only counts as a caller after <name> resolves through this map to the exact declaring module.

Detector rules (FR-002 — what each adds to proof-of-life)

DetectorTrigger ASTBinds toAdds edge
(a) module-styleast.Attribute(value=Name(id in alias_map), attr=N)alias_map[id]per_symbol[module].add(N)
(c) Typer (free)same ast.Attribute inside app.command()(…) Call argvia (a)— (subsumed)
(d-getattr)Call(func=Name("getattr"), args=[Name(id in alias_map), Constant(str=N)])alias_map[id]per_symbol[module].add(N)
(b) facademodule with FunctionDef("__getattr__") + dict-literal {… : (submodule, "N")}submodulemark submodule's N live

After widening per_symbol, the existing _symbol_has_caller (direct/parent/submodule) is unchanged.

Disposition set (per-symbol outcomes)

DispositionSymbolsActionNet effect on __all__ / allowlist
DETECT-LIVE~the 119 wave minus the residue belowrecognized by FR-002 detectors0 allowlist entries
DELETEsync.owner::_daemon_rootremove re-export + __all__ entry−1 symbol
DEMOTEFR-004 set (7) + register-arg (migrate_v1_to_v2, _orchestrator_api_predicate, _mission_state_predicate) + residual annotation-only/test-onlydrop from __all__, keep defsymbols leave public surface; no allowlist growth
WIRE-LIVEorchestrator_api.envelope::BANNED_FLAGSFR-005 enforces it → becomes a live referenced constantno disposition needed
ALLOWLIST-DEFERREDauth.transport::{get_client, get_async_client, reset_clients}1 justified allowlist entry (SaaS migration wave)+1 entry (deferred, not false-positive)

Invariant (NFR-003): post-mission category_a_slice_f_deferred + category_b_grandfathered_legacy frozenset entry counts ≤ their pre-mission base (the only addition is the deferred auth trio; everything else is detected-live or demoted/deleted). Re-confirm vs #2159/#2152 merge state (C-003).

The no-false-negative guard (NFR-001)

A regression test: construct a synthetic module with a symbol in __all__ that has NO caller of any recognized kind (no import, no alias.symbol, no getattr, no facade tuple, no registration) and assert the gate STILL flags it. This proves the detectors widened vision without blinding the gate.