Files
hq/02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md

5.7 KiB

topic, status, date, deciders, reconstructed
topic status date deciders reconstructed
building it accepted 2026-09-03 jochen false

39. What the SDK holds, and what it refuses

Reconciliation note (2026-09-05): supersedes the earlier "repository structure" decision, which the consolidation folded; no standalone record remains to point at, so body references to it now point at the nearest surviving record, ADR 0015.

Context

The earlier "repository structure" decision (folded in consolidation; see the reconciliation note above, and ADR 0015 as the nearest survivor) named mesh-sdk "contracts shared across tiers: types, not behaviour." That line is superseded here, because it draws the boundary in the wrong place. The boundary that matters is not types versus behaviour — it is how often the thing changes.

The current SDK is the cautionary tale, and its failure is precise. hal/sdk holds all the code, including a per-module API client for every service (clients/plex.ts, clients/gitea.ts, …) and a per-module tool implementation for each (tools/plex.ts, …). Every module depends on the SDK, so every edit to any of that per-module code rebuilds every module — the cascade. The SDK is under constant maintenance precisely because it became the place all the volatile per-module logic accumulated.

The root cause is worth stating exactly, because the fix follows from it: the pressure was never to share a client between modules. It was to share a client between one module's own features — plex's tools, its health check and its hooks all wanted the same PlexClient — and the only place to share code across a module's features was the global SDK. So intra-module sharing leaked out as inter-module coupling.

Decision

The SDK holds the stable spine that modules build against, and earns its place by rarely changing. The test for membership is change-frequency, not kind.

What it holds

  • The tool-serving harness — the worker and registration mechanism, and the tool-definition type. How a tool is declared and served is settled; it does not change when an individual tool does.
  • The messaging and event framework — the broker client, the event consumer, the envelope.
  • The contracts — the manifest, declaration, provision and link shapes.
  • Core primitives — sealing and crypto, semver, the shared resolution helpers.

These change rarely and deliberately. When one of them does change, a rebuild of everything is the correct outcome, because the contract every module shares has genuinely changed.

What it must not hold — the more important half

  • A module's API client. A Plex client, a Gitea client, a MinIO client belong in their module. They change when that service's API or the module's use of it changes, which is often, and which has nothing to do with any other module.
  • A module's tool implementations. Same reason, same place: in the module.
  • Anything volatile — anything that changes when one service's features change.

The rule, stated so it can be applied without re-deriving it:

If editing a thing recompiles unrelated modules and it changes often, it does not belong in the SDK.

Both conditions are load-bearing. A rare change that cascades is fine — that is a contract, and the cascade is correct. A frequent change that stays local is fine — that is a module minding its own business. Only frequent and cascading is the disease, and per-module clients and tools are its carriers.

Where per-module shared code lives instead

Code shared among a module's own features lives in the module. The default is the plainest thing that works: an ordinary shared file the features import — plex/client.ts, imported by plex/tools/. Within one module, features are files importing sibling files; no package boundary, no ceremony.

A module-local SDK (a sub-package with its own version) is warranted only for the few modules whose shared surface is large enough to version on its own. It is the exception, not the shape.

Either form gives the property the global SDK could not: editing a module's shared code rebuilds that module and nothing else.

Consequences

  • The cascade becomes structurally impossible for module logic. There is no longer an edge from one module's internals to another, so the only thing that can rebuild everything is a real change to a shared contract in the SDK — which is rare, and when it happens, is right.
  • The SDK is small and stable by construction, not by discipline. Its size is no longer a thing anyone has to police.
  • Converting a module from the current system is partly a de-coupling, not just a move. Its client and its tools are pulled out of the shared SDK and into the module. A conversion that copied clients/plex.ts into the SDK's replacement would rebuild the exact mistake.
  • The host still does not import the SDK. It depends on nothing (ADR 0005) and mirrors the contracts rather than importing them, exactly as its apply-shapes table already does deliberately. The SDK is shared by the tiers that can share code; the host is not one of them.

References

  • The earlier "repository structure" decision — named the repositories; its mesh-sdk description ("types, not behaviour") is superseded by this record (folded in consolidation; nearest survivor ADR 0015).
  • ADR 0001 — the decomposition this serves: code belongs to the boundary that owns it.
  • ADR 0005 — why the host mirrors the contracts instead of importing the SDK.