Skip to content

ADR 0010: Native generators and the standalone compiler

Status: accepted

Generators outside the TypeScript ecosystem are native to their ecosystem and read the IR (amendment A7). Java users install a Gradle or Maven plugin; PHP users install Composer packages (A15). The consumer never needs Node.js: a native plugin gets the IR from a contract bundle, or, for a local YAML source, from a standalone compiler binary pinned by hash (A9). Generated Java compiles and is tested on Java 21 and 25 (A13). PHP packages live in the monorepo and reach Composer through read-only mirrors (A19). The binary ships for five platforms (A26), and the namespaces are fixed (A27).

Two risks shape this ADR: Node single executable applications (SEA) are still experimental and need per-platform signing, and Composer cannot install from a monorepo subdirectory.

Native generators read generated IR models

Section titled “Native generators read generated IR models”

Options: hand-written Kotlin and PHP models, a general JSON Schema code generator (quicktype, jsonschema2pojo), an in-repo generator.

Choice: an in-repo generator, written in TypeScript as a tool package, reads the IR JSON Schema and writes a Kotlin model and a PHP model (FR-40). General generators were rejected: they do not let us control the three properties the IR relies on (ADR 0005), they add a large dependency, and their output changes between versions. Hand-written models drift.

Both models follow the same rules:

  • Unknown fields are ignored on read, so an additive IR change does not break a reader.
  • Every IR enum is open: a value class or enum wrapper with an Unknown(raw) case. A target that meets an unknown kind fails with a diagnostic naming the kind and the operation; it never guesses.
  • Every discriminated union becomes a sealed hierarchy with an Unknown variant.
  • Extension slots stay as raw JSON values.
  • Reading checks irVersion against the declared range and, before 1.0, irRevision exactly (ADR 0005), and fails with both versions named.
  • The model package ships the native naming implementation (about thirty lines, ADR 0007), tested against the shared vectors from @seampact/spec (FR-38).

The Kotlin model uses kotlinx.serialization (no reflection, explicit unknown-key handling). The PHP model is plain readonly classes with a generated hydrator from decoded JSON and has no dependency.

CI regenerates both models and fails on any difference, so every IR change PR updates them (FR-40). A Kotlin and a PHP reader review the IR before the frozen-for-targets gate (FR-37).

JVM, groupId io.github.jubasse:

Artifact Language Role (ADR 0009) Content
seampact-ir Kotlin core IR model, naming, app-config reader (the file or the plugin DSL, A42), bundle reader and verifier, binary runner
seampact-hooks Java hooks access hook interfaces and runtime types imported by generated Java, the three-state Field<T> (ADR 0003 section 5); no dependency; vendorable (ADR 0009)
seampact-runner Kotlin core the run both plugins share, free of any build-tool API: the pinned binary, the app layout, the generate and check runs and their problems; the generated header names the runner’s version, so both plugins write the same files
seampact-spring-boot Kotlin target the spring-boot generator, shared by both plugins
seampact-spring-runtime Java helper the Spring boundary runtime generated code imports (ADR 0025); same version as the generator; its package stays io.github.jubasse.seampact.springboot.runtime, an exception to io.github.jubasse.seampact.<module>, since no generator jar is on the app’s classpath
seampact-gradle-plugin Kotlin target plugin id io.github.jubasse.seampact; task seampactGenerate, wired before Java compilation, with the config, the bundle or source, the binary hash and the options declared as task inputs and the output directory as its output, so up-to-date checks and the build cache work; the seampact { } DSL takes the app-config keys
seampact-maven-plugin Kotlin target goal generate, bound to generate-sources, adds the output as a source root; goal check, bound to verify, runs the pinned-bundle check and the generated drift; <configuration> takes the app-config keys, each typed by the app-config schema, and an element with no JSON form for its key (an attribute, a repeated key, an unresolved ${...}) is refused (config.maven.shape); generate skips when the stamp in target/seampact/ records the same inputs (the config, the path source trees read through their links, the binary pin, SEAMPACT_ALLOW_LOCAL, the Spring Boot version, the bundle origins, the plugin’s jars by coordinates, and by content for a snapshot or 0.0.0) and the same generated files; bundles and binaries are cached under ~/.m2/seampact/

Plugin and generator bytecode targets Java 21, the lowest version A13 supports. Both plugins call the same generator library, and a golden test checks they produce identical files. seampact-hooks is plain Java so a Spring application does not pull the Kotlin standard library at run time.

Configuration follows each build tool’s habit (A42): the app-config keys are written in seampact.config.yaml or in the plugin’s DSL, with the same schema, never both for one app. Output goes to the app’s outDir; a JVM app usually sets it to the build directory (build/generated/sources/seampact/main, target/generated-sources/seampact), leaves it uncommitted and sets checks.generated to disabled, while a team that reviews generated diffs keeps the default __generated__ and commits it (ADR 0011).

Composer, vendor seampact:

