ADR-0024 — Consumer customisation is declarative data: no repo-path plugin loading, no trust store¶
- Status: Accepted (2026-08-29) — supersedes
ADR-0019. The deletion half stands as decided;
the replacement surface (
[[axis]], the conflict relation,[localhost_tls]) is not yet built, soauth0_sim.pyandllm.pystill ship as declared built-ins today. - Sources: spinout-plan D-C; the host-exec surface audit (internal working note). Related: 0015 (the plugin framework this bounds), 0022 (the config channel), 0023 (the same principle applied to credential code).
Context¶
ADR-0019 decided to let a consumer point
[plugins].load at a Python file in their own repo, guarded by a host-side, hash-acknowledged
trust store — the direnv model. Two things happened between that decision and the extraction that
argue against building it, and a third removed the reason it was wanted.
1. It re-opens the class the codebase just spent five rounds closing. The audit behind
ADR-0023 did not gate host-executed
repo-mount code; it deleted every instance — both minters, [[inject]].minter, worktree_init,
the VS Code generator — and the codebase got smaller. [plugins].load proposes re-opening exactly
that class in its most privileged form: arbitrary consumer Python imported into the supervisor
process, next to the minters and host.env.
2. The trust store as specified is weaker than what was later built for config. ADR-0019 is
check-hash-then-import-from the mount. ADR-0022
concluded, for foldyard.toml, that the only sound form is adopt a snapshot outside the mount and
never read the working tree again — because the path that matters (unattended daemon relaunch) has
no TTY to re-acknowledge at. A gate that re-reads the mount at use time fails on the same path.
3. The migration that justified it turns out not to need plugins at all. ADR-0019 scheduled the two Tangible-shaped built-ins to migrate out of the package as the "API-sufficiency proof". Reading them:
llm.py(66 lines) implements exactly one hook —axes()— returning a single axis with three rungs and a blurb. Its overlays are already[[overlay]]config; its GCP coupling is already[[require]]config. It is data wearing a class.auth0_sim.py(158 lines) implementsaxes(),mode_issues(),doctor_checks()anddoctor_fixes()— no daemons, noderive_env, nocompose_overlays, those having been superseded by[[overlay]]beforehand.
So the proof would have proved nothing about the hooks that make plugins powerful.
Decision¶
Add a declarative [[axis]] table and delete both built-in plugins. No [plugins].load, no
trust store, no fy plugins trust, no new doctor tier — because data is not code. This fits the
pattern the config surface already converged on: [[overlay]], [[require]], [[secret]] and
[[inject]] are all consumer-declared data evaluated by package-side code.
That deletes llm.py outright and reduces auth0_sim.py to two residues, each of which gets a
home rather than an exception:
- The
auth0×storageconflict warning becomes a conflict relation alongsideRequires:axis="auth0", when=["sim"], conflicts="storage", at=["staging"], severity="warn", message="…". It cannot be a[[require]]row, becauseRequiresmeans "while the owner is at rung X, the other axis must be at one ofaccepts" and an absent axis satisfies nothing — correct for a dependency, wrong for a conflict, which must warn only when the other axis IS at a rung. This is the first[[…]]table whose payload is prose, and it extends an evaluator that is already property-tested over generated constraint graphs. - The mkcert/nss doctor checks and fixes are absorbed into a small core
[localhost_tls]check set with a configurablecert_dir. These cannot become config:DoctorFix.cmdis a command the host executes, and a consumer-declared command is precisely theminter = "<command>"hole ADR-0023 closed. Keeping the commands package-side keeps both the checks and the fix buttons. The sim-container bounce is dropped —stack.up()already bounces the sim whenauth0=sim.
Why local TLS is "core", since core needs a boundary. foldyard hosts a consumer's stack with
compose, and a compose-hosted stack is overwhelmingly a web stack: services on localhost ports,
a browser as the client. Anything reaching for a third-party IdP, a Secure cookie, a service
worker or WebAuthn hits a browser rule demanding HTTPS on a non-localhost host. So "the
developer's browser trusts the certs this stack serves" sits inside foldyard's job the way "the box
can reach the engine" does. It is a bounded claim: foldyard checks for and points at the local
TLS toolchain — it does not become a CA and does not touch the trust store itself.
Entry-point plugins remain the third-party tier ("installed = trusted" — installing a
distribution into the tool venv is already an explicit, host-side act), plus one cheap doctor
guard: warn if a loaded plugin's __file__ resolves inside the repo mount, since an editable
install from the mount reopens the hole by the back door.
Consequences¶
- A consumer can no longer ship host-side hook code with config alone. The routes are: declare an axis as data, upstream the behaviour, or publish an entry-point plugin and install it into the tool venv. Combined with ADR-0023, foldyard now has no path by which repo contents become host-executed code — a property worth stating as a whole rather than per-field.
- The package stops carrying one consumer's shape.
auth0_simandllmwere Tangible-specific built-ins living in a general-purpose tool; they leave without landing anywhere else. - Two new surfaces to document and property-test: the conflict relation and
[localhost_tls]. Small, but they are the honest cost of the deletion — this ADR is not free, it is cheaper. - The counter-pressure to watch is the same one ADR-0023 named: config-DSL creep. Each new
[[…]]table is a step toward a programming language in TOML. The boundary that keeps it honest is a small set of tables with fixed shapes, and the entry-point plugin — with its friction — for anything that doesn't fit. - A wish-list item, recorded so it isn't re-derived. A PyPI-installable replacement for
brew install mkcert nsswould ride the tool venv foldyard already owns and work on Linux hosts. The generation half is easy in pure Python (trustme, orcryptography); the half that earns mkcert its keep is trust-store installation (the macOS keychain viasecurity add-trusted-cert, plus NSS viacertutilfor Firefox/Chrome), which is irreducibly platform-specific. Do not solve it by reusing the egress proxy's mitmproxy CA: that CA is trusted inside the box only, and promoting the key that sits beside a live intercepting proxy into the host's system trust store is a materially worse posture than mkcert's own CA, which signs nothing but localhost leaves.
Rejected alternatives¶
- Build ADR-0019 as designed (path loading + hash-acknowledged trust store). Sound as a design — its detailed shape is preserved in ADR-0023's rejected alternatives, including the copy-the-acked-bytes-out-of-the-mount correction — but it has no customer. Count the customers before building the gate; there were two, and both were data.
- Drop the
auth0×storagewarning entirely. Free, and the boundary stays clean, but it loses a real guard on a one-command mistake with cloud-write consequences. - Ship auth0-sim as a small consumer distribution installed via an entry point. Works today, but it must be installed from outside the mount or the hole returns, and it adds an onboarding step plus a reinstall per edit — a lot of ceremony for two hooks.
- A declarative
[[check]]table with a closed predicate vocabulary (which = "mkcert",file = "…") plus a printedhowhint — the[[secret]]pattern. Buildable and safe (data with a fixed evaluator, not a command), but it loses the fix buttons and adds a config surface, and thewhich/filevocabulary is a mild host-probing primitive that would want naming in its own ADR if ever built.