0346 — Service application code lives beside its stack and is mounted, not baked
A third category of code had been in production for months with no standard describing it: the application code that is a containerised service, mounted into a stock image rather than baked. Prime Directive 6 said all scripts live in ansible/scripts/, with one carve-out for pipeline gates. This code fits neither, so the pattern survived as folklore and every occurrence re-derived it from scratch.
Status
Section titled “Status”Accepted — 2026-08-08. Delivered under Issue #2236, raised as a standards gap by the topcharts design (Epic #2225). Amends Prime Directive 6 and the Pipeline-Gate Script Location Standard (ADR-0106).
Context — an undocumented category that was already widespread
Section titled “Context — an undocumented category that was already widespread”Prime Directive 6 read: every script must live in ansible/scripts/ with a corresponding deployment task, with a single carve-out for docker-stacks pipeline gates (ADR-0106). Both categories share an assumption — the code runs on a host, either deployed to /usr/local/bin or repo-relative on a CI agent.
A third kind of code does not run on a host at all. It runs inside a container, because it is the service. The estate was already full of it before any rule described it:
| Code | Stack | Stock image it mounts into |
|---|---|---|
obs-*-exporter family | stacks/observability/ | python:*-alpine |
incident-webhook | stacks/incident-webhook/ | python:*-alpine |
topcharts_generate.py | stacks/topcharts/ | python:*-alpine |
None of these can live in ansible/scripts/: there is no host path where they would ever be executed, and a deploy task copying them to /usr/local/bin would produce a file nothing runs. Yet nothing said so. Each time the pattern came up it was reasoned out again from first principles, and each occurrence looked like an unexplained exception to a directive rather than an instance of a rule — the condition under which a good pattern quietly drifts, because there is nothing to conform to.
The gap is the finding. A pattern used three times, load-bearing for the estate’s vulnerability coverage, and written down nowhere is the same defect as a missing test: it works until someone reasonable does it differently.
Decision
Section titled “Decision”1. Service application code lives in docker-stacks/stacks/<service>/, beside the compose template that mounts it. The stack directory is the unit: docker-compose.yml.j2, deploy.yml, testplan.yaml and the application code are one reviewable, one-commit change.
2. It is mounted read-only at runtime, never baked into an image. This is the load-bearing clause, and the reason is supply-chain coverage, not convenience:
- Renovate raises bump PRs against a pinned stock tag (
python:3.14-alpine) automatically. A custom image has no upstream tag to track, so currency becomes a human obligation. - Trivy and Dependency-Track scan a stock image against a live upstream CVE feed. Baking makes us the owner of a base image’s currency, and staleness stops surfacing as an instrumented finding and starts surfacing as an incident.
- No CI build step exists to break, and rollback stays “revert the pinned tag in Git” — the house GitOps pattern.
Baking is therefore a decision that costs Pillar 4 coverage, and must be justified explicitly rather than reached for by default.
3. Prime Directive 6 is restated as a routing question with three categories — host cron/timer/operational script → ansible/scripts/; pipeline gate → docker-stacks/scripts/; service application code → docker-stacks/stacks/<service>/. The directive previously read as a single rule with an exception, which framed every non-host case as a deviation. It is not a deviation; it is a different question with a different correct answer.
4. One script, one home. A script used by both a pipeline and a host cron is host tooling — a single deployed copy that the pipeline invokes. Two copies is always wrong, in any direction.
Alternatives considered
Section titled “Alternatives considered”Bake the code into a custom image. The conventional answer, and correct for a real application with dependencies, a build step, or a compiled artifact. Rejected as the default here: every current instance is a single dependency-free Python file, so a custom image would buy nothing and cost the Renovate/Trivy/DT coverage described above. The rule is scoped accordingly — it governs code that mounts cleanly into a stock image, and a service that genuinely needs a build is a different decision, made explicitly.
Keep it in ansible/scripts/ and have the deploy copy it into the stack directory. Preserves the letter of the old directive and nothing else: it splits one reviewable unit across two repos, so the compose file and the code it mounts can no longer change in one commit, and it invents a deployed copy whose only purpose is to be mounted. Rejected — this is the drift the ADR-0106 carve-out already rejected for gates.
Leave it as folklore. What was in place. Three instances, no rule, and a design review (Epic #2225) that had to re-derive the reasoning before it could proceed — which is exactly the cost a standard removes.
Consequences
Section titled “Consequences”- The pattern is now a rule with a name, so the next occurrence conforms instead of re-reasoning; the three existing instances become conforming precedents rather than unexplained exceptions.
- Prime Directive 6 is a routing question, which makes the host default explicit rather than implicit — a genuine host script is no more likely to end up in a stack directory than before.
- Renovate/Trivy/Dependency-Track coverage of these services is now a stated, defended property rather than an accident of how the first one happened to be built.
- Enforcement is by review, not by gate. No static signal distinguishes “application code mounted into a stock image” from “a script that should have been deployed to a host” — both are a file in a repo. The compose
volumes:entry is the closest thing to a machine-checkable tell, and a future gate could assert that astacks/<service>/*.pyfile is mounted by that stack’s compose; it is not built here because a rule with three instances does not yet justify one.