@12-apps/wiring
v1.17.0
Published
The package-to-host wiring contract: a producer half for packages (capability manifests — HTTP routes, job blueprints, MCP tools with annotations, permissions, notification blueprints, mailers, Prisma partials, web surfaces) and a consumer half for hosts
Readme
@12-apps/wiring
The package-to-host wiring contract. A producer half for packages — declare what you provide and what you require, per capability — and a consumer half for hosts — adopt the declarations, bind each one against shared ports, and get one ordered, conflict-checked, reported aggregate back.
Zero dependencies. Every capability shape is a structural twin of a seam a package in this repo already ships, so adopting the contract is declaring what already exists, not rewriting it.
The problem
Every @12-apps/* package already follows the same implicit plugin shape —
createApi*(config) → { routes }, createWeb*(config) → { page, … }, a
./hono adapter, a Prisma partial, sometimes ./mcp or job blueprints. But
the shape lives in TypeScript conventions and per-package ADOPTING.md files,
so:
- every host rediscovers each package's contract by reading it;
- the origin host wires ~24 packages through hand-written surface files, route files, nav files, icon files, badge files and policy tables;
- shapes that must stay identical across packages (
JobBlueprintvsPaymentsJobBlueprint) are kept identical by convention, with no compile check anywhere; - a version bump that adds a capability arrives silently: report-builder 5.x shipped three working-copy endpoints its own client calls, and the origin host never mounted them — the editor's autosave 404s and nothing is red;
- packages that should notify or mail cannot say so: there is no notify port at all, and the only email port lives inside another package's server entry.
The model
PRODUCER (the package) CONSUMER (the host)
┌──────────────────────────────┐ ┌────────────────────────────────┐
│ <pkg>/manifest │ │ createWiringHost({name, kind, │
│ defineManifest({ │ │ ports}) │
│ name, contract: 1, │ │ │
│ permissions, mcp, │──────▶ .adoptServer({manifest, │
│ notifications, db, e2e, │ │ server, bindings}) │
│ server: ['http','jobs'], │ │ .adoptWeb({manifest, web, │
│ web: ['surface','areas'] │ │ bindings}) │
│ }) │ │ │
│ <pkg>/manifest/server │ │ .assemble() ──▶ routes, jobs, │
│ defineServerManifest(...) │ │ mailers, surfaces, │
│ <pkg>/manifest/web │ │ permissions, notifications, │
│ defineWebManifest(...) │ │ mcpEndpoints, db, areas, │
└──────────────────────────────┘ │ report │
└────────────────────────────────┘Separate manifests per runtime because bundles are physics: the shared
manifest is data every runtime can hold; the server manifest carries the
createApi* factory and job blueprints; the web manifest carries the
createWeb* factory; a package with React Native screens adds a native
manifest (see React Native hosts). The shared manifest
inventories the runtime manifests, and that inventory is the
integrity mechanism: the producer refuses a runtime manifest that drifts from
it, and a host that adopts a manifest without answering an inventoried
capability gets a red assemble() naming the package and the capability.
Every declared capability must be bound or declined with a written
reason. Declining is legitimate — a harness with no worker declines jobs —
but silence is not: assemble() throws while anything is unanswered. That is
what turns "the bump shipped a capability the host never wired" from a silent
404 into a build failure.
Capabilities
| kind | producer declares | consumer binds with | aggregate |
|---|---|---|---|
| http | create(config) → { routes } — the existing createApi* factory | { mountPath, config } | routes, specificity-ordered, conflict-checked |
| jobs | namespace + dep-free blueprints (twin of @12-apps/jobs' JobBlueprint) | { deps } | jobs — JobDefinition twins; jobs.map(defineJob) and done |
| email | createMailer(port) → mailer (the createAuthMailer pattern) | { port? } (defaults to the host port) | mailers[pkg] |
| mcp | WireMcpTool[] — McpEndpoint plus annotations | collected; host-built vocabulary tools join via mcpEndpoints | mcpEndpoints, uniqueness-checked |
| permissions | an rbac-shaped contribution (source, ids, permissions, labels) | collected | permissions — feed composePermissions |
| notifications | blueprints: type, suggested category, generate | collected | notifications — feed the notifications mount |
| db | the Prisma partial + migrations paths | collected | db — feed the sync tooling |
| surface | create(config) → surface — the existing createWeb* factory | { config } | surfaces[pkg], built once (the memoisation rule, held in one place) |
| surface (native) | the same create(config) → surface, returning React Native component types, in <pkg>/manifest/native | { config } on a native host | surfaces[pkg], built once |
| areas | route/nav/gate suggestions per host area | collected | areas — project nav from data |
| e2e | the journeys' entry subpath | collected | in the report |
React Native hosts
A React Native app is a third host kind. A package that ships screens for
one adds a fourth manifest, <pkg>/manifest/native, declared with
defineNativeManifest and inventoried as native: ["surface"] in the shared
manifest. Its surface has the web surface's shape — one config object in,
component types out — but returns React Native components, because a native
bundle can import neither react-dom nor a web component library, and a web
bundle must never import React Native.
// package: <pkg>/manifest/native — a plain value; wiring stays a type-only edge
import type { AnyNativeManifest } from "@12-apps/wiring";
export const threadsNativeManifest = {
name: "@12-apps/threads",
surface: { create: createNativeThreads },
} as const satisfies AnyNativeManifest;
// package: its own test suite runs the producer's assertions
expect(defineNativeManifest(threadsManifest, threadsNativeManifest)).toBe(threadsNativeManifest);
// host: the React Native app
const host = createWiringHost({ name: "field-app", kind: "native", ports: { loggerFor } });
const { surface } = host.adoptNative({
manifest: threadsManifest,
native: threadsNativeManifest,
bindings: { surface: { config: { fetch, copy } } },
});
host.assemble();surface is ONE capability with two possible runtimes, so the vocabulary
every package answers for does not grow: the conformance gate checks only
that a native: inventory has its ./manifest/native subpath, and that the
subpath does not outlive the inventory (assertExportsMirror checks the same
in the package's own suite). A native host has no areas (its navigation is
its own; a native manifest carrying areas is refused), reports the server
and web halves out-of-scope with the runtime named on each entry, and answers
env vars declared with scope: "native". Like a web host, it still collects
the shared data capabilities (permissions, notifications, MCP, db) into its
aggregate; it simply has nothing to feed them to.
Ports (@12-apps/wiring/ports)
What a host provides once, for every package to require: EmailPort (the
EmailDriver shape, verbatim), NotifyPort (new — emit a typed notification
event; never throws), LoggerPort, JobsEnqueuePort (never throws),
ClockPort. Memory reference implementations ship for tests.
What stays host-owned
The contract deliberately moves no decision that is host vocabulary today: every user-facing sentence (pt-BR copy arrives as config, exactly as report-builder 4.0 inverted it), guards and actor resolution, plan gates, nav placement/labels/icons, MCP operation ids and summaries for vocabulary-driven tools, the RBAC catalog assembly, and the database itself. The contract makes each of those a named, typed, refusable argument instead of a convention.
Example
See src/__tests__/fixture-package.ts for a complete producer, and
src/__tests__/consumer.test.ts for both host kinds. The short version:
// host (API server)
import { createWiringHost } from "@12-apps/wiring/consumer";
import { defineJob } from "@12-apps/jobs";
const host = createWiringHost({ name: "web", kind: "server", ports: { email } });
host.adoptServer({
manifest: notesManifest,
server: notesServerManifest,
bindings: {
http: { mountPath: "/api/admin/:tenantSlug", config: { store } },
jobs: { deps: { store, ran } },
email: {},
},
});
const wired = host.assemble(); // throws while anything is unanswered
wired.jobs.map((job) => defineJob(job)); // every adopted package's jobs, one line
console.log(renderWiringReport(wired.report));Hosts that must keep one route file per endpoint (coverage gates read guards
out of route files) still adopt: they mount by hand and pin
unclaimedRoutes(wired.routes, claimedKeys) to [] in a unit test, which is
the test that would have caught the working-copy 404.
Status
Released: the package is in release-packages.txt (first — it has no
dependencies) and @12-apps/report-builder is the first producer, adopted by
the consumer harness. The RFC and the per-package adaptation report live in
docs/wiring/.
