ADR-0440: Shared library code that must run on a host AND in a container is a fourth script home — a versioned package in its own repo
ADR-0426 put the agent access contract in its own repo and stated plainly that “this is a Prime Directive 6 gap, not a Prime Directive 6 exemption”, deferring the amendment. This ADR is that amendment: the estate’s script-home taxonomy gains a fourth category for code that must execute on a managed host and inside a container, and the rule is written into the IaC & Deployment Standard where it is enforceable.
| Status | Accepted |
| Date | 2026-08-18 |
| Amends | the script-home taxonomy in the IaC & Deployment Standard, and by extension Prime Directive 6 |
| Closes | the gap ADR-0426 deferred |
| Builds on | ADR-0106, ADR-0219, ADR-0346 |
| Epic / Issue | #2488 (Agent system-access helpers) / #2527 (Standards and ADRs) |
Context
Section titled “Context”The estate has three declared homes for code, and the routing question — what runs this? — has always answered it:
| Home | Executed by |
|---|---|
ansible/scripts/ | a managed host: cron, a systemd timer, Zabbix, a login shell, the agent |
docker-stacks/scripts/ | a docker-stacks pipeline, repo-relative from the CI agent’s checkout (ADR-0106) |
docker-stacks/stacks/<service>/ | a container — service application code mounted read-only into a stock image (ADR-0346) |
Every one of the three assumes one execution context. pitlab_access has two by construction: the CLI binding runs on control01 for its callers and crons, and the MCP gateway binding runs inside a container on docker01. Neither of the two container categories fits, because the code is not a pipeline gate and it is not a service’s application code — it is a library that two different deployments both import. And ansible/scripts/ cannot serve it, because there is no Ansible task that installs a host script into a container image.
The only precedent in the tree is a deliberate copy. incident_webhook.py carries the comment “copied from freescout_trace.py — different exec context” — an honest workaround, and exactly the drift a shared contract exists to end. A synced copy is worse than an obviously-missing one, because staleness in a copy is silent: both files exist, both are committed, both look current, and only one is running in the place that matters (FC-06, edited-not-running).
The gap is not specific to this Epic. Any future shared library — a common notification client, a shared parsing module, a credential resolver — hits the same wall, and without a declared category each one re-derives the answer, or copies.
Decision
Section titled “Decision”A fourth category: code that must execute in two or more deployment contexts lives in its OWN repo, as a versioned installable package, and is installed at each binding by version.
The routing question gains one branch, and it is asked before the other three:
Three obligations attach, and they are what make the category a rule rather than an escape hatch:
- One source, two installs, and the version visible at both. The point of the category is that a consumer can be asked which version are you running and answer. A copy cannot.
- Deployment is deliberate, never triggered on the producing repo’s push. A new wheel is produced by a push to a different repo, and that push completes before the artifact exists — so a naive
resources: repositories:trigger reliably installs the previous build, which is worse than no automation because it looks like currency. The correct mechanism is a pipeline-completion resource. - The new repo joins every fleet conformance registry in the same change that creates it — secret scanning, target-lock serialisation, pipeline agent-portability, the CI reconciler, the agent-repo list. A registry entry landing later than the thing it governs is FC-13 (
orphaned-declaration), and the estate has already paid for it once:claude-configsat outside the secret-scan sweep for that map’s whole life while holding two live credentials in permission strings.
The category is narrow by design. Two invocations of one context is not two contexts — a script used by both a pipeline and a host cron is still host tooling with a single deployed copy the pipeline invokes, exactly as the existing rule says. The test is whether the code must be installed twice, in two places that cannot share a filesystem.
Alternatives considered
Section titled “Alternatives considered”- Keep it in
ansible/scripts/and sync a copy into the image. Rejected: staleness in the copy is silent, and the copy is the failure mode being fixed. It also cannot express a version. - Keep it in
docker-stacks/beside the gateway stack. Rejected: it inverts the primary home. The CLI binding is the older and larger consumer, and control01’s callers and crons would then depend on a repo whose purpose is container stacks. - Declare a per-case exemption from Prime Directive 6 instead of a category. Rejected, and this is the substantive call. An exemption is a decision that has to be re-argued every time and leaves the next author with the same wall and no answer; a category is a routing branch that answers it once. It is also the difference ADR-0426 drew explicitly — a gap, not an exemption.
- Publish to the org-level
pitbunADO Artifacts feed rather than consuming a pipeline artifact. Deferred, not rejected: the runners’ PAT is scopedcode_writeonly andpackaging_managewas deliberately excluded when it was created, so publishing is a credential-scope decision — an authority change owed its own ADR, not a side effect of shipping a library. - Allow a git submodule shared between the two repos. Rejected: a submodule pins a commit rather than a version, gives the two bindings no independently visible identity, and adds a checkout mode every pipeline in the estate would have to learn.
Consequences
Section titled “Consequences”-
pitlab-accessis the first member of the category and the reference implementation — own ADO repo, a wheel built by its own CI, installed to/opt/pitlab-access/libwith a.pthon control01, and mounted as a pinned version into a stockpython:3.14-alpineat the gateway (ADR-0434). -
The estate acquired a Python packaging idiom it did not have —
pip wheel --no-build-isolation(chosen becausebuildis absent from both runners and adding it would have made this package’s build a change to the shared pool’s pinned toolchain), a version reconciled off the built artifact, a package-data assertion, and an install-and-exercise gate that proves the guard holds in the wheel rather than in the source tree. -
incident_webhook.py’s deliberate copy now has a category to migrate into, if and when its drift becomes real. It is named here so the precedent is not lost, and it is deliberately not migrated by this ADR — that is a change to a live notification path with its own blast radius, and it belongs to whoever next touches it. -
This ADR governs the standard, and Prime Directive 6 has since been amended to match. APPLIED 2026-08-19. The rule is enforceable where it is written — the IaC & Deployment Standard — and Prime Directive 6’s own text is Pit’s to change.
CLAUDE.mdis edited only on his explicit instruction, so the wording below was raised as a recommendation via a snag on Epic #2488 rather than left sitting in an ADR nobody re-reads. At that Epic’s close-out review Pit approved it, and it was applied toCLAUDE.mdverbatim as RULE 6’s fourth category (claude-config repo; the routing preamble now reads “four categories” and the closing citation carries ADR-0440 alongside ADR-0106 and ADR-0346). The recommendation below is therefore the as-applied text, not a pending proposal:Proposed RULE 6, fourth category — 4. Shared library code that must run in two deployment contexts → its own repo, as a versioned installable package. Code that must execute both on a managed host and inside a container fits none of the three homes above: it is not host tooling (there is no Ansible task that installs into an image), not a pipeline gate, and not one service’s application code. It lives in its own repo, is built into a versioned artifact by its own CI, and is installed by version at each binding — one source, two installs, and the version answerable at both. A synced copy is forbidden: staleness in a copy is silent, and the copy is the drift the shared source exists to end. The reference implementation is
pitlab-access. Two invocations of one context is not two contexts — a script used by both a pipeline and a host cron is still host tooling. Governed by ADR-0440. -
The obligation is declared
advisoryin the standard’s Enforcement table, honestly. No gate can look at a new piece of code and decide it will need a second execution context; the violation is visible only once the copy exists, and by then the drift has already started.validate_repo_script_locality.pyis adjacent — it forbids a deployed script executing out of a working tree — but it is a different question and claiming it would be over-claiming enforcement. The concrete checkpoint is/code-reviewand this ADR.
Related
Section titled “Related”- ADR-0426 — deferred this amendment and named the gap
- ADR-0439 — the standard the first member of this category is governed by
- ADR-0106 — category 2, pipeline gates
- ADR-0346 — category 3, service application code mounted into a stock image
- ADR-0219 — the Ansible Scripts Standard this section extends
- IaC & Deployment Standard — Ansible Scripts — where the rule is written