Surface parity¶
Agent Factory ships three clients over one daemon: the TUI, the web UI, and the CLI. Since #960 the daemon is the single writer, and since #1592 it is the central orchestrator with all three clients as thin clients over one API. The promise that follows is that they are the same product.
In practice capabilities drift. One surface gains a verb, an option, or a button; the others silently fall behind; nobody notices until a user hits the missing thing. That is a bug class, not a one-off — so it has a detector.
Drift is bidirectional — do not assume any surface is complete¶
The obvious story is that the TUI is the mature surface and the web is playing catch-up. That story is wrong, and believing it will make you read this table incorrectly.
Capabilities land wherever the implementer happened to be standing. The first audit (#1937) found gaps pointing in every direction:
- The TUI was behind the other two on create-time prompts: the web modal
and
af sessions create --promptboth sent one, and the TUI could not (#1936 — now closed; the naming form grew ashift+tabinitial-prompt field). ThePromptfield was plumbed end-to-end to the daemon (app/session_control.go:106) and its only construction site never populated it — the plumbing was finished and simply never fed. - The CLI was behind both UIs on limit-retry: the TUI had
cand the web gained a Retry button in #1934, while the CLI had no exit from the same[limit]state.af sessions retry-limit <title>now sends the same daemon action, closing that gap without duplicating the recovery logic client-side. - Only the CLI could choose a backend per session
(#1933) — the web
gained a picker over the daemon's
ListBackendscatalog in #1968, and the TUI's naming form gained the same field onctrl+r. Both read the daemon's catalog rather than a copy of the enum, for the reason the enum level below explains.
The sharpest way to hold this: on CreateSession, no surface is a superset of
another. All three accept different subsets of the same eight-field request —
the TUI now sends a backend but still cannot send title_base, which the web
does.
| Create option | TUI | Web | CLI |
|---|---|---|---|
| Title, program | yes | yes | yes |
| Initial prompt | yes | yes | yes |
| Backend (docker/ssh/hook) | yes | partial | yes |
| Force-remote (hook) | yes | partial | yes |
In-place (--here) |
no | no | yes |
The web's two partial cells are not missing controls: the browser sends
backend and can select hook, but no one has yet shown a working remote
session created from it (#1968 watched provisioning succeed and the session then
time out waiting for its program). Reachable is not the same as proven — see
"Known blind spots".
So when this check fails, the question is never "does the web need to catch up?" It is "which surfaces should have this, and which deliberately should not?" — asked in all three directions.
The two pieces¶
parity/inventory.json — every user-facing capability and which surfaces
expose it, with a code pointer per cell and a verdict per row.
parity/parity_test.go — derives the real surfaces from code and fails when
they disagree with the inventory. Run it with go test ./parity/.
The check is deliberately code-derived on all four halves:
| Surface | Derived from |
|---|---|
| CLI | commands.NewRootCommand() — the real cobra tree, walked for verbs and flags after initCobraDefaults finishes building it (see below) |
| API | daemon.HTTPRoutes() — the same table that builds the live mux, with request fields reflected off the wire structs |
| TUI | keys.EffectiveBindings(nil) — the canonical binding table |
| Web | the af<T>(method, body, token) call sites in web/src/api.ts — af() is POST-only, so it is the chokepoint for the SPA's POST RPCs, and a static read of those call sites is what the audit derives. A few non-POST control-plane calls hand-roll fetch instead (e.g. the config-assistant reap's DELETE /v1/config-assistant, since af() cannot express a DELETE) and are NOT derived here; those routes are also kept out of daemon.HTTPRoutes() (registered directly on the mux like the stream routes), so the audit stays consistent |
A hand-maintained table would drift, which is the failure this exists to catch. So the only hand-maintained part is the verdict — the judgment a machine cannot make. Everything else is read out of the code at test time.
Four levels: verbs, options, enums, identifiers¶
A verb-level check alone is not enough, and that is not a theory — it is how
#1948 got missed.
af sessions preview existed, so "preview a session" looked like parity. But
PreviewRequest carries Tab, TabID, and Full; the TUI sent all three and
the CLI sent none, so the CLI could only ever see tab 0. The verb was present
and the options were not. It was found by someone using the product, not by
this check (and is now fixed — see the gap list below).
So the check works at four levels, each blind to the one below it:
| Level | Question | Derived from |
|---|---|---|
| Verb | can this surface do X at all? | the cobra tree, the route catalog, the binding table, the web's RPC call sites |
| Option | can it do X with the options the daemon accepts? | CLI flags off the cobra tree; the wire structs by reflection, vs the AST of api/+app/ and the web's request bodies |
| Enum | does it offer the same VALUES for those options? | the canonical Go enum, vs what a surface actually lists |
| Identifier | is the string we SHOW presentation-only, with the string we ACCEPT discoverable from it? | the display rule (session.TabLabel) vs the resolver (session.TabMatches) |
The option level is where the interesting gaps live, because they hide behind a verb that looks present. Two of the same shape so far — a field the daemon accepts that a surface never sends:
- #1933 — the TUI
never set
CreateSessionRequest.Backend, so every TUI session ran on whatever the repo's checked-inbackendkey said (now closed; its fixture stays, repointed, andin_placeis the field still unsent) - #1948 — the CLI
never set
PreviewRequest.Tab/TabID/Full(now closed; the fixture stays, repointed, in the honesty table below)
The enum level is the newest and the easiest to miss, because the other two
both pass while it drifts. #1970
found it: the web hardcoded a copy of tmux.SupportedPrograms in two pickers,
so the web did send program (field coverage called it covered) while adding
a new agent server-side would have left the web silently unable to offer it with
the whole suite green. A surface serving a stale copy of something the daemon
owns is the #1933 shape one level down, so it gets the same answer — derive both
sides and compare, rather than trusting a copy to stay in step. The structural
fix is to SERVE the enum (the ListBackends pattern — POST /v1/ListPrograms),
at which point the check is deleted and the row flips to parity, which is how
1970 was closed.¶
Every field a surface does not send must be declared in field_coverage as
either {"gap": "<capability-id>"} (a tracked divergence) or {"ok": "<reason>"}
(its absence is correct, and why). The field lists are derived, so a new field
on any request forces a decision on every surface that builds it — nobody has
to remember.
The identifier axis: what we show vs what we take¶
The nastiest of the four, because it is invisible until someone types it (#1984):
$ af sessions tab-delete alpha --name Terminal
session "alpha" has no tab named "Terminal" # the TUI tab bar says "Terminal"
$ af sessions tab-delete alpha --name shell
# works
The TUI rendered a label and the CLI demanded a name, so the error asserted a tab was absent while the user could see it on screen — and left them to discover the mapping. One concept, two representations: the same disease as
1972 and #1970.¶
The rule lives beside the Tab type, not in the TUI, so "what a user reads"
sits next to "what a user types" — and the two are deliberately allowed to
differ (#1986):
session.TabLabel— the one definition of what a user SEES. Presentation only: agent and shell tabs render fixed labels (Agent/Terminal) that are not their names (agent/shell).ui/treedelegates to it, so display has a single source.session.TabMatches— resolves on the canonical name alone. The label is never an identifier: accepting it would make two strings address one tab, the ambiguity #1929/#1904 removed from the tab surface.session.TabIdentifiers— renders a tab as both spellings, so "no tab named X" lists the valid options with their labels. This is the whole mechanism now: the label never resolves, but a user who readTerminaloff the bar is told the real nameshellrather than left at a dead end.
#1937 first closed the gap by accepting the label as an alias; #1986 reversed that so the label carries no identity, keeping the #1984 symptom from returning through discoverability rather than a second handle.
TestLabelIsNeverAnAcceptedIdentifier enforces the invariant for every
TabKind, including kinds with no UI yet: the name resolves, the label does
not, and where the two differ the label is still named in the error.
The CLI-vs-CLI axis: argument shape¶
Parity is not only between surfaces. Within one noun group, does the same CONCEPT take the same SHAPE across sibling verbs? It is the same failure — a user who learned one verb cannot predict its sibling — and it was found the same way, by someone driving the CLI and getting stuck.
af sessions create --prompt X takes the prompt as a flag; af sessions
send-prompt <title> <prompt> took it positionally and hard-errored with
"unknown flag: --prompt" — naming the flag as wrong without mentioning that
the positional is what it wants. Two siblings, one concept, two shapes.
Both halves are derived: flags from the cobra tree, positionals from each
command's Use line. The check is a non-empty intersection of accepted
forms, not identical sets — a verb that accepts both forms is compatible with
either neighbour, which is why the fix is always additive: teach one verb the
other's form and keep the old one. send-prompt now accepts --prompt as an
alias and its positional still works, so nothing breaks and the shapes reconcile.
The one hand-maintained part is synonyms, and it is keyed per verb on
purpose: --name on create means the session title, but --name on
tab-create <title> --name <tabname> means the tab. A global name → title rule
invents a divergence that is not there.
The same additive rule closed
#1972: af sessions
create <title> now shares the positional shape and vocabulary of its ten
siblings, while --name <title> remains an alias for existing scripts.
What the check enforces¶
- Every CLI verb, CLI flag, daemon route, TUI binding, and web RPC has an
inventory entry. Adding one without an entry fails the build. Flags count
because a flag is a capability —
af sessions createexisting says nothing about whether it can pass--backend. - Nothing is inventoried that no longer exists, so the table cannot advertise a capability af has lost.
- Every field of every audited request is either reachable from a surface or declared, in both directions — a field a surface has quietly started sending also fails, so a fixed gap cannot keep being described as broken.
- The table cannot contradict itself. A ledger mapping proves a surface
reaches a capability, so the row cannot still say that surface is
no; and every verdict is checked against its own cells (see Verdicts) —paritycannot outlive a missing surface,real-gap/unclearcannot outlive the gap closing, and adeliberateomission cannot outlive every surface making it. Otherwise "add the ledger entry" would be enough to make the check pass while the table went stale — which would make the inventory lie in exactly the way it exists to prevent. - No verdict passes by omission. A verdict with no rule is reported as
uncheckedand fails the run, so adding a fifth verdict value forces a decision about what it promises rather than inheriting a silent pass. - A declaration that silences a check must say why. An
okinfield_coverageorargument_shapescarries the reason the absence is correct; a blank one is rejected, so silencing is never free. - Quality bar: a surface marked
yes/partialmust cite code, adeliberateverdict must explain itself, and areal-gapmust name an issue.
Is the derivation itself honest?¶
This is the question that matters most, and it is not answered by the checks above. They compare the derivation to the inventory; none of them asks whether the derivation sees anything.
A derivation with a hole does not fail loudly. It silently under-reports and the suite goes green — which is worse than having no parity check, because the green gets trusted. A detector that manufactures confidence is the failure mode this package exists to prevent, so it must not be one.
parity/honesty_test.go therefore pins the derivation against verified
field-level behavior — both shipped reaches and declared gaps — as fixtures, not
aspirations:
| Fixture | Derivation path it proves |
|---|---|
cobra's lazy surface — af completion bash, af help, --help, --version |
the tree is walked after cobra finishes building it |
#1933 — the web now does send CreateSession.backend (#1968 landed), via a const body variable |
the web body parser, incl. variable resolution |
#1933 (TUI half) — closed; sessionStartRequest now carries Backend, so the fixture is repointed to track that the walk still sees the TUI's use of backend/prompt/force_remote and its non-use of in_place |
the Go AST walk |
#1948 — closed; the CLI now sets Preview.Tab/TabID/TabName/Full, so the fixture is repointed to track that the walk still sees both the CLI's usage and the TUI's non-use of tab_name |
the AST on an internal route, invisible to the public catalog |
#1935 — TaskUpdate.project_path is now sent by every surface; the web still omits max_concurrent_runs |
nested recursion behind a wrapper route, plus the TS-interface read, web value walk, and the CLI's field-by-field assignment walk |
Each fixture asserts in both directions: that the known gap is seen, and that a field the surface demonstrably does send is not reported as a gap. A parser returning nothing would satisfy the first half alone; it cannot satisfy both.
If a gap is ever fixed, its fixture fails and says so. That is correct — the fixture is retired deliberately, not silently.
The fixtures are verified by blinding each path and watching them fail: drop the nested recursion, remove the wrapper packages, break the TS parser, or break the call-body regex, and the matching fixture reports "the parser is blind" rather than passing.
The audit knows its own denominator¶
The question that comes before "do the surfaces agree?" is what did the audit actually look at?
An audit that under-covers does not merely miss gaps. It asserts parity over surfaces it never opened, and it is believed, because it is a green check — which turns "we have a gap" into "we have a gap and a test says we do not". Every hole found in this package so far had exactly that shape: a walk that ran before cobra finished building the tree, a web scan that skipped subdirectories, an enum check that read one of two selectors, a request the analyzer dropped instead of reporting.
So the audit states its denominator, and fails closed:
=== surface-parity audit coverage ===
cli.arg-concepts 89
cli.commands 70
cli.flags 182
cli.noun-groups 9
cli.verbs 62
daemon.audited-request-types 29
daemon.public-routes 31
go.cli.files 13
go.cli.request-sites 32
go.tui.files 47
go.tui.request-sites 23
inventory.capabilities 73
tui.bindings 49
web.hardcoded-enum-sites 0
web.rpcs 25
web.source-files 35
verdicts: deliberate=22 parity=35 real-gap=7 unclear=9 unchecked=0
SKIPPED: none — every surface above was read
(A sample, not a contract: the counts move with the program. Only the floors are enforced.)
Three rules make that number honest:
- Anything not covered is a finding, not a pass. A file that will not parse, a construct the analyzer cannot read, a directory not entered — each is reported with its reason and fails the run.
- Unanalyzable is never a shrug. A request built in a shape the walk cannot read used to vanish from the derived set, taking its declarations with it. Now it is named: "cli builds PreviewRequest in a shape this analyzer cannot read … its field coverage is therefore UNVERIFIED."
- The denominator itself has floors. If
cli.verbsorweb.rpcscollapses, the run fails — a shrinking denominator makes every parity claim above it meaningless, and it is exactly what a silently-blinded derivation looks like.
The web body parser reads variables, not just literals¶
The web builds some request bodies as a variable — const body = {…}; body.x = y;
af("Method", body, token) — which #1968 introduced for CreateSession's optional
backend. A parser that only reads an inline literal after the method name goes
BLIND on that shape and reports the body as empty, which is under-coverage: it
would have said the web sends nothing for CreateSession and reported false parity
over every create option.
So the parser resolves the body argument: an inline {…} literal, or a plain
variable traced to its nearest preceding const|let|var … = {…} plus any
body.field = … additions. Anything else at the body position — a function call,
a spread — is reported UNANALYZABLE and fails the RPC's coverage, never dropped.
This is finding (4)'s web analogue and it is LIVE, not latent: #1968's
body.backend = … is exactly it.
Reach is derived from values, not types¶
For a wrapper payload the obvious move is to read its TypeScript interface. That is wrong in the dangerous direction: an interface says what is possible, and a client that never sends a field still passes.
TaskUpdate declares seven options; the single call site
(web/src/index.ts:862) sends { enabled }. Reading the type credited the web
with six options it cannot reach and reported parity over them. Reading the
values it actually passes reports them as the gaps they are (#1935).
"Could not check" is a third answer¶
Worth stating because this package learned it twice, independently, and it generalises past parity.
The ListBackends contract (#1968) returns three outcomes, not two:
available, unavailable + reason, and unknown + reason — where
unknown means the daemon could not check (a repo config that would not parse),
which is a different answer from yes and from no. Collapsing it into either one
invents a fact. The same PR found the related trap: configured is not
available — the hook backend was reported available without checking its
commands were runnable.
This audit has exactly that shape and resolves it the same way. A request the analyzer cannot read is not "reached" and not "unreached" — it is unanalyzable, and it is a finding:
cli builds PreviewRequest in a shape this analyzer cannot read … its field coverage is therefore UNVERIFIED
Before that existed, an unreadable construct simply vanished from the derived set — unknown collapsing into fine, which is how a checker reports green over code it never understood. If you add a dimension here, give it three answers.
Known blind spots¶
Stated so nobody mistakes a passing check for total coverage. Note which way each one fails — over-reporting forces a decision, under-reporting hides one, and only the second is dangerous.
- Reachable ≠ user-settable (under-reports — the dangerous direction). The
AST proves a construction site sets a field, not that a user can choose its
value.
session.create.opt.prompt(#1936) was the canonical trap:app/session_control.go:106setsPrompt, so the field read as covered while the TUI's naming flow never populated it upstream. The gap is closed (the naming form now has ashift+tabinitial-prompt field), but the category remains — a field-level pass does not excuse reading the flow. - Internal routes are not in the verb-level route check (under-reports).
daemon.HTTPRoutes()is the public catalog;Previewand the two*StatusPollroutes live ininternalHTTPRoutes. They are covered at the option level viaauditedRequests— which is what catches #1948 — but a new internal route that no surface calls would not trip the route check.ResumeFromLimitused to be in that list and is the cautionary case: it sat there for so long that the web shipped the usage-limit state with no way out of it, and no check failed, because an unreachable verb breaks nothing. It was promoted in #1934. - A nested payload built by a shared helper (over-reports — safe). The TUI
patches a task via
task.DiffTask, in a package every surface shares, so the walk cannot attribute it. Those fields are reported unreached and must be declaredokwith evidence: a decision is forced rather than skipped. - Composite literals and simple var-assignment only. The walk reads
T{Field: …}andvar x T; x.Field = …. A request built some third way would read as setting nothing — over-reporting, andminGoLiteralstrips if the surfaces move wholesale to another style.
Two things that must stay true¶
Both were once false, both reported green, and both are now fixtures:
Walk the tree only after cobra has finished building it. cobra adds
completion, help, --help and --version lazily inside Execute(), so a
walk of the freshly-constructed tree omits commands users can actually run.
initCobraDefaults runs them first; TestDerivationSeesLazyCobraSurface fails
if it is ever removed.
Declarations are validated from both ends. Walking only the derived
requests catches a surface that is missing a declaration, but not a surface that
drops a request another still uses — the CLI dropping PreviewRequest while
the TUI keeps it would simply vanish from the derived set, leaving its
declarations to rot while the suite stayed green.
TestFieldCoverageDeclarationsAreLive walks the declarations the other way, so
every declared (type, surface) must still be something that surface really
does, and every declared field must still exist on the wire struct.
Verdicts¶
Each verdict is a claim about the row's own cells, so each one owes those cells a
check. The checks live in verdictRules (parity/parity_test.go) — a table, not
a switch, because TestAuditCoverageReport reads it too, to count the rows whose
verdict nothing checks:
| Verdict | Meaning | What must be true of the cells |
|---|---|---|
parity |
every applicable surface exposes it | no surface is no/partial |
real-gap |
a surface should have it and does not — issue names the ticket |
some surface is no/partial |
deliberate |
the surface legitimately cannot or should not; notes says why, so it is never re-reported as a gap |
some surface is no/partial, or some surface is n/a |
unclear |
needs an owner decision; not filed | some surface is no/partial |
n/a as a status means the surface has no analogue by nature — af doctor
diagnoses a broken install, including the case where the daemon is down and
neither UI can run.
deliberate is the one whose rule needed care, and it is worth reading before
changing it. It legitimately coexists with nothing missing, because its
deliberateness usually lives in an n/a cell — af doctor has no UI analogue by
nature. So it cannot join the real-gap/unclear rule ("something must be
missing"); its rule is that the deliberateness must live somewhere. A row
whose every cell is a plain yes has nothing deliberate left about it.
For how many rows that is, run TestAuditCoverageReport — this page does not
restate the number. The first draft of this section did, in two places, and the
two disagreed with each other and with the subset the sentence described, inside
the PR that added the rule against unchecked claims. Derived tally or nothing.
That rule is late (#2609).
Until it existed, deliberate fell through the switch and was the one verdict
never compared to its cells — so a row could keep asserting an omission that every
surface had since closed, with the suite green. It was found when two PRs
collided in git over one row, which is not a detector: two PRs touching
different rows would have left it standing. Hence unchecked=N in the coverage
report — the number that surfaces this class without needing a collision.
What is deliberately NOT held to parity¶
Not every divergence is a bug, and recording that is half the value here.
- Navigation and layout chrome. Each UI navigates its own layout idiomatically; the web has mouse drag-and-drop the TUI cannot express, the TUI has keyboard splits the web reaches via Alt-chords. A CLI has nothing to navigate. Key-for-key parity is a non-goal.
- Scripting primitives.
af sessions watchblocks until a session goes idle; both UIs show liveness continuously, so there is nothing to block on. Same forget,whoami, and--allbroadcast. - Host lifecycle. Daemon install/restart,
upgrade,reset,doctor, andtokenact on the host or the daemon itself. The web is served by the daemon — a button to stop it would kill the page — and the token is the credential the web needs before it can talk at all. - Internal plumbing.
af gen-docs(hidden),af agent-server(daemon-consumed),af --daemon(hidden flag).
Each of these is recorded in the inventory with a deliberate verdict and a
reason, so the next audit does not re-report it.
When the check fails¶
The failure names the item and the fix: add it to parity/inventory.json, map it
in the ledger, and give its capability a status per surface with a code
pointer and a verdict. If the other surfaces deliberately will not have it, say
so in notes — that records the decision.
Do not silence the check. A new capability on one surface is precisely the moment to decide what the other two do about it, which is the whole point.
Updating the parser¶
If web/src/api.ts is restructured so its calls no longer match
webCallRe, the check fails loudly via minWebCalls rather than quietly
concluding the web calls nothing. Fix the parser in parity/derive_test.go;
never lower the floor to make it pass.