Package Role Content
seampact/ir core IR model, naming, app-config reader, bundle reader and verifier, binary runner, and the vendor/bin/seampact runner
seampact/hooks hooks access hook interfaces and runtime types imported by generated PHP, the three-state Field; no dependency; vendorable (ADR 0009)
seampact/laravel target a routes file or macro, controller interfaces, a FormRequest and middleware per operation (ADR 0012), the full server boundary around the developer’s controller; an artisan seampact:generate command beside vendor/bin/seampact
seampact/symfony target a route loader, controller interfaces, the operation attribute and its kernel.controller listener, the input value resolver, Validator constraints, the full server boundary around the developer’s controller
seampact/api-platform target resource metadata, DTOs, State Provider and Processor interfaces, the kernel.controller listener and the decorated deserialize provider; API Platform’s own OpenAPI generation left as the app has it, completed with nativeDocs or turned off with frameworkDocs: off (A43)
seampact/laravel-spatie adapter spatie/laravel-permission behind the decide hook (A24)

Targets are development requirements (require-dev), except the server targets seampact/laravel and seampact/symfony, production packages since their runtime is imported by the generated code (ADR 0025); seampact/hooks is a runtime requirement of the application. The vendor/bin/seampact runner discovers installed targets through a key in each target’s composer.json extra section, the PHP counterpart of the Node CLI resolving targets from the project (ADR 0009). The PHP floor is 8.3, patched: every seampact Composer package requires >=8.3.36 <8.4 || >=8.4.27 <8.5 || >=8.5.12, the releases that fix PHP2Shell (CVE-2026-109618 and CVE-2026-109619), so Composer refuses to install them on an unpatched PHP. Composer’s runtime platform check tests only the lowest bound, so the runner also refuses to run on an unpatched PHP (php.unpatched, naming the running version and the fixed release of its branch). A distribution build that backports the fixes under an older version number (Debian, Ubuntu) passes with extra.seampact.phpPatched: true in the root composer.json, beside Composer’s own config.platform for the install. The check runs when seampact generates or checks code; Composer’s install check covers a deploy, and nothing is checked while the app serves requests. CI runs the latest 8.3 and 8.5.

As A27: Maven groupId io.github.jubasse, Gradle plugin id io.github.jubasse.seampact, Composer vendor seampact, npm scope @seampact. Code namespaces follow: io.github.jubasse.seampact.<module> on the JVM, Seampact\<Module> in PHP. Generated code goes into a namespace or package the consumer chooses as a target option, never into a seampact namespace. None of these is claimed before publication (A3, A27).

  • Bundle source, the documented default. The plugin fetches the pinned bundle (ADR 0011), verifies its integrity hash, and reads the precompiled IR. No compiler runs.
  • Local YAML source. The plugin runs the standalone binary, pinned by hash, to compile the source to IR in a temporary directory, then reads that IR.

In both cases no Node.js installation is involved (CHK-076).

Content: the compiler and every CLI command that needs no target or plugin, init, compile (with --editor-schemas), lint with the core rules, diff, explain and bundle, with the first-party packs embedded (ADR 0008). A release build embeds each pack’s files from its npm tarball, so the binary vendors the bytes npm publishes. A Java or PHP team that owns a contract repository therefore runs its release gate (diff under compat.policy: additive-only), its lint and its editor schemas without Node.js. It carries no codegen target and no npm package loader, so it runs generate and check only on an app config whose target entries are all native, each use a Maven coordinate or a Composer package: it writes or checks the config’s seampact OpenAPI documents and its pinned bundles, prints that it leaves each entry to its build plugin, and reads each native entry as a server entry, since every native target is a server target. An npm entry is refused by name, binary.generate-unavailable or binary.check-unavailable naming the entry (exit 1), pointing to the npm CLI. Native consumers generate their entries with their own plugin, which asks the binary for the documents; and lint plugins, being npm packages, stay with the Node CLI (FR-12).

Build: esbuild bundles the compiler and CLI into one file (architecture external dependency), which is wrapped as a Node single executable application on the Node version pinned by ADR 0001. One Linux job builds all five binaries by injecting the same blob into the official Node binaries of each platform, verified against the Node project’s signed checksums. Code cache and startup snapshot stay off, since cross-platform builds do not support them.

Platforms (A26): linux x64 and arm64 (glibc), macOS x64 and arm64, Windows x64.

Signing:

  • macOS: the injected binary is re-signed ad hoc on a macOS runner; Apple silicon refuses to run an unsigned binary. Developer ID signing and notarisation are not needed for a binary that a build plugin downloads, since no quarantine attribute is set; the spike verifies this.
  • Windows: unsigned in 1.0. Authenticode signing is added if a consumer’s policy requires it.

Smoke tests: on each platform’s own CI runner, the binary compiles the reference contract and its IR must be byte-identical to the IR produced by the Node CLI on Linux (A26). The CLI version and the binary version are the same.

