Skip to content

Product decisions

The ADRs and the conformance checklist cite product decisions by id, from A1 to A48. This log states each decision as it stands now. When a later decision refines an earlier one, the entry gives the final form and names the decision that refined it. The ids are stable: a decision is never renumbered.

A36 is the governing principle: where an older decision reads as imposing a convention, A36 prevails.

The ADRs also cite six design lessons, listed under Lessons below, and FR-n and SCN-n ids. They refer to the requirement and scenario catalogue of FEAT-001, the project’s first feature, which is maintained outside the repository for now.

A1. The project is named seampact, with the npm scope @seampact, the public GitHub repository jubasse/seampact, the MIT license and one monorepo.

A2. Modularity is a hard condition: installing the tool gives only the YAML and IR core. Each code generation target, and each target’s runtime helpers, is a separate package the consumer adds explicitly, and the CLI never depends on or bundles a target.

A3. Nothing is published to npm until the project is complete, functional and tested. There are no 0.x pre-releases; the first publication follows the end-to-end proof of A12 and a full test pass.

A4. Development process choice: each feature is framed (requirements, scenarios, architecture) before it is built, and the framing work is kept outside the repository. No effect on the product.

A5. Inbound signed webhooks are in 1.0, as a signed identity kind. Besides signature schemes, a neutral form hands the raw body and the declared headers to the authenticate hook, so any library can verify (refined by A40).

A6. 1.0 includes every planned target, the JVM and PHP targets included, and the end-to-end proof in test consumer projects (refined by A12, A15).

A7. Generators outside the TypeScript ecosystem are native to their ecosystem (Gradle and Maven plugins for Java, Composer packages for PHP, pip packages for Python (A48); later NuGet for .NET). They read the IR, and their users install them with the ecosystem’s own tool.

A8. The IR is under semver from 1.0 (before 1.0 it may change, with release notes): an additive change keeps irVersion, a removal or a change of meaning bumps irVersion and the major version, and each target declares the irVersion range it reads.

A9. A native plugin gets the IR from the contract bundle (precompiled and hash-checked) or, for a local YAML source, from a standalone compiler binary pinned by hash. The consumer never needs Node.js.

A10. .NET (NuGet) targets illustrate the native principle in the design and are a v2 objective. The Python targets ship in v1 (amended by A48).

A11. No performance threshold gates CI in 1.0; thresholds may come in v2 or v3.

A12. A first production adopter migrates only after 1.0. Before 1.0, the conformance checklist is proven by the monorepo’s fixtures and by standalone test consumer projects, which live outside the repository; one of them carries the end-to-end proof (refined by A16).

A13. The spring-boot target generates Java that compiles and is tested on Java 21 and 25.

A14. The TypeScript validator targets in 1.0 are zod and yup (both through Standard Schema) and class-validator with class-transformer (decorated DTO classes). Any other validator library is a new target.

A15. 1.0 also includes adonis (npm, VineJS) and the native PHP targets laravel, symfony and api-platform (Composer packages written in PHP, no Node.js on the consumer side). Which OpenAPI documents api-platform emits is the user’s choice (refined by A43).

A16. Refines A12: research into existing production APIs may inform the concepts the checklist names, but no outside code or document enters the repository, and the first production adopter still migrates only after 1.0.

A17. Full parity: every 1.0 server target generates the whole server boundary, proven by one shared black-box HTTP conformance suite and by one test consumer project per target, which is that target’s named consumer.

A18. The rights model is generic: it covers roles, fine-grained permissions, multi-tenant setups and full authorization libraries (Laravel Policies, Symfony Voters, Spring Security, CASL), whatever the framework. It has its own ADR, written after research (ADR 0012; refined by A20, A25).

A19. PHP packages live in the monorepo. CI pushes each one to a read-only mirror repository for Composer; the mirrors are created only before publication.

A20. Rights stay flexible: an application may bypass the native framework mapping and plug in its own implementation; the core never imposes right names or conventions (naming and derived rights come from opt-in convention packs the user can write); custom metadata on a right reaches the generated boundary; and each target lets the app override how a right maps to its hook.

A21. A restricted field sent on input without the right is either rejected with 403 and the field’s pointer (the default) or stripped. The choice is set per api in conventions and can be overridden per field.

A22. Changes-since paging is in 1.0, with cursors and pages.

A23. Tenancy is open: a route may run in zero, one or several nested scopes. Each scope id comes from a declared source (path, query, header, host or subdomain, principal field) or from a resolver interface the app implements. No single resolution scheme is imposed.

A24. 1.0 ships the generic rights decider, the opt-in derived CRUD rights pack, and the CASL (NestJS) and spatie (Laravel) adapters, each with its own conformance tests. The adapters are optional packages (A25). Amended 2026-11-06: the CASL and spatie adapters move to 1.1; 1.0 keeps the generic rights decider and the CRUD rights pack.

