
Wiring Audit
- 1 installs
- 28 repo stars
- Updated June 29, 2026
- nickcrew/claude-cortex
Audits UI-to-backend wiring drift, finding orphan surfaces, unwired endpoints, and contract, method, and validation mismatches.
About
Audits a project for wiring drift between UI surfaces and backend capabilities, reporting orphan surfaces, unwired endpoints, and contract mismatches. A developer uses it when they want a prioritized findings report of UI/backend drift, optimized for React apps.
- Diffs UI-consumed surfaces against backend-produced capabilities to find drift
- Reports orphan surfaces, unwired capabilities, and contract, method, and validation drift
Wiring Audit by the numbers
- 1 all-time installs (skills.sh)
- Ranked #981 of 1,354 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nickcrew/claude-cortex --skill wiring-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 28 |
| Last updated | June 29, 2026 |
| Repository | nickcrew/claude-cortex ↗ |
What it does
Audits UI-to-backend wiring drift, finding orphan surfaces, unwired endpoints, and contract, method, and validation mismatches.
Files
Wiring Audit
Overview
Diff a project's consumed surface (UI buttons, components, hooks, fetch calls, tRPC client invocations, GraphQL queries, server actions) against its produced capability (route handlers, hook exports, tRPC routers, GraphQL resolvers, exported async functions) and report every mismatch as a prioritized finding with citations on both sides.
The audit's load-bearing claim: every UI consumption should match exactly one backend production, and every production should be consumed somewhere. Orphans, drift, and gaps are the failure modes. The report is the punch list.
When to use
Trigger this skill when the user asks for:
- A wiring/drift audit ("audit our wiring," "find drift")
- Identification of unused or orphan code on the wire (unused endpoints, unused hooks)
- Verification that backend capabilities are surfaced ("what's not exposed in the UI?")
- Verification that UI surfaces are wired ("is anything in the UI broken or stale?")
- Contract violation detection between FE and BE
- Stale label detection (UI copy that lies about what the backend does)
Do not trigger for:
- Descriptive architectural snapshots (use
architectural-analysis) - Security review (use
security-auditoror/security-review) - Performance audit (use
workflow-performance) - Test coverage audit (use
test-review)
Workflow
Six phases. The diff (phase 3) is the load-bearing one — the rest are supporting work to feed it accurate inputs.
1. Scope
Establish:
- Target: full repo, or a frontend-path and backend-path pair in a monorepo, or a single full-stack project (Next.js, Remix, etc.).
- Stack detection: identify FE framework (React + which router, which data layer) and BE framework (Express, Fastify, Hono, Next.js routes/route handlers/server actions, tRPC, GraphQL). The signal-set in
references/react-patterns.mdandreferences/capability-enumeration.mdadapts based on what's detected. - Output root:
docs/audits/<YYYY-MM-DD>/— create now if missing. - Priors: if
docs/architecture/<recent-date>/exists from a previous architectural-analysis run, the UI-surfaces and integrations reports can be passed as priors to skip rediscovery (saves sub-agent time).
2. Dispatch enumerators (parallel)
Two sub-agents, dispatched in a single message with two Agent tool calls (parallel, one-shot, no team_name):
- Surface enumerator (
general-purpose, sonnet) — walks UI, returns the consumed-set perreferences/surface-enumeration.md. - Capability enumerator (
general-purpose, sonnet) — walks backend, returns the produced-set perreferences/capability-enumeration.md.
Both return YAML registries (see references/audit-protocol.md for the contract). Both need sonnet because correlation reasoning across full files matters here — Explore excerpt reads aren't sufficient.
3. Drift detection (orchestrator)
Read references/drift-detection.md. The orchestrator:
1. Builds consumption and production maps keyed by (kind, identifier). 2. Computes set differences (orphans on each side). 3. For matched pairs, runs shape/method/auth/validation comparison. 4. Each mismatch becomes a candidate finding tagged with one of the 8 categories from references/finding-categories.md.
4. Severity assignment
Apply the rubric in references/finding-categories.md:
| Severity | Default applies to | Override when |
|---|---|---|
| broken | orphan-surface, method-drift | Surface is dead code (no callers itself) — downgrade to stale |
| drifted | shape-drift, validation-drift, permission-drift | Auth-bypass flavor → upgrade to broken |
| stale | stale-label | Public-facing user surface → upgrade to drifted |
| gap | unwired-capability, unsurfaced-config | Capability is brand-new (recent commit) → annotate "expected, planned" |
5. Verify
Every finding's citation must resolve. Use codanna (when .codanna/ exists) or grep + Read to confirm:
- The cited UI line exists and contains the asserted consumption.
- The cited backend line exists and contains (or fails to contain, for orphan claims) the asserted capability.
- For "stale label" findings, confirm the asserted-renamed backend symbol actually exists at the new location.
Discard findings whose citations fail to resolve. Maintain a discard log for the report's verification section.
6. Render report (and optional wiring graph)
- Author
docs/audits/<date>/report.mdperreferences/report-template.md. - Persist raw registries:
docs/audits/<date>/registries/surfaces.yamlandcapabilities.yaml. These let future audits diff against them (compare-to-history is a future v2 feature). - Optionally author
docs/audits/<date>/wiring.mmdshowing surface→capability with broken edges highlighted in red, unwired capabilities in amber. - If the wiring graph is authored, run
bash scripts/render.sh docs/audits/<date>/to producewiring.svg.
Output layout
docs/audits/2026-05-10/
├── report.md ranked findings, severity-grouped
├── wiring.mmd optional supplemental graph
├── wiring.svg optional, run scripts/render.sh
└── registries/
├── surfaces.yaml raw consumed-set
└── capabilities.yaml raw produced-setStrict citation policy
Every finding must carry path:line citations on both sides where applicable:
- UI side citation for the consumption (the
fetchcall,useQueryinvocation, hook import, form submission, etc.). - Backend side citation for the production (the route handler, exported function, tRPC procedure definition).
- For orphan-surface findings, the backend side citation is replaced by a grep-evidence line ("
grep -rn 'route /api/old' server/returned 0 matches"). - For unwired-capability findings, the UI side is replaced similarly.
- For stale-label findings, both the UI label location and the renamed backend symbol's new location are cited.
Fabricated "missing X" claims are the dominant failure mode for audits — sub-agents over-fire on absence. The verification protocol grep-checks every absence claim before the finding is recorded.
React focus
Generic by design but optimized for React. The signal catalog in references/react-patterns.md covers:
- Hook naming conventions and custom hook unwrapping
- React Query / SWR / Apollo / urql data layer signals
- tRPC client patterns (and the type-safety caveat)
- Next.js server actions (capabilities, not routes)
- React Router and Next.js routing (loaders, actions)
- Form action patterns
- Permission-aware rendering vs backend authorization
For non-React targets, the same workflow applies but the FE-side signal catalog is sparser. Tell the user up front if the UI-side detection will be limited.
Resources
references/audit-protocol.md— workflow phases, registry contracts, sub-agent promptsreferences/surface-enumeration.md— UI consumption discovery (generic + React-aware)references/capability-enumeration.md— backend capability discovery (Node.js HTTP, Next.js, tRPC, GraphQL)references/drift-detection.md— the diff algorithm in detailreferences/finding-categories.md— 8 categories, severity rubric, evidence requirementsreferences/react-patterns.md— React-specific signal catalogreferences/report-template.md— ranked findings report skeletonscripts/render.sh— renderwiring.mmdtowiring.svgviammdcscripts/verify-findings.sh— quick path:line existence check over a findings report
Audit Protocol
The orchestrator's playbook. Defines the sub-agent dispatch contract, registry shapes, and the diff sequence. Read this first; the per-side enumeration files (surface-enumeration.md, capability-enumeration.md) describe what each enumerator looks for, but this file describes how the audit runs end-to-end.
Phases
1. Scope — establish target, stack, output root, priors. 2. Dispatch — two parallel one-shot sub-agents. 3. Diff — compute consumption/production set differences. 4. Categorize — assign each finding to one of the 8 categories. 5. Severity — apply the rubric from finding-categories.md. 6. Verify — orchestrator-side citation resolution. 7. Render — write report.md, persist registries, optionally render wiring graph.
Sub-agent dispatch contract
Two parallel calls in a single message. Both use general-purpose with model: sonnet. Do not use team_name (memory: team-spawned agents lose tools).
Surface enumerator
Returns YAML:
surfaces:
- id: S-1
label: <short human label, e.g., "User profile page" or "useCreateProject hook">
location: <repo-relative-path>:<line>
kind: component | hook | page | route | form | settings-panel
consumes:
- kind: http | trpc | graphql | server-action | hook-import | websocket | env-var | config-key
identifier: <stable identifier — see below>
method: <only for kind=http>
location: <where the consumption happens>:<line>
evidence: <verbatim line content>
response_shape: <inferred or declared shape, when available>
response_shape_source: type-inference | tRPC-procedure-type | OpenAPI | usage-pattern | unknown
permission_signal: <required role/scope if visible at the call site>
user_label: <visible UI text when this surface is a button or labelled element>Capability enumerator
Returns YAML:
capabilities:
- id: C-1
kind: http_route | trpc_procedure | graphql_field | server_action | exported_hook | websocket_handler | env_var_consumer | config_key_consumer
identifier: <see below>
method: <only for kind=http_route>
location: <repo-relative-path>:<line>
evidence: <verbatim line content>
response_shape: <declared or returned shape>
response_shape_source: declared-type | tRPC-output | GraphQL-schema | inferred-from-return | unknown
auth: <required guard, e.g., "isAdmin", "session.user.id === params.id", or "none">
consumed_env: <list of env vars or config keys this capability gates on>Identifier conventions (stable matching keys)
The diff algorithm matches on (kind, identifier). Identifier formats:
| Kind | Identifier format |
|---|---|
| http / http_route | <METHOD> <path-template> e.g., GET /api/users/:id |
| trpc / trpc_procedure | dotted path e.g., users.create |
| graphql / graphql_field | <Type>.<field> e.g., Query.userById |
| server-action / server_action | exported function symbol e.g., createUser |
| hook-import / exported_hook | hook export symbol e.g., useUsers |
| websocket / websocket_handler | event name or topic e.g., ws:project:update |
| env-var / env_var_consumer | env var name e.g., STRIPE_API_KEY |
| config-key / config_key_consumer | dotted config key e.g., auth.providers.github.enabled |
Both enumerators must use the same identifier format. If a sub-agent returns malformed identifiers, re-dispatch that side only with the format reinforced.
The diff (orchestrator's algorithm)
consumption_map = {} # (kind, identifier) -> [consumption_records]
production_map = {} # (kind, identifier) -> [production_record]
for surface in surfaces:
for c in surface.consumes:
key = (c.kind, c.identifier)
consumption_map.setdefault(key, []).append({
"surface_id": surface.id,
"location": c.location,
"evidence": c.evidence,
"method": c.get("method"),
"shape": c.get("response_shape"),
"permission": c.get("permission_signal"),
})
for cap in capabilities:
key = (cap.kind, cap.identifier)
production_map.setdefault(key, []).append({
"capability_id": cap.id,
"location": cap.location,
"evidence": cap.evidence,
"method": cap.get("method"),
"shape": cap.get("response_shape"),
"auth": cap.get("auth"),
})
findings = []
# Orphan surfaces: consumed but not produced.
for key in consumption_map:
if key not in production_map:
for c in consumption_map[key]:
# Mediated-persistence calibration — see drift-detection.md.
# If consumption was annotated `mediated: ...` by the surface
# enumerator, OR the indirect-persistence probe finds a form
# library / cycle handler / URL-state / batched mutation in
# the same component, downgrade severity from broken to
# mediated and tag the finding's notes with the indirect path.
mediated = c.get("mediated") or run_indirect_persistence_probe(c)
findings.append(orphan_surface_finding(key, c, mediated=mediated))
# Unwired capabilities: produced but not consumed.
for key in production_map:
if key not in consumption_map:
for p in production_map[key]:
findings.append(unwired_capability_finding(key, p))
# Drift: in both — compare shape, method, auth.
for key in consumption_map:
if key in production_map:
for c in consumption_map[key]:
for p in production_map[key]:
findings.extend(compare_shapes(c, p))
findings.extend(compare_methods(c, p)) # http only
findings.extend(compare_auth(c, p)) # if both have permission/auth signalsThe 1:N case is real (one consumer of a capability that several surfaces share). Multiple surfaces consuming the same capability is fine; pair each consumer with the producer for shape/method comparison.
Stale label detection (separate pass)
Stale labels don't fit the diff cleanly because they're text-on-the-wire, not code-on-the-wire. Run after the main diff:
1. From the surfaces registry, collect every user_label that contains a noun phrase (heuristic: 1–4 words, no whitespace-stripped lowercase, often capitalized). 2. For each label, search the capability registry for a capability whose identifier or evidence references the noun phrase. 3. If the label's noun phrase appears in NO capability evidence, but a similar phrase does (one rename hop — Levenshtein distance ≤ 3, or a known pattern like commit_pending ↔ "Save Draft"), flag a stale-label finding.
This is heuristic — false positives are normal. Severity is stale by default; the report's narrative explains the inferred rename.
Verification
For each candidate finding before it lands in the report:
1. UI side citation — Read the cited line, confirm content matches evidence. 2. Backend side citation — same. 3. Absence claim — for orphan-surface findings, grep the asserted-missing identifier across the backend subtree. If it turns up, discard the finding (the enumerator missed it). 4. Severity sanity — broken-severity findings must have both citations resolved (not just one). Discard if the backend side is unresolvable for an orphan claim unless the grep evidence is documented.
Maintain a discard log for the verification section of report.md.
Re-dispatch
If a sub-agent returns malformed output (missing identifiers, prose-only, wrong YAML shape), re-dispatch that side only with the format requirement sharpened. Limit to two re-dispatches per side; on the third failure, escalate to the user (the codebase may have non-standard surface or capability patterns the audit doesn't recognize).
Composing with architectural-analysis
If docs/architecture/<recent-date>/ exists:
- The UI-surfaces report's callouts feed the surface enumerator's prompt as priors. The enumerator's job becomes "find what each surface consumes" rather than "find every surface" — saving substantial work.
- The integrations report's callouts feed the capability enumerator's prompt similarly.
Pass the priors as a priors: block in the prompt:
priors:
ui_surfaces:
- id: U-7
label: Settings page
location: src/pages/Settings.tsx:1
- id: U-12
label: useUserMutation
location: src/hooks/users.ts:14The sub-agent uses these as the surface set and focuses on enumerating consumptions. Same shape applies for capabilities ↔ integrations.
Capability Enumeration
What the capability-side sub-agent looks for in the backend. The goal: build a production registry — every backend capability that could be consumed.
Identifier formats must match surface-enumeration.md exactly so the diff produces clean matches.
HTTP route handlers
Express / Fastify / Koa / Hono
app.get('/api/users', listUsers)
app.post('/api/users', createUser)
fastify.route({ method: 'PUT', url: '/api/users/:id', handler: updateUser })
hono.delete('/api/users/:id', deleteUser)Capture each as kind: http_route, identifier: "<METHOD> <path>".
Cite the registration line (the app.get(...) line), not the handler function.
Next.js Route Handlers (app router)
app/api/users/route.ts:
export async function GET(req: Request) { ... }
export async function POST(req: Request) { ... }Each export is a capability:
- File path becomes the URL:
app/api/users/route.ts→/api/users. Dynamic segments ([id]) →:id. - Each exported HTTP method (
GET,POST,PUT,DELETE,PATCH) is a separate capability. - Identifier:
GET /api/users,POST /api/users, etc. - Cite the export line for each method.
Next.js API Routes (pages router, legacy)
pages/api/users.ts:
export default function handler(req, res) {
if (req.method === 'GET') { ... }
else if (req.method === 'POST') { ... }
}Trickier because one file handles multiple methods. Capture each branch as a separate capability:
- Identifier:
GET /api/usersetc. - Cite the method check line (
if (req.method === 'GET')).
If the handler doesn't check method, capture as * /api/users (any method) — the diff will match any consumption URL.
Response shape
For each route, capture the response shape when inferable:
- Explicit return type:
function listUsers(): Promise<User[]>→User[]. res.json(x)wherexis typed: capture x's type.- Zod/Yup schemas:
res.json(UserListSchema.parse(...))→ schema's inferred type. - OpenAPI generated handlers: declared response in the schema.
If shape isn't inferable, set response_shape_source: unknown and continue. Shape-drift findings only fire when both sides have a known shape.
tRPC procedures
export const usersRouter = t.router({
list: t.procedure.query(({ ctx }) => { ... }),
create: t.procedure.input(CreateUserInput).mutation(({ input }) => { ... }),
byId: t.procedure.input(z.object({ id: z.string() })).query(({ input }) => { ... }),
})Capture each procedure:
- Identifier: dotted path from the root router.
users.list,users.create,users.byId. - Method:
queryormutation(captured separately, used for method-drift detection — frontenduseQueryagainst backendmutationis a finding). - Cite the procedure definition line.
- Response shape: from the return type inference (procedure return value).
- Auth: if
t.procedure.use(authMiddleware)..., capture the middleware as the auth signal.
tRPC type-safety caveat
tRPC catches drift at compile time if and only if:
1. Frontend and backend share the same AppRouter type. 2. Types regenerate after backend changes (no stale generated client). 3. No as any / @ts-ignore defeats type checking.
The audit still scans because (a) generated types may be stale, (b) multi-package setups often miss regeneration, (c) escape hatches defeat the type system. tRPC drift findings should note in the suggested fix that "running pnpm generate-types (or the project's regen command) may resolve this."
GraphQL
Schema-first
type Query {
user(id: ID!): User
users(filter: UserFilter): [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
}Each top-level field is a capability:
- Identifier:
<Type>.<field>—Query.user,Query.users,Mutation.createUser. - Cite the field declaration line.
- Response shape: derived from return type.
Code-first (Nexus, Pothos, TypeGraphQL)
Same shape; cite the field's .field() or decorator declaration.
Resolvers
The resolver implementation is not a separate capability — it's the implementation of the schema's field. Cite the schema field, not the resolver.
Server actions (Next.js)
'use server'
export async function createUser(formData: FormData) {
// ...
}- Identifier: function symbol —
createUser. - Cite the export line.
- Method:
server-action(no HTTP method semantics). - Response shape: from return type.
If the file has 'use server' at the top, all exported async functions are server actions. Otherwise, only functions with their own 'use server' directive at the top of their body.
Exported hooks (when treated as a capability surface)
This is the inverted case — frontend "capability" surfaced via hooks rather than HTTP. Common in component library packages.
// packages/ui/hooks/index.ts
export function useTheme() { ... }
export function useToast() { ... }Capture as kind: exported_hook, identifier: <hook-symbol>. Cite the export line.
The audit will diff against hook-import consumption findings — if a component imports useToast from @ui/hooks but useToast isn't exported, that's an orphan-surface finding.
WebSocket handlers
io.on('connection', (socket) => {
socket.on('project:update', handleProjectUpdate)
socket.on('project:delete', handleProjectDelete)
})Each socket.on(<event>, ...) is a capability:
- Identifier: event name —
project:update,project:delete. - Cite the registration line.
Environment variables (consumed by backend)
const stripeKey = process.env.STRIPE_API_KEY
if (!stripeKey) throw new Error(...)Capture as kind: env_var_consumer, identifier: STRIPE_API_KEY. Cite the read site.
These are capabilities in the sense that the backend's behavior depends on them. The diff finds unsurfaced config: env vars consumed but with no UI/CLI/admin surface to set them, no .env.example documentation, no settings panel.
Config key consumers
if (config.auth.providers.github.enabled) { ... }Capture as kind: config_key_consumer, identifier: auth.providers.github.enabled. Cite the read site.
Same role as env vars — the diff finds keys read but never surfaced to users to control.
Auth signals
For each capability, capture the auth/permission requirement:
| Pattern | Auth value |
|---|---|
if (!session) return 401 | requires-session |
if (session.user.role !== 'admin') return 403 | requires-role:admin |
if (params.userId !== session.user.id) return 403 | requires-self |
| Middleware applied to route group | the middleware's name |
| No check | none |
The diff uses these to detect permission-drift: UI shows a button to all users, backend rejects non-admins (or vice versa, UI hides the button but backend doesn't actually enforce).
What NOT to enumerate
- Internal helper functions called only from within handlers — not exposed.
- Database queries — those are implementation detail, not capability.
- Logging / metrics calls — not capabilities.
- Test fixtures, mocks, dev-only routes (those are sometimes capabilities — flag them with
dev-only: trueso the diff can decide whether to include them).
Sub-agent prompt seed (capability side)
# Mode
Wiring audit — capability side. Enumerate every backend capability the UI could consume.
# Scope
[<backend path or "the entire backend rooted at <path>">]
# Stack signal
[Detected: Express, Next.js route handlers, tRPC, etc.]
# What to find
1. HTTP route definitions (Express, Fastify, Hono, Koa, Next.js route handlers, Next.js API routes).
2. tRPC procedures (router definitions, query/mutation distinction).
3. GraphQL schema fields (Query, Mutation, Subscription top-level fields).
4. Server actions (Next.js 'use server' exports).
5. Exported hooks (when the project has a UI library that exports hooks).
6. WebSocket event handlers.
7. Env var reads (process.env.X usage in backend code).
8. Config key reads.
# Identifier format
- http_route: "<METHOD> <path>" — extract from registration
- trpc_procedure: dotted path from root router
- graphql_field: "<Type>.<field>"
- server_action: function symbol
- exported_hook: hook symbol
- websocket_handler: event name
- env_var_consumer: VAR_NAME
- config_key_consumer: dotted.path
# For each capability, capture
- Identifier (per format above)
- Location (path:line)
- Evidence (verbatim line)
- Response shape (when inferable from types or schemas)
- Auth requirement (or "none")
# Output contract
Return YAML matching the capabilities[] schema in references/audit-protocol.md.
# Important
- The audit relies on identifier format consistency. Verify each identifier follows the format exactly.
- For Next.js dynamic routes ([id], [...slug]), templatize as :id, :*slug.
- For tRPC, the dotted path is from the root router exposed to the client (e.g., appRouter.users.list → "users.list").
- Don't enumerate internal helpers, only exposed capabilities.
# Verification expectation
The orchestrator will verify every citation. Findings whose evidence doesn't match the cited line will be discarded.Drift Detection
The diff algorithm. Run by the orchestrator after both enumerators return. This is the core of the audit — every finding category falls out of the same set-comparison logic.
Step 1 — Build the maps
consumption_map: dict[tuple[Kind, Identifier], list[Consumption]] = defaultdict(list)
production_map: dict[tuple[Kind, Identifier], list[Production]] = defaultdict(list)
for surface in registry.surfaces:
for c in surface.consumes:
consumption_map[(c.kind, c.identifier)].append(
Consumption(
surface_id=surface.id,
location=c.location,
evidence=c.evidence,
method=c.get("method"),
shape=c.get("response_shape"),
permission=c.get("permission_signal"),
)
)
for cap in registry.capabilities:
production_map[(cap.kind, cap.identifier)].append(
Production(
capability_id=cap.id,
location=cap.location,
evidence=cap.evidence,
method=cap.get("method"),
shape=cap.get("response_shape"),
auth=cap.get("auth"),
)
)Step 2 — Identifier normalization
Before diffing, normalize identifiers to maximize matches:
- HTTP paths: lowercase the path; collapse trailing slashes; templatize numeric/UUID concrete IDs in URLs to
:param(matching the surface enumerator's templating). - Methods: uppercase HTTP verbs.
- tRPC paths: trim whitespace; case-sensitive (tRPC procedure names are case-sensitive).
- GraphQL fields: case-sensitive
<Type>.<field>exactly. - Hook symbols: case-sensitive function name.
- Env vars: uppercase, exact match.
- Config keys: case-sensitive, dot-separated.
Two consumptions or productions whose identifiers differ only by normalization are treated as the same key — no method-drift finding for GET vs get.
Step 3 — Set differences
findings = []
# Orphan surfaces — consumed, not produced.
for key, consumptions in consumption_map.items():
if key not in production_map:
for c in consumptions:
findings.append(orphan_surface(key, c))
# Unwired capabilities — produced, not consumed.
for key, productions in production_map.items():
if key not in consumption_map:
for p in productions:
findings.append(unwired_capability(key, p))Near-match detection (orphans)
Before recording an orphan, check for near-matches in the other map:
- Levenshtein distance ≤ 2 on the identifier string → likely typo or rename.
- Same identifier, different method (HTTP only) → method-drift candidate, not orphan.
- Same path, different parameter shape (
:idvs:userId) → likely rename, surface as drift with high-confidence note.
If a near-match exists, the finding is upgraded from orphan-surface to method-drift or stale-rename (a flavor of drift) with both citations.
Mediated persistence calibration (orphans)
The audit's central premise — every consumption maps to exactly one production — fails for mediated persistence: patterns where a UI input's value reaches the backend through a different trigger than the input itself. Common shapes:
- Cycle-coupled batch persistence — user edits accumulate in form state; persistence happens on a "regenerate," "save all," or "submit" action that reads the entire form payload.
- Form library state —
react-hook-form,Formik, controlled inputs via libraries that useregister/Controller/field.onChange, not direct state setters. - URL-as-state — input value lives in
useSearchParamsor route params; "setting" is arouter.replace(...)call, not a state setter. - Optimistic-with-batched-write — UI reads from
useQuery, accumulates local edits, persists via a singleuseMutationon a separate trigger. - Computed/derived inputs — the input's value is derived from another piece of state via a selector or memo; never written directly.
In all of these, a setter or onChange handler can look orphan (no direct backend call from the setter) while the input genuinely works. The audit will over-flag if it doesn't compensate.
Before recording an orphan-surface finding for a setter, input handler, or hook that lives in a UI component, the orchestrator runs an indirect-persistence probe in the same component (or its parent up to two levels):
1. Look for a form library import (useForm, Formik, Form.Item, Controller, register, useFormContext). If present, the input is mediated — downgrade from broken to mediated and tag the finding's notes with the form library name. 2. Look for a useSearchParams, useRouter().replace, or URL-state library import in the same component. If present and the input value flows into a URL update, mark mediated. 3. Look for a sibling event handler (commonly named onSubmit, onRegenerate, onSave, handleSubmit) that reads from form state or component-level state including the orphan'd input. If present, the input is cycle-coupled — mark mediated. 4. Look for a useMutation / useQuery whose body or mutationFn references the orphan'd value. If present, mark mediated.
When the probe matches any of (1)–(4), the finding becomes severity mediated (a non-broken severity meaning "indirect path exists, manual review needed"). The report's narrative should explicitly call out which probe triggered.
The probe is intentionally conservative: false negatives (missed mediation, finding stays orphan) are recoverable by user reading; false positives (mediated tag on a genuinely orphan setter) just delay action by one verification cycle. Over-reporting orphans is the worse failure mode — it erodes trust in the audit fast.
If the probe finds no indirect path, the orphan finding stands at its default broken severity.
Step 4 — Shape comparison (matched pairs)
For each key in both maps, compare:
for key in consumption_map:
if key in production_map:
for c in consumption_map[key]:
for p in production_map[key]:
# method drift (HTTP only)
if c.method and p.method and c.method != p.method:
findings.append(method_drift(c, p))
# shape drift
if c.shape and p.shape:
diff = shape_diff(c.shape, p.shape)
if diff:
findings.append(shape_drift(c, p, diff))
# auth/permission drift
if c.permission or p.auth:
if not auth_aligned(c.permission, p.auth):
findings.append(permission_drift(c, p))Shape diff
For object shapes, check field-by-field:
- Field on consumption side, missing on production side:
consumption expects field X, production never returns it. - Field on production side, ignored on consumption side: usually not a finding (frontend doesn't have to use everything backend returns), unless the field is required and the consumption code path crashes when absent.
- Field on both, types differ:
expected string, got numberetc. — high-confidence drift finding. - Field renamed: heuristic — if a field on one side has a similarly-named field on the other (
first_name↔firstName,userId↔user_id), flag as case-style drift with severitydrifted.
When shapes are inferred (no explicit type), confidence drops. The finding's confidence field reflects this.
Step 5 — Validation drift (optional pass)
Run when zod / yup / joi schemas are detected on both sides:
// frontend
const FormSchema = z.object({ email: z.string().email().max(320), age: z.number().min(0) })
// backend handler
const ApiSchema = z.object({ email: z.string().email().max(100), age: z.number().min(13).max(120) })Diff the schemas:
- Different
.max()/.min(): validation-drift finding. - Different
.email(),.url(),.regex()constraints: validation-drift. - Required vs optional mismatch: validation-drift.
When schemas are imported from a shared module, no drift is possible — note that the project has a shared validation module and skip this pass for fields covered by it.
Step 6 — Permission drift
def auth_aligned(consumption_perm, production_auth):
if consumption_perm is None and production_auth in ("none", None):
return True # both unguarded
if consumption_perm and production_auth in ("none", None):
return False # UI gates, backend doesn't (often a real bug)
if consumption_perm is None and production_auth and production_auth != "none":
return False # backend gates, UI doesn't (often shows-then-fails)
# both gate — heuristic match on the gating expression
return permission_expressions_match(consumption_perm, production_auth)The permission_expressions_match heuristic:
- Both contain the word
admin→ aligned. - Both reference
session.user.id === <param>→ aligned (both gate on self). - Mismatched roles or scopes → permission-drift finding.
False positives are common here. Severity stays at drifted unless the production side has auth: none while the consumption side gates — that case is upgradable to broken because the backend genuinely lacks enforcement.
Step 7 — Stale label detection
Run after the main diff:
all_labels = collect_user_labels(registry.surfaces)
all_capability_evidence = collect_evidence_strings(registry.capabilities)
for label, label_location in all_labels:
noun_phrases = extract_noun_phrases(label)
for phrase in noun_phrases:
# Phrase in current capability evidence?
if any(phrase.lower() in e.lower() for e in all_capability_evidence):
continue # current; not stale.
# Find candidate renames — phrases differing by ≤ 3 edit distance.
candidates = find_near_matches(phrase, all_capability_evidence, threshold=3)
if candidates:
findings.append(stale_label(
label=label,
label_location=label_location,
phrase=phrase,
near_matches=candidates,
))This is heuristic — both false positives and false negatives are expected. Default severity stale. Note the finding's confidence field as low since the heuristic can't distinguish a renamed concept from a coincidentally-similar word.
Step 8 — Unsurfaced configuration
For each env_var_consumer and config_key_consumer capability:
- Check if any surface's
consumes[]references the same env var or config key. - Check if there's an admin UI / settings page / CLI flag that lets a user set the value (heuristic: search the surface registry for buttons or inputs whose label or attribute name contains the var/key).
- Check if there's a
.env.exampleorconfig.example.tomlthat documents the var/key.
If the var/key is consumed in code but has neither a settings surface nor documentation, flag as unsurfaced-config with severity gap.
Step 9 — Output
The orchestrator passes the findings list through severity sorting (finding-categories.md rubric) and citation verification (audit-protocol.md step 6) before rendering to report.md.
Notes on false positives
Each diff step can produce false positives. Calibration:
- Wrapper functions: a custom HTTP wrapper might obscure URLs — if the surface enumerator can't templatize the wrapper's parameters, mark consumption with
confidence: lowand the orchestrator de-prioritizes it. - Dynamic dispatch:
app[method](path, handler)style routing in Express defeats static enumeration. Sub-agent should note when it sees dynamic dispatch and capabilities are flagged withdynamic_dispatch: true. Diff treats them as wildcard matches. - Code-generated routers: tRPC's generated
useQueryis heavily reliant on type inference; generated clients can lose precision. Note in finding's confidence. - Test routes:
pages/api/test/...or routes guarded byif (env === 'test')should be markeddev-only: trueand excluded from the diff by default.
Tooling preference
- codanna when
.codanna/exists — fast symbol resolution. - `Read` for line-content verification.
- `grep` for absence checks across subtrees.
- TypeScript compiler API is overkill for the audit; sub-agents can read types out of source files directly via
Read.
Finding Categories
The 8 categories of wiring drift the audit detects. Every finding belongs to exactly one category. Severity defaults follow the rubric below; severity overrides are documented in the finding entry.
Findings use callout prefix W- (Wiring). IDs increment from W-1.
The 8 categories
1. Orphan surface
Definition: UI consumes a capability that doesn't exist on the backend.
Default severity: broken
Detection:
- Consumption identifier
(kind, identifier)not present in production map. - No near-match in production map (Levenshtein > 2).
Evidence required:
- UI side citation (the consumption call).
- Grep evidence that the identifier is absent in the backend subtree.
Examples:
fetch('/api/users')but backend only has/api/v2/users.trpc.users.list.useQuery()but backend renamed totrpc.users.findAll.- Component imports
useDeleteProjectfrom a hook module that no longer exports it.
Severity overrides:
- stale: when the surface is dead code (the component/hook is itself never used). Bug is real but inert.
- drifted: when a near-match exists (Levenshtein ≤ 2 or known case-style variant) — re-classify as method-drift or stale-rename.
- mediated: when the orphan'd value reaches the backend via a different trigger (form library, cycle-coupled batch persistence, URL-as-state, batched mutation). The setter looks dead but the input works. See
drift-detection.md§ Mediated persistence calibration for the indirect-persistence probe. Findings taggedmediatedneed manual review — they're not bugs, but they're not certified clean either; the orchestrator surfaces them so a human can confirm the indirect path is intentional.
2. Unwired capability
Definition: Backend exposes a capability that nothing in the UI consumes.
Default severity: gap
Detection:
- Production identifier not present in consumption map.
Evidence required:
- Backend side citation.
- Grep evidence that the identifier is absent in the UI subtree.
Examples:
app.get('/api/admin/audit', ...)with no fetch caller.- tRPC procedure
users.exportdefined but no client call. - Server action
archiveProjectexported but no<form action={archiveProject}>or direct call. - WebSocket event
project:lockregistered but no client subscriber.
Severity overrides:
- broken: capability is announced (in changelog, README, public API docs) as available but never wired — this is a release-promise bug.
- stale when capability is dead code (still exported but the only test coverage is for the capability itself; no integration test exercises it).
3. Method drift
Definition: URL identifier matches but HTTP method differs.
Default severity: broken
Detection:
- Same
(http, path)exists on both sides but with different method. - For tRPC: same procedure path but
queryon one side andmutationon the other.
Evidence required:
- Both citations.
- Both methods captured.
Examples:
- UI
fetch('/api/users', { method: 'POST' }), backend has onlyGET /api/users. - UI uses
useMutationagainst a tRPC procedure defined as.query().
4. Shape drift
Definition: Both sides exist; response shape (or input shape) doesn't match.
Default severity: drifted
Detection:
- Matched pair has shapes captured on both sides.
- Field-by-field diff finds: missing field, extra required field, type mismatch, or case-style rename.
Evidence required:
- Both citations.
- The diffed shapes (both sides) excerpted in the finding.
Examples:
- UI expects
{ name, email }, backend returns{ first_name, last_name, email }. - UI passes
{ projectId }, backend handler reads{ project_id }. - UI handles
data.items: User[], backend returnsdata: User[](envelope mismatch).
Severity overrides:
- broken: when the missing field is the only one the UI uses (the consumption code path crashes immediately).
- stale: when the consumption ignores the field anyway (UI receives it, doesn't read it; bug is latent).
5. Validation drift
Definition: Frontend and backend validation rules diverged for the same field.
Default severity: drifted
Detection:
- Both sides have explicit validation (zod/yup/joi/manual) for the same field on the same identifier.
- Constraints differ: max length, min/max value, regex, required-vs-optional, allowed values.
Evidence required:
- Both citations.
- Both schemas excerpted.
Examples:
- FE allows email up to 320 chars, BE caps at 100.
- FE
age: z.number().min(0), BEage: z.number().min(13).max(120). - FE optional, BE required (form submits successfully but backend rejects with 422).
6. Permission drift
Definition: UI's permission gate disagrees with backend's authorization check.
Default severity: drifted
Detection:
- Either UI gates the surface behind a permission check while backend has
auth: none, or backend requires a role/scope the UI doesn't gate on. - Heuristic comparison on the gating expression.
Evidence required:
- UI permission signal (or absence).
- Backend auth requirement (or absence).
Examples:
- UI:
if (user.role === 'admin') { <DeleteAccountButton /> }. Backend: route has no auth check. UI hides the button but backend would let anyone call it. - UI: button always visible. Backend: rejects non-admins. UI shows the button to users who can't actually use it.
Severity overrides:
- broken: backend
auth: nonewhile UI gates (auth-bypass) — upgrade urgency. - stale: UI gates and backend gates with different but compatible expressions — annotate but lower severity.
7. Stale label
Definition: UI text references a backend concept that has been renamed.
Default severity: stale
Detection:
- Heuristic: a noun phrase from a
user_labeldoesn't appear in current capability evidence, but a near-match (Levenshtein ≤ 3, or a known rename pattern) does.
Evidence required:
- UI label location and text.
- Backend symbol's new location (the renamed concept).
- Confidence:
lowby default — heuristic detection.
Examples:
- Button "Save Draft" but backend action renamed from
saveDrafttocommitPending. - Help text mentions "the user's avatar" but backend field renamed from
avatartoprofile_image_url.
Severity overrides:
- drifted: public-facing user surface (landing page, signup flow) — labels that lie cause real user confusion.
- broken: when the label is part of a contract (e.g., button label rendered into an email or invoice) and a downstream system depends on the label string.
8. Unsurfaced config
Definition: An env var or config key gates backend behavior with no UI/CLI/admin surface to control it, and no documentation.
Default severity: gap
Detection:
- Capability of kind
env_var_consumerorconfig_key_consumer. - No surface in the UI references the same var/key (no settings page, no admin form).
- No documentation in
.env.example,config.example.*, or README.
Evidence required:
- Backend citation (where the var/key is read).
- Grep evidence of absence in UI and docs.
Examples:
process.env.ENABLE_BETA_BILLINGchecked in code, no admin toggle, not in.env.example.config.features.experimental_searchread by handlers, no config UI exposes it.
Severity overrides:
- drifted: when documentation exists but is contradictory or stale.
- stale: when the var/key is read but the code path is dead (always-false gate, never reached).
Severity rubric
| Severity | Meaning | Action timeline |
|---|---|---|
| broken | Runtime failure imminent or certain. Users hit a 404, 500, or visible bug. | Fix before next deploy. |
| drifted | Works in some cases, fails in others. Contracts mismatched, validation gaps. | Fix before next release. |
| mediated | Looks orphan but indirect-persistence probe found a likely cycle-coupled / form-library / URL-state path. Not a bug; needs manual confirmation. | Review when convenient; close as not-a-finding once confirmed intentional. |
| stale | Cosmetic or latent. Labels lie, but the wire still carries data. | Fix opportunistically. |
| gap | Capability or config exists but unsurfaced. No bug yet, but feature is invisible. | Fix as feature work. |
Severity priority sort
Within the report, findings are grouped by severity (broken first, then drifted, then stale, then gap), then by category, then by ID. Each finding gets a P-ranking inferred from severity:
- broken → P0
- drifted → P1
- mediated → P2 (manual review)
- stale → P2
- gap → P3
The report's frontmatter aggregates by-severity counts.
Confidence vs severity
Confidence and severity are independent. A broken finding can have confidence: low (heuristic detection of a possible orphan) or confidence: high (definitive grep returned zero matches across the backend). The report shows both: severity drives ordering; confidence drives whether the reader treats the finding as actionable or speculative.
A finding with severity: broken, confidence: low should usually be presented as: "if this consumption is hit, the call would 404 — but the consumption itself may be dead code; confirm by running the surface."
React Patterns
The signal catalog for React-side surface enumeration. The audit is generic across UI frameworks, but in practice React is the common case — and React's patterns have unique pitfalls (custom hooks hide URLs, server components blur FE/BE, tRPC's type-safety can mislead).
Data layer signals
Plain fetch and axios
Direct calls. Easiest to detect.
const data = await fetch('/api/users').then(r => r.json())
const { data } = await axios.get('/api/users/' + id)Capture the URL via templatization (/api/users/:id).
React Query (TanStack Query)
const { data, isLoading } = useQuery({
queryKey: ['users', id],
queryFn: () => fetch('/api/users/' + id).then(r => r.json()),
})
const mutation = useMutation({
mutationFn: (input: NewUser) => fetch('/api/users', {
method: 'POST',
body: JSON.stringify(input),
}),
})The queryFn / mutationFn is where the consumption lives. Capture the inner fetch.
Caveat: when fetchers are wrapped (fetcher(url)), follow the wrapper to find the underlying URL. If the wrapper is sufficiently dynamic that you can't templatize, mark confidence: low.
SWR
const { data } = useSWR('/api/users', fetcher)The first arg is the URL (often the cache key too). Capture as GET <url>.
Apollo Client / urql (GraphQL)
const QUERY = gql`
query GetUser($id: ID!) {
user(id: $id) { name email }
}
`
const { data } = useQuery(QUERY, { variables: { id } })Capture each top-level field as kind: graphql, identifier: Query.user. Sub-fields (user.name, user.email) get captured as the response_shape.
tRPC client
const { data } = trpc.users.byId.useQuery({ id })
const create = trpc.users.create.useMutation()
await create.mutateAsync(newUser)Capture as kind: trpc, identifier: users.byId / users.create. Method captured separately:
.useQuery/.query→ methodquery.useMutation/.mutate/.mutateAsync→ methodmutation
Type-safety caveat: tRPC catches procedure renames at compile time only if:
- Frontend imports the current
AppRoutertype from the backend (no stale generated client). - Both sides type-check successfully (
tsc --noEmitpasses). - No
as any,@ts-ignore, or<any>cast in the call site.
In real codebases, all three are routinely violated:
- Multi-package monorepos miss the regen step.
- Pre-commit type-check is sometimes skipped.
- Generated client packages get out of sync with backend types.
The audit still scans tRPC consumption because the type-check might be lying.
Custom hook unwrapping
Most React apps have a hook layer between components and fetchers:
// hooks/users.ts
export function useUsers() {
return useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users') })
}
// components/UserList.tsx
function UserList() {
const { data } = useUsers()
}Two consumptions to capture:
1. Hook-import: UserList consumes useUsers (kind: hook-import, identifier: useUsers). Diffs against an exported_hook capability. 2. Inner consumption: useUsers's fetch('/api/users') (kind: http, identifier: GET /api/users). Diffs against an http_route capability.
The audit's diff treats these as separate findings. A component that imports a hook that doesn't exist is one bug; a hook that fetches a URL that doesn't exist is another.
Next.js patterns
Route handlers (app router)
// app/api/users/route.ts
export async function GET(req: Request) { ... }
export async function POST(req: Request) { ... }These are capabilities, captured by the capability enumerator. From the surface side, calls to /api/users are the consumption.
Server actions
// app/actions.ts
'use server'
export async function createUser(formData: FormData) { ... }// components/CreateUserForm.tsx
import { createUser } from '@/app/actions'
<form action={createUser}>Two surface signals: 1. Import: kind: hook-import, identifier: createUser (treat server action import as the same kind as hook import for diff purposes). 2. Form action: kind: server-action, identifier: createUser at the <form action={createUser}> line.
Both diff against the server_action capability. The capability enumerator captures every exported async function in a file with 'use server' at the top, or any function with 'use server' as its first body statement.
Route loaders / actions (Remix-style)
export async function loader({ params }) {
return fetch(`/api/users/${params.id}`)
}The loader/action is part of the route surface, but the consumption is the inner fetch. Capture the fetch.
use() hook for data (React 19+)
function UserProfile({ userPromise }) {
const user = use(userPromise)
}Trace upward to the source of userPromise — usually a server component fetching directly. Capture the source fetch.
Server components
Server components run on the server but logically belong to the UI surface from the audit's perspective. A server component with await db.users.findUnique(...) is not a wiring drift candidate (it's direct data access, not surface-to-capability). Skip database calls in server components — they're implementation, not consumption.
A server component that calls a backend route via fetch('https://...') IS a consumption. Capture it.
React Router
Routing is a UI surface. <Route path="/users/:id" element={<UserPage />} /> is captured in the surfaces registry but doesn't itself consume anything — only its rendered components do.
Loader/action functions (data router):
const router = createBrowserRouter([
{
path: "/users/:id",
loader: async ({ params }) => fetch(`/api/users/${params.id}`),
element: <UserPage />,
},
])The loader's fetch is a consumption. Capture it as if it were inside the component.
Form patterns
Form submissions are consumptions:
<form
onSubmit={async (e) => {
e.preventDefault()
await fetch('/api/contact', { method: 'POST', body: ... })
}}
>Capture as POST /api/contact.
<form action="/api/contact" method="post"> (without JS handler) — same identifier, capture from the action and method attributes.
Mediated persistence patterns
The audit's central premise — every UI consumption maps to exactly one backend production — fails when an input's value reaches the backend through a different trigger than the input itself. These are not bugs; they're legitimate architectural patterns. The audit will over-flag them if it doesn't compensate.
When sub-agents see one of these patterns, they should annotate the consumption with mediated: true and name the indirect persistence path. The orchestrator's drift-detection step then runs an indirect-persistence probe (see drift-detection.md § Mediated persistence calibration) before classifying any setter or input handler as orphan.
Cycle-coupled batch persistence
User edits accumulate in component state or form state. Persistence happens on a separate trigger — "regenerate," "save all," "submit," route navigation, periodic flush — that reads the entire payload at once.
function PromptEditor({ initialPrompt, initialCorrections }) {
const [prompt, setPrompt] = useState(initialPrompt)
const [corrections, setCorrections] = useState(initialCorrections)
// setCorrections looks orphan — no direct backend call.
// But:
const regenerate = trpc.image.regenerate.useMutation()
const onRegenerate = () => regenerate.mutate({ prompt, corrections })
// ^^^^^^^^^^^^ corrections persisted here
}Indicators: a useMutation / useQuery whose body or mutationFn references the orphan'd value, triggered by a different handler than the setter. Common in AI/LLM apps with regenerate cycles, multi-step forms with batch save, and editor UIs with explicit save actions.
Form library state (react-hook-form, Formik)
Form libraries manage state via register / Controller / field.onChange rather than explicit setters. Persistence happens via handleSubmit reading the entire form payload.
function ProjectSettingsForm({ defaultValues }) {
const { register, handleSubmit } = useForm({ defaultValues })
const onSubmit = (data) => trpc.project.update.mutate(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
<input {...register('description')} />
</form>
)
}There are no setters for name or description from the audit's perspective — the form library owns the wire. Indicators: imports from react-hook-form, formik, react-final-form, @tanstack/react-form. The register / Controller calls are the indirect-persistence signal.
URL-as-state
Input value lives in URL query params or route state. "Setting" is router.replace / setSearchParams, not a state setter.
function FilterBar() {
const [params, setParams] = useSearchParams()
const status = params.get('status')
return <Select value={status} onChange={(v) => setParams({ status: v })} />
// ^^^^^^^^^ "setter" is a URL update
}Backend reads via the URL on its own. Indicators: useSearchParams, useRouter().replace, setSearchParams. The state lives in the URL bar, not in component state.
Form hydration with batched persistence
Initial values are loaded from the backend on mount (useQuery); user edits are tracked in form state; persistence happens via a separate useMutation triggered on save/regenerate/navigation.
function UserSettings() {
const { data: user } = trpc.user.me.useQuery()
const update = trpc.user.update.useMutation()
const { register, handleSubmit } = useForm({ values: user }) // hydration
return <form onSubmit={handleSubmit(update.mutate)}>...</form>
}This is the canonical form-with-server-state pattern. The hydration (read) and the persistence (write) flow through different identifiers — user.me (query) vs user.update (mutation). Neither is orphan, but the audit might miss the connection between them.
Optimistic UI with server reconciliation
Local state mirrors server state and updates locally first, then reconciles via mutation. The local setter is real; the mutation is the persistence path.
const [local, setLocal] = useState(serverData)
const update = useMutation({
mutationFn: (next) => fetch(...),
onMutate: (next) => setLocal(next), // optimistic
onSuccess: () => queryClient.invalidate(['serverData']), // reconcile
})Indicators: onMutate / onSettled callbacks that touch local state. The setter is exercised; the mutation is what ultimately reaches the backend.
Computed/derived inputs
The input's value is derived from another piece of state via a selector or memo. Never written directly.
const total = useMemo(() => items.reduce((sum, i) => sum + i.price, 0), [items])
return <input value={total} readOnly />There's no setter at all because the value isn't user-mutable in this layer. Indicators: useMemo, useDerivedValue, useSelector with a computed selector. The audit should not look for a setter for these values.
Server actions consuming form state
'use server'
async function saveAll(formData: FormData) { ... }
// component
<form action={saveAll}>
<input name="title" defaultValue={post.title} />
<input name="body" defaultValue={post.body} />
</form>title and body have no setters — they're submitted as form data on submit. The action={saveAll} is the indirect persistence path. Indicators: <form action={X}> where X is a server action.
Indirect-persistence probe (sub-agent guidance)
When the surface enumerator sees a useState setter, an onChange handler, or any input-tied event handler, before annotating the consumption, scan the same component (and one parent up) for:
1. Form library imports — useForm, Formik, Controller, useFormContext, Form.Item. Annotate as mediated: form-library. 2. Cycle handlers — onSubmit, onRegenerate, onSave, handleSubmit, save-on-navigation handlers. Annotate as mediated: cycle-coupled. 3. URL-state hooks — useSearchParams, useRouter().replace, setSearchParams. Annotate as mediated: url-state. 4. Mutations referencing the value — useMutation / useQuery whose body references the orphan'd value, triggered separately from the setter. Annotate as mediated: batched-mutation. 5. Computed-value indicators — useMemo, useSelector(selector), useDerivedValue. Annotate as mediated: derived.
Annotation flows through to the orphan-detection step. Annotated consumptions are not orphan candidates — they map to the backend via the indirect path, which the orchestrator can name in the report.
Permission-aware rendering
{user.role === 'admin' && <Button onClick={deleteUser}>Delete</Button>}
<Button disabled={!can.editProject(project)} onClick={...}>Edit</Button>Capture the gating expression as permission_signal on the wrapped consumption. The audit will diff against the backend handler's auth requirement.
Common patterns:
- Role-based:
user.role === 'admin','admin' in user.permissions. - CASL / abilities:
can('delete', 'Project'). - Custom hooks:
useCanDelete(),useIsAdmin().
When the gate is a custom hook, follow it to its return logic when feasible. When the logic is opaque, capture the hook name as the signal.
Anti-patterns to flag
These often correlate with drift and warrant elevated finding confidence:
Stringly-typed URL fragments
const url = `/api/${resource}/${id}`Where resource is a parameter. Capture as kind: http, identifier: dynamic and mark confidence: low. Surface enumerator should still try to enumerate the call sites that reach this code with concrete resource values.
Catch-all dispatch
api[method](path, ...) // server-side
client[method](url, ...) // client-sideDefeats static enumeration. The audit notes this and reduces precision in that area.
Comments naming drift
// TODO: this endpoint was renamed, update later
const data = await fetch('/api/old-endpoint')
// FIXME: shape changed in v2, fix the consumerThese TODO/FIXME comments are high-signal drift markers. The surface enumerator should capture them and the orchestrator should elevate the associated finding's severity.
Type assertions hiding drift
const data = await res.json() as UserDefeats type-checking. The audit's shape-drift detection can't rely on TypeScript here. When seen, mark the consumption's response_shape_source: usage-pattern (inferred from how data is used) rather than declared.
Generic-ness check
For non-React UIs (Vue, Svelte, plain JS, Angular), apply the same pattern but with framework-specific signals:
- Vue:
<script setup>+useFetch(Nuxt),axioscalls,<form @submit>. - Svelte:
+page.tsload(),fetch. - Angular: services with
HttpClient, route guards.
The output contract (consumption registry) is unchanged. The signal catalog adapts.
When the audit runs on a non-React UI, surface enumerator's prompt should include the framework's signal patterns inline (the orchestrator constructs the prompt). For React, this file's contents flow into the prompt verbatim.
Report Template
The skeleton for docs/audits/<date>/report.md. The report is a prioritized findings list — broken first, gap last — with citations on both sides for every finding.
Structure
Eight sections. Front-loaded so a reader who only reads the first screen sees the highest-severity findings.
---
date: <YYYY-MM-DD>
target: <repo or path>
fe-stack: <react | next | remix | vue | other>
be-stack: <express | next | trpc | graphql | other>
total-findings: <N>
by-severity:
broken: <N>
drifted: <N>
mediated: <N>
stale: <N>
gap: <N>
by-category:
orphan-surface: <N>
unwired-capability: <N>
method-drift: <N>
shape-drift: <N>
validation-drift: <N>
permission-drift: <N>
stale-label: <N>
unsurfaced-config: <N>
priors_used: <true | false>
---
# Wiring Audit — <YYYY-MM-DD>
## Summary
<3–6 sentences: the headline state of the wiring. State the count of broken
findings, the most concerning category, any cross-cutting pattern that shows
up. Don't summarize each finding — that's the body.>
## Scope
- Target: <repo or path>
- Frontend: <stack and entry path>
- Backend: <stack and entry path>
- Excluded: <list anything skipped — test fixtures, dev-only routes, archived code>
- Priors: <if architectural-analysis priors were loaded, name the report date>
## P0 — Broken
<Sorted by category, then ID. Each finding gets a sub-section.>
### W-1 Orphan surface — `GET /api/old-users`
| Field | Value |
|---|---|
| Severity | broken |
| Category | orphan-surface |
| Confidence | high |
| UI side | `src/components/UserList.tsx:18` |
| Backend side | (absent) — `grep -rn "/api/old-users" server/` returned 0 matches |
| Identifier | `GET /api/old-users` |
**Evidence (UI):**
const { data } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/old-users') })
**Suggested fix:**
The endpoint appears to have been renamed to `/api/users` in commit `<hash>`. Update the consumption:
- queryFn: () => fetch('/api/old-users')
+ queryFn: () => fetch('/api/users')
If multiple consumers exist, consider extracting a `usersUrl` constant.
> **Before triaging an orphan-surface finding:** verify there is no indirect persistence path. If the orphan'd value is fed into a form library, persisted via a cycle-coupled handler (regenerate / save-all / submit), or read via URL state, the wire is mediated, not broken. See the indirect-persistence probe in `drift-detection.md`. If a probe match was found, this finding would carry severity `mediated` instead of `broken`.
---
### W-2 Method drift — `POST /api/projects`
| Field | Value |
|---|---|
| Severity | broken |
| Category | method-drift |
| Confidence | high |
| UI side | `src/components/CreateProject.tsx:24` (POST) |
| Backend side | `server/routes/projects.ts:42` (PUT) |
| Identifier | `/api/projects` |
**Evidence (UI):**
fetch('/api/projects', { method: 'POST', body: JSON.stringify(input) })
**Evidence (backend):**
app.put('/api/projects', createProject)
**Suggested fix:**
Either change the UI to PUT, or change the route to POST. The handler name `createProject` suggests POST is intended; the route was likely changed to PUT inadvertently.
---
## P1 — Drifted
<Same structure for shape-drift, validation-drift, permission-drift findings.>
### W-3 Shape drift — `GET /api/users/:id`
| Field | Value |
|---|---|
| Severity | drifted |
| Category | shape-drift |
| Confidence | high |
| UI side | `src/components/UserProfile.tsx:14` |
| Backend side | `server/routes/users.ts:67` |
| Identifier | `GET /api/users/:id` |
**UI shape (expected):**
{ name: string; email: string }
**Backend shape (actual):**
{ first_name: string; last_name: string; email: string; createdAt: Date }
**Diff:**
- `name` (UI) → no equivalent on backend; backend has `first_name` + `last_name`.
- `email` matches.
- `createdAt` (BE) → unused by UI (not a finding).
**Suggested fix:**
Either:
- Backend adds a computed `name` field (`first_name + ' ' + last_name`), or
- UI consumes `first_name + ' ' + last_name` directly.
The first is cheaper if multiple consumers expect `name`.
---
## P2 — Stale
<Stale-label findings.>
### W-4 Stale label — "Save Draft"
| Field | Value |
|---|---|
| Severity | stale |
| Category | stale-label |
| Confidence | low |
| UI side | `src/components/Editor.tsx:88` |
| Backend candidate | `server/actions/posts.ts:23` (`commitPending`) |
| Inferred rename | "saveDraft" → "commitPending" |
**Evidence (UI):**
<Button onClick={savePost}>Save Draft</Button>
**Backend evidence (current):**
export async function commitPending(post: PendingPost) { ... }
**Suggested fix:**
If "Save Draft" is the user-facing language and "commit pending" is internal jargon, the label is fine — close as not-an-issue. If the rename was supposed to be reflected in the UI, change to "Commit Pending" or update both to a unified term.
---
## P3 — Gap
<Unwired capabilities and unsurfaced configuration.>
### W-5 Unwired capability — `archiveProject` server action
| Field | Value |
|---|---|
| Severity | gap |
| Category | unwired-capability |
| Confidence | high |
| Backend side | `app/actions.ts:42` |
| UI side | (absent) — `grep -rn "archiveProject" src/` returned 0 matches |
| Identifier | `server-action archiveProject` |
**Evidence (backend):**
'use server' export async function archiveProject(projectId: string) { ... }
**Notes:**
- Capability was added in commit `<hash>` on `<date>`; possibly not yet wired up by design.
- No UI form, no direct call, no mention in any README or docs.
**Suggested fix:**
Either wire up a UI surface (likely a button on the project settings page), or remove the unused export. If this is in-progress feature work, leave a TODO at the export site noting the planned UI.
---
### W-6 Unsurfaced config — `ENABLE_BETA_BILLING`
| Field | Value |
|---|---|
| Severity | gap |
| Category | unsurfaced-config |
| Confidence | high |
| Backend side | `server/billing/index.ts:12` |
| Surface side | (absent) — no admin toggle, not in `.env.example` |
**Evidence (backend):**
if (process.env.ENABLE_BETA_BILLING === 'true') { // beta path }
**Suggested fix:**
Either:
- Document in `.env.example` with a comment explaining the flag.
- Add an admin settings toggle.
- If always-on or always-off in practice, remove the gate.
---
## Wiring graph
<Optional — if `wiring.mmd` was authored.>
See `wiring.svg` for the visual graph showing surface→capability with broken edges in red and unwired capabilities in amber.
## Methodology
This audit was produced by the `wiring-audit` skill. Two parallel sub-agents enumerated UI surfaces and backend capabilities; the orchestrator computed the diff, applied severity, and verified every citation.
- Sub-agents: <2 (general-purpose, sonnet)>
- Citations verified: <N total — discarded <M> as unresolvable, <K> as fabricated absence claims>
- Priors: <true | false — if true, name the architectural-analysis report date>
## Verification log
### Discarded findings
- <bad citation> — <asserted label> — reason: <e.g., evidence didn't match cited line; absence claim refuted by grep>
### Synthesized inferences
<Stale-label findings, near-match orphan upgrades, and any heuristic-driven
detections list here with their inference path.>
## Open questions
<Architectural questions surfaced by the audit but not findings themselves.
These are seeds for follow-up.>
- Is `archiveProject` (W-5) intended to be wired, or is it dead code?
- The `ENABLE_BETA_BILLING` flag (W-6): is it a runtime kill-switch or a deploy-time toggle? Treatment depends on the answer.Authoring rules
- Sort findings by severity desc, then category, then ID. Always P0 first.
- Every finding cites both sides (or grep evidence for the absent side).
- Suggested fix is mandatory. A finding without an actionable fix isn't useful triage. If you don't know the fix, name the question that needs answering.
- Confidence visible per finding. Readers calibrate action by confidence × severity.
- Don't aggregate. Each finding is its own entry. A pattern affecting 5 endpoints becomes 5 findings, with a cross-reference noting the pattern in the summary.
- No findings without callouts. The frontmatter's
total-findingsmatches the count ofW-Nentries. Off-by-one means you missed a section.
Length
Most reports run 4–10 pages depending on codebase size. Reports exceeding 30 findings should add a "Pattern" section in the summary highlighting cross-cutting issues — readers can't triage 30+ individual findings without help.
Filename
Always report.md. Don't customize.
Surface Enumeration
What the surface-side sub-agent looks for in the UI. The goal: build a consumption registry — every place the UI calls into a backend capability.
The audit's diff key is (kind, identifier). Every consumption must produce both with stable formatting.
Generic patterns (any UI framework)
HTTP calls
Look for these call patterns and capture the URL + method + location:
| Pattern | Example |
|---|---|
fetch() | fetch('/api/users', { method: 'POST', body }) |
axios.* | axios.get('/api/users/' + id) |
| Custom wrapper | api.users.list(), client.post(...) — follow the wrapper to the underlying URL |
XMLHttpRequest | xhr.open('POST', '/api/...') |
For each HTTP consumption:
- Identifier:
<METHOD> <path-template>. Templatize concrete IDs back to:paramform when the URL is built from string concatenation or template literals (e.g., `fetch(/api/users/${id})becomesGET /api/users/:id`). - Location: cite the call site (the
fetchline), not the import. - Evidence: verbatim call line.
- Response shape: if the response is consumed (e.g.,
const data = await res.json()→data.foo), capture the access pattern as{ foo: unknown }. If TypeScript types are present (fetch<User>(...)or generated client), capture the declared shape.
Form submissions
<form action="/api/users" method="post"> or <form onSubmit={...}> with a fetch in the handler. Capture as HTTP consumption with the form action + method.
WebSocket connections
new WebSocket('/ws/...'), socket.io-client. Capture the URL or topic/event name as identifier.
Environment variables and config
process.env.X reads in client code (Next.js: NEXT_PUBLIC_*). Capture as kind: env-var, identifier: NEXT_PUBLIC_X. Config file reads in client code: config.foo.bar capture as kind: config-key, identifier: foo.bar.
React-specific patterns
(See react-patterns.md for the deep dive. Summary here.)
Hooks
Both imported hooks and hook usage count as surface consumption signals.
- Imported hook (
import { useUsers } from '@/hooks/users'): capture askind: hook-import, identifier: useUsers. The diff matches against an exported_hook capability. - Hook call (
const { data } = useUsers()): also a consumption — but if the call doesn't go via an imported hook (it's defined inline), capture the inner call (e.g., thefetchinside).
React Query / SWR / Apollo
useQuery({ queryKey: ['users', id], queryFn: () => fetch('/api/users/' + id) })
useMutation({ mutationFn: (body) => fetch('/api/users', { method: 'POST', body: JSON.stringify(body) }) })
useSWR('/api/users')Surface the inner fetch (or fetcher function) as the HTTP consumption. The query key isn't the identifier; the URL is.
tRPC client
trpc.users.list.useQuery()
trpc.users.create.useMutation()
trpc.users.byId.useQuery({ id })Capture as kind: trpc, identifier: <dotted path> — users.list, users.create, users.byId.
The dotted path is the diff key. Method (query vs mutation) is captured separately; tRPC procedures are typed as query or mutation and a frontend useQuery against a backend mutation is a method-drift finding.
GraphQL
const { data } = useQuery(USER_QUERY)
gql`query Foo { user(id: $id) { name email } }`Capture as kind: graphql, identifier: <Type>.<field>. For multi-field queries, emit one consumption per top-level field.
Server actions (Next.js)
'use server'
export async function createUser(formData: FormData) { ... }
// elsewhere
<form action={createUser}>The call site (<form action={createUser}> or await createUser(...)) is a consumption with kind: server-action, identifier: createUser. The function definition is a capability — the capability enumerator handles that side.
React Router / Next.js routing
Routing itself is a UI surface, not a consumption — but route loaders/actions can be consumptions:
export async function loader({ params }) {
return fetch(`/api/users/${params.id}`)
}Surface the inner fetch as the consumption. The route definition (path: "/users/:id") is a UI surface but doesn't itself consume anything; only its loader/action does.
Permission signals
When a consumption is gated by a permission check at the call site, capture:
if (user.role === 'admin') {
return fetch('/api/admin/audit')
}→ permission_signal: "user.role === 'admin'"
Used by the diff to detect permission-drift (e.g., UI requires admin, backend has no auth check).
User labels
For surfaces that have user-visible text (buttons, links, menu items, headings), capture the label string:
<button onClick={() => deleteUser(id)}>Delete account</button>→ user_label: "Delete account"
Used by the stale-label detection pass to find labels that reference renamed backend concepts.
What NOT to enumerate
- Pure styling — no consumption.
- Internal state mutations (
setX(y)) — no consumption. - React Query cache reads (
queryClient.getQueryData(...)) — these are derived; capture the original fetcher. - Imports of pure types or constants — not consumption (no runtime call).
- Third-party SDK calls (Stripe.js, Auth0, etc.) — those are integrations, captured as
kind: httponly if you want to audit drift against your own backend's mirror of those concepts.
Custom hook unwrapping
When a component calls a custom hook that wraps backend calls:
// hooks/users.ts
export function useUsers() {
return useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users') })
}
// components/UserList.tsx
function UserList() {
const { data } = useUsers() // ← surface, consumes useUsers
}Two records:
1. The component's useUsers() call — kind: hook-import, identifier: useUsers. Diffs against the exported_hook capability. 2. The hook's inner fetch('/api/users') — kind: http, identifier: GET /api/users. Diffs against the http_route capability.
Both must be captured. Components surface to hooks; hooks surface to backend. The audit checks both layers.
Sub-agent prompt seed (surface side)
# Mode
Wiring audit — surface side. Enumerate every UI consumption.
# Scope
[<UI path or "the entire frontend rooted at <path>">]
# Stack signal
[Detected: React + react-query + tRPC + Next.js, etc.]
# What to find
1. HTTP calls (fetch, axios, custom wrappers) — capture URL + method.
2. tRPC client calls (trpc.x.y.useQuery / .useMutation / .query / .mutate).
3. GraphQL queries (useQuery, gql tags) — top-level field per consumption.
4. Server action call sites (form action + direct calls).
5. WebSocket connections.
6. Hook imports (custom hooks wrapping backend calls).
7. Form submissions to backend routes.
8. Permission-gated calls — capture the gating expression.
9. User-visible labels (buttons, links, menu items) — capture the label text.
10. Env var reads (process.env.X) and config-key reads in client code.
# Identifier format
- HTTP: "<METHOD> <path-template>" — templatize :params
- tRPC: dotted path
- GraphQL: <Type>.<field>
- Server action: function symbol
- Hook: hook symbol
- WebSocket: event/topic
- Env var: VAR_NAME
- Config key: dotted.path
# Output contract
Return YAML matching the surfaces[] schema in references/audit-protocol.md.
# Important
- For absence claims (e.g., "this component has no consumption") — only assert if you've thoroughly reviewed the file.
- Capture user_label for any element with visible text — used downstream for stale-label detection.
- Custom hooks: capture BOTH the hook-import consumption AND any inner consumptions inside the hook definition.
# Verification expectation
The orchestrator will verify every citation. Findings whose evidence doesn't match the cited line will be discarded. Optimize for accuracy.#!/usr/bin/env bash
# Render every *.mmd file in an audit directory to *.svg via mmdc.
#
# Usage: render.sh <audit-dir>
#
# Walks the audit directory and any immediate subdirectories, finds *.mmd
# files, and runs mmdc on each producing a sibling *.svg. Idempotent —
# re-running overwrites existing SVGs. Skips files where the SVG is newer
# than the source.
set -euo pipefail
AUDIT_DIR="${1:?usage: render.sh <audit-dir>}"
if [[ ! -d "$AUDIT_DIR" ]]; then
echo "error: not a directory: $AUDIT_DIR" >&2
exit 1
fi
if ! command -v mmdc >/dev/null 2>&1; then
cat >&2 <<'EOF'
error: mmdc (mermaid CLI) is not installed.
Install with one of:
npm install -g @mermaid-js/mermaid-cli
pnpm add -g @mermaid-js/mermaid-cli
brew install mermaid-cli
The wiring graph is optional; the audit report is still valid without
SVG rendering. Skip this step if mmdc is unavailable.
EOF
exit 2
fi
shopt -s nullglob globstar
rendered=0
skipped=0
failed=0
while IFS= read -r -d '' mmd; do
svg="${mmd%.mmd}.svg"
if [[ -f "$svg" && "$svg" -nt "$mmd" ]]; then
skipped=$((skipped + 1))
continue
fi
echo "render: $mmd → $svg"
if mmdc -i "$mmd" -o "$svg" -b transparent 2>&1; then
rendered=$((rendered + 1))
else
echo " failed: $mmd" >&2
failed=$((failed + 1))
fi
done < <(find "$AUDIT_DIR" -maxdepth 2 -type f -name '*.mmd' -print0)
echo
echo "summary: rendered=$rendered skipped=$skipped failed=$failed"
if (( failed > 0 )); then
exit 1
fi
#!/usr/bin/env bash
# Sanity check over a wiring-audit report's citations.
#
# Usage: verify-findings.sh <report.md> [<repo-root>]
#
# Extracts every path:line citation from the report and confirms:
# - the path exists relative to repo-root (default: cwd)
# - the line number is within the file's line count
#
# Coarse check, not full verification. Does NOT confirm that the cited
# line content matches the finding's evidence string. Use as a fast gate;
# the orchestrator's verification protocol is still required for
# trustworthy findings.
set -euo pipefail
REPORT="${1:?usage: verify-findings.sh <report.md> [<repo-root>]}"
REPO_ROOT="${2:-.}"
if [[ ! -f "$REPORT" ]]; then
echo "error: report not found: $REPORT" >&2
exit 1
fi
if [[ ! -d "$REPO_ROOT" ]]; then
echo "error: repo root not a directory: $REPO_ROOT" >&2
exit 1
fi
ok=0
missing=0
oob=0
# Extract path:line patterns. Path must contain at least one slash and a
# file extension (so URLs and bare words don't match).
mapfile -t citations < <(
python3 - "$REPORT" <<'PY'
import re, sys, pathlib
report = pathlib.Path(sys.argv[1])
text = report.read_text()
seen = set()
pat = re.compile(r'([A-Za-z0-9_./\-]+\.[A-Za-z0-9]+):([0-9]+)')
for m in pat.finditer(text):
path, line = m.group(1), m.group(2)
if '/' not in path:
continue
key = f"{path}:{line}"
if key in seen:
continue
seen.add(key)
print(key)
PY
)
if (( ${#citations[@]} == 0 )); then
echo "warn: no citations found in $REPORT" >&2
exit 0
fi
for cite in "${citations[@]}"; do
path="${cite%:*}"
line="${cite##*:}"
full="$REPO_ROOT/$path"
if [[ ! -f "$full" ]]; then
echo " MISSING: $cite (file not found at $full)"
missing=$((missing + 1))
continue
fi
total_lines=$(wc -l < "$full" | tr -d ' ')
if (( line > total_lines + 1 )); then
echo " OUT-OF-BOUNDS: $cite (file has $total_lines lines)"
oob=$((oob + 1))
continue
fi
ok=$((ok + 1))
done
echo
echo "summary: ok=$ok missing=$missing out-of-bounds=$oob (total=${#citations[@]})"
if (( missing > 0 || oob > 0 )); then
exit 1
fi