A spike runs first: a hello-world compiler built for the five platforms from one Linux job and smoke-run on each OS.

Fallback, if SEA proves unfit (a platform fails, startup or size is unacceptable, or a needed Node API is missing): Bun’s --compile, which cross-compiles every platform from one host; Deno’s compile second. To keep the fallback open, the compiler reads files only through an injected reader, and the bundled CLI may require only these Node built-ins: buffer, child_process, crypto, fs, fs/promises, https, module, os, path, process, url, util and zlib. The build fails on any other, so a new built-in gets a decision (another runtime may lack it). The decision (one standalone binary per platform, pinned by hash) does not change with the runtime; the artifact names and the hash manifest stay the same.

Distribution and pinning:

  • The binaries and a hash manifest (SHA-256 per platform) are release assets of the seampact repository.
  • Each plugin release embeds a default binary version and its hashes. A consumer may override the version and hashes, the download URL (for a mirror) or a local path, always with a hash.
  • The plugin verifies the hash before every first use and never runs a binary whose hash differs. Downloads are cached by hash in the ecosystem’s cache directory.
  • Before publication, test consumers point the plugin at a locally built binary and its hash (ADR 0011).

PHP packages are developed under php/<name> in the monorepo (ADR 0009). A CI split job extracts each directory’s history and pushes it to a read-only mirror repository, jubasse/seampact-<name>, with composer.json at its root and tags kept in step with the monorepo release (A19). The mirrors are created only before publication. Until then the job runs without pushing, and the produced trees are installed through Composer path repositories in CI to prove they are installable (CHK-088).

Before work on the PHP targets starts, a spike builds one API Platform operation that serves the default format (bare payload, problem+json errors) and, with the api-platform pack, JSON-LD items, a Hydra page and Hydra errors, with API Platform’s own OpenAPI generation on (its default, which A43 leaves to the app), completed by nativeDocs, and off through frameworkDocs: off. It records which API Platform mechanisms the target configures and which it keeps (A28), and in particular:

  • how security without object is evaluated before the provider reads (API Platform 4.3), against the check order of ADR 0012;
  • @id generation for a nested item whose parent id is not an item property, since the IRI converter needs every uriVariable from the object (idProperty, ADR 0002 section 6);
  • the target’s own reject of a restricted input field before denormalisation, since property securityPostDenormalize only strips (A21, A42);
  • validation answered as the schemaInvalid 400 rather than 422, with collectDenormalizationErrors and allow_extra_attributes: false;
  • the JSON-LD context routes, and the documentation and entrypoint routes while API Platform’s document is on, as framework routes the boot check accepts (ADR 0008, ADR 0011);
  • the kernel.controller listener finding the operation from _api_operation_name, and the generated decorator of the deserialize provider running after API Platform’s read and before denormalisation (ADR 0012);
  • which API Platform flavours the target supports (Symfony, and whether Laravel too).

The generator is a repository tool in scripts/ir-models, not a package: node --import ./scripts/ir-models/register.ts scripts/ir-models/cli.ts --write regenerates both models, and --check, run in CI, fails on any difference. The rules above are read as follows.

  1. “Unknown fields are ignored” above, and ADR 0005 section 3’s “the generated Kotlin and PHP models are built to keep unknown members”, mean tolerated and dropped: a reader accepts a document with members it does not know and does not keep them, so writing a read model back gives the document without them. Three things are kept raw instead: an unknown union variant, whose Unknown holds the whole object, an extension slot, and an opaque value.
  2. The tag member of a union (kind, in, verify, policy) is the serializer discriminator in both languages: its value picks the variant on read. Each variant class also carries the tag as an ordinary property fixed to its value, so a variant used on its own (Scalar.type is a ScalarTypeUse) reads and writes its tag. An unknown tag gives Unknown(tag, raw); a missing tag, or one that is not a string, is a read error naming the union.
  3. The sealed base (a Kotlin sealed interface, a PHP abstract class) exposes the tag only. Members that every known variant shares today, such as the id and origin of a Scheme, stay on each variant. An Unknown variant’s other members are not validated, so a typed member on the base would make the reader guess or fail on a document the validator accepts. And a member common to today’s variants need not be on a future one, so moving it to the base would turn an additive IR change, a new variant, into a breaking change of the model. A reader that needs a member of an unknown variant reads it by name from raw.
  4. Names are the schema’s. A definition name reserved in a language, judged on the final identifier with the lists of ADR 0007, takes the ADR 0007 escape, a trailing _ (Enum becomes Enum_ in Kotlin and in PHP); a member that is a Kotlin keyword is renamed the same way (in_, for_, as_) and keeps its JSON name. An escaped name that collides with another generated name fails generation, naming the definition. The constants of an open enum are the ADR 0007 enum member identifiers of its known values (APPLICATION_JSON in Kotlin).
  5. A string, integer, reference or map definition is a type alias: a Kotlin typealias, and in PHP the native type with a @phpstan-type alias. Every IR integer is a Kotlin Long, from -(2^53 - 1) to 2^53 - 1 (ADR 0004 section 10); one outside that range is a read error naming it in both languages. Both read a JSON number literal (RFC 8259 section 6) as the correctly rounded double, as the TypeScript reader does, and refuse other number text the platform would read (3d, 0x1.8p1), so the three accept the same JSON number literals: an integral number written with a fraction (2.0), which JSON Schema counts as an integer, reads as that integer, and so does 3.0000000000000000001, which rounds to 3; a fractional number is a read error naming it. No literal length cap applies, and a message cuts the literal it shows; the nesting limit guards the read, and the embedder bounds the size of the input it reads.
  6. A member name that starts with a NUL character ("\u0000x") cannot be held in a PHP object, so the PHP reader refuses such a document as malformed where the TypeScript and Kotlin readers drop the member as unknown; it is a platform limit, and the shared corpus has no such case. The PHP model also copies every value it stores from its caller (an Unknown variant, an opaque value, an extension slot) and returns a copy from toJson(), so a read model never changes.
  7. The native readers do not run the invariants of the IR README. They give the TypeScript reader’s verdict (validation in the open form, version and support checks, unknown tags and values) on a shared corpus, packages/core/spec/vectors/ir/, which records each verdict.

