ADR-0437: Agent-access conformance is a DECLARED register, a gate over it, and a discovery sweep beside it — because a gate can only check what is declared
Status: Accepted Date: 2026-08-18 Epic: #2488 — Agent system-access helpers, the missing standard Issue: #2518 — Conformance: register, CI gate and discovery sweep Builds on: ADR-0426, ADR-0430, ADR-0431, ADR-0141
Context
Section titled “Context”Epic #2488 put 38 systems behind one access contract with two bindings — a helper spec in the
pitlab-access package, and a read_<system>/write_<system> tool pair published by the MCP
gateway. Nothing kept those artefacts together. A helper deleted in a refactor, a system retired
in one binding and not the other, or a tool name that stopped normalising the way the gateway
normalises it are all silent: the estate keeps working and the agent simply cannot reach one
system unattended, discovering that mid-run at 03:00.
Two facts from this Epic’s own delivery shaped the answer more than the requirement did.
Four of the design’s recalled figures failed derivation. 26-vs-9 caller scripts, 14-vs-27 mutating Zabbix methods, 40-vs-42 credential-scoping rows, and 40/80-vs-38/76 systems and tools. Every one was a confident inventory that turned out short, which is FC-19’s inventory half: a short list and a complete one are identical from the inside. Anything hand-authored here would be the fifth.
A fail-fast guard job is a blind watcher. On 18 August the ansible-ci guard job was found
to have been skipping thirteen guards behind one red for two days (incident #2470, ADR-0436).
Their silence carried no information, and nothing distinguished a skipped guard from a passing
one. Any control added here inherits that lesson or repeats it.
Decision
Section titled “Decision”Three artefacts, with a deliberate and load-bearing separation of concerns.
1. The register is a file separate from the helper specs. ansible/scripts/agent_access_register.yml
is the estate’s declaration of which systems the agent operates; pitlab-access/src/pitlab_access/systems/*.yml
is the implementation of reaching them. The separation is the entire mechanism: if the register
were the helper specs, deleting a helper would delete its register row and the deletion would be
conformant by construction — FC-02, an assertion that cannot fail.
2. The register’s population is derived; its judgements are not. The systems: block is
generated from the helper specs, with the MCP tool names computed by the gateway’s own
_tool_ident function extracted and executed rather than re-implemented, and the gate
re-derives it and compares byte for byte. The adjudications: block is hand-maintained and
copied through verbatim, holding the decisions no sweep can derive: which credential belongs to
which registered system, which systems are deliberately absent, which credentials are pending
under a tracked Issue.
3. A CI gate over the declared population, and a discovery sweep over the live one. The gate runs hermetically on the CI pool and asserts seven properties of what is declared. The sweep runs daily on control01 and re-derives the population from evidence that exists whether or not anyone declared it — the credential provisioning surface, the configured MCP servers, and the authority taxonomy’s action classes — alerting on anything the register does not hold.
Three supporting choices follow from the two facts above.
- Every gate check reports a verdict. The gate is one process, so ADO’s fail-fast step
semantics are not available to it — but the equivalent shape (return on the first finding) is,
and it is declined. All seven checks run, findings are collected, and a
PASS/FAILline prints per check. - Exit 2 is not a pass. “I could not run” and “I ran and it was fine” get different exit codes in both the gate and the sweep, and the sweep’s exit 2 deliberately leaves the heartbeat unstamped so the dead-man fires.
- A pending credential is not a conformance failure. #2525 is deferred and #2526 is staged for Pit. Those are tracked states, and a gate that reds on known work trains people to ignore it. The register models them explicitly, and the gate asserts only that each carries a tracking reference — an absence with nothing pointing at it is the failure, not the absence itself.
Alternatives considered
Section titled “Alternatives considered”A gate alone. Rejected: it is structurally incapable of seeing a system the register never
mentions, and a register missing an entry passes clean while reading exactly like a complete one
(FC-21, present-but-incomplete — the inverse of an empty result, and it hides better,
because a populated list reads as a satisfied check).
A sweep alone. Rejected: it observes the live estate daily and would let a broken declared helper sit for up to 24 hours, where the gate catches it at the commit that broke it.
The helper specs AS the register. Rejected above — the check could not fail.
A fully auto-derived register, adjudications included. Rejected: it would absorb every newly discovered system on the next derivation, so the sweep could never report one. An auto-reconciling register is not a register, it is a mirror.
Re-implementing the gateway’s tool-naming rule in the gate. Rejected: three characters and a
permanent liability. The day the gateway changes how it normalises pbs-xt035, a re-implemented
copy keeps asserting the old names and stays green while every affected tool vanishes from
tools/list (FC-13, orphaned-declaration). The real function is extracted and executed, and
a gate that cannot extract it exits 2 rather than guessing.
A Prometheus alert rule for the unlisted-system finding, matching the house pattern used by
the host→repo stack-source reconciler. Not taken here: the rule files live in docker-stacks,
which Issue #2518 does not carry, and /api/v2/alerts is Alertmanager’s designed interface for
an external alert source — which is exactly what a scheduled sweep is. The Prometheus gauges are
still written, so a rule can be added later without changing the sweep.
A regex sweep of ansible/scripts for credential-shaped names, and enumerating every
Infisical path a helper spec declares. Both were implemented and measured before being
rejected: the first returns 123 names dominated by container variables, local shell variables and
Zabbix item keys; the second adds 27 unattributable names, every one a service deployment secret
rather than an agent access credential, and can only re-read paths the register already declares —
high noise, zero discovery power. Recorded in the sweep’s own docstring and runbook rather than
dropped, because an unstated exclusion is how a coverage hole hides behind a plausible count.
Consequences
Section titled “Consequences”- A helper cannot be deleted, renamed or broken without a red build naming the system.
- A system cannot be added to the estate’s credential surface, MCP configuration or authority taxonomy without either a register row or an explicit, evidenced adjudication — and the adjudication is a commit somebody has to write, which is the point.
- The register carries a standing maintenance cost: every new credential belonging to an existing system needs an alias row with evidence. That cost is the control; a register nobody has to update is a register that has stopped describing anything.
- Both controls are proven red by their own
--self-testbefore they are trusted green (ADR-0141) — the gate against seven real breaks, the sweep against a broken evidence set that must refuse to report clean. - The sweep’s dead-man is unusually load-bearing. Its healthy state is “no findings”, the alert is
silent, and
agent_access_unlisted_systemssits at0— so a dead sweep and a conformant estate are byte-identical everywhere except the heartbeat’s mtime.