A25. Authentication, rights, roles and tenancy are unopinionated, even at the cost of less generated code: the contract declares what a route requires, and the generated boundary is one neutral, typed hook per concern (authenticate, decide, resolve scope) plus registration and a boot check. Any library or hand-written code plugs in behind it, and library integrations are optional adapter packages that no target requires.

A26. Distribution is complete in 1.0: bundles ship as an npm package, a release asset and a git tag, and the standalone compiler binary ships for Linux x64 and arm64, macOS x64 and arm64 and Windows x64, each with a CI smoke test.

A27. Namespaces: Maven groupId io.github.jubasse, Gradle plugin id io.github.jubasse.seampact, Composer vendor seampact (seampact/laravel, seampact/symfony, seampact/api-platform) and npm scope @seampact. They are claimed only at publication.

A28. seampact adapts to API Platform rather than disabling it: API Platform’s native formats (JSON-LD, Hydra paging, Hydra or problem+json errors) come through an opt-in api-platform convention pack (refined by A30, A32, A43).

A29. Development tooling choice; no effect on the product.

A30. seampact is a contract system, not a framework: the core default wire format is the bare payload with RFC 9457 problem+json errors, and every other format (JSON:API, HAL, JSON-LD and Hydra, Laravel resources and paginators, Spring Page and ProblemDetail, house envelopes) is a convention pack the contract opts into. Each target maps the declared format onto the framework’s own mechanism and plugs into its popular libraries through neutral hooks and optional adapters.

A31. Paging imposes no convention and the core ships no default page shape: a contract declares its paging style (cursor, page or offset, infinite scroll, changes-since, or its own) as semantic roles, each mapped to a location and a name of its choosing, and format packs only provide presets of that mapping. A paged list with neither a mapping nor a pack is a compile error that names both fixes.

A32. The first-party format packs in 1.0 are api-platform (JSON-LD, Hydra), laravel (API Resources and paginators), spring (Page, ProblemDetail), JSON:API and HAL, and RFC 9457 problem+json stays the error default without a pack. @seampact/http-suite is published with 1.0, for third-party target authors.

A33. On optionalAuth, a refused credential is treated as anonymous by default (refined by A40); a versioned DELETE carries its version in the query or in If-Match, never in a body; and a /v{n} path prefix is the default, which an api may opt out of (header, media type, fixed third-party path). Generated code keeps the authored property order, with a defined include merge order.

A34. Derived errors have default codes in the core (api.schema_invalid, api.unauthenticated, api.forbidden, …), each renamable in the contract or by a pack and rendered as RFC 9457 problem+json unless a format pack says otherwise, so a contract with no pack compiles. Idempotency keys and CSRF are opt-in: conventions.idempotency makes every write carry a key unless exempt with a reason, and a cookie scheme that declares csrf enables CSRF on its writes.

A35. Custom error codes can be declared at every level of the tree (base and api, resource, single, path scope, route): an error applies to every operation below its node, children add or narrow, and the IR records the node each error came from. Declaring the same code twice with different definitions is a compile error, re-attaching an inherited code is allowed, and derived default codes (A34) can be renamed at any of these levels.

A36. Governing principle: seampact imposes nothing. On every point (auth, rights, roles, tenancy, formats, envelopes, paging, errors, headers, naming, versioning, idempotency, validation library, client library, file layout, code style) the core offers neutral, typed boundaries, renamable defaults where a default is needed to compile, and opt-in packs and adapters; what a contract must give is explicitness (every route states its access, every exception its reason), never a way of implementing it.

A37. Each app has its own seampact.config.yaml, which can stand alone or be listed by a monorepo’s root config that shares contract sources; it selects the targets combined for that app and, per target, which artefacts are generated, and generating only the OpenAPI document is a first-class setup. A published compatibility matrix lets the CLI refuse an incompatible combination with a diagnostic that names the working alternatives.

A38. YAML is the only authoring medium, and editor support comes through editor schemas, seampact explain and pick/omit derived schemas. A one-shot importer from an existing OpenAPI document is planned for v3.

A39. Bodies and statuses beyond JSON and 2xx are in 1.0 and typed in the contract: multipart uploads (typed fields and files), binary downloads, application/x-www-form-urlencoded bodies and 3xx redirects. Every target renders them with its native mechanism.

A40. Six refinements under A36: optionalAuth takes onRefused: anonymous | reject, default anonymous (refines A33); a reason is required only for safety exceptions (access, CSRF, idempotency, unknown keys); signed webhooks add a neutral verify: application form (refines A5); the x-seampact-* extensions of the emitted OpenAPI document are switchable, and the conformance item that requires every IR member holds only with all of them on; the hooks package stays small and dependency-free, or an app vendors its types; and a contract may add error natures, each mapped onto one of the three built-in natures that drive retries.