The binary and the contract bundle reach a JVM build through its own Maven repositories, so Nexus, proxies, mirrors and offline mode work unchanged, and the build machine needs no Node.js:

  • The binary as classifier artefacts. Each release publishes io.github.jubasse:seampact-compiler:<version> once per platform, as an artefact of type exe whose classifier uses protoc’s names, which os-maven-plugin users already know:

    Release asset Classifier
    seampact-linux-x64 linux-x86_64
    seampact-linux-arm64 linux-aarch_64
    seampact-darwin-x64 osx-x86_64
    seampact-darwin-arm64 osx-aarch_64
    seampact-win32-x64.exe windows-x86_64

    Each file is checked against the release SHA256SUMS before it is published. The GitHub release assets and their manifest stay the source and the manual-download path.

  • Resolution. The Gradle plugin resolves the artefact in a detached configuration on the project’s repositories; the Maven plugin through Resolver with the project’s remote repositories, which honours settings.xml mirrors and -o. The JVM has no download URL override: the repository is the mirror. An artefact the repositories do not resolve is refused (binary.artefact-unresolved, naming the coordinates and the build tool’s reason), with two fixes: add the repository that serves io.github.jubasse:seampact-compiler, or point path at a binary on disk, with its hash.

  • Pin. Each plugin jar embeds seampact-binaries.properties, the version and one SHA-256 per classifier, written at plugin build time from the release SHA256SUMS. A consumer may override, in the build file, the version with all its hashes, or a local path with its hash; an override without a hash is refused (binary.pin-missing), and so is a hash that is not 64 lowercase hexadecimal digits or a classifier of no release binary (binary.pin-invalid). Before every first use the plugin hashes the resolved file and never runs one whose hash differs (binary.hash-mismatch, naming both values).

  • Cache. The cache root is <Gradle user home>/caches/seampact/binaries for the Gradle plugin and ~/.m2/seampact/binaries for the Maven plugin. A relative user.home is refused (binary.cache-unavailable, naming the property); the Gradle user home is always absolute. Every binary, config and output path is made absolute before any check and used as is, so the check and the use name one file. Every binary, a resolved artefact or a path override, is copied into <cache root>/<sha256>/. The source is hashed before the cache is looked at, so a warm cache never hides a changed source. The runner creates the cache root, each missing directory on its way and <sha256>/ owner-only (0700) whatever the umask; an existing directory with wider modes is refused by the checks below, naming the chmod 700 fix. Once created, the cache root is resolved to its real path, and only that path is checked and run, so a link on the way cannot be swapped between the check and the start. The copy streams into a temporary file created owner-only in <sha256>/ and is hashed as it streams; the file is then made executable and renamed atomically to the binary name; an entry already there with the right hash counts as success, so concurrent builds are safe. The runner hashes that copy right before each start and runs only that copy, never the file it came from.

  • Cache checks. Before each start, the copy, its directory and each directory above it up to the cache root must be owned by the current user, must not be a link and must not be writable by group or others. Each directory above the cache root, up to the filesystem root, must be owned by the current user or by root and must not be a link. Above the cache root, a directory may be writable by its group only when that group is the running process’s primary group; no directory may be writable by others. No account database is read; a directory writable by another group is refused, naming the chmod g-w fix. On Windows the ACL rule replaces both owner rules, from the copy up to the user profile directory (%USERPROFILE%, taken by its real path as the cache root), which Windows creates private, or up to the drive root for a cache outside it, the fix naming %LOCALAPPDATA%: none may be a reparse point, and the ACL of each may grant write to no account but the current user, SYSTEM and Administrators, where write is any right to write data, add a file or a folder, delete, or change the permissions or the owner. SYSTEM and Administrators are found by their well-known SIDs (S-1-5-18, S-1-5-32-544) and the current user by the SID of the process, never by name, since Windows translates the names. Otherwise the binary is refused (binary.cache-unsafe, naming the path).

  • Lazy. The binary is resolved only when an input needs it: a path source, or the OpenAPI documents of an app config. A bundle-only app with openapi: none never downloads it.

  • Documents. Unless openapi is none, the plugin’s generate and check run the binary’s generate or check on the app config, from its directory, with the environment and the exits of the compile, after the entries are planned and before any is written; the binary’s lines are printed after compiler:, a refusal is binary.documents-refused with its diagnostics. An app config the build file writes is handed over as a JSON file in a run directory under the build’s temporary directory (Gradle build/tmp/seampact, Maven target/seampact), read as the app directory’s config (--app-directory) and named after the build file in problems (--config-name), so its relative paths stay the app’s; the run directory is removed after the run, refused or not. The binary fetches nothing: the plugin reads every bundle source the documents read (the sources holding the api of any target entry or of any document openapi lists, by the rule of ADR 0011) through its own channel and the pinned-bundle check, and hands each one over as its checked cache directory (--bundle-directory <source>=<dir>), which the binary checks again against the source’s integrity. Gradle declares each listed document and the manifest of its directory as output files of seampactGenerate, never a directory; the default documents are named by the IR only, so with no openapi key the task runs every time and is never taken from the build cache. Maven’s generate stamp hashes the documents with the entry roots, so a deleted or edited document runs the goal again. An app config that mixes npm entries with Maven or Composer ones has no runner for its documents, since each CLI refuses the other’s entries: it sets openapi: none, or is split into one app config per runner.

  • Platforms refused by name (binary.platform-unsupported, naming the detected OS, arch and libc): linux with musl (the C library the JVM itself has mapped, read from /proc/self/maps; without /proc, an ld-musl-* loader under /lib), FreeBSD, and any other OS or arch, an arm64 JVM on Windows included. The JVM’s own arch decides: an x64 JVM under emulation (Rosetta, Windows on ARM) gets the x64 binary, which runs under the same emulation. The fixes: use a bundle source; build on a glibc image; or point path at your own build, with its hash.

  • Runner. The plugin runs the binary’s compile --config <app config> --out <directory> with an argument list, so no shell reads it. The runner creates a fresh work directory owner-only in the system temporary directory, the binary writes its output directory ir/ in it, and the runner reads the <api>.ir.json files there with the seampact-ir reader. The child gets only the environment variables of the binary’s contract, TMPDIR, TEMP, TMP and, on Windows, SystemRoot, so no code the environment names, such as a NODE_OPTIONS --require or an OpenSSL configuration, runs inside the pinned binary, which is also built to ignore NODE_OPTIONS.

  • Exits. Exit 1 or 2 is refused (binary.compile-refused), carrying the binary’s diagnostics with control characters other than newline and tab removed. A compile past its deadline is stopped (binary.compile-timeout). Any other exit, a signal, or an IR file missing after exit 0 is refused (binary.compile-failed, naming the exit code, the signal or the api). The plugin compiles one source per run under one entry, so an exit 0 that wrote no <api>.ir.json left the contract.

  • The contract bundle as a Maven artefact. Besides the three channels of ADR 0011, a contract bundle may be published and consumed as a Maven artefact. The app config names it as a source { maven: "<groupId>:<artifactId>:<version>", integrity }, the DSL with the same keys. The version is one release: a dynamic version (+, 1.+, latest.release, a range) or a -SNAPSHOT is refused by name in both runners (config.source.maven-unpinned). The plugins resolve it through the same repositories and run the pinned-bundle check of ADR 0011 on it. The Node CLI does not resolve Maven artefacts and refuses the source by name (bundle.maven-jvm-only), naming the release URL, git tag and npm package forms of the same bundle.

  • What a JVM build reads. The plugins read the app config, from the file or the DSL, with the schema and the problem codes of the Node CLI, and fetch, cache and check bundles as the Node runner does. Two forms no JVM consumer needs yet are refused by name: a package bundle source, which needs the npm registry (bundle.package-unsupported), and a monorepo root config that lists the app (config.root.unsupported).

