ADR-0031: Publish a narrow consumer SDK as the contract, and document the CLI JSON as its sibling
| 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, distribution, governance, api |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Review tier | arb |
| Review reason | Defines the narrow consumer contract to construct, declares that @adrkit/core is not that contract, and amends two clauses of the accepted ADR-0029. This record authorizes design and construction only; publishing the independently versioned package remains a later one-way-door decision. |
| Relates to | 0003, 0007, 0010, 0013, 0019, 0025, 0029, 0030 |
| Affects | path:packages/sdk/**, path:packages/core/src/index.ts, path:packages/cli/src/index.ts, path:packages/cli/src/queue.ts, path:scripts/release-pack.ts, path:docs/RELEASING.md |
| Source | docs/adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md |
Status: accepted. Agent-drafted, ratified by
@mbeacomon 2026-08-24. Creates the conduit ADR-0030 foresaw. It authorizes the SDK’s design and construction, not its release; publishing is a later record’s act, as ADR-0029 clause 10 requires. It amends ADR-0029 clauses 6 and 11 by reference — clause 11’s current promise is measurably unkeepable.
Context
Section titled “Context”ADR-0030 pushed extension surfaces that carry a dependency tree out of this repository, and recorded that the consumption surface each of them must rebuild is why a consumer SDK was intended. This record decides it.
The promise that is currently false
Section titled “The promise that is currently false”ADR-0029
clause 11 binds adrkit to additive-only change on “the consumed shapes,” defining them as
@adrkit/core’s exported API and types. Measured at this revision:
| count | |
|---|---|
symbols exported from @adrkit/core |
173 |
re-export lines in its index.ts |
23 |
| symbols its in-repo library consumers import | 17 (@adrkit/ci 14, @adrkit/catalog-envelope 3) |
Corrected 2026-08-16, under action item 3. This table originally read “symbols the only existing consumer (
@adrkit/ci) imports — 3.” Both halves were wrong:@adrkit/ciimports 14 unique symbols (7 values, 7 types), and it is not the only consumer —@adrkit/catalog-envelopeimports 3 more. The conclusion is unaffected (17 « 173) but the ratio is 5.7×, not 58×, and a record arguing for a narrower surface is the last place an unverified count belongs. Method:docs/sdk-surface.md§Measurements.
Clause 11 therefore promises stability across a 173-symbol internal engine to protect a consumer surface of roughly seventeen. There are only two ways that resolves: freeze the engine against ordinary refactoring, or break the promise quietly. The second is what happens in practice, and a governing clause that is routinely and invisibly violated is worse than no clause — it is the failure ADR-0016 and ADR-0014 exist to prevent, expressed as an API.
A narrow published facade is what makes the promise keepable. That is the SDK’s primary purpose; reducing consumer boilerplate is secondary.
Two consumption modes already exist, and they do not agree
Section titled “Two consumption modes already exist, and they do not agree”Both are in production in this repository today: @adrkit/ci consumes the library
(fourteen imports from @adrkit/core), and @adrkit/spec-kit consumes the CLI
(scripts/context.sh shells out to adr check). But the CLI’s JSON is only sometimes a
core shape:
| command | --json source |
matches the library? |
|---|---|---|
adr queue --format json |
formatQueueReportJson(report) — a core formatter |
yes |
adr check --json |
the CheckOutcome core type, serialized |
yes |
adr explain --json |
assembled inline in packages/cli/src/index.ts |
no |
adr lint --json |
assembled inline: { checked, findings } |
no |
adr new --json |
{ id, path }, CLI-specific |
no |
So “one interface, two adapters” is not available at this revision. It would additionally require a normalization layer for three commands whose CLI shape exists nowhere in core, and a cross-mode equivalence property tested on every release.
And the isolation argument does not apply in reverse
Section titled “And the isolation argument does not apply in reverse”@adrkit/core depends on picomatch, semver, yaml, and zod — four deterministic,
credential-free packages. A library consumer inherits almost nothing. The dependency-tree
asymmetry that justified pushing Backstage out (1,274 packages) does not exist in the other
direction, so process isolation buys a Node consumer very little here.
Decision
Section titled “Decision”Publish a narrow, independently versioned consumer SDK as the contract adrkit commits to, and document the CLI JSON as a separate sibling contract rather than as an SDK adapter.
-
@adrkit/sdkis the consumer contract. A deliberately small typed facade over@adrkit/core, living atpackages/sdk/. It satisfies ADR-0030 clause 1 — its only dependency is the workspace core, so it adds no install cost and belongs in this repository. -
@adrkit/coreis explicitly not a consumer contract. Its 173 exported symbols are the internal engine, free to change with the engine. This is stated so the status quo stops being ambiguous by default, and so a core refactor stops being a governance question. -
ADR-0029 clause 11 is amended by reference: adrkit’s additive-only obligation attaches to
@adrkit/sdk’s surface and to the CLI JSON contract of clause 5, not to@adrkit/core. That is the narrowing which makes the obligation keepable rather than nominal. ADR-0029 clause 6 is amended likewise: a downstream surface binds to@adrkit/sdkand the documented CLI JSON, not to core’s runtime API. -
The SDK declares its own types rather than re-exporting core’s. A facade that re-exports is an alias and insulates nothing — the first core rename would reach every consumer. The mapping layer is the insulation, and the duplication is deliberate. This follows
packages/catalog-envelope/, whose own//boundarynote records the identical choice: both packages “declare the envelope’s shape independently, which is the single deliberate duplication in this design… a shared type module would be an import edge, and it is exactly that independence that makes this package’s structural validation a check rather than a tautology.” -
The CLI JSON is a documented, versioned contract in its own right — not an SDK adapter. Its consumers are language-agnostic (
spec-kitshells out; the badges readqueue.json), and what they need is a stable schema, not a JavaScript package. It is documented indocs/RELEASING.mdalongside the SDK’s surface and carries the same additive-only obligation. -
Library-first, and the door to a CLI adapter stays open. The SDK commits to the library mode only. The three commands whose
--jsonis assembled in the CLI —explain,lint,new— are converged onto core formatters as hygiene, the wayqueuealready is. That is independently worth doing, and once done a CLI adapter becomes nearly free. The direction matters: adding an adapter later is additive; withdrawing one is a break. -
Independently versioned,
versioning: 'independent'inscripts/release-pack.ts, following@adrkit/spec-kit. A stability facade on the lockstep engine’s cadence would inherit the churn it exists to absorb. -
No release is authorized here. ADR-0029 clause 10 continues to govern; publishing
@adrkit/sdkis a later record’s act, taken when its surface has been exercised by a real consumer rather than designed against a hypothetical one.
Options considered
Section titled “Options considered”Option A: Library-first SDK, CLI JSON documented separately (chosen)
Section titled “Option A: Library-first SDK, CLI JSON documented separately (chosen)”| Dimension | Assessment |
|---|---|
| Makes ADR-0029 clause 11 keepable | Yes — the point |
| Serves the one known consumer (Backstage, Node) | Yes, typed, no subprocess |
| Serves language-agnostic consumers | Yes, via the documented CLI contract |
| Commitment surface | One facade plus one JSON schema |
| Path to both modes | Open, additively |
Option B: CLI-first SDK — spawn adr, parse JSON
Section titled “Option B: CLI-first SDK — spawn adr, parse JSON”Pros: process isolation; no library coupling; the same mechanism for every language.
Cons: the isolation buys little, since core drags four deterministic packages; it imposes a subprocess and JSON parsing on a Node consumer that could just call a function; and it would make the typed surface a wrapper over stringly output. It also does not remove the need to document the JSON, so it is Option A’s second half without its first.
Option C: Both modes behind one interface
Section titled “Option C: Both modes behind one interface”Pros: genuinely serves both consumer classes from one package; both modes already exist in production here.
Cons: not available at this revision without inventing a normalization layer for
explain, lint, and new, plus a cross-mode equivalence property maintained forever. It
also widens the very surface the SDK exists to narrow — the commitment becomes the
facade, the JSON, and their agreement. Rejected now, not forever: clause 6 makes it cheap
later, and A → C is additive while C → A is a break.
Option D: Do nothing — let each consumer import @adrkit/core
Section titled “Option D: Do nothing — let each consumer import @adrkit/core”Pros: no new package, no new semver commitment.
Cons: leaves clause 11 false, leaves every core refactor a governance question, and makes each out-of-repo surface rebuild the same consumption layer — the duplication ADR-0030 recorded as its own wrongness signal.
Trade-offs
Section titled “Trade-offs”- A second surface to maintain, and a mapping layer with it. Clause 4’s deliberate duplication means a core change that should reach consumers now requires a deliberate act in the SDK. That is the cost of insulation, and it is paid on every intended change, not only on breaking ones.
- A published SDK is a one-way door. Once a consumer depends on it the surface cannot be withdrawn, only deprecated. This is why clause 8 withholds the release until a real consumer has exercised it.
- Designing against a hypothetical consumer. The only out-of-repo surface does not exist yet, so the SDK’s first shape is a guess. Clause 8 is the mitigation; the risk is that the guess hardens before the Backstage plugin tests it.
- Two contracts can drift from each other. The SDK and the CLI JSON both carry the additive-only obligation, and nothing yet asserts they describe the same decisions. Clause 6’s convergence work reduces this; it does not eliminate it.
Consequences
Section titled “Consequences”-
Easier: ADR-0029 clause 11 becomes a promise that can actually be kept; core refactors stop being governance events; out-of-repo surfaces get one documented way in; ADR-0030’s conformance fixture and the SDK’s test fixture are the same artifact.
-
Harder: one more package, a mapping layer, and two contracts to keep honest.
-
How we would know this was wrong: if more than a third of the SDK’s declared object shapes are structurally identical — same member names, same member types — to a type
@adrkit/coreexports, then the facade is an alias for core under another name and the insulation is fictional. Measured baseline at the surface’s first enumeration: 0 of 12 object shapes identical, 7 diverged, and 5 with no core counterpart at all. The three vocabulary unions (DecisionStatus,DecisionStanding,SlaState) are identical, 3 of 3, and are deliberately excluded from this test: they areschema/adr.schema.json’s vocabulary rather than core’s, ADR-0029 clause 6 commits it separately, and clause 3 above does not narrow it. Re-deriving those values under new spellings would add a translation table that can drift from the schema, and buy no insulation for doing it. A second signal: if the first real consumer needs something the SDK lacks and reaches into@adrkit/coredirectly, the facade failed at its only job.This criterion was corrected before ratification, and the correction is itself evidence. It originally read: “if the SDK’s surface has to grow past roughly a dozen entry points to serve its first real consumer, then it is not a facade but a re-export of core under another name.” Action item 3 enumerated the surface at 7 callable entry points and 17 exported symbols including types, and the original criterion fired on the second count while the structural measurement showed no aliasing whatsoever. It was testing the wrong property: it counted symbols while its stated concern was aliasing, so as written it would have condemned a facade that demonstrably insulates — and, worse, it never said which of the two counts it meant, leaving the record’s own falsification test to be settled by whoever read it. A criterion that cannot be evaluated without a judgment call is not a falsification test. Both counts are retained above so a ratifier sees what was measured rather than only the conclusion drawn from it.
-
Revisit if: a non-JavaScript consumer appears, which would make clause 6’s CLI adapter worth building rather than merely worth keeping cheap.
Action items
Section titled “Action items”-
Ratify or reject this record. Ratified by
@mbeacomon 2026-08-24. -
Land the amendment note on ADR-0029 recording that clauses 6 and 11 are narrowed by this record’s clause 3, per the mechanism ADR-0013 used on ADR-0007. Done 2026-08-16 and made authoritative by this record’s ratification on 2026-08-24.
-
Enumerate the SDK’s first surface from what a real consumer needs — the Tier 1 capabilities of ADR-0029 clause 1 — rather than from what core exports, and record the count. Done 2026-08-16:
docs/sdk-surface.md, sketched atpackages/sdk/. 7 callable entry points; 17 exported symbols including types; 0 of 12 object shapes structurally identical to a core type. Measuring it is what revealed that this record’s own wrongness criterion tested the wrong property — see Consequences, where the criterion is replaced and the correction recorded. -
Converge
explain,lint, andnew--jsononto core formatters, asqueueis, so the two modes describe the same shapes. -
Document the CLI JSON contract and the SDK surface in
docs/RELEASING.md, with the additive-only obligation stated at both. -
Build the conformance fixture ADR-0030 action item 5 names, as the SDK’s own test fixture, so one artifact discharges both records.
-
[~] Close the
--as-ofresolution gap:queue’s date resolver is a contract that lives outside the contract.resolveAsOfinpackages/cli/src/queue.tsis ~25 lines implementingcli-contract.md §As-Of Resolution— bareYYYY-MM-DDor an ISO datetime with an explicit timezone, rejecting timezone-less datetimes as ambiguous — and it is not exported from@adrkit/core.buildQueueReporttakes an already-resolvedasOfstring, so a library consumer computing the ARB queue today either reimplements that resolver or silently gets a different answer than CI produces for the same corpus on the same day.This is a live defect independent of whether this record is ratified, and it is the clearest evidence for clause 3’s narrowing: it is a contract gap that additive-only discipline on
@adrkit/core’s exported API could never have caught, because the missing piece was never exported in the first place. Whatever the SDK’s fate, the resolver belongs in core beside the kernel it feeds. Deliberately not fixed alongside action item 3; recorded here so it cannot be lost withdocs/sdk-surface.md.Done 2026-08-16 — the parsing clause only.
resolveAsOfand itsAsOfResolutiontype now live atpackages/core/src/queue/as-of.ts, exported from the package entry point besidebuildQueueReport, andpackages/cli/src/queue.tsconsumes them — so the rule that turns a supplied value into a calendar date has exactly one implementation and a library consumer computes the same date the CLI does.The third clause of
cli-contract.md§As-Of Resolution is NOT closed. “If absent:new Date().toISOString().slice(0,10)” is still inlined in three places (packages/cli/src/queue.ts,packages/ci/src/queue-action-entrypoint.ts,packages/core/src/scaffold/new.ts) and exported from none, andresolveAsOftakes a requiredstringso it cannot express the absent case. That matters more than it sounds: defaulting to today is the path CI actually uses, and a consumer defaulting from a local calendar date rather than a UTC one disagrees with CI near the midnight boundary — the same divergence class this item was opened to close. Carried as a successor item rather than marked closed. -
Export the absent-input clause of
cli-contract.md§As-Of Resolution — successor to item 7.new Date().toISOString().slice(0,10)is inlined atpackages/cli/src/queue.ts,packages/ci/src/queue-action-entrypoint.ts, andpackages/core/src/scaffold/new.ts. Until it is exported, “the queue as of now” — the dominant consumer case, and the one CI uses — is still hand-rolled downstream. Publication gate: this item must be complete before a later record authorizes publishing@adrkit/sdk.Two defects were found in the moved rule during review and fixed before it was published, which is the cheap moment: an expanded-year datetime was truncated into a non-date (and, because the kernel compares
asOflexicographically and+sorts below every digit, silently reported every deadline-bearing item as within SLA), and an impossible date was rejected when written bare but normalized to a different day when written as a datetime. Both predate the move; publishing them as contract is what made fixing them urgent.Observed failing per ADR-0016, and the observation is the interesting part: with the entry-point export removed to reproduce the gap as it stood, 7 of the then-8 tests still passed. Only the reachability test failed. That is the defect stated precisely — it was never a wrong answer, it was an unreachable rule, so no behavioural assertion could have detected it. A suite of correctness cases alone would have reported this contract gap as fully covered.