A41. No target ever generates the real framework controller: code generation emits annotations, attributes, decorators, validation schemas, types and interfaces, and generated utilities (guards, interceptors, argument resolvers, form requests, attributes) place every step of the check order around the controller the developer writes, which implements the generated interface. Generated code goes to one directory, __generated__ by default, configurable per app.

A42. On the JVM, the seampact config is written either in seampact.config.yaml or in the Gradle or Maven plugin DSL, with the same keys and schema, and an app with both is an error. Spring may get opt-in generated Spring Security request matchers for public and optionalAuth routes, A21’s reject stays the default (API Platform enforces it before denormalisation), and accessHooks: false with the framework’s native access control is an official setup in which seampact exports each route’s access metadata.

A43. Which OpenAPI documents exist is the user’s choice, for every target alike: per app, seampact’s document (the default), the framework’s native document completed by code generation (nativeDocs), both, or none, with native generation left as the framework has it unless nativeDocs is on. In a monorepo, the root config may also emit one merged document across the apps.

A44. Development process: git flow, with feat/ branches squash-merged into develop, release/ branches merged into main and back into develop, and hotfix/ branches from main merged into main and develop. develop is the default branch until 1.0 and main from then on; both are protected (required ci check, no force push, no deletion) and receive no direct commits.

A45. Validation beyond the portable subset of ADR 0003 is added in the app’s code, through each framework’s native mechanism, on top of the generated schemas and classes. Every validator and server target emits output an app can extend: generated classes are never final, rules come in a form a subclass can merge, zod object schemas are exported unrefined, and the generated boundary accepts the app’s own derived class or schema. The contract has no extraRules key and the config no per-target rule mapping, so the contract stays the portable subset (lesson 6); named formats keep their target-option mapping of ADR 0003 section 8. The user guide documents, per framework, how to add rules on top of the generated validation. (Revises the first form of A45, which declared named checks in the contract and mapped them per target in the config.) Amended by ADR 0028: the app registers its rules per operation and input part, in the configured validator library only, and the boundary runs them at step 8b after the idempotency claim; generated classes may stay final.

A46. The loader’s trust model: contract files and the packs installed beside them are trusted like any dependency the user chooses to install, as an npm package, an ESLint or a Babel plugin is (a pack, unlike those, is data only). seampact is not an antivirus. The loader keeps the guarantees that are correctness or safety bugs whoever wrote the files: root containment (nothing is read outside the workspace root, or outside a pack’s package directory, through links and ../ included), prototype keys refused (__proto__, constructor and prototype as keys and api names), a depth limit, a per-file size limit and a per-load budget of work and text, which turn a runaway include or expansion into a located error instead of a hang. Defending against an adversarial contract or pack author, and denial of service beyond those limits, are non-goals. ADR 0006 section 1 states the same model, and the loader’s module comment points to it.

A47. Development comes before proof. Until the end-to-end slice runs (the reference contract compiled, generated and served by the four Node targets, NestJS, Fastify, Express and Adonis, with the TypeScript types, zod and the fetch client, in a test consumer project), work favours reaching it. Unit tests cover the bricks that will not move (the IR format, the YAML reader, naming); other code is covered by the slice’s end-to-end test. Conformance proofs and per-branch fixtures wait for a testing phase before M3, and a case no consumer needs yet is refused by name rather than modelled. A PR carries one deliverable (a generator, a target with its consumer project), about 3,000 to 4,000 hand-written lines, with agents relaying on one branch; a seam others wait on stays small and lands first. The 1.0 scope (A6, A15, A17) is unchanged.

A48. The Python targets ship in v1: FastAPI, Django REST Framework and Django Ninja, on a shared Python core (the IR reader, the codegen core, the hooks and the shared runtime parts), with each framework’s own idioms on top. They are pip packages, published with the v1 publication beside the npm, Maven Central and Packagist ones, and read the IR as A7 says; a Python build machine needs no Node.js (A9). They support Django 5.2 LTS and 6.x and Python 3.12 or later, and CI runs them on Python 3.12 and the latest Python. This amends A10 for Python; .NET stays a v2 objective.

Six design lessons, drawn from experience with an earlier contract system written in TypeScript, guide the design. The ADRs cite them as “lesson n”.

Lesson 1. One fact, one place. Each fact is stated once and the IR holds the only copy, because a fact written twice needs a gate to keep its copies together.

Lesson 2. Declared means derived. When the contract declares a behaviour, at least one server target and one client target generate it; otherwise the declaration does not ship.

Lesson 3. One emitter. seampact writes its OpenAPI documents one way, from the IR, and nothing in seampact rebuilds them a second way (whether a framework also writes its own document is the app’s choice, A43).

Lesson 4. Deny by default has no transition hole. A route with no access declaration is a compile error, and a bound route with no access fails the server’s boot instead of falling back to some other guard.

Lesson 5. Nothing ahead of use. A feature is built when a named consumer needs it, never before.

Lesson 6. No validator in the semantics. The schema language belongs to seampact, and each target maps it onto its own validator.