Composer has no classifier and no OS or arch filter, so the binary does not travel as a Composer package: seampact/ir gets it itself, and the PHP build machine still needs no Node.js.

  • Contract only. seampact/ir depends on the binary through a contract, never on how it is built:

    • the five release assets of the JVM channels table, and the release manifest SHA256SUMS, one <sha256> <asset> line per asset;
    • the command compile --config <app config> --out <directory>, both absolute paths, run from the app config’s directory, which writes one <api>.ir.json per api of the app config into the directory;
    • the exit codes: 0 compiled, 1 refused with its diagnostics, 2 a usage error; the diagnostics are on stderr;
    • the environment: the binary reads no environment variable but TMPDIR, TEMP, TMP and, on Windows, SystemRoot, and makes no network request: the runner fetches bundle sources itself and hands the binary local files only.

    A compiler rebuilt in another language that keeps these names, this manifest, this command, these exit codes and this environment drops in with no change to any PHP package.

  • Pin. seampact/ir ships resources/binaries.json: the binary version and one SHA-256 per asset, written at release time from SHA256SUMS. An app overrides it under extra.seampact.binary, as the JVM plugins take binary in their own configuration and not in the app config, with one of: a version with all its hashes, a mirror url with all its hashes, or a local path with its hash. A mirror url is a base URL to which /<asset> is appended; it never reuses the embedded hashes, so a hash set that lacks one asset is refused like a missing hash.

  • Who sets the pin. The override is read only from the root package’s composer.json: the file the COMPOSER variable names, else composer.json in the root package’s directory. A relative path resolves against that file’s directory. A dependency’s extra.seampact reaches vendor/composer/installed.json too, so a binary key there is refused (binary.override-not-root, naming the package).

  • Pin refusals. They are the JVM’s: an override with no hash (binary.pin-missing); a hash that is not 64 lowercase hexadecimal digits, or an asset of no release binary (binary.pin-invalid); a file whose hash differs from the pin (binary.hash-mismatch, naming both values).

  • Cache. The cache root is $COMPOSER_CACHE_DIR/seampact, else $XDG_CACHE_HOME/seampact, else $HOME/.cache/seampact; on Windows it is %LOCALAPPDATA%\seampact, whose ACL is per user. With no root, the binary is refused (binary.cache-unavailable). Every binary, downloaded or a path override, is copied into <cache root>/<sha256>/. The runner creates the cache root, each missing directory on its way and <sha256>/ owner-only (0700) whatever the umask; an existing directory with wider modes is refused by the checks below, naming the chmod 700 fix. The runner hashes that copy right before each start and runs only that copy, never the file it came from. A relative XDG_CACHE_HOME is ignored, as the XDG specification says; any other relative cache variable is refused (binary.cache-unavailable, naming the variable). Every binary, config and output path is made absolute before any check and then used as is, so the file checked is the file started, whatever directory the child starts in; on POSIX only a path that starts with / is absolute, so \x and C:/x are relative names. Once created, the cache root is resolved to its real path, and only that path is checked and run: a link on the way to the cache never decides which file starts.

  • Cache checks. Before each start, the copy, its directory and each directory above it up to the cache root must be owned by the current user, must not be a link and must not be writable by group or others. Each directory above the cache root, up to the filesystem root, must be owned by the current user or by root and must not be a link. Above the cache root, a directory may be writable by its group only when that group is the running process’s primary group; no directory may be writable by others. The primary group is read with posix_getgid(), and no account database is read; a directory writable by another group is refused, naming the chmod g-w fix. On Windows the ACL rule replaces both owner rules, from the copy up to the user profile directory (%USERPROFILE%), which Windows creates private and which is resolved to its real path as the cache root is, so a junction or a short (8.3) name never puts the profile elsewhere: none may be a reparse point, and the ACL of each may grant write to no account but the current user, SYSTEM and Administrators, where write is any right to write data, add a file or a folder, delete, or change the permissions or the owner. A cache outside %USERPROFILE% is checked up to the drive root, and the fix names %LOCALAPPDATA%. Otherwise the binary is refused (binary.cache-unsafe, naming the path).

  • Install. A path source is hashed first, before the cache is read, so a warm cache never hides a source that changed; the cache checks then run before any copy. A download or a path copy streams into a temporary file created exclusively in <cache root>/<sha256>/ and is hashed as it streams, readable and writable by its owner only; once its hash matches, the file is made executable and renamed atomically to the asset name. An entry already there with the right hash counts as success, so concurrent runs and a running .exe on Windows are safe. A copy whose hash differs is deleted and refused as a hash mismatch.

  • Fetch. The download uses https only, never turns TLS verification off, follows a bounded number of redirects, each https too, and stops at a deadline. A download that fails or ends early is refused (binary.fetch-failed, naming the URL without its userinfo and query, and the HTTP status or error), and nothing is installed. It reads no proxy variable and trusts the system’s certificate authorities, as the bundle download does, and stops at 512 MiB and at a 600 s deadline that covers the connection, the headers and the body; a body shorter than its Content-Length has ended early. A URL that holds a control character or a space, or a mirror base URL with a query or a fragment, is refused by name before any request (binary.fetch-failed).

  • Lazy. As on the JVM, the binary is resolved only when an input needs it: a path source, or the OpenAPI documents of an app config. A bundle-only app with openapi: none never downloads it.

  • Platforms. PHP_OS_FAMILY gives the OS and php_uname('m') the arch. On Linux the C library is the one PHP has mapped, read from /proc/self/maps. Without /proc, or when the maps name neither glibc nor musl (a static PHP build), the loader under /lib decides: ld-linux-* is glibc, ld-musl-* is musl, and neither is refused.

    PHP_OS_FAMILY php_uname('m') Release asset
    Linux, glibc x86_64 seampact-linux-x64
    Linux, glibc aarch64 seampact-linux-arm64
    Darwin x86_64 seampact-darwin-x64
    Darwin arm64 seampact-darwin-arm64
    Windows AMD64 seampact-win32-x64.exe

    The platform unit tests of seampact/ir pin these values. Any other row, musl and BSD included, is refused (binary.platform-unsupported, naming the detected OS, arch and C library) with the JVM’s three fixes: use a bundle source; build on a glibc image; or point path at your own build, with its hash. Most Alpine PHP images are musl: the PHP guide names the bundle source for them.

  • Runner. vendor/bin/seampact and the framework commands run the binary’s compile through proc_open with an array command, so no shell reads it. The output directory is a fresh one the runner creates owner-only in the system temporary directory, and the runner reads from it only the <api>.ir.json of each api of the app config, with the seampact/ir reader. The child gets only the environment variables of the contract, so no code the environment names, such as an OpenSSL configuration, runs inside the pinned binary.

  • Exits. Exit 1 or 2 is refused (binary.compile-refused), carrying the binary’s diagnostics with control characters other than newline and tab removed. A compile past its deadline is stopped (binary.compile-timeout). Any other exit, a signal, or an IR file missing after exit 0 is refused (binary.compile-failed, naming the exit code, the signal or the api). There is no Composer plugin: nothing runs on composer install.

  • Documents. Unless openapi is none, vendor/bin/seampact generate and check run the binary’s generate (with --force when given) or check on the app config file, from its directory, with the environment, the deadline and the exits of the compile, after every entry is planned and before any is written; the binary’s lines are printed after compiler:, except its line leaving an entry to its build plugin for the entries this runner writes, its warnings as seampact warnings, and exit 1 or 2 is refused as binary.documents-refused with its diagnostics, so a refused document leaves the entries as they were. The app config is a file, so no temporary config is written. The binary fetches nothing: the runner reads every bundle source the documents read (the sources holding the api of any target entry or of any document openapi lists, by the rule of ADR 0011) through its own channels and the pinned-bundle check, and hands each one over as its checked cache entry (--bundle-directory <source>=<dir>), checked again right before the run (bundle.cache-invalid when it changed since). A path source only the documents read is compiled by the binary alone, once the runner’s local-source policy allows it. An npm entry in the app config is the binary’s own refusal.

  • Compatibility. The framework version of each PHP entry is read from Composer’s installed.json and checked against the phpTargets of the compatibility matrix, which seampact/ir ships a copy of, with the codes of the Node CLI and the JVM plugins; a framework Composer did not install is refused (framework.version.unresolved). A PHP row may set a floor, the lowest <major>.<minor> of a tested major the target works on, and an older minor is refused (config.matrix.incompatible); readers that compare majors only ignore it.

  • Smoke. The PHP CI builds the linux-x64 binary and runs vendor/bin/seampact with it on a path source for each PHP target, documents included, with Node removed; a bundle hand-over row writes and checks the documents from a bundle the runner fetched, and checks again with the bundle’s repository removed; its channel smoke generates from the release and git channels with no binary and refuses each tampered copy (ADR 0011 “Channel smoke tests”).

  • Until publication. The download, from the embedded pin or a version or url override, is in place, but the compiler is not published yet: the embedded pin stays empty until the first release fills it, and no release asset exists to fetch. Until then a path override with its hash, or a url override on a mirror of your own builds, is the way to the binary, in CI and in the test consumers. An input that needs the binary without one is refused by name (binary.unpublished, naming the path override). The JVM plugins have no such code because they resolve the artefact from the build’s repositories, and they refuse one they cannot resolve there.

