sdk-design
Doctrine for designing and evolving any **SDK** Grida ships — TypeScript, Rust, or otherwise. "SDK" here means a surface that crosses a foreign-or-foreign-treated boundary: published packages, separately-versioned consumers, FFI bindings, public-by-design modules. An SDK's job is to refuse; a strict, honest surface rejects the wrong contents and keeps the package testable in isolation. Default is "core, not customizable"; customization is the exception, defended by a deciding table. Use when authoring or evolving any such surface — `@grida/*` published packages, `crates/*` published or FFI-exported, intent/message vocabularies, any contract a second author will compile against. Internal-only helper packages are welcome to follow, not forced. Companion skill for two-sided contract work: $sdk-seam. Critique partners: $pedantic, $etiology. Related: $naming.
适合你,如果你正在设计或维护一个对外发布的 SDK 或公共模块
npx oh-my-skill add gridaco/grida/sdk-designcurl -fsSL https://oh-my-skill.com/install.sh | bash -s -- gridaco/grida/sdk-designnpx oh-my-skill verify gridaco/grida/sdk-design怎么用
技能原文 SKILL.md
sdk-design
This is not a style guide. Style and language-specific code shape are downstream (see $code-ts, $code-react for the TS sides). This is about what an SDK refuses to do — the discipline that keeps a package small, legible, and replaceable, regardless of language.
The thesis
An SDK lives or dies by what it refuses to expose. Default is core; customization is the exception. Every public knob is a contract you cannot retract without a semver break and a coordinated migration across every downstream call site.
A library with too few knobs is easy to grow. A library with too many is impossible to retire. The asymmetry is brutal — design from it.
This doctrine applies whether the package ships as an npm scope, a crate, a header-only library, a WASM module, a Python wheel, a hosted service with an SDK, or a pair of microservices defining a shared message vocabulary. The mechanics of "publish" differ; the discipline of "refuse the wrong contents" does not.
Scope: what counts as an SDK
This is the gate. The skill says "SDK," not "package," because the two are different. An SDK is:
- A surface that crosses a foreign-or-foreign-treated boundary. Published to a registry (
npm,crates.io,PyPI); linked by a separately-versioned consumer (a desktop binary against a crate, a generated WASM/FFI binding); or authored as if a foreign consumer existed even if one doesn't yet (any package whose README documents it as a public surface, anything tagged for publication, anything in a*-hostedsuffix family). - Versioned independently of its callers, even if today every caller lives in the same monorepo and ships on the same commit. The intent to be replaceable is what counts.
What this excludes — where the doctrine is welcome but not load-bearing:
- A package with exactly one internal caller, shipping on the same commit, where if the caller's needs changed the package would be rewritten freely. That's not an SDK; that's a refactored module that happens to live in
packages/. Adopt the parts of this skill that pay; skip the rest without apology. - One-off helper crates pulled in by a single binary in
crates/. Same logic.
Don't extend the doctrine to internal-only utilities just because the file structure looks like an SDK. The discipline costs something — designed views over raw streams, anti-goals as perimeters, promotion-on-dogfooding — and that cost is paid by the foreign-callers it protects. If there are no foreign callers (now or planned), the strictness doesn't pay back.
sdk-seam triggers on the same gate from the other angle: any boundary that meets the SDK bar above, where the same author writes both sides. Include FFI bindings to internal crates here — binding regeneration cost makes the boundary foreign-treated even when the crate is same-repo.
The deciding table
When a new decision lands — "should this be a provider hook? a built-in toggle? a sibling package? a public type or an internal seam?" — walk these in order. First match wins.
| Question | If yes → | Why | | ----------------------------------------------------------------------------------------- | --------------------------------------- | ---------------------------- | | Would customization let a consumer break the invariant this package exists to protect? | Core, non-customizable | Sovereignty | | Is this genuinely a host-owned concern (I/O, locale, surface, credentials, clock)? | Provider at construction | Host knows what you can't | | Is this per-variant edit/parse/render semantics, complex but bounded by a spec or schema? | Internal seam, no public API | Code organization, not API | | Does the candidate have ≥2 internal consumers AND can be tested without mounting the SDK? | Separate layer (own module/package) | Earned its separation | | Have ≥2 internal consumers shaped the contract already? | Eligible for public | Public only after dogfooding | | Otherwise | Core, internally modular | Default-in, not default-out |
The third rung — "complex but internal" — is where most "extension-point" mistakes get caught. A real spec or schema (SVG element table, MIDI event types, OpenType tables, USB device classes) is the registry; the SDK implements against it. Don't re-invite the spec to be re-implemented at runtime by consumers.
Five disciplines
D1. Subscribe to outcomes, not events
The public observation surface is designed, not raw. It exposes purpose-built views — selection, mode, dirty/version, computed property — each handling multi-target, capability variance, and bookkeeping internally. Consumers never receive raw input events, reducer actions, or internal state frames.
If a needed view doesn't exist, that's an API gap to close, not an internals hatch to open. Exposing the internal stream because "the consumer can compose it themselves" is how you wake up six months later unable to refactor the core.
The same rule applies to the other direction: emit named outcomes (intents, commands, requests), not "the user moved their pointer." If your outputs carry phase markers (preview / commit, begin / progress / end), the consumer wraps history/transactions without guessing internal state.
D2. Pure-logic core, thin adapter shell
One-directional dependency, layered:
primitives / math ← logic core ← adapter shell ← host
The math/logic core has no I/O, no DOM, no canvas, no UI runtime, no global clock. Plain function over plain inputs returns plain output. Runnable under the language's basic test runner with zero mocks. A Rust crate's core compiles under no_std where feasible; a TS package's core has no window / document import; a Python package's core does not touch the filesystem.
The shell is a thin wire: lifecycle, draw loop, host wiring. Its own logic should be trivial enough to verify by inspection, because it's the part you can't test headlessly.
Why this matters: when a shell grows logic, that logic ships unguarded. Common failure: the shell holds a switch (render, dispatch, route) and a new core variant is added without updating the switch — the core's tests pass; the shell silently drops the variant; downstreams hit it in production. Push logic into the core. Tests follow.
D3. Outputs that satisfy different constraints stay separate
Don't conflate outputs that exist to satisfy different constraints. Every paired-but-asymmetric surface — render vs. hit-test, read vs. write, declared vs. computed, preview vs. commit, encode vs. decode — earns its asymmetry from a real disagreement in requirements. When you find yourself unifying them "for elegance," you're about to break one.
Concrete pattern: a UI surface that draws and hit-tests as two separate outputs. Drawing optimizes for legibility at any zoom; hit-testing optimizes for Fitts'-reach (fat targets, virtual regions that extend past the visible shape). Collapsing them — sizing the visual to match the hit AABB, or shrinking the hit region to match the visual — breaks one of the two; each side has to compromise to satisfy the other.
The generalization: tests assert each side separately, and — where they intentionally differ — assert the direction of difference (e.g., the hit region strictly contains the rendered bbox).
D4. Anti-goals as defensive perimeters
Every published SDK ships an explicit Anti-goals section in its README. It is not aspirational; it is the perimeter that lets the package stay small. Examples that have already prevented bloat across various Grida packages:
- "Not a host of plugins." — kills every PR that wants to add a widget registry.
- "Not undo-aware." — host owns history; SDK emits phase markers.
- "Not a private IR." — file bytes are the source of truth; the parsed view is rebuilt on load.
- "Not a renderer." — the surface backend is intentionally minimal.
When a feature request arrives, the first question is which anti-goal it would violate. If it violates one, the right answer is "this is the wrong tool." If it threatens one without crossing it, write the anti-goal sharper.
Adding an anti-goal is the cheapest design work an SDK author does.
D5. Names commit you
See $naming for the full treatment. The SDK-specific corollary:
- Public identifier costs ≫ directory cost. Directory rename is
git mv; published-name rename is a coordinated downstream migration. Invest heavily before a name escapes its file. - Terseness is a uniqueness claim. A bare
Surface,Encoder,Intent,Paintin a package asserts "nothing else competes for this slot here." If a peer could be added later, qualify now. - Suffix siblings over nested folders. Keep the parent's scope tight; new subdirectories quietly widen it.
- Avoid leaking consumer concerns into the producer's names. A type field documented as "used by
<consumer>for<feature>" is leaking the consumer's problem into the contract. The field name should justify itself in producer-only terms.
The promotion contract
Internal seams stay internal until ≥2 internal consumers have shaped the contract. This is not a bureaucratic gate — it's the only way to avoid public APIs designed against one use case.
Promoting too early produces:
- The shape ossifies around the first caller's quirks.
- The second caller can't use it and writes a parallel API.
- Now you have two surfaces that drift, and you can't kill either.
Promoting too late costs little. Internal callers reach into internals; you tighten when the second consumer arrives. Default direction of pressure is inward, not outward.
When you do promote, the contract test is: "could a stranger build the next caller against this API alone, without reading the SDK's source?" If no, it's not promoted; it's exposed.
For very new packages without a second internal consumer yet, the honest move is to mark the surface as unstable in its README ("v0.x.y — no compatibility guarantees") and let the second consumer's needs shape the contract before locking it.
Three extension paths, in order of preference
For any extensibility request, walk this ladder. Reach down only when the rung above doesn't fit.
- Named built-in. Things every consumer of this SDK will want live inside the package as first-class features with their own toggles. New canonical needs land here; open a PR against the SDK.
- Host-fed extras. Transient, host-computed inputs/outputs passed through a designed slot (per-frame draw, per-event hook, per-message middleware). Best for things the host already computes and just wants threaded through.
- Escape hatch. The host owns some boundary (container element, raw socket, file descriptor) and can splice its own logic in around the SDK. **Deliberate escape hatch — reach for it only when (1) and (2) don't fit, and prefer pushing canonical needs into (1) over keeping them at (3).**
What's absent from this ladder is a generic plugin / widget / middleware registry. That's the point. A registry is the path that turns small packages into god-classes — the lesson is repeated across the industry (jQuery plugins, Babel plugins of the early era, Webpack loaders) and locally (the Grida main editor's 6,800-line god-class grew partly from this).
Tests are spec
For an SDK, tests carry double weight:
- The SDK is configurable — hosts pass styles, providers, callbacks. Small refactors land all the time.
- There's no visible behavior to inspect from outside the package; one regression can ship silently across every downstream.
Discipline: every default behavior is locked by a test whose description names the behavior in plain language. The test name is the spec. The body proves the code obeys it. A comment above explains _why_ — the design intent that the code itself can't carry.
Where applicable, embed scenario names verbatim in test text so "did we drop a rule?" is grep-able across implementations and ports. This matters most for SDKs that ship parallel implementations (TS + Rust + WASM bindings) of the same contract.
A PR that touches a public behavior without touching the matching test is a smell; a PR that flips a test's assertion without changing the test name is a near-certain regression.
Critique partners
$pedantic— before drafting a public API, run the design through pedantic. The probes for unfalsifiability, vague quantifiers, and assumed-bedrock catch the "this feels finished but isn't grounded" failure mode that produces APIs you can't retract.$etiology— before patching across an SDK boundary, walk the diagnostic ladder. Most "quick fixes" at a boundary are API-contract bugs (rung 3), not call-site bugs (rung 2). Treating one as the other is how contracts rot.
Cross-package work — the seam
Work that touches more than one SDK — your producer and its consumer, two sibling packages, a published surface and its tests, two crates on either side of an FFI boundary — has a specific failure mode of its own: when you control both sides, you shotgun changes across them in a single edit and the contract silently degrades. The joint between the two sides is a seam; keeping seams clean has its own discipline. See $sdk-seam.
The short version
- Default is core, not customizable. Customization is the exception, defended by the deciding table.
- Subscribe to outcomes, not events. Designed views, not raw streams. Missing view = API gap, not internals hatch.
- Pure core, thin shell. The logic is testable headlessly; the shell is boring on purpose. Logic in the shell is logic you can't defend.
- Asymmetric outputs stay separate. Render vs. hit, read vs. write, declared vs. computed — different constraints earn different surfaces.
- Anti-goals are defensive perimeters. Every SDK ships them. Sharpen them before adding features.
- Promote on dogfooding. ≥2 internal consumers shape the contract before it escapes the package.
- Three extension paths, in order: built-in → host-fed extra → escape hatch. Generic plugin registries are the road to god-classes.
- Tests are spec. Every default behavior pinned by a test whose name is the rule and whose comment is the why.
- The seam between two SDKs has its own discipline — see
$sdk-seam.