Skip to content

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

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.

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/FAIL line 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.

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.

  • 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-test before 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_systems sits at 0 — so a dead sweep and a conformant estate are byte-identical everywhere except the heartbeat’s mtime.