pip installs platform wheels and picks the one that matches the machine, so the binary travels as a wheel. seampact-ir runs it on the contract of the PHP channels, and a Python build machine still needs no Node.js (A48).

  • Contract only. seampact-ir depends on the binary through the contract of “Amendment 2026-10-26: the PHP channels”, never on how it is built: the five release assets and SHA256SUMS, the command compile --config <app config> --out <directory>, its exit codes and its environment.

  • Wheels. seampact-compiler ships one platform wheel per release asset, holding that asset unchanged: manylinux x86_64 and aarch64, macOS x86_64 and arm64, and win_amd64. The tags carry the oldest platform the embedded Node.js runs on: manylinux_2_28, and macosx_13_0, since pip reads a macOS tag by its major and Node needs macOS 13.5. It has no sdist, so pip never builds the binary. seampact-ir[compiler] pulls it, and the extra is optional: as on PHP, the binary is resolved only when an input needs it (a path source, or the OpenAPI documents of an app config), so a bundle-only app with openapi: none never installs it.

  • Pin. seampact-ir embeds binaries.json: the binary version and one SHA-256 per asset, written at release time from SHA256SUMS. The root project’s pyproject.toml overrides it under [tool.seampact.binary] with one of: a version with all its hashes, a mirror url with all its hashes, or a local path with its hash, with the PHP rules for a mirror url and a relative path. The file the wheel installed is hashed against the pin before any use, like a download.

  • Pin refusals. They are PHP’s: an override with no hash (binary.pin-missing); a malformed hash or an unknown asset (binary.pin-invalid); a file whose hash differs from the pin (binary.hash-mismatch, naming both values); a [tool.seampact.binary] table outside the root project’s pyproject.toml (binary.override-not-root, naming the file).

  • Cache. The cache root is $XDG_CACHE_HOME/seampact, else $HOME/.cache/seampact; on Windows it is %LOCALAPPDATA%\seampact. The binary, from the wheel or a path override, is copied owner-only into <cache root>/<sha256>/ and only that copy runs. The cache, install and check rules are PHP’s, with their codes (binary.cache-unavailable, binary.cache-unsafe); the primary group is read with os.getgid() on POSIX; on Windows the per-user ACL of %LOCALAPPDATA% stands in for the owner and group checks, as for PHP.

  • Runner. The runner starts the binary’s compile with subprocess.run on an argument list and shell=False, so no shell reads it, in an output directory it creates owner-only, with the contract’s environment only and a deadline. The exits are PHP’s: binary.compile-refused, binary.compile-timeout and binary.compile-failed.

  • Platforms. sys.platform gives the OS and platform.machine() the arch. On Linux the C library is glibc when os.confstr("CS_GNU_LIBC_VERSION") answers; otherwise the loader under /lib decides, ld-musl-* being musl.

    sys.platform platform.machine() Release asset
    linux, glibc x86_64 seampact-linux-x64
    linux, glibc aarch64 seampact-linux-arm64
    darwin x86_64 seampact-darwin-x64
    darwin arm64 seampact-darwin-arm64
    win32 AMD64 seampact-win32-x64.exe

    The platform unit tests of seampact-ir pin these values. Any other row, musl (Alpine) and BSD included, is refused (binary.platform-unsupported, naming the detected OS, arch and C library) with the three fixes of the JVM and PHP: use a bundle source; build on a glibc image; or point path at your own build, with its hash. The Python guide names the bundle source and the glibc python:3.x-slim images.

  • Documents. Unless openapi is none, seampact generate and check run the binary’s generate or check on the app config as the PHP runner does, with its environment, deadline and exits; a refusal is binary.documents-refused with its diagnostics.

  • use form. An app config names a pip target as pypi:<distribution> (use: 'pypi:seampact-fastapi'), beside the npm, Maven and Composer forms of ADR 0011. The Node, JVM and PHP runners leave such an entry to the Python runner, and the Maven form refuses the group pypi, so the forms cannot be confused. The app config schema gains the form in a spec change.

  • Until publication. The compiler and the pip packages are published once, at v1: until then the embedded pin stays empty and there is no seampact-compiler wheel on an index. The version and url overrides download as PHP’s do; a path override with its hash, or a url override on a mirror of your own builds, is the way to the binary in CI and in the test consumers, and an input that needs the binary without one is refused by name (binary.unpublished, naming the path override). Once the pin is filled, a host with no seampact-compiler wheel for its platform is refused with the same code, naming seampact-ir[compiler] and the url override.

  • A Java, PHP or Python consumer works with no Node.js installed, from a bundle (no compiler at all) or from a local source (the pinned binary).
  • Every IR change touches two generated models in the same PR; CI makes forgetting it impossible.
  • Each binary release implies a plugin release that embeds its hashes. Consumers can override them without waiting.
  • Linux musl (Alpine) is not a shipped binary platform; a consumer on Alpine uses the bundle path or a glibc build image.
  • Every Composer package needs a mirror at publication: the six above plus the signed-webhook helper of ADR 0013, not only the three targets A19 names.
  • FRs: FR-40, FR-38, FR-37 (version checks), FR-14 (native targets), FR-17 (standalone binary and mirrors), FR-19 (Gradle and Maven plugins), FR-21 (PHP targets), FR-13 (diff in the binary); amendments A7, A9, A13, A15, A19, A26, A27, A28, A41, A42, A43, A48.
  • Checklist items enabled: CHK-072 (unpublished installs from Maven local and Composer path repositories), CHK-076 (no Node.js on native consumers), CHK-077 and CHK-078 (version checks and open enums in the native models), CHK-079 (Java 21 and 25 through Gradle and Maven), CHK-083, CHK-084 and CHK-085 (PHP targets), CHK-088 (mirror trees installable), CHK-140 (binary commands), CHK-158 (Field), CHK-159 (PHP decoding), CHK-166 (JVM DSL, build-directory output, artisan), CHK-167 (JSON-LD contexts).