canonical(frontend): no single backend contract — 8 independent HTTP clients (2 generic) + 22 raw fetch sites, and generated api.ts is bypassed (4 importers vs 74 hand-typed responses) and stale (2424 vs 2152 paths) #12363
Description
Activity
Correction — the recommendation above under-scoped the canonical target
The original recommendation framed this as "8 HTTP client classes → 1." That is necessary but not sufficient: it points at the wrong consolidation surface and omits the type-level half. Two dimensions were missing.
1. Composables are the real data-access layer, not the client classes
API access is spread across the composables, not centralised in the 8 client classes:
composables total: 218 composables that make API calls: 125 (getApiBase / apiClient / fetch / axios)So consolidating the 8 client classes leaves 125 composables each still building their own calls, their own URL construction, and their own error handling. The canonical layering has to be explicit:
- One transport client (owns URL/auth/errors) — the original step 1.
- Composables are the only API-access layer for components — a component never calls the client directly; it calls a composable, and the composable calls the one client. This is the Vue-idiomatic canonical boundary and it is where the 125-way sprawl actually lives.
- Result: fixing URL construction once (e.g. the
:8443origin, frontend: .env.production hardcodes VITE_BACKEND_PORT=8443 but nothing listens there — onboarding/metrics calls fail with 'Failed to fetch' #12313) fixes it for all 125 consumers, instead of in whichever composables happen to use the shared client.
2. Enums — 51 hand-typed string-unions that should derive from the contract
The type half of the contract is reinvented the same way the transport half is. There are 51 hand-declared string-union types for status/type/mode/severity/level concepts, e.g.:
type AgentDisplayStatus = 'active' | 'idle' | 'error' | 'paused' | 'terminated' type AutomationMode = 'manual' | 'semi_automatic' | 'automatic' type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error'
These are the frontend parallel of the backend
PerformanceIssuestr-vs-enum divergence called out in #12362: the same concept (e.g. an agent's status) exists as a backend enum, a generated-type enum, and a hand-typed frontend string union — three definitions, free to drift. A component checkingstatus === 'terminated'silently breaks when the backend renames a variant, because nothing ties the string union to the source enum.Canonical fix: status/type/mode unions come from the generated types' enums, the same way response shapes should (original step 3). Hand-typed string-unions that duplicate a generated enum are the enum-level version of the hand-typed
*Responsebypass.Revised canonical target
Not "one client," but the full contract derived once:
- One transport client — owns URL/auth/errors (unchanged).
- Composables as the sole API layer — components → composables → the one client; no component-level fetch/axios/client calls. This is where the 125-way sprawl converges.
- Types and enums from
src/types/generated/api.ts— the 74 hand-typed*Responseinterfaces and the 51 hand-typed status/mode/type string-unions both migrate to the generated contract. - Lint: forbid
window.fetch/axiosoutside the client, hand-typed*Responsewhere a generated shape exists, and hand-typed string-unions where a generated enum exists. - ADR.
The through-line with #12362 is exact: both are "one concept, defined multiple times, free to diverge (incl. str-vs-enum)." The fix is the same on both sides — derive from one source. Thanks for the catch; the original three-step version would have consolidated transport while leaving the composable sprawl and the enum divergence in place.
Phased plan (from a read-only audit) — issue is ~75% already done
Key finding: a prior effort (#12152) already routed 6 of 8 clients through the canonical
utils/ApiClient.ts. The 'no single contract' framing is now stale. Canonical =utils/ApiClient.ts(owns base-URL resolution, auth+expiry, X-Organization-Id, 401 handling, retry/backoff, timeout/abort, FormData, rawRequest) —ApiServicealready delegates to it.Remaining, phased:
- Phase 1 (in progress): migrate the last 2 transport outliers —
AdvancedControlApiClient(still onfetchWithAuth) →apiClient.rawRequest(behavior-neutral), andSecretsApiClient.js→.ts. Completes the single-transport half. - Phase 2: retire raw
fetch(29 files) + axios (4 importers) in feature-batched PRs viafetchWithAuth/domain clients; add a lint rule forbidding raw fetch/axios. (SSE/streaming sites must userawRequest, not.get().) - Phase 3: adopt generated
src/types/generated/api.tsfor the ~74 hand-typed*Responseinterfaces; gategen:types/verify:typesin CI. Fold in the oxlint glob fix (_generatedvsgeneratedmismatch makes oxlint walk a 5MB file every run).
Owner decisions: whether AdvancedControl/Secrets should inherit ApiClient's retry + 401-auto-logout (behavior change — Phase 1 deliberately avoids it); whether to extract a shared
@autobot/http-core(do NOT put transport in @autobot/ui #10860 — violates its presentational charter); making gen:types a CI gate.- Phase 1 (in progress): migrate the last 2 transport outliers —
- added a commit that references this issue
on Jul 26, 2026 - added 5 commits that reference this issue
on Jul 26, 2026 1 remaining item
Part of umbrella #13916 — canonical-source duplication
Wave 3 — no canonical source chosen yet
Larger than the others and needs an architecture decision on what the single backend contract is before any consolidation. Deliberately last so it does not block Waves 1-2.
Depends on: Waves 1-2 should not wait on this.
Umbrella acceptance criteria that apply to this issue
- Lands with a guard test in the shape of
repo_tests/audio_extension_allowlist_test.py(tech-debt(transcriber): allowed-audio-extension set duplicated across three modules, one of them a security boundary #13512) — a
test that fails when a new duplicate of the canonical definition appears. Migration without a guard
regrows: refactor(types): consolidate 15+ Severity/RiskLevel/Level enums onto constants/status_enums.py:Severity #6689 consolidated 15+ severity enums and 15 exist again today. - Deliberate exceptions are recorded, not omitted — where a copy is kept on purpose, the guard names it
and says why. - No silent behaviour change — where copies differ, reconciling them alters what the system accepts;
that is an owner decision recorded before the change. - The canonical source is discoverable from the copy site — the reason each of these was written twice.
Update #13916 when this issue changes state
- Opening a PR → note the PR number against this item on Umbrella: canonical-source duplication — concepts with no single definition, where the copies have already drifted #13916.
- Closing → tick the box on Umbrella: canonical-source duplication — concepts with no single definition, where the copies have already drifted #13916 and state the evidence, including that the guard test exists.
- Not real / already fixed → say so on Umbrella: canonical-source duplication — concepts with no single definition, where the copies have already drifted #13916, not only here; a child vanishing without explanation
leaves the ordering wrong for everything downstream. - Re-scoping → update the wave placement on Umbrella: canonical-source duplication — concepts with no single definition, where the copies have already drifted #13916. Waves encode dependency, not priority.
Why these are grouped
Five findings sharing one cause: a concept has no single authoritative definition, so subsystems each
keep their own copy and the copies drift. The cost is already paid — a security boundary that drifted
(#13512/#13615), a week of stale workers (#13539 via #13915), and a severity consolidation that regrew
to 15 duplicates after being 'finished' (#6689 → #13597).#13512 is the worked example on the umbrella: three copies collapsed onto one constant, plus a guard
test, plus the two deliberate exceptions written down and tracked separately rather than silently
widening scope.- Lands with a guard test in the shape of
- addedarea: api-contractWave 2 · cluster C — Frontend↔backend API contractWave 2 · cluster C — Frontend↔backend API contract
on Sep 1, 2026
Summary
The frontend has no single source of truth for talking to the backend, in two dimensions:
window.fetchdirectly and 3 importingaxiosdirectly.src/types/generated/api.ts) that only 4 files import, while 74 files hand-type their own*Responseinterfaces. The generated file is also stale — it declares 2,424 paths against the live backend's 2,152.This is the root cause under several already-filed issues (#12313 port/CSP, #12317
/apiprefix, #12326 dead buttons): when every caller builds its own URL and declares its own response shape, nothing enforces that a path exists or that a response matches — and a config error like:8443lands in some clients but not others, which is exactly why features break inconsistently.1. HTTP-client sprawl
Eight distinct client classes, none of which extend the others — each reimplements request/error/auth/URL handling:
Plus 22 files using
window.fetchdirectly and 3 importingaxiosdirectly, bypassing all of the above.Two generic clients (
ApiClientandApiService) is itself an unresolved fork — there should be exactly one.This is the mechanism behind the
:8443breakage (#12313)Base-URL construction is not centralised, so it drifts per client:
Because there is no single place that builds the backend origin, the broken
:8443construction exists in some code paths and not others. That is precisely why chat/voice/onboarding break while other calls succeed on the same page — they are built by different clients. A single client would have made #12313 a one-line, one-place fix instead of a scattered symptom.2. Types: the generated contract is bypassed and stale
models/repositories/KnowledgeRepository.ts:69hand-declaresRagSearchResponserather than importing the generated shape.Per the canonical rule (#4): types come from the generated contract; a hand-typed response that duplicates a backend shape is a contract-check bypass. With 74 of them, TypeScript cannot catch a frontend/backend shape mismatch anywhere — which is why the 77 dead calls in #12326 compiled cleanly.
Why these two are one problem
Both are "the frontend invents its own version of the backend." Transport and types are the two halves of the contract, and both are currently reinvented per-file instead of derived once. Fixing them together is what makes the mechanical audit (
audit_api_wiring.py) meaningful: a single client that only accepts paths present in the generated types would make an unwired call a compile error rather than a runtime 404.Recommendation (architecture decision — needs owner sign-off)
Per the canonical rule for "two implementations already exist": pick one, migrate, shim/delete the rest — do not add a ninth.
utils/ApiClient.ts) that owns URL construction (onegetApiBase, correct origin — fixes frontend: .env.production hardcodes VITE_BACKEND_PORT=8443 but nothing listens there — onboarding/metrics calls fail with 'Failed to fetch' #12313 at the source), auth, and error handling. FoldApiServiceand the domain clients into thin typed wrappers over it, or delete them.api.tsfrom the live backend and make regeneration a required step on any backend route change (npm run gen:types), enforced in CI so it cannot drift to 2,424-vs-2,152 again.window.fetch/axiosoutside the canonical client, and forbidding hand-typed*Responseinterfaces where a generated equivalent exists.Discovery issue — filed per the marketing-session rule (expose, do not fix). The migration is an implementation task for a later session and should not begin without the canonical-client decision in step 1.
Environment
autobot-frontend/srcatorigin/Dev_new_gui; counts from the deployed frontend source; live path count from the running backend'sopenapi.json.