Skip to content

ADR 0022: restore the OpenAPI client pipeline and independent integration keys

  • Status: accepted
  • Date: 2026-10-26
  • Context: issue 15, architecture alignment requested by the owner
  • Supersedes: ADR 0021’s initial SDK implementation and authentication-derived integration encryption

The existing connection workflow must keep working after reload, failed replacement, workspace changes and cancellation. OpenAPI generates the SDK and query factories; the dashboard consumes those clients. Credentials remain encrypted and never enter logs, response state, read caches or mutation caches. Existing v1 credentials must remain readable during migration, and explicit maintenance must rewrite all records atomically. Isolation, PostgreSQL/PgBouncer tests, coverage gates and a production build remain required. This change does not implement booking or inventory sync.

tooling/codegen runs pinned Hey API 0.99.0 against the offline OpenAPI artifact. Hey API generates operations, TypeScript types, Zod validators and TanStack Query factories. There is no local schema/operation generator. The MIT sdk package owns the HTTP client and safe error/cancellation handling. MIT react owns the generated query factories and workspace-scoped query lifetimes. Credential writes use the SDK directly so the plaintext never enters a TanStack mutation cache.

Hey API’s emitted Fetch runtime is incompatible with exactOptionalPropertyTypes (the earlier API review already identified it). Only upstream-generated code is compiled using the isolated TypeScript 6 API with that option disabled; it emits JavaScript and declarations. Relative declaration imports are normalized to .js. Every handwritten consumer still uses strict TypeScript 7 with exact optional types. Generation runs in a temporary directory; a check compares every output byte without rewriting it. Generated runtime/types carry MIT notices and stay outside handwritten coverage and lint. The generated query artifacts reach SDK exports by package names, not relative imports across package boundaries. Zod is the SDK’s runtime dependency; React and TanStack Query belong to react, with no React dependency in the SDK.

The core’s channex context owns authorization, validation and persistence orchestration. Its repository, remote and credential-vault ports contain no infrastructure imports. The channex package implements bounded production HTTP reads. Database repositories implement core ports. server supplies crypto and wires those adapters. Bootstrap, identity shell and protected first-account setup remain server entry points; operational tenant data uses the public API/SDK path. They do not grant the dashboard database access.

ENCRYPTION_KEYS is an independent, versioned keyring (2:secret,1:secret, newest first; v2:secret is also accepted). Authentication secrets cannot be reused as its values. New credentials use v2 envelope encryption: a random 256-bit data key per record, wrapped by the current integration key. Both encrypted values bind the instance, organization, workspace and key version. Removed-key and tampering errors remain generic. Fresh installations generate this key separately from AUTH_SECRET. Without the keyring, login/setup and old connection reads still work; new connection writes fail before any outbound request. An auth secret is never a fallback write key.

Legacy v1 values remain decryptable using ENCRYPTION_LEGACY_AUTH_SECRETS, or the existing session secret ring while transitioning. credentials:reencrypt uses the direct migration connection, checks the initialized instance and locks credential rows. Every rewrite commits together or rolls back together. It makes no remote requests, preserves connection metadata and never prints credential values or database errors. Retain old keys while backups still need them. Rewrapping current records does not make an old backup readable without the keys that encrypted it.

The SDK and React package remain private, unpublished workspaces. Discovery, bearer keys, automatic retries/idempotency, pagination helpers, Fumadocs, mobile, worker entry points and synchronization are not claimed as implemented by this decision. Existing ADR 0019 (pg-boss), 0011 (SQL migrations) and 0018 (design-system adjustments) stand.

Amendment 2026-10-30: one generated module per OpenAPI tag

Section titled “Amendment 2026-10-30: one generated module per OpenAPI tag”

Bundlers drop a module’s unused exports for the whole application, not per route, and they keep the z.object(…) calls of unused schemas, which they cannot prove pure. With one module of operations, one of schemas and one of query factories, every route that used the SDK carried all of them: one chunk of about 53.6 kB of gzip with every operation, including the settings-only API key and webhook ones (ADR 0040).

