ADR-0030: Keep extension surfaces that carry a dependency tree outside this repository
| Field | Value |
|---|---|
| Status | accepted |
| Date | 2026-08-16 |
| Review by | 2027-02-16 |
| Schema version | 0.1.0 |
| Reversibility | two-way-door |
| Blast radius | org |
| Scope | org |
| Tags | architecture, packaging, governance, distribution, supply-chain |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Review tier | arb |
| Review reason | Sets the rule every future extension surface is placed by, narrows ADR-0007’s Option C disposition on evidence ADR-0007 did not have, and resolves ADR-0029 action item 2. It also reverses this record’s own first draft, so the reasoning that changed needs to be reviewable rather than quietly replaced. |
| Assertions | no-backstage-sdk-in-this-repository (custom, error) |
| External refs | @backstage-community/plugin-adr — the document layer the publication surface extends |
| Relates to | 0003, 0007, 0010, 0013, 0016, 0019, 0025, 0029 |
| Affects | path:scripts/check-deps.ts, path:packages/adapters/**, path:packages/ci/**, path:bunfig.toml, path:package.json |
| Source | docs/adr/0030-keep-extension-surfaces-that-carry-a-dependency-tree-outside-this-repository.md |
Status: accepted. Agent-drafted, ratified by
@mbeacomon 2026-08-16. Resolves ADR-0029 action item 2: the Backstage publication surface lives outside this repository. It also states the general rule future surfaces are placed by. It authorizes no release, and does not change ADR-0029’s Tier 1 / Tier 2 split. This record reverses its own first draft, which decided in-repo. What changed is measurement, recorded below.
Context
Section titled “Context”ADR-0029 clause 5 deferred the Backstage plugin’s repository home. This record’s first draft decided in-repo, on two findings that still hold and one argument that did not.
What holds: Constitution Principle III’s “external services at build, test, or run time”
does not bar the plugin — @adrkit/ci ships at packages/ci/ and calls getOctokit(token)
against the authenticated GitHub API at run time, so that clause is read as build/test
hermeticity plus dependency confinement, not as a rule about a shipped surface’s runtime
peers. And ADR-0007’s placement costs are already solved here: isAdapterPackage() is
location-based, ReleaseVersioning supports 'independent', and check-deps.ts is a
per-package allowlist.
What changed: the cost was measured rather than estimated
Section titled “What changed: the cost was measured rather than estimated”The first draft called the dependency cost real but did not quantify it. Measured on 2026-08-16 with Bun 1.3.14, installing the plugin’s plausible dependency set in a scratch directory:
| this repository today | + Backstage runtime set | + community ADR plugin and @backstage/cli |
|
|---|---|---|---|
| packages | 94 | 607 | 1,274 |
node_modules |
65 MB | 480 MB | 1.0 GB |
| install, warm cache | ~0.06 s | 11 s | 39 s |
| dependencies requesting lifecycle scripts | 0 | — | 2 (@swc/core, core-js-pure) |
Even the floor — runtime dependencies only, building with Bun per
ADR-0010 instead of @backstage/cli — is 7.4× the disk and 6.5×
the packages. The realistic set is 15× and 13.5×.
Three things make that worse than the multiples suggest. clean-clone-builds runs
bun install --frozen-lockfile from cold on every pull request, including ones touching
only docs/adr/, so 39 s is a lower bound. This repository currently has zero
dependencies requesting lifecycle scripts, and Backstage’s tree brings two — a new
supply-chain surface in a project whose constitution is built on hermetic, credential-free,
deterministic dependencies. And confinement does not help: an allowlist bounds which package
may import the SDK, not what gets installed, because Bun installs every workspace
member’s dependencies regardless.
And the argument that justified in-repo was overstated
Section titled “And the argument that justified in-repo was overstated”The first draft’s decisive claim was that separation makes ADR-0029 unenforceable. Broken down, one mechanism is lost, not a category:
| in-repo | out-of-repo | |
|---|---|---|
| ADR-0029 clause 4 — not a catalog adapter | checkable | stronger: a blanket SDK prohibition beats an allowlisted exception |
| SDK confinement | allowlist plus prefix rule | blanket prohibition, still checked here |
| ADR-0029 clause 8 — no reimplementation of the resolvers | not mechanically checkable either way; a dependency graph cannot detect re-derived affects matching |
unchanged |
| ADR-0029 clause 11 — adrkit’s additive-only obligation | governs this repository | unchanged |
| contract drift caught in a single CI run | yes | no — the one real loss |
That loss is real and is accepted below with named mitigations. It is not worth a 15× dependency tree.
The distinguishing property is install cost, not plugin-ness
Section titled “The distinguishing property is install cost, not plugin-ness”@adrkit/spec-kit is an extension surface that lives here and should. It declares no
dependencies, no devDependencies, and no peerDependencies — asserted by
packages/adapters/spec-kit/test/packaging.test.ts — ships no dist, and is copied verbatim
by specify extension add. It costs nothing to install.
So the question was never “is it a plugin.” It is whether the surface drags a tree.
Decision
Section titled “Decision”An extension surface may live in this repository only if it adds no install cost. Otherwise it lives in its own repository and consumes adrkit’s published contracts.
-
The rule. A surface qualifies to live here only if it declares no third-party
dependencies,devDependencies, orpeerDependenciesbeyond the vetted set already permitted byscripts/check-deps.ts.@adrkit/spec-kitis the exemplar, and its zero-dependency packaging test is the pattern a qualifying surface follows. -
The Backstage publication surface lives outside this repository, in its own repository, and is not a workspace package here. It fails clause 1 by 1,274 packages.
-
@backstage/*is prohibited in every workspace package, enforced byno-backstage-sdk-in-this-repositoryinscripts/check-deps.ts, observed failing first per ADR-0016. This is simpler and stronger than the confined-allowlist form the first draft proposed: there is no exception to get wrong, and it fires on a package the allowlist has never heard of, whichallowedDependenciesForwould otherwise leave silently unconstrained. -
The consumer contract is unchanged for now. ADR-0029 clauses 6, 8, and 11 govern the downstream surface exactly as written. Clause 11 — adrkit changes the consumed shapes additively or through a stated deprecation path — is the contractual counterpart to losing same-run drift detection, and it is enforceable here. This clause is expected to be superseded by the consumer-SDK record foreseen in Consequences: clause 11 currently promises additive-only change across
@adrkit/core’s whole exported surface, which is 173 symbols, where the only existing consumer of it (@adrkit/ci) imports three. A narrower published facade is what would make that promise keepable. -
Mitigations for the one accepted loss, so it is managed rather than merely admitted: the downstream repository pins published
@adrkit/*versions and tests against them in its own CI; and adrkit publishes a conformance fixture — a goldenadr queue --format json/adr check --jsonpair over a small fixed corpus — that a consumer can assert against, so a breaking change is detectable downstream without reading this repository. -
This narrows ADR-0007 Option C, and says so. ADR-0007 rejected separate repositories because they “fragment a project with one maintainer and no users,” and set the revisit condition “once an adapter acquires independent contributors.” That condition is not met — this record does not claim it is. It narrows the disposition on a different and new ground: ADR-0007 weighed fragmentation when no integration carried a dependency tree, and did not weigh an integration whose tree would be 15× the repository it joins. Action item 4 lands the amendment note by reference, the mechanism ADR-0013 used on ADR-0007 and ADR-0014 used on ADR-0013.
Options considered
Section titled “Options considered”Option A: Extension surfaces with a dependency tree live outside (chosen)
Section titled “Option A: Extension surfaces with a dependency tree live outside (chosen)”| Dimension | Assessment |
|---|---|
| Install cost here | Unchanged — 94 packages, 65 MB, 0 lifecycle scripts |
| Supply-chain surface | Unchanged |
clean-clone-builds |
Unchanged on every PR |
| SDK prohibition | Stronger than confinement — no exception to misuse |
| Contract drift | Not caught in one run — accepted, mitigated by clause 5 |
| Generality | States the rule the next surface is placed by |
Option B: In this repository as a confined surface (this record’s first draft)
Section titled “Option B: In this repository as a confined surface (this record’s first draft)”Pros: producer and consumer in one CI run, so drift breaks the build immediately; ADR-0029 clause 4 becomes a mechanical check; nothing to publish before Tier 1 can be built.
Cons: the measured cost — 15× packages and disk, 2 new lifecycle scripts, a slower and
more fragile clean-clone-builds on every pull request including documentation-only ones.
Confinement bounds imports, not installs. The enforceability it buys is one mechanism, and
three of the four boundary properties are better served by prohibition.
Option C: In-repo, but excluded from the default install
Section titled “Option C: In-repo, but excluded from the default install”Pros: would keep both the single CI run and the small default install.
Cons: Bun installs every workspace member’s dependencies; there is no supported “optional
workspace member.” Achieving it would mean a second lockfile or a filtered install, both of
which make clean-clone-builds prove something weaker than it proves today — the one
assertion this repository most relies on. Rejected as a real mechanism, not as an idea.
Option D: Keep deferring, as ADR-0029 clause 5 does
Section titled “Option D: Keep deferring, as ADR-0029 clause 5 does”Pros: no decision risk.
Cons: ADR-0029 clause 1 authorizes Tier 1 with nowhere to build it, and the deferral had no end condition. The measurement that closes the question now exists.
Trade-offs
Section titled “Trade-offs”- Contract drift is not caught in a single CI run. The accepted cost. Clause 5’s
mitigations reduce it; they do not remove it. A breaking change to
CheckOutcomewill be found by the downstream repository’s CI or by a bug report, not by this repository’s. - Two repositories, two cadences. Real ongoing overhead, and a real chance the plugin
lags the ADR schema after a
schemaVersionbump. - The rule is coarse. “No third-party dependencies” is a blunt qualifier: a surface
needing one small, vetted, deterministic library is pushed out alongside one needing 1,274.
Accepted because the alternative — a size or count budget — invites argument at every
increment, and because the vetted set in
check-deps.tsis the existing escape valve for a genuinely small addition. - Dogfooding gets harder. The plugin cannot be exercised by this repository’s suite, so adrkit loses the fastest signal that its own JSON outputs are usable.
Consequences
Section titled “Consequences”- Easier: the install stays 94 packages and 65 MB with zero lifecycle scripts;
clean-clone-buildskeeps proving what it proves today; the SDK prohibition is simpler than confinement; and future surfaces have a stated rule rather than a case-by-case argument. - Harder: cross-repository drift; two cadences; no dogfooding of the consumer.
- How we would know this was wrong: if the conformance fixture in clause 5 does not get built, or is built and still lets a breaking change reach a consumer undetected, then the mitigation failed and the drift cost is larger than accepted here. A second signal, now anticipated rather than merely watched for: the consumption surface this record leaves each downstream repository to rebuild is the reason a published consumer SDK is intended, and a record deciding that SDK is expected to follow this one. If it does not arrive before the second out-of-repo surface exists, the boilerplate this record accepts has become duplication it did not intend, and clause 4’s contract should be revisited rather than repeated. Recorded as an intent, not a discovery, because it was foreseen here.
- Revisit if: the install cost stops being the binding constraint — a Backstage plugin
that needs only peer dependencies, or tooling that makes workspace members genuinely
optional without weakening
clean-clone-builds.
Action items
Section titled “Action items”- Ratify or reject this record. Ratified by
@mbeacomon 2026-08-16. Agent-drafted and maintainer-ratified, both recorded rather than blurred. - Land
no-backstage-sdk-in-this-repositoryinscripts/check-deps.tswith its test, observed failing first per ADR-0016. - Record on ADR-0029 that action item 2 is resolved by this record.
- Land the amendment-by-reference note in ADR-0007 recording that its Option C disposition is narrowed for an integration whose dependency tree would dominate this repository’s install, citing the measurement in this record’s Context.
- Build the conformance fixture named in clause 5, so the accepted drift cost has the mitigation this record claims for it. Without it, clause 5 is an intention rather than a control.
- When the downstream repository is created, record its location here so this record names where the surface actually lives.