Skip to content

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.

StatusAccepted
Date2026-08-18
Amendsthe script-home taxonomy in the IaC & Deployment Standard, and by extension Prime Directive 6
Closesthe gap ADR-0426 deferred
Builds onADR-0106, ADR-0219, ADR-0346
Epic / Issue#2488 (Agent system-access helpers) / #2527 (Standards and ADRs)

The estate has three declared homes for code, and the routing question — what runs this? — has always answered it:

HomeExecuted 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.

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:

Yes a host AND acontainerNoA managed host cron,timer, Zabbix, the agentA docker-stacks pipelineA container, as the serviceitselfNew code whatEXECUTES it?More than onedeployment context?Category 4: own repo,versioned package,installed by version at eachbindingWhich one?ansible/scripts/docker-stacks/scripts/docker-stacks/stacks/<service>/
Yes a host AND acontainerNoA managed host cron,timer, Zabbix, the agentA docker-stacks pipelineA container, as the serviceitselfNew code whatEXECUTES it?More than onedeployment context?Category 4: own repo,versioned package,installed by version at eachbindingWhich one?ansible/scripts/docker-stacks/scripts/docker-stacks/stacks/<service>/

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-config sat 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.

  • 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 pitbun ADO Artifacts feed rather than consuming a pipeline artifact. Deferred, not rejected: the runners’ PAT is scoped code_write only and packaging_manage was 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.
  • pitlab-access is 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/lib with a .pth on control01, and mounted as a pinned version into a stock python:3.14-alpine at the gateway (ADR-0434).

  • The estate acquired a Python packaging idiom it did not havepip wheel --no-build-isolation (chosen because build is 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.md is 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 to CLAUDE.md verbatim 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 advisory in 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.py is 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-review and this ADR.

  • 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