Decision. After Hey API generates, tooling/codegen/split.ts moves each declaration of sdk.gen, zod.gen and react-query.gen into one module per tag, named in kebab case: operations/<tag>.gen.js, zod/<tag>.gen.js and react-query/<tag>.gen.js. An operation goes to its OpenAPI tag, a schema to the one tag whose operations validate with it, a query factory to the tag of the operation it calls. A schema no operation uses, such as a request body, goes with the tag modules it uses. Everything else goes to shared, with whatever it uses, so a shared module never imports a tag module. The split moves Hey API’s declarations unchanged: it does not interpret the contract beyond each operation’s tag, and the compiled output stays byte-checked. The three original files remain barrels of exactly their original exports. Zod request schemas are no longer generated: the SDK validates responses only, and bundles kept their construction calls.

The packages export @guestcraft/sdk/operations/<tag> and @guestcraft/react/query/<tag>. createGuestCraftTransport sends any generated operation with the error, cancellation and workspace-header behaviour of the facade, without referencing any operation. send is typed by the operation it sends, without casts; for that, the generator marks GuestCraft-Workspace-Id optional in its copy of the contract (the client sends the transport’s), and openapi/v1.json still says where the API requires it; createGuestCraft is built on it and is unchanged for its callers. The two facade groups with checks of their own (propertiesFacade, channexConnectionFacade) are exported for @guestcraft/react, whose createGuestCraftQueries no longer builds the whole facade. A bundler keeps every module that a barrel re-exports, so handwritten SDK code imports schemas from their defining module, and the web app imports tag modules, never the barrels, the root operations namespace or createGuestCraft (a Biome rule). pnpm web:budgets also fails when a route other than their two settings pages defines an operation of the api_keys or webhooks tag (tooling/quality/web-route-operations.json, ADR 0040); it reads each tag’s operations from the contract, so a new one is checked too.

Consequences. Every route that uses the SDK is 5.0 to 13.8 kB of gzip smaller in paired production builds; routes without it are unchanged. A route ships whole tags: the shell of every workspace page ships the properties, Channex (19 operations) and messages (24 operations) tags for the property list, the connection status and the unread count. Moving an operation to another tag in the contract moves it to another module, so its import path changes; the barrels keep the old names.

Amendment 2026-10-31: transport problem schema isolation

Section titled “Amendment 2026-10-31: transport problem schema isolation”

The transport validates errors on every route. Importing its problem schema from the generated shared module also initialized unrelated guest and reservation schemas, so new guest fields increased bundles on screens such as invitations. The split now treats zProblem as a separate schema root; its dependencies, including zFieldError, follow the existing dependency graph into zod/problem.gen. The transport imports that defining module. Hey API still owns the declarations and validation, and the original barrel keeps every public export. A regression checks that error validation has no feature-schema imports. Guest-only review panels load dynamically with loading, failure and retry states.

The handwritten facade validators are also explicit roots: zPropertySettings belongs to the properties module, and zChannexConnectionInput to Channex. These two inputs previously had no generated operation consumer and fell into the shared feature-schema module. Keeping them with their own tags prevents property setup and the shared workspace shell from initializing guest/stay schemas. Their declarations and input validation stay intact.

The workspace command palette loads the generated guest and reservation query modules only when a search is active. Both tags must be deferred because their response schemas share a module. A resolved import checks that the search context is still active before fetching; closing the palette or changing workspace cancels its queries and clears its private cache.

Amendment 2026-10-31: request bodies go with the tags that take them

Section titled “Amendment 2026-10-31: request bodies go with the tags that take them”

No response reaches a request body’s Zod schema, so the split placed one by the schemas it uses; a body built only from shared schemas (money, dates, identifiers) went to shared and shipped with every route that reports a problem. tags.ts now also reads, for each operation, the component schemas its JSON request body names, and the split roots those schemas at the operation’s tag, as it does a response: a body one tag takes goes to that tag’s module, one several tags take stays shared. The split still moves Hey API’s declarations unchanged. In the build that introduced it, the shared Zod module went from 4,437 to 3,192 bytes of gzip, and /invitations from 258,706 to 257,780 bytes of initial JavaScript. facades.ts imports the Channex connection and property settings schemas from their tag modules, which the workspace shell already ships.