ADR 0010: Native generators and the standalone compiler
Status: accepted
Context
Section titled “Context”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.
Decisions
Section titled “Decisions”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
Unknownvariant. - Extension slots stay as raw JSON values.
- Reading checks
irVersionagainst the declared range and, before 1.0,irRevisionexactly (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).
Native artifacts
Section titled “Native artifacts”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.
Namespaces
Section titled “Namespaces”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).
How a native plugin gets the IR
Section titled “How a native plugin gets the IR”- 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).
The standalone compiler binary
Section titled “The standalone compiler binary”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 mirror repositories
Section titled “PHP mirror repositories”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).
API Platform spike
Section titled “API Platform spike”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
securitywithoutobjectis evaluated before the provider reads (API Platform 4.3), against the check order of ADR 0012; @idgeneration for a nested item whose parent id is not an item property, since the IRI converter needs everyuriVariablefrom the object (idProperty, ADR 0002 section 6);- the target’s own reject of a restricted input field before denormalisation, since property
securityPostDenormalizeonly strips (A21, A42); - validation answered as the
schemaInvalid400 rather than 422, withcollectDenormalizationErrorsandallow_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.controllerlistener 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).
Amendment 2026-10-13: generator policies
Section titled “Amendment 2026-10-13: generator policies”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.
- “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
Unknownholds the whole object, an extension slot, and an opaque value. - 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.typeis aScalarTypeUse) reads and writes its tag. An unknown tag givesUnknown(tag, raw); a missing tag, or one that is not a string, is a read error naming the union. - 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
idandoriginof aScheme, stay on each variant. AnUnknownvariant’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 fromraw. - 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
_(EnumbecomesEnum_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_JSONin Kotlin). - A string, integer, reference or map definition is a type alias: a Kotlin
typealias, and in PHP the native type with a@phpstan-typealias. Every IR integer is a KotlinLong, 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 does3.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. - 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 (anUnknownvariant, an opaque value, an extension slot) and returns a copy fromtoJson(), so a read model never changes. - 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.
Amendment 2026-10-21: the JVM channels
Section titled “Amendment 2026-10-21: the JVM channels”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 typeexewhose classifier uses protoc’s names, whichos-maven-pluginusers already know:Release asset Classifier seampact-linux-x64linux-x86_64seampact-linux-arm64linux-aarch_64seampact-darwin-x64osx-x86_64seampact-darwin-arm64osx-aarch_64seampact-win32-x64.exewindows-x86_64Each file is checked against the release
SHA256SUMSbefore 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.xmlmirrors 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 servesio.github.jubasse:seampact-compiler, or pointpathat 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 releaseSHA256SUMS. 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/binariesfor the Gradle plugin and~/.m2/seampact/binariesfor the Maven plugin. A relativeuser.homeis 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 apathoverride, 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 thechmod 700fix. 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-wfix. 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
pathsource, or the OpenAPI documents of an app config. A bundle-only app withopenapi: nonenever downloads it. -
Documents. Unless
openapiisnone, the plugin’s generate and check run the binary’sgenerateorcheckon 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 aftercompiler:, a refusal isbinary.documents-refusedwith 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 (Gradlebuild/tmp/seampact, Maventarget/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 documentopenapilists, 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 ofseampactGenerate, never a directory; the default documents are named by the IR only, so with noopenapikey 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 setsopenapi: 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, anld-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 pointpathat 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 directoryir/in it, and the runner reads the<api>.ir.jsonfiles there with theseampact-irreader. The child gets only the environment variables of the binary’s contract,TMPDIR,TEMP,TMPand, on Windows,SystemRoot, so no code the environment names, such as aNODE_OPTIONS--requireor an OpenSSL configuration, runs inside the pinned binary, which is also built to ignoreNODE_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.jsonleft 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-SNAPSHOTis 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
packagebundle source, which needs the npm registry (bundle.package-unsupported), and a monorepo root config that lists the app (config.root.unsupported).
Amendment 2026-10-26: the PHP channels
Section titled “Amendment 2026-10-26: the PHP channels”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/irdepends 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.jsonper 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,TMPand, 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.
- the five release assets of the JVM channels table, and the release manifest
-
Pin.
seampact/irshipsresources/binaries.json: the binary version and one SHA-256 per asset, written at release time fromSHA256SUMS. An app overrides it underextra.seampact.binary, as the JVM plugins takebinaryin their own configuration and not in the app config, with one of: a version with all its hashes, a mirrorurlwith all its hashes, or a localpathwith its hash. A mirrorurlis 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 theCOMPOSERvariable names, elsecomposer.jsonin the root package’s directory. A relativepathresolves against that file’s directory. A dependency’sextra.seampactreachesvendor/composer/installed.jsontoo, so abinarykey 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 apathoverride, 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 thechmod 700fix. The runner hashes that copy right before each start and runs only that copy, never the file it came from. A relativeXDG_CACHE_HOMEis 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\xandC:/xare 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 thechmod g-wfix. 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
pathsource 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 apathcopy 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.exeon 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 itsContent-Lengthhas 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
pathsource, or the OpenAPI documents of an app config. A bundle-only app withopenapi: nonenever downloads it. -
Platforms.
PHP_OS_FAMILYgives the OS andphp_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/libdecides:ld-linux-*is glibc,ld-musl-*is musl, and neither is refused.PHP_OS_FAMILYphp_uname('m')Release asset Linux, glibcx86_64seampact-linux-x64Linux, glibcaarch64seampact-linux-arm64Darwinx86_64seampact-darwin-x64Darwinarm64seampact-darwin-arm64WindowsAMD64seampact-win32-x64.exeThe platform unit tests of
seampact/irpin 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 pointpathat your own build, with its hash. Most Alpine PHP images are musl: the PHP guide names the bundle source for them. -
Runner.
vendor/bin/seampactand the framework commands run the binary’scompilethroughproc_openwith 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.jsonof each api of the app config, with theseampact/irreader. 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 oncomposer install. -
Documents. Unless
openapiisnone,vendor/bin/seampact generateandcheckrun the binary’sgenerate(with--forcewhen given) orcheckon 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 aftercompiler:, 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 asbinary.documents-refusedwith 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 documentopenapilists, 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-invalidwhen it changed since). Apathsource 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.jsonand checked against thephpTargetsof the compatibility matrix, whichseampact/irships 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 afloor, 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/seampactwith it on apathsource 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
versionorurloverride, 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 apathoverride with its hash, or aurloverride 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 thepathoverride). 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.
Amendment 2026-11-01: the Python channels
Section titled “Amendment 2026-11-01: the Python channels”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-irdepends on the binary through the contract of “Amendment 2026-10-26: the PHP channels”, never on how it is built: the five release assets andSHA256SUMS, the commandcompile --config <app config> --out <directory>, its exit codes and its environment. -
Wheels.
seampact-compilerships 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, andmacosx_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 (apathsource, or the OpenAPI documents of an app config), so a bundle-only app withopenapi: nonenever installs it. -
Pin.
seampact-irembedsbinaries.json: the binary version and one SHA-256 per asset, written at release time fromSHA256SUMS. The root project’spyproject.tomloverrides it under[tool.seampact.binary]with one of: a version with all its hashes, a mirrorurlwith all its hashes, or a localpathwith its hash, with the PHP rules for a mirrorurland a relativepath. 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’spyproject.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 apathoverride, 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 withos.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
compilewithsubprocess.runon an argument list andshell=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-timeoutandbinary.compile-failed. -
Platforms.
sys.platformgives the OS andplatform.machine()the arch. On Linux the C library is glibc whenos.confstr("CS_GNU_LIBC_VERSION")answers; otherwise the loader under/libdecides,ld-musl-*being musl.sys.platformplatform.machine()Release asset linux, glibcx86_64seampact-linux-x64linux, glibcaarch64seampact-linux-arm64darwinx86_64seampact-darwin-x64darwinarm64seampact-darwin-arm64win32AMD64seampact-win32-x64.exeThe platform unit tests of
seampact-irpin 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 pointpathat your own build, with its hash. The Python guide names the bundle source and the glibcpython:3.x-slimimages. -
Documents. Unless
openapiisnone,seampact generateandcheckrun the binary’sgenerateorcheckon the app config as the PHP runner does, with its environment, deadline and exits; a refusal isbinary.documents-refusedwith its diagnostics. -
useform. An app config names a pip target aspypi:<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 grouppypi, 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-compilerwheel on an index. Theversionandurloverrides download as PHP’s do; apathoverride with its hash, or aurloverride 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 thepathoverride). Once the pin is filled, a host with noseampact-compilerwheel for its platform is refused with the same code, namingseampact-ir[compiler]and theurloverride.
Consequences
Section titled “Consequences”- 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.
Traceability
Section titled “Traceability”- 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 (
diffin 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).