Appendix: Satellite Packages

A reference map of what exists in each optional satellite package, by namespace. For core (kinetis/framework itself), see Appendix: System Layout.

Two entries under packages/ are not mapped here, because they are applications rather than libraries: kinetis/skeleton (one controller, one route, nginx + PHP-FPM) and kinetis/pingpong (a FrankenPHP worker over MySQL, a Redis queue, events, a scheduled command, an MCP tool and live WebSocket updates). Each is read as example code — its own README and Tutorial, which builds kinetis/pingpong from an empty directory, are where they are covered.

packages/bref-adapter (kinetis/bref-adapter)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\BrefAdapter\BrefLambdaAdapter implements Kinetis\Runtime\RuntimeAdapterInterface — constructed from the Runtime API endpoint and nothing else, no TrustedProxies: an invocation has no connecting peer whose forwarded headers would need weighing. run() polls the Lambda Runtime API for the next invocation in a while (true) loop, converts the API Gateway HTTP API (payload format 2.0) event into a PSR-7 request, and posts the response back as the invocation’s result; isPersistent(): true (a warm container keeps reusing the same process across invocations, the same shape FrankenPhpAdapter has). Talks to the Runtime API with plain stream-context HTTP, not ext-curl; a transport failure or a non-2xx status from the Runtime API itself throws rather than being treated as an empty response. Maps the event’s top-level cookies list into a real Cookie header/getCookieParams() and requestContext.http.sourceIp into the request’s REMOTE_ADDR server parameter — neither is in headers, and nothing else here has a real socket to read either from. handleEvent(array $event, callable $handler) is one invocation from decoded event to response payload — the Lambda counterpart of SuperglobalsBridge::handle(), and the entry point the runtime conformance suite drives (tests/Conformance/LambdaDriver). headersFromEvent() refuses an event carrying one header name under two spellings, a name that is not an RFC 9110 token, or a value with a control character, and the cookies list is validated rather than filtered — an ambiguity resolved by key order is not an identity, and a filtered list is a silently shortened one. requestFromEvent(array $event) decodes a base64 body strictly (invalid base64 is Exception\MalformedRequestBodyException, this package’s one Lambda-specific client error, never an empty body); responseToPayload() refuses a StreamableResponseInterface — abandoning it first, so the request scope the Kernel holds open for its emitter is released on that invocation without running the emitter — base64-encodes a response body that isn’t valid UTF-8 (json_encode() would otherwise reject it) and emits every Set-Cookie header value as its own entry in the payload’s cookies array rather than folding them into one comma-joined header. Hands the decoded body on as raw bytes: staging, ceilings, parsing and the parse-failure vocabulary belong to core’s Kinetis\Http\Middleware\RequestBodyMiddleware, inside the Kernel, so a form is accepted or refused identically under every runtime. Kinetis validates what API Gateway delivered; it cannot bound what API Gateway already read. See Runtime Adapters for the full supported/unsupported feature list.

  • Kinetis\BrefAdapter\LambdaRequestIdentity — where the request was addressed, decided once from the event: requestContext.domainName is the host, x-forwarded-port the port, requestContext.http.protocol the version, rawPath/rawQueryString the request target byte for byte. The scheme is https and comes from the platform, not the event: an API Gateway HTTP API and a Lambda Function URL are TLS-only, so x-forwarded-proto is checked against that fact rather than deciding it — absent or https is the event API Gateway builds, and any other value, http included, describes an invocation that cannot have happened. That, a host header naming a different domain, a port two fields disagree on, a path that is not absolute or carries its own query, or any of them not valid UTF-8 or carrying a control character is a malformed event, reported to the invocation error endpoint before anything is dispatched. authority()/uri()/requestTarget() are what requestFromEvent() builds the request from, so the URI and the Host header cannot disagree. The event’s own queryStringParameters is validated as part of the event shape and read nowhere: it comma-joins a repeated parameter, which PHP would then read as one parameter containing a comma.

  • Depends on kinetis/framework (via a path repository to this monorepo’s root), nyholm/psr7, psr/http-message. Own composer.json/phpunit.xml/phpstan.neon.

packages/roadrunner-adapter (kinetis/roadrunner-adapter)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\RoadRunnerAdapter\RoadRunnerAdapter implements Kinetis\Runtime\RuntimeAdapterInterface — run() builds a Spiral\RoadRunner\Http\PSR7Worker over Spiral\RoadRunner\Worker::create() and loops waitRequest()/respond(); isPersistent(): true. Catches any Throwable a handler throws and reports it via Worker::error() instead of letting it propagate — the opposite of FrankenPhpAdapter’s convention, and what roadrunner-server/http’s Go source calls for: an ERROR-framed Goridge reply becomes a clean error response to that one client while the worker process itself keeps serving the next request, whereas propagating would kill the whole persistent worker over one bad request. handle(ServerRequestInterface, callable, TrustedProxies) (public, static) is the per-request counterpart of SuperglobalsBridge::handle() and the entry point the runtime conformance suite drives (tests/Conformance/RoadRunnerDriver) — folds a repeated header into one comma-joined value first (PSR7Worker’s own mapping presents repeats as separate array values, unlike every other adapter here), applies X-Forwarded-Proto to the request URI only when the connecting peer matches the application’s TrustedProxies policy (RoadRunner’s own listener is plaintext whenever TLS is terminated in front of it; a directly reachable one reads the header from nobody, and a trusted proxy sending anything but one http/https is a fixed 400), and hands the body on as the raw bytes the client sent, for core’s Kinetis\Http\Middleware\RequestBodyMiddleware to stage, bound and parse inside the Kernel. A StreamableResponseInterface result is abandoned — releasing the request scope the Kernel holds open for its emitter, on that request, without running the emitter — and becomes a real 501 (STREAMING_NOT_SUPPORTED_MESSAGE, a public class constant RoadRunnerDriver matches by exact status/body pairing to report an AdapterRejection) rather than being buffered or dropped. Requires http.raw_body: true in RoadRunner’s own configuration — without it, RoadRunner parses form bodies itself, in Go, before PHP is ever invoked — and checks that on every request through the rr_parsed_body attribute the worker library stamps: true is the misconfiguration, and anything that is not false means the capability cannot be verified at all, which is refused rather than assumed good (Exception\RoadRunnerAdapterException::rawBodyNotEnabled()/rawBodyUndetectable()). See Runtime Adapters for the full supported/unsupported feature list, including the two environment-caused limitations (a purely-numeric header name; cookie order) that this adapter’s driver declares and the shared conformance suite asserts in both directions rather than skipping.

  • Depends on kinetis/framework (via a path repository to this monorepo’s root), nyholm/psr7, psr/http-message, spiral/roadrunner-http, spiral/roadrunner-worker; spiral/roadrunner-cli (dev-only, provides vendor/bin/rr get-binary) fetches the real binary the conformance suite spawns. Own composer.json/phpunit.xml/phpstan.neon.

packages/persistence (kinetis/persistence)

Separate Composer package, not part of kinetis/framework core, so core itself has no direct MySQL/Postgres dependency. Depends on no Kinetis package; kinetis/database-bridge (below) wires it into an application.

  • Kinetis\Persistence\TransactionGuard — SQL transaction safety net for one unit of work. transaction() (commit on success, rollback on throw) and beginTransaction()/rollbackDangling() for the manual case. beginTransaction() and the transaction() callback carry the link’s transaction type through Contract\SqlLink’s TTransaction template: MysqlTransaction for a MysqlLink, PostgresTransaction for a PostgresLink, SqlTransaction for a generic SqlLink. The host calls rollbackDangling() at the end of each unit of work; in a Kinetis application kinetis/database-bridge’s request-scope initializer registers it (see below). rollbackDangling() closes every tracked transaction independently (one failing never blocks the rest) and rethrows the first failure after all attempts; transaction() always preserves the exception that triggered cleanup over any secondary rollback failure, and neither path depends on the configured logger not throwing — see Appendix: Databases’s “What happens when cleanup itself fails”.

  • Kinetis\Persistence\Exception\QueryException — getQuery(): string; getCode(), the server’s error number or 0; getSqlState(): ?string, the SQLSTATE the server or driver reported; isUniqueViolation(): bool, true for SQLSTATE 23505 or MySQL-family error 1062 — see Database’s “Unique violations”.

  • Kinetis\Persistence\ConnectionDefinition — __construct(string $dialect, string $host, string $database, string $user, string $password, ?int $port = null, string $driver = 'auto', ConnectionOptions $options = new ConnectionOptions(), int $warmConnections = 0): $dialect is mysql|pgsql, a null $port takes the dialect’s default (3306/5432), and $driver is auto|native|pdo. Rejects an unknown dialect or driver, a port outside 1–65535 and a negative warm count with InvalidArgumentException; ConnectionOptions validates its own fields.

  • Kinetis\Persistence\SqlConnectionFactory::create(ConnectionDefinition $definition, ?Contract\SqlInstrumentation $instrumentation = null): Contract\MysqlLink|Contract\PostgresLink — builds a runtime-matched driver client: auto is the native async driver when frankenphp_handle_request() exists or RR_MODE=http is set, PDO everywhere else. A positive warmConnections opens connections at construction via each driver’s warmUp(?int $connections = null) — load-bearing for the mysqli driver under worker mode (see Performance tuning).

  • Kinetis\Persistence\SqlConnectionFactory::singleSession(ConnectionDefinition $definition, ?Contract\SqlInstrumentation $instrumentation = null): Contract\MysqlLink|Contract\PostgresLink — the same build under one session: PDO whatever the definition’s driver says, and closed for good rather than reconnected if that session is ever discarded. For work held in the session itself, which is why kinetis/migrations’ commands run on it.

  • Kinetis\Persistence\Contract\SqlInstrumentation — queryDispatched()/queryServerStarted()/queryReaped()/transactionStarted()/transactionEnded(), the moments every client and transaction reports to the instrumentation it was built with; without one they report nothing. A failure an implementation throws is contained by the drivers.

  • Kinetis\Persistence\Testing\DatabaseTruncation — per-test database isolation for a consumer’s PHPUnit suite (see Testing): the tables a test names are emptied before it runs, and the test supplies the connection via an abstract databaseLink(). Holds no transaction of its own, so it works on every driver and with code that opens its own transactions. Testing\DatabaseIsolation (@internal) holds its table-name check.

  • Kinetis\Persistence\Contract\PrefersPreparedStatements — a marker, no methods: this link is faster binding a value than reading it as a literal, so a caller that could safely emit either keeps binding. Carried by PdoMysqlClient/PdoPgsqlClient and their transactions, which run native prepared statements and memoize them per connection (a transaction runs on the client’s own connection, so it shares that one cache); the native drivers do not carry it, having no prepared statement for a bound value to reuse. Kinetis\QueryBuilder\Query is the caller that branches on it.

  • Depends on psr/log and revolt/event-loop, and on no Kinetis package (kinetis/framework only in require-dev); the drivers use ext-mysqli/ext-pgsql/PDO, suggested rather than required. The native Postgres driver additionally needs ext-sockets — it ends a connection’s transport with socket_shutdown() so a statement in flight can be abandoned without blocking the event loop — and refuses to construct without it. Own composer.json/phpunit.xml/phpstan.neon.

packages/database-bridge (kinetis/database-bridge)

Separate Composer package, not part of kinetis/framework core: the Kinetis wiring for kinetis/persistence.

  • Kinetis\DatabaseBridge\ConnectionFactory::fromConfig(Config $config, string $connection = 'default', array $poolOptions = [], ?string $driver = null): Contract\MysqlLink|Contract\PostgresLink — reads DB_* (or DB_{NAME}_* for a named connection, via Config::scopedKey()) into a ConnectionDefinition, validating every key before any driver is constructed — DB_CONNECTION first, so a named connection with no block at all is reported as its DB_{NAME}_CONNECTION — and builds the client through SqlConnectionFactory::create(). $driver overrides DB_DRIVER for one call; $poolOptions['maxConnections']/$poolOptions['warmConnections'] win over DB_MAX_CONNECTIONS/DB_WARM_CONNECTIONS. Used by this package’s PackageBootstrap and kinetis/queue-sql’s SqlQueueFactory; key reference in Configuration.

  • Kinetis\DatabaseBridge\ConnectionFactory::singleSession(Config $config, string $connection = 'default'): Contract\MysqlLink|Contract\PostgresLink — the same keys through SqlConnectionFactory::singleSession(): PDO whatever DB_DRIVER says. kinetis/migrations’ commands run on it.

  • Kinetis\DatabaseBridge\TelemetrySqlInstrumentation — adapts Kinetis\Persistence\Contract\SqlInstrumentation to core’s Kinetis\Instrumentation\TelemetryInterface. Both factory methods build every client with one over Telemetry::global(), so a backend kinetis/telemetry swaps in after a client was built is the one that client reports to.

  • Kinetis\DatabaseBridge\PackageBootstrap — declared via extra.kinetis. Registers an AppScope::onRequestScopeCreated() initializer that binds Kinetis\Persistence\TransactionGuard lazily on every RequestScope: the first resolution in a scope builds the guard and registers its rollbackDangling() on that scope’s disposal, and a scope that never resolves it builds none. Every unit of work whose scope comes from createRequestScope() gets the binding — an HTTP request (Kernel), a command (bin/kinetis), an MCP message over stdio (kinetis/mcp’s ScopedMessageHandler), a queued job (kinetis/queue’s QueueWorker/SyncQueue); a #[Command(bootstrap: false)] command runs no package bootstrap and gets none. With DB_CONNECTION set, also builds ConnectionFactory::fromConfig()’s default connection, registers that exact object’s close() on AppScope::onDispose() and then binds it under its dialect contract (Contract\MysqlLink or Contract\PostgresLink) before the application’s own bootstrap.php runs (which wins on the same binding), and binds Contract\SqlLink as an uncached alias resolving that dialect contract on every lookup — the same object, no second connection or close, an application’s replacement of the dialect binding included; an application SqlLink binding replaces the alias. The close is registered ahead of the binding, so a later bootstrap or boot failure still closes an already-open connection, and it holds the link this package built — an application that binds its own owns that one’s lifetime. With DB_CONNECTION absent, no connection is built, no link contract is bound and nothing is registered. Named (non-default) connections stay explicit app-side wiring for SQL. With kinetis/orm installed (detected with class_exists()), also binds Kinetis\Orm\OrmFactoryRegistry on AppScope — built on first resolution from the OrmMetadata bound then, the link bound under the dialect contract when DB_CONNECTION is set, an application’s own binding included, and a link for every connection an entity names: the application’s db.<name> binding, type-checked and never closed here, or else, after checking DB_<NAME>_CONNECTION (Exception\DatabaseNotConfiguredException when absent), ConnectionFactory::fromConfig($config, '<name>'), whose exact object is registered on AppScope::onDispose() and reused by a later resolution after a failed one — and registers an initializer binding Kinetis\Orm\EntityManagerRegistry lazily on every RequestScope: the first resolution creates it and registers its close() on that scope’s disposal. With DB_CONNECTION set, Kinetis\Orm\OrmFactory and the scope’s Kinetis\Orm\EntityManager are uncached aliases of the two registries’ default entries; with it absent, both ids are bound to throw Exception\DatabaseNotConfiguredException, as is the registry when an entity lives on the default connection. Without kinetis/orm, no ORM id is bound.

  • Kinetis\DatabaseBridge\OrmMetadata implements Kinetis\Cache\CacheableDiscoveryInterface — this package’s extra.kinetis discovery class. compile() reads the project’s PSR-4 roots and installed packages’ scan roots through the operation’s Kinetis\Cache\DiscoveryContext for classes carrying #[Kinetis\Orm\Attributes\Entity] and returns MetadataRegistry::fromClasses()->toArray() for exactly those; fromArray() rebuilds through MetadataRegistry::fromArray() and reports its MappingException as InvalidCacheArtifactException, so a stale entry recompiles. Loadable without kinetis/orm: it then compiles [], reconstructs [], and reports any other entry as stale.

  • Kinetis\DatabaseBridge\Exception\DatabaseNotConfiguredException — a RuntimeException naming the exact missing connection key. forOrm() names DB_CONNECTION when no default connection is configured, thrown by the OrmFactory and EntityManager bindings above, and by OrmFactoryRegistry when an entity lives on the default connection. forConnection() names a named connection’s DB_<NAME>_CONNECTION (DB_REPORTING_CONNECTION for reporting), thrown by OrmFactoryRegistry when an entity names that connection, the application binds no db.<name> and the key is absent — checked before any other key of that connection is read.

  • Depends on kinetis/framework, kinetis/persistence and psr/log (via path repositories to this monorepo’s root); suggests kinetis/migrations, kinetis/orm and kinetis/query-builder, and declares the last two as development dependencies only; conflicts with kinetis/orm <1.10.0, whose OrmFactoryRegistry and EntityManagerRegistry the ORM wiring requires. Own composer.json/phpunit.xml/phpstan.neon.

packages/redis (kinetis/redis)

Separate Composer package, not part of kinetis/framework core — and, like kinetis/revolt-http-client, not dependent on it either: kinetis/framework appears only in require-dev, for the NoStaticPropertiesRule dogfooding. Installable and usable in any Revolt-based project. It owns the transport; PSR-16, Config, serialization, and cache key policy stay with kinetis/cache-redis.

  • Kinetis\Redis\Client — one node, and the package’s whole single-node surface. create(Endpoint, ClientOptions) opens nothing: the socket, the TLS handshake, AUTH and SELECT all run on the first command. execute(string $command, int|float|string ...$parameters) returns the unwrapped reply. It implements RoutedExecutor too, ignoring the routing key, so one consumer works unchanged against a node and a cluster; allowsCrossSlotKeys() is true here, which is what keeps MGET and a multi-key DEL one round trip. link() returns the vendor Amp\Redis\Connection\RedisLink for new Amp\Redis\RedisClient($client->link()), which is how kinetis/queue-redis reaches the typed command facade.

  • Kinetis\Redis\ClusterClient — create(non-empty-list<Endpoint>, ClientOptions), refusing a non-zero database because Redis Cluster has no SELECT. The slot map is read from the seeds via CLUSTER SLOTS on the first operation, and accepted only when its ranges cover 0-16383 exactly once. executeKeyed()/script() route on a caller-named key — never one guessed from a command’s parameters. A MOVED reply patches that one slot and the command goes straight to the target it named, with no topology read in between — the reply proves both. Discovery itself reads the seeds in order, each given an equal share of what is left of the budget, and concurrent fibers share one in-flight read; a fiber waiting on another’s read reports its own expiry as Exception\TopologyUnavailable. A routed operation that fails with Exception\ConnectionFailed drops the cached map before reporting it, so the next operation rediscovers the topology and routes at the current owner; Exception\OutcomeUnknown leaves the map in place, and neither re-sends the command. An ASK reply leaves ownership alone and sends ASKING plus the command as one socket write on the target’s existing connection, so no other fiber’s command can land between the pair and no second connection per redirect exists. A script under ASK is sent as ASKING + EVAL, never EVALSHA, which would consume the ASKING. Six attempts in total per operation, then Exception\RedirectLimitExceeded. nodes() returns one Client per current master, deduplicated, from a fresh discovery — what a prefix scan across the whole keyspace needs.

  • Kinetis\Redis\Internal\Link (@internal) — the pipelined, non-replaying connection both clients run on, replacing amphp/redis’s ReconnectingRedisLink, which re-sends every queued command after a connection loss. Concurrent fibers share one socket and replies match commands in order. A failure before the write is Exception\ConnectionFailed and is safe to retry; a failure after it is Exception\OutcomeUnknown and is never written again. Discarding a connection settles every frame on it exactly once. close() aborts a connection attempt still in flight, and an attempt that is no longer the link’s current one closes its socket rather than publishing it. The socket is referenced only while a reply is outstanding, so an idle link lets EventLoop::run() — and therefore Kinetis\Async\concurrently() — return.

  • Kinetis\Redis\Deadline — one absolute monotonic budget per operation (ClientOptions::$timeout seconds), covering connect, TLS, AUTH, SELECT, write, reply, discovery, and every redirect hop. The write is bounded by closing the socket, since Amp’s writable stream takes no cancellation and backpressure would otherwise suspend past any deadline. A budget already spent is refused before dispatch as ConnectionFailed, on the first dispatch of an operation and on every later one. Expiry after the write closes the connection and fails every frame on it once as OutcomeUnknown, so a half-open connection to a vanished server heals on the next call rather than wedging the link.

  • Kinetis\Redis\Endpoint/ClientOptions/ConnectionUri — a {host, port} pair parsed from host:port or [ipv6-address]:port; an immutable password/database/TLS/timeout carrier propagated unchanged to seeds, discovered masters, and redirect targets alike; and a redis:// URI read through the vendor RedisConfig, so any URI amphp/redis accepts is accepted identically. The password reaches the wire only as the AUTH frame’s argument; no exception carries it, a URI holding it, or a server reply quoting a rejected credential, and the parameters that carry it are marked #[\SensitiveParameter] so a trace cannot either. A malformed URI is reported by a fixed message with no cause attached, since the vendor’s own failure quotes the whole URI back.

  • Kinetis\Redis\Cluster\HashSlot/SlotMap/Redirect — CRC16-XMODEM mod 16384 with {...} hash-tag handling; the parsed CLUSTER SLOTS map plus its MOVED patches; and a structurally validated redirect reply, null for a message that is not one and Amp\Redis\Protocol\ProtocolException for one naming a kind it then fails to parse.

  • Four exceptions, all extending Amp\Redis\RedisException: ConnectionFailed, OutcomeUnknown, RedirectLimitExceeded, TopologyUnavailable. A Redis error reply stays Amp\Redis\Protocol\QueryException.

  • Depends on amphp/redis, amphp/socket, amphp/amp, and revolt/event-loop. Own composer.json/phpunit.xml/phpstan.neon.

packages/cache-redis (kinetis/cache-redis)

Separate Composer package, not part of kinetis/framework core — extracted from it for the identical reason kinetis/persistence was: core has no direct Redis dependency either. NullSimpleCache and the PSR-16 exception types stay in core (see Appendix: System Layout’s Kinetis\SimpleCache section); only the classes with a real Redis dependency moved. The transport itself moved on again, into kinetis/redis (above), which this package composes.

  • Kinetis\SimpleCache\RedisSimpleCache — one PSR-16 class over kinetis/redis’s RoutedExecutor, serving a single node and a Redis Cluster alike: the executor routes each key, and the only behaviour that differs is whether one command may name keys from more than one slot. fromConfig(Config $config, string $connection = 'default') returns null when Redis is not configured at all. Values are serialized with Amp\Serialization\NativeSerializer. Also implements core’s Kinetis\SimpleCache\AtomicCounterInterface: increment() runs INCR and EXPIRE as one Lua script, so concurrent callers each receive a distinct value, and count() reads that counter — a bare integer, not a serialized value, so get() cannot read it. And Kinetis\SimpleCache\AtomicConsumeInterface: consume() runs GET and DEL as one Lua script, so at most one of two concurrent callers ever receives a given value. Both scripts carry exactly one key, so each runs on the node that owns that key’s slot. replace(string $key, mixed $value, int $ttlSeconds): bool is one SET ... EX ... XX — it writes only over a key that is already there and reports false otherwise, which is what kinetis/session’s RedisSessionStore needs and PSR-16 cannot express. And core’s Kinetis\SimpleCache\DisposableCacheInterface: the constructor’s optional ?Closure $disposer, after the serializer, closes the executor; fromConfig() passes the executor it opened as disposer: $client->close(...), and a cache constructed without one borrows its executor. dispose() drops the disposer before running it, so a second call closes nothing.

  • Physical keys are kinetis_cache:<namespace>:<key>, with REDIS_CACHE_NAMESPACE ([A-Za-z0-9_-]+, default unless set) naming the namespace. The prefix carries no {} hash tag, so keys still spread across cluster slots rather than collapsing onto one. clear() runs SCAN MATCH <prefix>* plus UNLINK in bounded chunks on every entry nodes() reports, never FLUSHDB, so keys this cache did not write survive it; it is non-atomic, and costs one pass over each node’s keyspace. getMultiple()/deleteMultiple() send one MGET/multi-key DEL when allowsCrossSlotKeys() is true and one command per key concurrently through Kinetis\Async\concurrently() otherwise, since Redis Cluster rejects a multi-key command whose keys do not share a slot. Cache failures surface as Exception\CacheException naming the operation and never the key, which is routinely a session identifier or a token hash.

  • Kinetis\SimpleCache\RedisConnectionFactory — the REDIS_* half: fromConfig() maps REDIS_CLUSTER/REDIS_CLUSTER_SEEDS, REDIS_URL, or discrete REDIS_HOST/REDIS_PORT/REDIS_DATABASE to a Kinetis\Redis\ClusterClient or Client, and options() maps REDIS_TIMEOUT (the whole operation budget), REDIS_PASSWORD, and the REDIS_TLS* keys to ClientOptions. Kinetis\Config\Config is a framework type, so this mapping lives here rather than in the standalone transport package.

  • Kinetis\Container\AppScope::boot() calls RedisSimpleCache::fromConfig(), class_exists()-gated against this package, and registers that cache’s dispose() on AppScope::onDispose(); a cache the application bound first is neither built nor registered. Redis configured (REDIS_HOST/REDIS_URL/REDIS_CLUSTER) but this package not installed binds core’s UnavailableSimpleCache, whose every operation throws Kinetis\SimpleCache\Exception\SimpleCacheUnavailableException naming this package — never a silent fallback to NullSimpleCache, and never a boot-time failure for an application that doesn’t touch the cache.

  • Depends on kinetis/framework and kinetis/redis (both via path repositories to this monorepo’s root), amphp/redis, amphp/socket, amphp/serialization. Own composer.json/phpunit.xml/phpstan.neon.

packages/mcp-protocol (kinetis/mcp-protocol)

Separate Composer package requiring PHP and nothing else — no framework, container, HTTP transport, attribute, reflection, discovery, docs fetching, filesystem policy or application bootstrap. It owns the MCP wire three servers share: kinetis/mcp, kinetis/mcp-docs and kinetis/orbitron. Namespace Kinetis\McpProtocol, no binary, no extra.kinetis, so installing it registers nothing anywhere. The contract is stated once in Appendix: MCP Reference.

  • ServerInfo — the immutable identity initialize answers with: name, version, and optional instructions.

  • ToolDescription / ToolAnnotations / ResourceDescription — wire DTOs, not discovery records: a tool’s published name, description, inputSchema and optional annotations, and a resource’s uri, name, description and mimeType. ToolAnnotations carries all four 2025-06-18 hints (readOnly, destructive, idempotent, openWorld) together, because a partial set is the one that misleads — a tool declaring readOnlyHint: false and omitting destructiveHint reads as the specification’s default, which is destructive. A tool with no annotations omits the object entirely.

  • ToolResult / ResourceResult — one text content block, an isError flag and optional structuredContent, and one read’s uri/mimeType/text. ToolResult::text() and ToolResult::error() carry text alone; ToolResult::structured(string $text, array $document, bool $isError) also carries the associative document that text encodes, which McpServer::callTool() sends as structuredContent beside the unchanged text block — cast to an object, so an empty document is {} — and omits when none was supplied. Encodability is settled by the server’s response check like every other member; no output schema is validated or published.

  • McpApplication — the one interface a consumer implements: tools(), resources(), callTool() and readResource(). The server owns everything around it — it validates every envelope and parameter, resolves name/uri against those two lists, and never invokes a call for a name they do not carry, so an implementation answers only for its own published surface. Capabilities are derived from the same lists: a consumer publishing no tools advertises no tools capability. $context is an opaque ?object the server passes through untouched; the package names no container, scope, lifecycle or logging concept.

  • ProgressEmitter — one tools/call’s progress, tied to that call’s token. report() invokes the transport’s emit closure synchronously on the tool’s own call stack, and does nothing when the call carried no token or the transport cannot carry a notification. total and message are omitted from the notification when not given rather than written as null.

  • MessageHandler — what a transport hands one decoded message to: handle(array $message, ?Closure $emit, ?object $context): ?array. McpServer implements it; an adapter needing a per-message unit of work wraps one and implements it too, which is how kinetis/mcp keeps its request-scope policy out of the transport.

  • McpServer — MCP 2025-06-18 and no other revision, against one McpApplication. Stateless: immutable server metadata and the consumer reference, and nothing else — no negotiated version, session, arguments, progress token or result survives a call, which is what lets one instance serve a persistent stdio process and a stateless HTTP route at once. It follows that initialization ordering is not enforced. initialize always selects 2025-06-18 whichever revision the client asked for, per the specification’s own negotiation rule, so a single-version server has no unsupported-version error to raise. Six methods: initialize, ping, tools/list, tools/call, resources/list, resources/read; anything else is -32601. Notifications are answered with nothing and dispatch nothing, and a well-formed client response message is ignored, since this server sends no requests. A tool that ran and failed is an ordinary isError: true result; only a malformed request, an unknown method, an unknown tool (-32602) or resource (-32002), or an unexpected failure is a JSON-RPC error. A consumer’s own JsonRpcException reaches the client as written; any other exception is contained as a generic -32603 whose caught text is discarded rather than reported, so a consumer that wants diagnostics writes them itself.

  • JsonRpcCodec / JsonObject — the one decode and structural-validation path every entry point shares, with three outcomes rather than one nullable answer: a message to dispatch, an error response to send, or a message to ignore. Object-mode decode keeps a JSON object a stdClass and a JSON array a plain array, and that fidelity is kept rather than flattened, so a tool’s arguments reaches its consumer with {} and [] still distinct at every depth. isValidId() admits a string or an integer only: 2025-06-18 states an id MUST NOT be null, so {"id": null} is an invalid request rather than a request whose id is null. JsonObject is the explicit “treat this as an object” marker a caller building a message in PHP needs, since [] is the only array literal for both empty shapes; toObjectTree() converts a tree holding markers back to the stdClass/array shape a consumer receives, so a marker never reaches consumer code.

  • StdioLoop — the transport: one message per line in, one frame per response out, synchronous and one at a time, which is the backpressure. EOF ends the loop normally. Input is read in bounded chunks and capped at MAX_PAYLOAD_BYTES (2 MiB, matching the framework’s default MAX_BODY_SIZE); a line past the cap is drained through its next terminator or to EOF, answered with exactly one -32700 under id: null, and the next frame is then processed normally, while a full chunk arriving with no terminator is not oversized on that ground alone and a final unterminated line at EOF is a complete message. Only \r/\n are stripped, never a bare trim(), whose default charlist would also remove NUL and vertical-tab bytes. Every frame is written whole, fwrite() looped until every byte lands; a write that stops making progress is Exception\StdioWriteException and ends the loop, since nothing can safely follow a partial frame. A failed progress write is stashed rather than thrown out of the emit closure — where the consumer would catch it as an ordinary tool failure and that failure would then be written into the corrupted stream — every later notification for that message is skipped, and the stash is re-thrown once the handler returns, before any final response is attempted. There is no output cap: a tool result has no universal safe size, so each consumer bounds its own.

  • Exception\JsonRpcException / Exception\StdioWriteException — one named constructor per code a call site may raise, so none is invented, and the partial-frame failure carrying the exact byte counts.

  • Depends on nothing. Own composer.json/phpunit.xml/phpstan.neon/psalm.xml — the last two without core’s NoStaticPropertiesRule/NoBlockingIoRule, which ship in kinetis/framework.

packages/mcp (kinetis/mcp)

The Model Context Protocol server for a Kinetis application. Installing the package is the whole setup: its extra.kinetis declares a scan root covering the namespace, a bootstrap, and a discovery class, so the /mcp route and mcp:serve appear with nothing to wire. Core has no MCP surface without it. The wire belongs to kinetis/mcp-protocol; what lives here is everything Kinetis-specific around it.

  • PackageBootstrap — lazy-binds Kinetis\McpProtocol\McpServer, built from a ServerInfo naming Kinetis and a KinetisMcpApplication around whatever McpRegistry is already resolvable from the container. McpRegistry is not discovered here at all — it’s declared as this package’s own extra.kinetis discovery class instead, so the framework itself compiles, caches, and binds it before this method ever runs (see Kinetis\Cache\PluginDiscovery).

  • KinetisMcpApplication — the whole adapter from this package’s registry and dispatcher to the shared McpApplication contract. tools()/resources() map ToolDefinition/ResourceDefinition to the protocol’s own descriptions. callTool() converts the raw stdClass arguments through Kinetis\Validation\JsonTree::convert() — which is what keeps a JSON object whose keys happen to look sequential ({"0":"a","1":"b"}) distinguishable from a real JSON array once flattened, so Hydrator’s array/iterable check and #[ListOf] can refuse the first — unwraps only the top-level marker, and hands the members to McpDispatcher with the per-message container as its scope. A returned ToolResult passes through unchanged, which is how a tool reports a deliberate refusal; any other return is JSON-encoded as a successful text result. A ValidationException becomes an isError: true result carrying its real violations, the argument feedback an agent retries on; any other exception becomes the fixed text Tool execution failed. with the real one logged. A resource has no error result of its own, so a throwing resource method is logged and raised as a generic -32603. Both logging calls route through a private logSafely(), which catches and discards a failure from the logger itself: one bad message, or one bad log call, must not crash a long-running stdio process or replace a result already decided on.

  • ScopedMessageHandler — the stdio lifecycle policy, and the only place it lives. It wraps the shared server and gives each decoded message a fresh RequestScope from AppScope::createRequestScope() with every package request-scope initializer run on it, passes that scope as the opaque context, and disposes it — followed by gc_collect_cycles(), since a Kinetis request scope can hold cycles — in a finally, before the transport writes the final frame. Disposal is guaranteed not to throw: a failure is logged through SafeLogger::logFrom() against AppScope’s own logger, since the message’s own scope is already disposed by then, and it never writes a second protocol response or ends the process. A parse error the codec answers on its own never reaches the handler and so creates no scope. Over HTTP there is no decorator: the request already has a scope Kernel created and disposes.

  • Http\McpController — /mcp as an ordinary route (#[Post('/mcp')], #[Middleware('@mcp')]). Decodes the body through the shared JsonRpcCodec, enforces the one protocol header, then either opens the SSE progress stream or dispatches and returns one buffered JSON response. MCP-Protocol-Version is the only protocol header: initialize may omit it, every later message must carry exactly 2025-06-18, and a missing header on a later message means the specification’s 2025-03-26 fallback this single-version server does not implement, so it is 400 rather than assumed — nothing is persisted between requests to infer it from. Malformed input is 400 with the JSON-RPC parse/invalid-request envelope; a protocol error after a valid envelope is an ordinary 200, because the request was understood and its outcome belongs in the envelope. A valid notification, and a client response message, are 202 with no body. Sessions are absent, so no Mcp-Session-Id is ever emitted, and GET/DELETE declare no routes: the router’s own 405 with Allow: POST is what a server implementing neither a server stream nor session termination returns. wantsProgressStream() requires array_key_exists('id', ...), the tools/call method, and a _meta.progressToken that is already a string or an integer — streaming is request-only, and a malformed token belongs in an ordinary bufferable -32602 rather than inside a stream already committed to text/event-stream. The stream is a StreamedResponse whose emitter dispatches on the RequestScope injected into the controller — the request’s own, which Kernel keeps alive for a streamed body and disposes through its lease once the emitter returns or fails, so a tool sees every binding an mcp-group middleware published, under every id it used, exactly as an ordinary call does; this package owns none of that lifecycle and creates no second scope. The body is read with a (string) cast, not getContents(): the staged stream is replayable, and the cast is the representation that rewinds first, so a middleware that already inspected the body cannot shorten the envelope this decodes.

  • Http\McpOriginMiddleware — the spec-required Origin validation, reading MCP_ALLOWED_ORIGINS (comma-separated exact list, empty means any request carrying an Origin is rejected 403). A permanent mcp-group member at priority 100 — which is also what guarantees the group McpController references always exists.

  • Http\McpIdentityGuardMiddleware — the group’s permanent member at priority 0, the last thing to run before McpController. Delegates when MCP_HTTP_PUBLIC (a Config::bool() read, so an unrecognized value throws Kinetis\Config\Exception\InvalidConfigValueException) is true, or when the request’s own RequestScope reports isRegistered(Kinetis\Http\CurrentUserInterface::class); otherwise ErrorResponse::create(401, 'Unauthenticated.'), with no WWW-Authenticate header — the scheme belongs to whichever authentication middleware the application put in the group at the default priority 50. isRegistered() specifically, never has()/ get(): both answer for any autowirable class, so either would accept a manufactured, disconnected object as proof of authentication. CurrentUserInterface alone counts — an identity published only under a concrete user class is not the portable boundary a tool or another package depends on. Model Context Protocol (MCP)’s “Securing the HTTP transport” is where the contract itself is stated; stdio, which has no middleware group, is unaffected.

  • Console\McpServeCommand — #[Command('mcp:serve')], resolving the bootstrap’s own server binding and running the shared StdioLoop over a ScopedMessageHandler built from the real AppScope.

  • McpRegistry — #[McpTool]/#[McpResource] discovery, toArray()/fromArray() for the AOT cache. Implements Kinetis\Cache\CacheableDiscoveryInterface; compile() delegates to McpDiscovery::discover() with the operation’s Kinetis\Cache\DiscoveryContext. A tool name and a resource URI are each globally unique across every registered class: register() stages a class’s own definitions and checks them — against everything already registered, and against each other — before appending any of them, so a class with one conflicting definition registers none of them (atomic per class), and a genuine name/URI collision between two different classes throws Kinetis\Mcp\Exception\DuplicateDefinitionException naming both conflicting Class::method() pairs rather than letting one silently shadow the other. register() is idempotent per class ($registeredClasses, the same pattern EventListenerRegistry::register() already uses): registering the same class a second time — directly, or via McpDiscovery’s own two scan passes both finding it — is a safe no-op, never re-appends its definitions and never treats them as a conflict against themselves. toArray()/fromArray() carry a tool’s inputSchema as its own JSON text, in inputSchemaJson — an unambiguous JSON representation of the schema, written so the cache can restore it, not the bytes a transport puts on the wire (a transport encodes the whole tools/list response itself, without JSON_PRESERVE_ZERO_FRACTION, so a stored 1.0 goes out as 1). JSON Schema distinguishes the empty object {} from the empty array []: JsonSchema spells {} as a live (object) [], produced for a zero-parameter tool’s properties and for a mixed-typed argument’s whole schema, while an empty required list is an ordinary empty array that stays an array. A PHP array expresses only the second of the two, and a compiled artifact carries plain data only — Kinetis\Cache\CacheStore refuses a live object anywhere in it before writing. A JSON string is plain data and already carries the distinction, so the artifact holds one self-describing field with nothing alongside it to disagree with, and every empty object and every empty array survives at any depth and in any combination. The root is cast to an object on the way out and read back as an array on the way in, since ToolDefinition::$inputSchema’s own array type fixes the root’s JSON type rather than the document doing so. fromArray() rejects three things as Kinetis\Cache\Exception\InvalidCacheArtifactException — text that is not valid JSON (a truncated or hand-edited file), a document whose root is not a JSON object, and an object whose member names PHP reads as numbers, which a PHP array cannot hold apart from a list and which toArray() never writes. That is what lets the framework’s cache loaders classify the artifact as unusable and recompile from live discovery, rather than tools/list advertising — and tools/call validating against — a schema the application never declared. fromArray() validates the exact top-level and per-entry shape via Kinetis\Cache\Exception\ArtifactValidation, satisfying CacheableDiscoveryInterface::fromArray()’s own contract to throw something implementing Kinetis\Cache\Exception\CacheArtifactExceptionInterface for malformed data — inputSchemaJson is validated only as far as decoding it requires, per the three rejections above, its own deeply recursive JSON-Schema vocabulary never re-checked here — and re-checks the same tool-name/resource-URI uniqueness invariant register() enforces live, rejecting a compiled artifact carrying a duplicate rather than silently preserving whichever entry was listed first; every controllerClass it reads also marks that class registered, so a later live register() call for a class already present in a loaded artifact is the same no-op it would be after a live registration.

  • McpDispatcher — the MCP analogue of Http\Dispatcher. callTool()/readResource() take an optional per-call scope the adapter passes through from the transport; the controller and its dependencies resolve from it, falling back to the constructor’s container when none is given (which is then not per-message-scoped). Every argument failure is a Kinetis\Validation\Exception\ValidationException carrying structured violations, never a dispatcher-specific exception: an argument that is absent with no default is required at its own path, and a scalar one enters Hydrator::resolveScalar() under InputSource::Json. An #[ObjectMap] argument, recorded as the binding plan’s objectMap flag from Hydrator::objectMap(), enters Hydrator::resolveObjectMap() instead — the path a DTO’s #[ObjectMap] field takes — and binds a JSON object as a recursively plain array, reporting not_a_json_object for a JSON array. A DTO-typed argument decides null first, exactly as a #[Body] field does — a nullable parameter takes it, a non-nullable one reports null_not_allowed — then accepts an existing instance or hydrates an object-shaped value, and reports not_an_instance for any other object and type_mismatch for a scalar. The arguments object is closed: a key naming no client-facing parameter is unexpected_field at its own path (Hydrator::unexpectedFieldViolation(), the same code and sentence a JSON DTO member gets), and an injected ProgressReporter parameter is never such a name. Every argument failure a call has — unknown, missing, wrong-typed or rule-refused — is collected and raised in one ValidationException, in parameter order followed by the call’s own key order. A resource has no client argument object, and an already-constructed DTO instance handed straight to a parameter has no member map to close. derivePlan() refuses a composite parameter type with the framework’s own Validation\Exception\JsonSchemaException::compositeType() rather than binding it as mixed, so live binding and McpRegistry’s schema generation admit exactly the same method declarations; $bindingPlans/$hydrationPlans remain an optional constructor seam nothing currently fills.

  • ProgressReporter — the Kinetis-facing progress API, injected by type into a tool method so an application controller imports nothing from the wire layer. It wraps the protocol package’s own ProgressEmitter and delegates; without _meta.progressToken on the request there is no emitter and report() does nothing, so tool code calls it unconditionally.

  • McpDiscovery::discover(Kinetis\Cache\DiscoveryContext $context, ?array $paths = null): McpRegistry — builds a registry from every class found anywhere under a project’s own PSR-4 root(s), plus Kinetis\Mcp itself, read through the context (see Appendix: System Layout’s Kinetis\Cache), rather than an explicit registration file. $paths, or MCP_DISCOVERY_PATHS when omitted, restricts the project-side scan. This is the same live-discovery path McpRegistry::compile() calls for the AOT-cache build, not a separate mechanism.

packages/mcp-docs (kinetis/mcp-docs)

Separate Composer package, framework-agnostic — kinetis/mcp-protocol is the only Kinetis package it depends on, and that one registers nothing — the owner of this documentation site’s catalogue and fetch, described in full in MCP Documentation Server. Runs as a server of its own, and is also what kinetis/orbitron requires and composes to publish the same kinetis://docs/* resources and the same window and search tools from its own connection; no copy of the catalogue or of either tool exists, since Orbitron holds a DocsApplication and delegates to it. Namespace Kinetis\McpDocs, one binary (bin/kinetis-mcp-docs), setup.sh, which installs and registers it with Claude Code or Codex, and start.sh, the command that registration spawns.

  • DocsCatalogue — the fixed list of pages this server serves, as DocsPage entries, plus find() for the URI a resources/read or a window call names. Three constants fix the resource URI prefix, the raw source base URL, and the text/markdown type; nothing about any of them is configurable. tests/DocsCatalogueTest.php pairs the list against the repository’s own docs/*.md, so a page added without an entry, or an entry naming a page that no longer exists, fails the suite.

  • DocsPage — one entry: slug, name, description, and the uri()/sourceUrl() both derived from the slug.

  • DocsFetcher — reads one page over HTTPS through Symfony’s HTTP client. The client is the constructor’s only argument, so the suite can pass a MockHttpClient; every property of the request is fixed here — TLS verified peer and host at TLS 1.2 or better, no redirect followed, an idle timeout and a total deadline, and a streamed body abandoned past 4 MB. A non-200 status, a transport failure, or an oversized body is Exception\DocsFetchException.

  • DocsApplication — the whole consumer adapter over Kinetis\McpProtocol\McpServer: the catalogue as ResourceDescriptions, one read per resources/read, the kinetis_read_doc and kinetis_search_doc tools, and serverInfo(), the one authority for the server’s name, version and instructions. READ_TOOL and SEARCH_TOOL are the exported tool names, and readTool() and searchTool() the one authoring of each tool’s description, schema and annotations — both read-only, non-destructive, idempotent, open-world — which kinetis/orbitron includes rather than restating. A call validates the whole closed schema (uri, optional startLine, optional lineCount of 1..MAX_LINE_COUNT) into -32602 before the catalogue is consulted; a well-typed URI the catalogue does not carry, and a startLine past the page, are isError: true documents carrying resource_unknown and line_out_of_range. A window splits the fetched page after every newline, so terminators, CRLF and a missing final newline survive and successive windows concatenate back to the page exactly as long as it has not changed on the remote between calls, and it ends at the requested line count or MAX_CONTENT_BYTES, whichever comes first — never mid-line, and never empty, so a single line past the ceiling is served whole. Every window, search and refusal document is returned through ToolResult::structured() with the array it was encoded from, so the text and structuredContent carry one document. A search validates its closed schema (uri, a query of 1..MAX_QUERY_LENGTH code points, optional startLine) the same way, shares the window’s page lookup, refusals and fetch, compares each line literally and case-sensitively without its terminator, and returns at most MAX_MATCH_COUNT matches, with hasMore set when a later line matches. A page whose fetch failed, or whose bytes are not valid UTF-8 — which could not go into a JSON frame at all — is reported to the diagnostics stream and raised as -32603 with the fixed message Could not read "<uri>". for a resource read, a window and a search alike; the URL and the real reason never enter the response. Nothing is cached, so every call fetches the page again and hasMore describes only the response carrying it. SERVER_VERSION is paired against this package’s manifest version by the suite.

  • bin/kinetis-mcp-docs — locates the autoloader through Composer’s generated bin proxy, then runs the shared StdioLoop over an McpServer built from DocsApplication, with stderr as the diagnostic stream. Nothing but JSON-RPC frames reaches stdout, and a failure that ends the loop is reported on stderr with exit code 1.

  • Depends on kinetis/mcp-protocol, symfony/http-client and symfony/http-client-contracts. Own composer.json/phpunit.xml/phpstan.neon — the last without core’s NoStaticPropertiesRule, which ships in kinetis/framework.

packages/orbitron (kinetis/orbitron)

Separate Composer package, installed with composer require --dev. The development-only construction harness described in full in Orbitron: four documents for the coding agent a developer brings, three of which only read, reachable as four commands or as four MCP tools; three further MCP-only tools reach one real installed, non-root package’s own source, Kinetis first and any other dependency the project installed the same way — one reads a bounded line window of a file, one searches a file for a literal string, one lists the direct children of a directory; plus its context resource and — over MCP only — this documentation’s kinetis://docs/* resources, served through the kinetis/mcp-docs it requires. Namespace Kinetis\Orbitron, one binary (bin/kinetis-orbitron-mcp, launched by an MCP client rather than registered with the framework), no bootstrap, no discovery plugin — extra.kinetis.scan naming Kinetis\Orbitron\Console\ is the whole framework registration.

  • PackageFact — one record from Composer’s installed set: name, the nullable version/installPath, and a root flag. The first two are nullable because Composer\InstalledVersions::getInstalledPackages() also lists every name an installed package replaces or provides, reporting null for each on those; root is the other distinction that list does not draw, read from InstalledVersions::getRootPackage(), the one place the root project’s name is stated.

  • InstalledPackages — the retention rules, and the seam the suite constructs directly. A list of PackageFact objects is read as given; null reads Composer’s own installed set instead, which is what the container’s autowiring builds for a command. fromProject(projectRoot) is the third construction, for the MCP server: it evaluates that project’s generated vendor/composer/installed.php — the shape Composer\InstalledVersions documents and itself requires — and converts its root name and its versions entries’ pretty_version and install_path into the same records, so a server outliving a composer require is not bound to Composer’s process-global cache and never mutates it; an absent or malformed inventory is a RuntimeException rather than an older set reported as current. A record is kept only when it carries both a version and an install path, so a replaced- or provided-only name, and a metapackage with no files of its own, never reaches a document; vendor is not part of that test, because an exact installed dependency is the authority for its own behavior and its install root has to reach source() whoever published it. Entries are keyed by name — one per name, first record winning, as Composer’s own lookup does — and ksorted. records() returns {name, version} pairs, and is the one Kinetis-only view: it applies the kinetis/ filter, because the reported inventory is the harness’s own, and leaves out the Composer root project, which is what is being developed rather than something the project installed, so a root under the kinetis/ vendor is not a dependency to report; install paths never reach these records. source(name) is the one other accessor: {version, root} for a real installed, non-root package of any vendor, null otherwise, and the one place an install path leaves this object — handed to PackageSourceReader alone, which reports neither the root it resolved nor the path it opened. orbitronVersion() is the detected kinetis/orbitron version, the single authority every document reports; it answers from the retained set rather than from records(), so a checkout of this package developing itself still names its version, and it throws rather than inventing one when no such record exists. Nothing is memoized between instances.

  • Context — the context document. toArray() is the document: the harness identity and its limits, the guide links, the workflow, each command’s own effect boundary, the launcher note, and the package facts. toMarkdown() renders that same array, so the two formats cannot state different things.

  • LayoutState / LayoutCheck — the state one layout check concluded (pass, error, skip), and the check itself: reported name, state, and the stable machine code for why. skip is the state of a check that was never performed, not a weaker error.

  • ProjectLayout — the layout reader and its immutable result. read() takes the already-detected project root and appends the fixed composer.json itself, so no caller-selected path is opened. The manifest is read through fopen()/stream_get_contents() bounded at MAX_MANIFEST_BYTES (1 MiB) plus one byte, the single byte that separates an admitted manifest from an oversized one; file_get_contents() is never used. A JSON object root is required — decoding cannot tell {} from [], so the first non-whitespace byte is checked too. Each of autoload.psr-4 and autoload-dev.psr-4 must carry exactly one prefix whose single string mapping is exactly src/ or tests/, with a non-empty PSR-4 prefix ending in \; two prefixes on a fixed path, or an array-valued mapping reaching it, are errors. Unrelated mappings, array-valued ones included, are left alone. A manifest that could not be used makes both namespace checks skip. namespaces() answers only when both are valid. Codes name an outcome and never carry file contents, exception text, or a path.

  • ScaffoldMode / ScaffoldStatus / ScaffoldOutcome — which operation ran (preview, apply), what it concluded (ready, created, refused, failed), and the protocol-neutral result: mode, status, the stable codes in a fixed order, and the project-relative files a failed rollback may have left. The two targets are not carried on the result because they are fixed; HealthScaffold::TARGETS is the one authority. succeeded() is ready or created.

  • ScaffoldWriter / FileScaffoldWriter — the exclusive-create write set and nothing wider: create()/write()/flush()/close()/remove(), each reporting failure as a value and none throwing. FileScaffoldWriter opens with fopen($path, 'x+b') only, so an occupied path — regular file, directory, symlink, or symlink pointing at nothing — is never opened and never truncated. The interface exists so the suite can drive a short write, a write that makes no progress, a failing flush, close, second create or removal.

  • HealthScaffold — the one construction workflow, protocol-neutral, with no input beyond the project root each call is given. preview() reads; apply() re-reads everything and is the only write. Both resolve the layout through ProjectLayout, then the four fixed directories (src, src/Http, tests, tests/Http: each a real directory, resolving under the physical root, none a symlink) and both targets (occupied is file_exists() or is_link()). Refusal codes are deduplicated and emitted in one declared order. The write set is TARGETS, written in that order, each byte looped until written, flushed and closed with both outcomes checked; a failed create, write, flush or close removes every file this invocation created and never a pre-existing one, and a removal that fails is reported with exactly the relative paths that may remain. It creates no directory, reads no application source, and therefore cannot pre-detect a GET /health route declared elsewhere — the generated test surfaces that through route discovery. Nothing per-invocation survives the call; the object holds only its writer.

  • PackageSourceReader — one bounded line window of one real installed, non-root package’s own file, a literal search of such a file or of a bounded directory tree of such a package, or a directory listing, read live on every call: InstalledPackages::source() resolves the package to its install root and version; path is admitted by syntax alone before any realpath() runs — relative, /-separated, no empty segment, no segment beginning with . (which covers ., .. and every hidden name) and no first segment of vendor — so the install root, not a fixed location list, is the read boundary; that is what makes a root-mapped production class, a lib tree, a generated or classmap directory and the package’s own tests readable. The resolved target is realpath()d and must both remain inside the admitted root with the directory separator checked (so a sibling merely sharing a name prefix is not treated as inside) and be a regular file; the path a symlink resolved to is re-admitted against the same syntax rule, so one pointing out of the package, or onto a hidden name or the package’s own top-level vendor/, is refused rather than followed. The read is one fopen()/stream_get_contents() bounded at MAX_SOURCE_BYTES (1 MiB) plus one byte, the single byte that separates an admitted file from an oversized one; a NUL byte or invalid UTF-8 is refused rather than returned. Lines are split keeping each own ending, startLine past the last line is line_out_of_range, and the window and hasMore are computed from the total line count. One private resolve() owns the lookup, the admission, the realpath(), the root-prefix check and the resolved-target re-admission, and lines() the file work behind them, so read(), search(), searchTree() and list() are admitted and confined by the same code rather than by copies of it. Because a directory name is admitted syntax, a read or a search naming one reaches the regular-file check and is source_unreadable. search() adds only the scan: a case-sensitive str_contains() over each line as it is reported — without the \n or \r\n the file stores after it — from startLine, stopping at the first match past MAX_MATCH_COUNT (50), which is what hasMore reports; a query carrying a line terminator therefore matches nothing, finding none is a success with an empty matches, and no cursor is returned because the next one is the last reported line plus one. list() adds only the directory work: the literal . is the one path naming the install root itself, the target must be a directory (source_not_directory otherwise, which is what a root file is), each child is admitted by its own name and then realpath()d and re-admitted against the same root before it is classified with is_file()/is_dir() — so a hidden, vendor, escaping, dangling or special child is skipped rather than named, and no isLink() branch decides anything — a name that is not valid UTF-8 refuses the listing as source_unreadable, the child past MAX_ENTRY_COUNT (200) refuses it as directory_oversize with no partial names, and the accepted entries are sorted bytewise by name with strcmp() rather than inheriting the filesystem’s order. searchTree() takes the same directory path as list() and walks it through the same child admission, visiting each resolved directory once so a link back to an ancestor cannot loop. The walk spends two budgets before any file is opened: the reportable regular file past MAX_TREE_FILE_COUNT (512), or the size that takes the files it would read past MAX_TREE_BYTES (8 MiB), refuses the call as package_search_oversize with no partial matches. A file past MAX_SOURCE_BYTES is left out unread, and one lines() classifies as source_not_text is skipped, so an asset beside the source does not stop the search. Matches are ordered bytewise by root-relative path and then by line, compared exactly as search() compares them, and capped at MAX_MATCH_COUNT; the match past the cap sets hasMore, which asks for a narrower query or path because no cursor is returned. A refusal carries only its fixed code — no resolved path, no exception text, no content. Both searches report a whole matching line only through MAX_MATCH_CONTENT_BYTES (2048). A longer line becomes a UTF-8-boundary-safe excerpt containing the first literal occurrence and carries truncated: true, so a minified line cannot defeat the response bound.

  • JsonDocument::render() — the one JSON encoding every Orbitron document is written in: key and list order as built, slashes and unicode unescaped, one trailing newline. Shared by the commands and the MCP server, which is why it sits beside the services rather than under Console\.

  • Document / Documents — the document layer both adapters sit on. Document carries one document’s body and whether the operation it reports failed; the two travel together because neither can be derived from the other, and the CLI turns the failure into exit 3 while MCP turns it into isError: true on a result still carrying the same document. Documents is the one place a document’s shape is decided: context()/contextBody(), inspect(string $projectRoot, ?string $checkoutRoot = null), verify(string $projectRoot) and scaffold(string $projectRoot, ScaffoldMode $mode), each with its own *_SCHEMA_VERSION. Each $projectRoot is the detected consumer root; inspect() reports it as projectRoot after realpath() and throws a RuntimeException when it does not resolve. The optional $checkoutRoot is reported as checkoutRoot exactly as given and never reaches the filesystem; null, the command’s case, reports the physical root again. Nothing is memoized, and every call re-reads the bounded project inputs it needs. The installed inventory is the snapshot the object was handed and is never refreshed here: a command builds one per invocation, the MCP adapter one per operation.

  • Console\Invocation::format() / Console\Invocation::apply() — the whole invocation surface: --format, a bare --apply, no positionals. format() returns the command’s default when --format is absent, and null for a positional value, a bare --format, or a format the command does not render. apply() returns false when the flag is absent, true when it is bare, and null when it carries a value. An option no command reads is invisible to CommandArguments and is left alone rather than met with a second parser.

  • Console\ContextCommand / Console\InspectCommand — orbitron:context (--format=markdown|json, markdown default) and orbitron:inspect (--format=json only, and the default). Both bootstrap: false, both writing one document to STDOUT and returning 0, or writing a diagnostic to STDERR and returning 2 for a rejected invocation with STDOUT left empty. InspectCommand resolves the consumer root the same way VerifyCommand does.

  • Console\VerifyCommand — orbitron:verify (--format=json only, and the default), bootstrap: false, and the same 2 for a rejected invocation. Resolves the consumer root with Kinetis\Runtime\ProjectRoot::detect(dirname(__DIR__)), past an optional constructor override that exists only as a test seam. Writes the document Documents::verify() built and returns 3 when it reports an error — distinct from the launcher’s 1.

  • Console\ScaffoldCommand — orbitron:scaffold (--format=json only, and the default; --apply bare), bootstrap: false, and the same 2 for a rejected invocation, an --apply carrying a value included. Resolves the consumer root the same way VerifyCommand does, and returns 3 when the completed operation refused or failed.

  • Mcp\OrbitronMcpApplication — the MCP adapter, reaching the same Documents the commands do rather than invoking a command or parsing its output. Ten tools — orbitron_inspect, orbitron_verify, orbitron_scaffold_plan, orbitron_scaffold_apply, orbitron_read_package_source, orbitron_search_package_source, orbitron_search_package_source_tree, orbitron_list_package_source, kinetis_read_doc and kinetis_search_doc, which are DocsApplication::readTool() and DocsApplication::searchTool() themselves, included rather than restated, with every call to either handed straight back to that application — and, as resources, kinetis://orbitron/context as Markdown plus the DocsApplication it was constructed with: resources() appends that server’s own descriptions rather than restating them, so a page added to kinetis/mcp-docs appears with no change here, and readResource() answers the context URI locally and hands every other one — the refusal for an unknown URI included — to that application. Reading a documentation page — as a resource, a window or a search — is the one operation that leaves the machine; its diagnostics go to the stream the binary passed, never stdout. Four tools publish a closed, empty input schema, and a call carrying any argument at all is -32602 before anything runs; the four installed-source tools are the only ones this package gives a schema, each validated member by member — presence, type, range and length — before PackageSourceReader::read(), ::search(), ::searchTree() or ::list() is reached, and an unknown key is the same -32602. They share those checks, so none admits a member, a type or a length another refuses, and each schema names only its own members: the window takes lineCount, the file search takes query and startLine, the tree search takes query and an optional path defaulting to ., and the listing takes neither. No message can otherwise name a project root, a source body, a URL, a template or a command. The document-reading tools are annotated read-only, non-destructive, idempotent and closed-world, all four installed-source tools included — the two documentation tools carry kinetis/mcp-docs’ own annotations instead, open-world because they reach that origin; orbitron_scaffold_apply is annotated destructive and explicitly not idempotent, because a second apply refuses rather than overwriting. Selecting that tool is the whole mutation request — it has no boolean to set, and the MCP client’s configured approval policy controls whether it runs. A refusal or a failed write is an isError: true result still carrying the document, never a transport error that would leave the codes unreadable. Every document result is built with ToolResult::structured() from the Document body its text encodes, so structuredContent and the text carry one document. The project root, that DocsApplication and an optional checkout root are the only things this object holds, and the root is detected once at construction; the checkout root reaches Documents::inspect() alone, and every read, verification and write goes through the project root; every operation that reports or uses installed package facts builds one InstalledPackages::fromProject() snapshot and the Documents or PackageSourceReader that reads it, then discards both with the response, so a completed Composer dependency change is visible to the next such call while one call’s reported versions and resolved source still come from one inventory. Documentation reads, resource, window and search alike, remain delegated to DocsApplication and do not need the Composer inventory. serverInfo() reports Orbitron’s own installed version.

  • bin/kinetis-orbitron-mcp — detects the consumer root through Composer’s generated bin proxy (Kinetis\Runtime\ProjectRoot::detect()), falling back inside this repository to the package’s own root, reads the checkout root a containerized launcher hands over in KINETIS_ORBITRON_CHECKOUT_ROOT (OrbitronMcpApplication::CHECKOUT_ROOT_ENV) — an empty or relative value writes one line to stderr and exits 1 before the loop, with nothing on stdout — then runs the shared StdioLoop over an McpServer built from OrbitronMcpApplication and a Kinetis\McpDocs\DocsApplication constructed with STDERR as its diagnostic stream — the one place a failed documentation fetch is reported, and never stdout. It never boots the Kinetis application, so no route, listener or package bootstrap runs; nothing but JSON-RPC frames reaches stdout, and a failure that ends the loop is reported on stderr with exit code 1.

  • Depends on kinetis/framework for the command attribute, CommandArguments and Kinetis\Runtime\ProjectRoot, on kinetis/mcp-protocol for the MCP server, on kinetis/mcp-docs for the documentation catalogue and fetch it publishes — never on kinetis/mcp, whose installation would register a bootstrap and a discovery plugin in the consumer application — and on composer-runtime-api for Composer\InstalledVersions. Own composer.json/phpunit.xml/phpstan.neon/psalm.xml.

packages/migrations (kinetis/migrations)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\Migrations\Migration — the interface a migration file’s anonymous class implements: up(MysqlLink|PostgresLink $db): void/down(...): void, raw SQL only, issued via $db->execute().

  • Kinetis\Migrations\MigrationFile — discovers the <timestamp>_<description>.php files directly in one directory, sorted by filename; load() is a bare require of the file, and checksum() is the SHA-256 of it, which is what the ledger records and every later command compares against.

  • Kinetis\Migrations\MigrationRepositoryInterface / SqlMigrationRepository — ensureTableExists()/applied()/markApplied()/markRolledBack() over a kinetis_migrations table of exactly three columns: migration (primary key), checksum (the SHA-256 of the file that ran), and application_order (NOT NULL UNIQUE). applied() returns name => checksum in ascending application_order. markApplied() assigns that order with one INSERT ... SELECT ?, ?, COALESCE(MAX(application_order), 0) + 1 FROM kinetis_migrations, which compiles identically on MySQL and Postgres — the runner’s advisory lock is what keeps two writers off the same number, and the unique constraint is what fails the insert if one ever reaches it without the lock. Typed against the generic Kinetis\Persistence\Contract\SqlLink since its own bookkeeping SQL is dialect-agnostic.

  • Kinetis\Migrations\MigrationRunner — pending()/migrate()/rollback()/status(). Never wraps a migration in a transaction. All four verify the ledger against the migrations directory before acting on it — every applied migration still has a file, and that file still hashes to the checksum recorded when it ran — throwing Exception\MigrationIntegrityException on the first missing or changed source, before any up(), down() or ledger write; migrate()/rollback() verify inside the lock, pending()/status() directly. migrate() hashes each pending file immediately before it loads and runs it, and records that checksum only once up() has returned. rollback() undoes the migration with the highest application_order, so a migration merged from another branch and applied after a later-timestamped one comes back off first, and one rolled back and applied again is the newest from then on. migrate()/rollback() hold a cross-process advisory lock (MySQL GET_LOCK(), Postgres pg_advisory_lock()) for their whole duration, throwing Exception\MigrationLockTimeoutException if it can’t be acquired within $lockTimeoutSeconds (10 by default). Its link must hold one session for the whole run — the lock is session-scoped — which is what SqlConnectionFactory::singleSession() builds.

  • Kinetis\Migrations\MigrationScaffolder — writes a new timestamped migration file with the up()/down() stubs filled in, via an exclusive (x) file create rather than an unconditional overwrite; a same-second name collision retries with a random suffix. Throws Exception\MigrationScaffoldException on a real I/O failure creating the directory or writing the file.

  • Kinetis\Migrations\Console\{MigrateCommand, RollbackCommand, StatusCommand, MakeCommand} — the migrate/migrate:rollback/migrate:status/migrate:make <description> commands on vendor/bin/kinetis, registered through this package’s extra.kinetis scan root and all #[Command(bootstrap: false)]. Console\MigrationContext (@internal) is their shared partition/connection holder. The project-root migrations/*.php files are the default connection’s partition, and each direct child directory migrations/<name>/ is connection <name>’s; deeper directories are not scanned. Every child directory must match ^[a-z][a-z0-9]*$, with app reserved (its DB_APP_NAME is a default-connection key) and default refused as a directory, and any other name throws InvalidArgumentException naming it before any database configuration is read. --connection=<name> (validated the same way; default maps to the root, and a bare --connection is refused), else a non-empty MIGRATE_CONNECTION_NAME, selects one partition; otherwise migrate/migrate:status select every partition, default first, then the named ones in SORT_STRING order. Each partition runs its own MigrationRunner over its own Kinetis\DatabaseBridge\ConnectionFactory::singleSession() client — always PDO whatever DB_DRIVER says and never on a replacement session, reading that connection’s DB_CONNECTION/DB_{NAME}_CONNECTION (mysql|pgsql, required) plus the rest of its keys — closed in finally before the next opens. A multi-partition migrate first runs status() on every partition, so an unconfigured connection or a failing ledger check stops it before any up() — printing only the failing partition’s Connection: <name> line before rethrowing the unchanged exception — then migrates the partitions sequentially, failing fast with earlier partitions left migrated; with more than one partition, migrate/migrate:status print Connection: <name> before each partition’s lines. migrate:rollback covers exactly one partition, and with several selected writes its usage to STDERR and exits 1. migrate:make writes to the root, or to migrations/<name>/ for --connection=<name>; MIGRATE_CONNECTION_NAME does not move it. Two connections on the same physical database share one ledger and are unsupported. MigrateCommand/RollbackCommand constructor-inject EventDispatcher and dispatch the two events below once per migration actually run/rolled back.

  • Kinetis\Migrations\Events\MigrationApplied/Events\MigrationRolledBack — name and connection each — see Events.

  • Depends on kinetis/framework, kinetis/persistence and kinetis/database-bridge (via path repositories to this monorepo’s root); suggests ext-pdo_mysql and ext-pdo_pgsql, since every migrate* command needs the PDO driver matching DB_CONNECTION even when request work selects a native driver; SqlMigrationRepository types against the generic Kinetis\Persistence\Contract\SqlLink. Own composer.json/phpunit.xml/phpstan.neon.

packages/query-builder (kinetis/query-builder)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\QueryBuilder\Query — a mutable, parameterized SQL builder over the SQL shared by MySQL 8.4, MariaDB 11.4 and PostgreSQL 16; not an ORM. new Query(MysqlLink|PostgresLink|SqlTransaction $link): the link’s MysqlLink or PostgresLink marker is the only dialect authority, and a SqlTransaction link runs the statement inside that transaction. A SqlTransaction carrying neither marker throws QueryBuilderException at construction. Sources: table(string $table, ?string $as = null), fromSub(Query, string $as), with(string $name, Query, array $columns = []), withRecursive(...). Projection: select(string ...), selectAs(string $column, string $as), selectRaw(string, array $params = []), selectSub(Query, string $as), selectExists(Query, string $as) (1 or 0 under $as), distinct(). Predicates, each delegating to Conditions: where()/orWhere(), whereColumn()/orWhereColumn(), whereIn()/whereNotIn() (a list or a Query), whereBetween()/orWhereBetween()/whereNotBetween()/orWhereNotBetween(), whereExists()/orWhereExists()/whereNotExists()/orWhereNotExists(), whereRaw(string, array $params = [], string $boolean = 'AND'), whereGroup(Closure)/orWhereGroup(Closure). Joins: join(string $table, string $first, string $operator, string $second, string $type = 'INNER', ?string $as = null), leftJoin(), joinOn(string $table, Closure $on, string $type = 'INNER', ?string $as = null), joinSub(Query, string $as, Closure $on, string $type = 'INNER'), crossJoin(string $table, ?string $as = null); types INNER/LEFT/RIGHT. Grouping: groupBy(), groupByRaw(), having()/orHaving(), havingRaw(). Ordering: orderBy(), orderByRaw(string, array $params = []), limit(), offset(). Set operations: union()/intersect()/except() (Query, bool $all = false), applied in call order, with the outer order, limit and offset applying to the combined result. Locks: lockForUpdate(LockWait $wait = LockWait::Wait), lockForShare(). Reads: get(?string $dtoClass = null) (list<array>, or list<T> through one RowMapper per result set), first() (array|T|null), value(string $column): mixed, pluck(string $column): list<mixed>, exists(): bool, count(): int, sum()/min()/max()/avg() : int|float|string|null, paginate(), cursorPaginate(). Writes: insert(array $values): void (a row or a batch of identically keyed rows, at most 65,535 bound values), insertGetId(array $values, string $primaryKey = 'id'): int|string|null, insertUsing(array $columns, Query $select): int, insertOrIgnore(array $values): int, upsert(array $values, array $uniqueBy, array $update): int, update(array $values): int, increment()/decrement() (string $column, int|float $amount = 1, array $extra = []): int, delete(): int. Compile without running: toSelectSql(), toUpdateSql(array), toDeleteSql(). Bindings follow the emitted SQL: CTEs, select expressions, FROM subquery, joins, WHERE, cursor predicate, GROUP BY, HAVING, set operands, ORDER BY. A Query passed as a subquery, CTE, operand or insert source is compiled into an immutable snapshot when passed, must share the dialect, and cannot carry with() (an insert source excepted) or a lock; its raw state carries to the parent. __clone() copies the predicate state. run() writes int/bool values as literals on a link without Contract\PrefersPreparedStatements and binds otherwise; a raw fragment containing ? anywhere in the statement binds everything. Refusals, all before execution: an update, increment, decrement or delete with no effective predicate or with any clause besides the table and WHERE; an insert with any clause besides table(); a lock outside an active SqlTransaction, on a terminal other than get()/first()/value()/pluck(), or combined with distinct(), grouping, having, set operations, CTEs, derived tables, or LEFT/RIGHT/CROSS joins; on the MySQL family, an IN subquery carrying limit()/offset(). count() and the aggregates read a distinct, grouped, having or set-operation query as a derived table, and every other query directly without its projection. Dialect spellings and behavior differences: Appendix: Query Builder’s “Dialect spellings”.

  • Kinetis\QueryBuilder\Conditions — the predicate-only object whereGroup()/orWhereGroup()/joinOn()/joinSub() callbacks receive and Query’s WHERE/HAVING delegate to; the predicate methods above and nothing else. Each call renders its fragment and bindings together; an empty group adds nothing. Operators (=, !=, <>, <, <=, >, >=, LIKE, NOT LIKE) and booleans (AND, OR) are allow-listed. A null value compiles to IS NULL/IS NOT NULL for =/!=/<> and throws for other operators; a null IN member or BETWEEN bound throws; an empty IN list is 1 = 0 and an empty NOT IN list 1 = 1; whereRaw() refuses an empty fragment.

  • Kinetis\QueryBuilder\LockWait — Wait, NoWait, SkipLocked: FOR UPDATE, FOR UPDATE NOWAIT, FOR UPDATE SKIP LOCKED on every target.

  • Kinetis\QueryBuilder\RowValues::fromObject(object $object, array $columns = [], array $except = []): array — a stateless map of the object’s initialized public properties: null kept, a backed enum as its value, anything but null/bool/int/finite float/string — a unit enum such as Kinetis\Validation\Absent included — refused with InvalidArgumentException naming the property but not the value. $columns renames and $except omits; unknown names, a name in both, and two properties mapping to one column throw. It recognizes no presence marker: an application omits Absent fields through $except (Query Builder’s “Partial updates”).

  • Kinetis\QueryBuilder\RowMapper — RowMapper::for(class-string<T> $class): RowMapper<T> reflects and validates one instantiable constructor DTO into an immutable plan held by that instance, with no static or process cache; map(array $row): T passes each constructor parameter the column of exactly its name, ignores other columns, leaves a missing optional parameter to its default, and checks every value before invoking the constructor, whose own exceptions propagate unchanged. Admitted parameter types: untyped or mixed (the raw value), string, int (an int or its canonical decimal string), float (a finite int, float or numeric string), bool (a bool, 0/1 or "0"/"1"), and a backed enum (a case, or a backing value under its backing type’s rule), each also nullable. Query::get()/first()/paginate() and cursorPaginate() build one per result set. The full domain: Appendix: Query Builder’s “Row mapping”.

  • Kinetis\QueryBuilder\Exception\RowMappingException — final, extends InvalidArgumentException. unsupportedDefinition() for a class for() cannot fill (not instantiable; a variadic or by-reference parameter; an intersection, a union other than T|null, or any other builtin or class type), missingColumn() for a parameter with no default, invalidValue() for a value its type does not admit, null for a non-nullable one included, and unknownEnumCase() for a backing value naming no case. A message names the class, the parameter or column and the expected shape, and the value only by its get_debug_type() kind.

  • Kinetis\QueryBuilder\Dialect (+ Dialect\MySqlDialect/Dialect\PostgresDialect) — the spellings that differ: identifier quoting, limitOffset() (MySQL family: LIMIT 18446744073709551615 OFFSET n for an offset alone), sharedLock(), admitsLimitedInSubquery(), insertOrIgnoreClause(), upsertClause(), insertGetIdClause()/extractInsertedId(), and literalFor().

  • Kinetis\QueryBuilder\CompiledQuery — the {sql, params} output of the to*Sql() methods.

  • Kinetis\QueryBuilder\Exception\QueryBuilderException — a RuntimeException for a query built into a shape it cannot compile truthfully.

  • Kinetis\QueryBuilder\Exception\InvalidPaginationException — final, extends InvalidArgumentException: a caller’s pagination argument outside its domain, or a clause cursorPaginate() would contradict, refused before any SQL runs. See the two entries below.

  • Query::paginate(int $perPage, int $page = 1, ?string $dtoClass = null): Paginator — count() for total/lastPage plus a limited get(). Requires an orderBy()/orderByRaw() and throws Exception\QueryBuilderException without one. A page past the last returns empty data with the real total.

  • Query::cursorPaginate(int $perPage, ?string $cursor, string $cursorColumn = 'id', ?string $dtoClass = null, ?string $cursorAlias = null): CursorPaginator — orders by $cursorColumn, filters (existing predicate) AND $cursorColumn > ?, and reads nextCursor from the same result as the delivered rows. An omitted unqualified cursor column is selected and stripped from the rows; a qualified one requires $cursorAlias. A pre-existing order, a limit, an offset above zero, a missing alias for a qualified column, or an alias a listed column already uses throws Exception\InvalidPaginationException; a lock or a set operation throws Exception\QueryBuilderException.

  • Kinetis\QueryBuilder\Paginator (data, currentPage, perPage, total, lastPage) / Kinetis\QueryBuilder\CursorPaginator (data, nextCursor, hasMore) — final readonly envelopes of public fields, so json_encode() produces their flat shape. They parse no request and build no response; in a Kinetis application kinetis/framework’s #[PaginatedItem] describes either in OpenAPI like any other wrapper (Appendix: System Layout).

  • One Query instance is one query — nothing resets between fluent calls; construct a fresh instance per query.

  • Depends at runtime on kinetis/persistence alone, and no production source imports a kinetis/framework class; kinetis/framework is a development dependency that supplies the PHPStan rule (both via path repositories to this monorepo’s root). kinetis/database-bridge suggests it for Kinetis applications. Own composer.json/phpunit.xml/phpstan.neon.

packages/orm (kinetis/orm)

Separate Composer package, not part of kinetis/framework core, and not dependent on it. Its README is the mapping, value and lifecycle contract; ORM covers the Kinetis wiring.

  • Kinetis\Orm\Attributes\Entity(?string $table = null, string $connection = 'default'), Attributes\Column(?string $name = null), Attributes\Id(bool $generated = false) — the mapping attributes. #[Entity] is required; the table defaults to the short class name in snake case, the connection — lowercase ASCII letters and digits, starting with a letter, and not the reserved app, whose scoped DB_NAME would be the default connection’s DB_APP_NAME — to default, a column to the property name in snake case, and the identifier to the property named id when no property carries #[Id]. An identifier is assigned by the application unless generated: true, which requires a ?int property. Attributes\Version marks the optional optimistic-locking version property: at most one, typed int and not the identifier. Attributes\BelongsTo(?string $column = null) marks a relationship property typed with an entity class of the same registry, over a foreign-key column that defaults to the property name in snake case followed by _id. Attributes\HasOne(string $mappedBy, bool $owned = false) and Attributes\HasMany(class-string $target, string $mappedBy, bool $owned = false) mark inverse relationship properties — typed with the target entity class, or exactly array — that map no column and read the column of the target’s #[BelongsTo] property mappedBy, whose target is the declaring class. owned: true makes the relationship an aggregate edge: flush() discovers new targets across it, remove() takes the targets with the owner, and a target dropped from a relationship the manager loaded is deleted or moved. An entity class is the target of at most one owned inverse relationship in a registry, and one #[BelongsTo] property is named by at most one of them. Attributes\ManyToMany(class-string $target, ?string $table = null, ?string $joinColumn = null, ?string $inverseJoinColumn = null, ?string $mappedBy = null) marks an array property over a join table: an owning side names table, joinColumn (this entity’s column) and inverseJoinColumn (the target’s) and is the only side that writes join rows, and an inverse side names mappedBy, the owning #[ManyToMany] property of the target, alone and reads that table with its columns swapped. Neither side maps a column of its own table, neither cascades a target’s persistence or removal, and the join table’s unique pair and two foreign keys stay database authority.

  • Kinetis\Orm\Date — final readonly calendar date for a SQL DATE column: public int year, month and day, __construct(int $year, int $month, int $day) admitting a day that exists in the Gregorian calendar in years 0001 to 9999, fromString(string $value): self admitting exactly YYYY-MM-DD, and __toString() returning that form; any other day or string throws InvalidArgumentException without quoting it. No time, time zone or instant. A property declared exactly Date or ?Date has the metadata type date, loads from and writes, snapshots, binds predicates and cursors as its YYYY-MM-DD string, and is never the identifier or the version: the package README’s “Dates”.

  • Kinetis\Orm\Metadata\MetadataRegistry — fromClasses(iterable $classes): self, fromArray(array $data): self, toArray(): array{entities: list<array>}. Immutable class, table, column and type names, flags, relationship targets and, apart from each entity’s column-mapped properties, its inverses and its joins, each join carrying the table and the two columns that end reads, and its connection, ordered by class; connections(): list<string> lists every connection an entity names once, in byte order, and connectionFor(class-string $class): string returns one entity’s. A relationship of any kind between entities on two connections is refused. fromArray() accepts only toArray()’s output for the named classes as currently declared and reflects nothing else. Every refusal is Exception\MappingException. No static cache.

  • Kinetis\Orm\OrmFactory — create(MysqlLink|PostgresLink $link, MetadataRegistry $metadata, string $connection = 'default'): self behind a private constructor, mapping only the entities on $connection (none for a connection no entity names), refusing a SqlTransaction with InvalidArgumentException; open(): EntityManager; transaction(callable(EntityManager): TResult $callback): TResult, which begins a transaction on the client, passes the callback an EntityManager bound to it, commits once the callback returns, and closes the manager on every way out, refusing a nested call from the same Fiber on the same factory with Exception\InvalidEntityStateException before it begins. Request-neutral: one runtime plan per entity, built once, and a per-Fiber session mark cleared when each transaction() call ends.

  • Kinetis\Orm\OrmFactoryRegistry — final, request-neutral. create(array<string, MysqlLink|PostgresLink> $links, MetadataRegistry $metadata): self behind a private constructor builds one OrmFactory per link, keyed by connection, and refuses a connection an entity names without a link with InvalidArgumentException; factory(string $connection): OrmFactory (InvalidArgumentException without a link), factoryFor(class-string $class): OrmFactory (Exception\MappingException outside the metadata).

  • Kinetis\Orm\EntityManagerRegistry — final, one unit of work’s managers. create(OrmFactoryRegistry $factories): self behind a private constructor, owned by the calling Fiber; manager(string $connection): EntityManager and managerFor(class-string $class): EntityManager open that connection’s manager on first use and return it after, refusing another Fiber (Exception\CrossFiberAccessException) and use after close() (Exception\ClosedEntityManagerException); close(): void closes every opened manager without flushing, from any Fiber, idempotently. No flush or transaction spans two connections.

  • Kinetis\Orm\EntityManager — non-public constructor. repository(class-string<T> $class): EntityRepository<T>, contains(object $entity): bool, persist(object $entity): void, remove(object $entity): void, flush(): void, builder(): Query (a fresh query builder on the manager’s link, its results unmanaged), clear(): void, close(): void (idempotent, never flushes, closes a running flush’s or a session’s transaction, the link left open), isClosed(): bool. Holds the identity map, keyed by class and identifier, a snapshot of each managed entity, the membership of each owned inverse relationship and owning join collection it loaded, a weak set of the entities whose deletion it committed, and the inserts and deletions scheduled for flush(), which writes them and every changed column in one transaction — one it begins on the client, or a session’s — and changes nothing before its COMMIT returns. A versioned entity’s UPDATE and DELETE also match the version the manager holds, and its UPDATE sets the next one; either affecting no row throws Exception\OptimisticLockException. A #[BelongsTo] relationship it writes holds null or an entity it holds — managed, or awaiting insert in the same flush, whose generated key that flush carries from one statement to the next without writing it into a property before COMMIT; anything else is refused before SQL with Exception\InvalidEntityStateException. flush() walks every initialized owned inverse relationship once before its transaction, schedules the new entities it reaches, deletes or moves the targets a loaded relationship no longer holds, orders every statement behind the rows its foreign keys name, and breaks a loop of references through a nullable foreign key or refuses it; it reads no inverse relationship otherwise and never writes an owner column from one. It writes an owning #[ManyToMany] collection as join rows alone: every link of a new owner’s collection, the pairs a loaded one gained and lost, and one DELETE by the join column before a removed owner’s own; a managed owner’s collection the manager never loaded is refused before SQL, an inverse collection is inert, and a link DELETE admits any affected-row count. A managed owner carrying #[Version] whose membership changed advances that version once, through the UPDATE its changed columns send or through one writing the version column alone, ordered after its link DELETEs and before its link INSERTs. Owned by the Fiber that opened it: every other method, and every repository, query and terminal it creates, refuses a closed manager (Exception\ClosedEntityManagerException), another Fiber (Exception\CrossFiberAccessException), a call while flush() runs, and in a session a call after a flush that wrote or an ORM failure (Exception\InvalidEntityStateException) before SQL; close() accepts any Fiber. Its @internal members serve the factory, repository and query. Relationship, write, locking, session and failure semantics: the package README’s “Relationships”, “Many-to-many relationships”, “Writing”, “Flushing”, “Optimistic locking” and “Transaction sessions”.

  • Kinetis\Orm\EntityRepository<T> — final. find(int|string $id): ?T, findOrFail(int|string $id): T (Exception\EntityNotFoundException), findBy(array<string, mixed> $criteria): list<T>, query(): EntityQuery<T>.

  • Kinetis\Orm\EntityQuery<T> — final, over a fresh Kinetis\QueryBuilder\Query selecting every mapped column. where(string $property, string $operator, mixed $value), whereIn(string $property, array $values), orderBy(string $property, string $direction = 'ASC'), limit(int), offset(int), lockForUpdate(LockWait $wait = LockWait::Wait), lockForShare() (a session’s manager only; Exception\InvalidEntityStateException before SQL otherwise), with(string ...$relations) (relationship paths such as author.organization or comments.author, checked before SQL and loaded after the root statement by get(), first(), paginate() and cursorPaginate() through unlocked selects on the manager’s link of at most 1,000 foreign keys each, or for an inverse relationship 1,000 identifiers against the target’s foreign-key column, ordered by the target’s identifier, or for a #[ManyToMany] 1,000 identifiers against that end’s join column ordered by both, then the targets those rows name, with every collection buffered in full; exists() and count() load nothing); an inverse or #[ManyToMany] relationship maps no column of its own table, so a predicate, order or cursor naming one throws Exception\MappingException before SQL; get(): list<T>, first(): ?T, exists(): bool, count(): int, paginate(int $perPage, int $page = 1): Paginator, cursorPaginate(int $perPage, ?string $cursor, string $property = 'id'): CursorPaginator, each envelope carrying managed entities; builder(): Query, a copy whose terminals return unmanaged arrays or DTOs.

  • Exception\MappingException (RuntimeException) — metadata, property names, relationship paths, predicate values, loaded rows, relationship targets no row matches, a non-nullable #[HasOne] without a row or any #[HasOne] with more than one, a join row naming a missing target or holding no identifier, and property values to write. A mapped-value failure names its class, property, table, column and expected shape, but reports the value only by its get_debug_type() kind because a column may hold a secret. Exception\InvalidEntityStateException (RuntimeException) — lifecycle refusals, relationship targets the manager does not manage, calls while flush() runs, a locking read outside a session, a nested session, calls after a session’s writing flush or ORM failure, an owning join collection the manager never loaded or holding one object twice, and row-integrity failures. Exception\OptimisticLockException (RuntimeException) — a stale versioned write, rethrown unwrapped after the rollback. Exception\RollbackFailedException (RuntimeException; getPrevious() is the first failure, $rollbackFailure the rollback’s), Exception\UnknownFlushOutcomeException (RuntimeException; getPrevious() is the COMMIT failure of a flush() on a manager from open()) and Exception\CommitNotAcknowledgedException (RuntimeException; getPrevious() is the failure of a session’s COMMIT, which may have rolled back or have an unknown outcome), each thrown with the manager closed. Exception\EntityNotFoundException (RuntimeException), Exception\ClosedEntityManagerException and Exception\CrossFiberAccessException (LogicException, each with a manager() and a registry() constructor).

  • Depends at runtime on kinetis/query-builder and kinetis/persistence, and no production source imports a kinetis/framework class; kinetis/framework is a development dependency that supplies the PHPStan rule (all via path repositories to this monorepo’s root). kinetis/database-bridge suggests it for Kinetis applications. Own composer.json/phpunit.xml/phpstan.neon.

packages/queue (kinetis/queue)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\Queue\Job — a marker interface (no declared methods) a job class implements. handle() is discovered and invoked by reflection, not a fixed interface method, since its parameter list varies per job.

  • Kinetis\Queue\QueueInterface — push(Job $job, int $delaySeconds = 0, string $queue = 'default', ?int $maxAttempts = null): void, pop(int $timeoutSeconds = 0, array $queues = ['default']): ?QueuedJob, ack(QueuedJob $job): void, release(QueuedJob $job, int $delaySeconds = 0): void, fail(QueuedJob $job): void, size(string $queue = 'default'): int (jobs waiting to be popped — delayed included, reserved excluded). Only what every backend performs identically lives here; clearing is a separate capability interface (below). $queues is checked in the given order, on every sweep — priority by list position, not a numeric score; see Appendix: Queue Contracts’s “The pop() priority/timeout contract” for the full cross-backend behavior. $maxAttempts null (the default) defers to the processing QueueWorker’s own $defaultMaxAttempts, which is never itself unlimited; once QueuedJob::$attempts reaches the effective cap, fail() removes the job permanently instead of release() retrying it. release()’s $delaySeconds is a floor, exactly as push()’s is, and every backend holds the job with its own durable primitive rather than the worker waiting; it is validated through QueueContract::assertValidReleaseDelay() (negative rejected) before telemetry, serialization or I/O, with no universal ceiling — SqsQueue layers ChangeMessageVisibility’s 43200-second request-field range and RabbitMqQueue its delay ladder’s.

  • Kinetis\Queue\ClearableQueueInterface extends Kinetis\Queue\QueueInterface — adds clear(string $queue = 'default'): int, declared by a backend that can discard exactly the jobs waiting on a queue and report how many it removed. “Waiting” means unreserved, delayed jobs included; every live reservation is left alone — Queue’s “Clearing is a separate capability” gives the reasoning. The return value is what that call removed, never a size() taken alongside it: a queue accepts pushes throughout, so the two are separate observations of a moving number. It extends rather than sits beside QueueInterface because clearing is a queue operation — one instance still pushes, pops and reports size. SyncQueue, RedisQueue, SqlQueue, and RabbitMqQueue declare it; SqsQueue does not, since Amazon SQS’s PurgeQueue deletes in-flight messages too, keeps deleting messages sent while it runs, reports no count, and is rate-limited to once per 60 seconds per queue — see Queue (SQS). Same shape as Kinetis\SimpleCache\AtomicCounterInterface against PSR-16: a capability named in a consumer’s own type, or checked with an instanceof where only the base contract is held.

  • Kinetis\Queue\DisposableQueueInterface extends Kinetis\Queue\QueueInterface — adds dispose(): void, declared by a backend whose factory opened a connection nobody else owns: RedisQueue, SqlQueue, RabbitMqQueue. SqsQueue does not, its transport being an HTTP client with no queue-owned connection to close. Ownership travels with construction, not with the type — a backend’s fromConfig() hands the queue the operation that closes what it opened, while a constructor called directly receives a caller-owned client or link and closes none of it, making dispose() a no-op there unless the caller passes the closing operation itself as the constructor’s last argument. Idempotent, and safe before the queue’s first I/O: the disposer is dropped as it runs, so a second call releases nothing a second time and a worker that never popped anything still disposes cleanly. PackageBootstrap registers it on the AppScope for the backend it builds and for nothing else — Appendix: Queue Contracts’s “Connection ownership” gives the reasoning.

  • Kinetis\Queue\RenewableQueueInterface extends Kinetis\Queue\QueueInterface — adds visibilityTimeoutSeconds(): int and renew(QueuedJob $job): void, declared by a backend holding a delivery for a finite window it can push forward: RedisQueue, SqlQueue, SqsQueue. RabbitMqQueue does not — its channel holds the unacknowledged delivery for as long as the connection lives — and neither does SyncQueue, which has no reservation. renew() extends the delivery QueuedJob::$handle names, fenced on that receipt, and claims nothing about whether the delivery was still current: MySQL and Redis both report zero changed rows or members for a write that stores the value already there, which a renewal at one-second resolution routinely does, so there is no Exception\StaleJobHandleException here and no JobSettlement case. It settles nothing and consumes no attempt; transport and backend errors propagate as from any other operation. Repeated calls are supported, and one failure says nothing about whether a later attempt will fail — but renew() is not idempotent: every successful call moves the reservation window forward from that call. It may return synchronously when it needs no I/O; any I/O must suspend its Fiber rather than block the event-loop thread and must be bounded by the backend or client’s own operation timeout, since the worker joins a call still in flight before it settles and cannot abandon one. QueueWorker resolves the capability once in its constructor and drives renewal itself — application jobs never see their receipt and get no API of their own. See Appendix: Queue Contracts’s “Reservation renewal”.

  • Kinetis\Queue\QueueContract — the shared validation and decode helpers every backend’s push()/pop()/size() — and clear(), where a backend has it — runs before any I/O. assertValidQueueName(string) checks a logical queue name against /^[A-Za-z0-9_-]{1,80}$/D — the conservative grammar this project settled on as the intersection every backend can portably support, derived from Amazon SQS’s own standard-queue naming rule (the narrowest of the four: alphanumeric plus hyphen/underscore only, up to 80 characters, no periods, since a period is reserved for the .fifo suffix this project’s SqsQueue doesn’t support); Redis, SQL, and RabbitMQ all permit far more, so this stays portable-safe everywhere. assertValidQueueList(list<string>) checks every name plus rejects a repeated one (an empty list is accepted, the one “nothing to check” case). assertValidPopArguments(int, list<string>) combines the non-negative timeout check with the queue-list check. assertValidQueueNamePrefix(string) checks a backend’s own $queueNamePrefix constructor argument against the same grammar, with an empty string accepted as “no prefix.” assertValidPushArguments(int, string, ?int) is the push()-side counterpart. Every one of them throws Exception\InvalidQueueArgumentException. assertValidConnectionName(string $name, string $source) is the connection-name grammar (^[a-z][a-z0-9]*$) PackageBootstrap applies to QUEUE_CONNECTION_NAME and queue:work to --connection; it throws InvalidArgumentException naming $source. The decode side — storedInt(), storedNullableInt(), storedJsonArray(), storedClass(), storedArgs(), storedMetadata(), assertFieldPresent() — parses a raw stored field into the shape QueuedJob requires, throwing Exception\MalformedQueuedJobDataException; settleIfMalformed() is the one place every durable backend routes its decode through, so a corrupted message is settled permanently instead of replayed forever.

  • Kinetis\Queue\QueuedJob — {class, args, handle, queue, attempts, maxAttempts, metadata}. $handle is a delivery receipt: it identifies one exact delivery of a job, not the logical job, so the same job body reaching a worker again — after a release(), or after a reservation expired and the backend handed the work on — carries a different $handle. A backend that can tell a live reservation from a finished delivery settles only the former and answers the latter with Exception\StaleJobHandleException; one that cannot says which of its settlements are unfenced in its own docblock. Queue’s “When a settlement is lost” carries the per-backend table. The constructor validates $queue via QueueContract::assertValidQueueName() — the one point every ack()/release()/fail() call’s $job->queue ultimately passes through, closing the gap a hand-constructed or forged instance would otherwise leave open. $attempts is the attempt number the current pop() represents (1-indexed), not a raw failure count. $metadata is opaque string metadata stored at push time — the instrumentation propagation channel, carried verbatim by every backend.

  • Kinetis\Queue\JobSettlement — a string-backed enum of the three durable transitions a popped job can end in: Ack, Release, Fail. Each case’s value is the string Kinetis\Instrumentation\TelemetryInterface::jobFinished() takes as its $outcome, so an operation named once here reaches telemetry, Exception\StaleJobHandleException, and Events\JobSettlementLost without a second mapping to drift from it.

  • Kinetis\Queue\Exception\StaleJobHandleException — a settlement found no live reservation to act on: the delivery QueuedJob::$handle names is over, settled through another call or reclaimed after its reservation expired. Raised for ack(), release() and fail() alike, built through forSettlement(JobSettlement $operation, string $queue) and carrying that operation on a public $operation property. Nothing the call wanted is left to do and nothing it wanted was written, which is why QueueWorker treats it as an outcome to report rather than a failure to stop on.

  • Kinetis\Queue\Exception\QueueNotClearableException — ClearableQueueInterface was resolved from the container while the bound QueueInterface is a backend that does not declare it. Names the backend and what to do instead; Console\ClearCommand prints the same wording, via that class’s own describe().

  • Kinetis\Queue\JobSerializer — converts an object (a Job, or an event — deserialize()/the general path is not Job-specific) to plain {class, args} data by reading each constructor parameter’s value off a same-named property via reflection, and back. It enforces the portable wire contract on every argument, recursively: null/bool/int/a finite float/a valid-UTF-8 string, and a dense zero-based list or string-keyed map of those, nested up to 32 levels (any other array shape — sparse, mixed-key — is rejected, having no lossless JSON round trip; so is anything past the depth bound, which is what turns a self-referential array into a push()-time rejection instead of an exhausted worker). A BackedEnum case and a DateTimeImmutable (the exact class, not a subclass) are accepted as a top-level argument, written as the backing value and a Y-m-d\TH:i:s.uP timestamp respectively, and restored from the constructor parameter’s declared type — which must be a single ReflectionNamedType naming that exact enum class or exactly DateTimeImmutable, so a union, an intersection, mixed, an untyped parameter, an interface and a supertype are all rejected during serialize(), before anything is stored. Nested inside an array they are rejected too, since nothing there identifies what a bare string was meant to become — see Queue’s “What a constructor argument can hold”. Everything else (a resource, a Closure, any other object, NAN/INF, invalid UTF-8) throws Exception\UnserializableJobException::forUnsupportedValue(), naming the argument path but never the value; a list index is kept (items[3]) and a map entry is located by its ordinal position ({0}) rather than its key, since a key is application data. A #[Sensitive]-marked argument goes further: serialize() catches any rejection within its subtree and re-throws forSensitiveValue(), naming only the argument, never a nested path — a map key can itself be a secret. Two reconstruction entry points: deserialize(class-string $class, array $args): object (the general path — no Job requirement, used for event reconstruction) and deserializeJob(class-string $class, array $args): Job (what QueueWorker calls — identical, plus a check that $class implements Job). Both validate $args’ names against $class’s current constructor signature (every required parameter present, no unrecognized extra key), wrapping any failure — a missing class, a missing/unrecognized argument, a value that cannot be restored to its declared type, or the constructor itself throwing — in Exception\JobReconstructionException rather than letting a raw Error/TypeError escape with no payload context. A constructor’s own Throwable is chained; the cause behind an unrestorable value is not, because PHP’s “not a valid backing value” and date-parse messages quote the stored value, which may be a #[Sensitive] one. This defends against schema drift (a rolling deployment where the pushing and popping process disagree on a class’s shape), not a hostile payload — the queue is trusted infrastructure on the database’s own tier, not an input boundary. redact(string $class, array $args): array returns those arguments with every value whose constructor parameter carries Attributes\Sensitive replaced by JobSerializer::REDACTED ([redacted]), for logging; a class that no longer loads redacts every value rather than none.

  • Kinetis\Queue\Attributes\Sensitive — TARGET_PARAMETER, no arguments. Marks a job constructor parameter whose value must never reach a log; affects logging only, never what is written to the backend. Redacts an array or object value whole, with no per-element redaction within one.

  • Kinetis\Queue\JobInvoker — invoke(Job $job, ContainerInterface $container): void, reflecting and calling handle() with each parameter resolved through the given container. Shared by QueueWorker and SyncQueue.

  • Kinetis\Queue\SyncQueue — runs push()’s job immediately, inline, via JobInvoker; pop() always returns null, ack()/release()/fail() are no-ops — release() still validates its $delaySeconds the way a durable backend does, so a value production rejects is not quietly accepted in development. Declares ClearableQueueInterface, whose clear() always reports 0 — nothing is ever stored, so nothing is ever waiting. push() first runs $job through JobSerializer::serialize() then deserializeJob(), exactly like a durable backend’s push()/worker pair — the reconstructed instance is what JobInvoker::invoke() actually runs, never the caller’s own object; a payload JobSerializer rejects fails here too, at push() time — see Queue’s “What a constructor argument can hold”. For local development; not selectable via QUEUE_CONNECTION. A fresh RequestScope per push() from AppScope::createRequestScope(), same as QueueWorker, so every package request-scope initializer runs on it; unlike QueueWorker, a failing job’s exception propagates rather than being caught and logged, but the scope’s dispose hooks — kinetis/database-bridge’s dangling-transaction cleanup included — still run before it does. If disposal itself then fails too, push() rethrows the job’s exact exception, never the disposal failure — logged separately through AppScope’s own logger instead; if only disposal fails (the job succeeded), that failure propagates normally, since it is the only thing that went wrong. See Appendix: Queue Contracts’s “A disposal failure never rewrites the outcome or stops the worker”.

  • Kinetis\Queue\QueueWorker — __construct(AppScope $app, QueueInterface $queue, int $defaultMaxAttempts = 0, int $retryBaseDelaySeconds = 5), run()/processNext()/stop(). SIGTERM/SIGINT (via ext-pcntl, when loaded — supportsGracefulShutdown()) stop run()’s loop after the job in flight finishes, so a deploy never truncates a job. One fresh RequestScope per job via AppScope::createRequestScope(), so every package request-scope initializer runs on it, with handle()’s parameters autowired through it via JobInvoker — see Appendix: Container Lifecycle. Only JobSerializer::deserializeJob()/JobInvoker::invoke() decide the outcome; every call that merely describes or observes it — jobStarted() (ahead of the job), the real-failure log line (ahead of the transition on a failure), and, strictly after the one matching durable transition (ack()/release()/fail()), completion telemetry and the lifecycle-event dispatch — runs through the private runBestEffort(), before or after, so none of them can ever block, delay, or replace that transition; a throwing listener or telemetry backend is caught, reported through the logger (itself run the same no-throw way), and can never trigger a second transition or escape processNext() — see Appendix: Queue Contracts’s “Observers never decide or rewrite the outcome”. The job’s own RequestScope is disposed last, in its own contained block, after that transition and every observer above have already run: a disposal failure there is logged (through AppScope’s own logger, not the now-disposed scope) and, like every other observer failure, can never trigger a second transition or escape processNext()/stop run()’s loop — see Appendix: Queue Contracts’s “A disposal failure never rewrites the outcome or stops the worker”. The JobSerializer::redact() call behind the failure log line is contained the same way in spirit but through its own dedicated fail-closed try/catch, not runBestEffort() — a reflection failure there falls back to every argument redacted, with no separate report of its own, so fail() still runs regardless. While a renewable backend’s job runs (RenewableQueueInterface, resolved once in the constructor), a package-internal DeliveryHeartbeat owns one unreferenced Revolt repeat watcher at half the backend’s visibility window and renews that delivery’s reservation — unreferenced so it never keeps the event loop alive and never hides the empty-loop condition Kinetis\Async\ConcurrentBatch reads as a task deadlock. At most one renewal is in flight; a tick arriving while the previous call has not answered is dropped. Before any settlement the watcher is cancelled and an in-flight renewal is waited out through a Revolt suspension, bounded by that adapter’s own operation timeout — mandatory, since SQS renews and releases with the same ChangeMessageVisibility call and a late renewal would replace a delayed release()’s backoff. A failure of that wait is not contained: the renewal is still suspended and can resume, so the error propagates and no ack()/release()/fail() is attempted for that delivery at all. A failed renewal call is caught, counted, and retried on later ticks; once the settlement has been attempted — and its lifecycle event dispatched, when it succeeded — one error line reports the count and the last exception, still written when the settlement itself threw and never in place of that exception. Nothing else follows from it: no event, no exception, no retry policy, no worker restart. A handler that never yields to the event loop cannot be renewed. A throwing job is always logged (job class, queue, attempt number, and the exception, plus the arguments — redacted per Attributes\Sensitive — only when the job is being given up on, since a job about to be retried still holds its payload in the backend); the effective cap is QueuedJob::$maxAttempts ?? $defaultMaxAttempts — released while $attempts is below it, fail()ed once reached. $defaultMaxAttempts is non-nullable: there is no configuration on this class that produces unlimited retries by default. A release carries a delay the worker computes privately — min(900, $retryBaseDelaySeconds * 2 ** min($attempts - 1, 10)), admitted range 0–900 via assertValidRetryBaseDelay(), with the 900-second ceiling a code constant rather than a second setting — and the backend holds the job for it: the worker never sleeps, keeps no timer, and retains no request scope during a backoff. fail() takes no delay, so nothing about the schedule reaches a final attempt. Events\JobReleased carries no delay; the failure log line and its job context report the computed delay instead. Dispatches, through the job’s own RequestScope: Events\JobSucceeded on ack(), Events\JobReleased on release(), and Events\JobFailedPermanently on fail() — see Events for the full catalog. A settlement the backend rejects with Exception\StaleJobHandleException is caught on all three paths: the loop continues, none of those three events fires (each asserts a durable transition that did not happen), and Events\JobSettlementLost plus a warning-level log line report the loss instead. Telemetry still closes the job’s span — a stale ack() closes as a settlement failure carrying the stale exception, a stale release()/fail() keeps the job’s own exception, which is what the span was opened to describe. Every other exception from ack()/release()/fail() propagates and stops the loop: a backend refusing writes is not a settled job.

  • Kinetis\Queue\QueuedListenerInvoker — implements core’s Kinetis\Events\ListenerInvokerInterface. invoke() receives the listener as a class-string, never a resolved instance — EventDispatcher checks the registry’s own queued flag before ever constructing one, so nothing about the listener runs in the process that dispatched the event. Serializes the event (via JobSerializer, generalized to accept any object, not Job specifically) and pushes an InvokeListenerJob carrying the listener’s class/method as plain strings; the given RequestScope is accepted only to satisfy the shared interface, never used.

  • Kinetis\Queue\InvokeListenerJob — the job QueuedListenerInvoker pushes. Its $eventArgs holds an earlier JobSerializer::serialize() call’s output, so serializing this job walks values that are already wire values and passes them through unchanged. handle(RequestScope $scope) resolves the listener through the given scope and reconstructs the event via JobSerializer::deserialize($this->eventClass, $this->eventArgs), invoking the original method by name.

  • Kinetis\Queue\QueueFactory::fromConfig(Config $config, string $connection = 'default'): QueueInterface — builds the backend $connection’s own selector names: Config::scopedKey('QUEUE_CONNECTION', $connection), so QUEUE_CONNECTION for default and QUEUE_JOBS_CONNECTION for jobs (redis|sql|sqs|rabbitmq, required, with no fallback from a named selector to QUEUE_CONNECTION), and passes $connection unchanged to that backend’s factory. The name is not validated here; a caller taking it from outside the application validates it. An unknown value throws InvalidArgumentException naming the selector and the four accepted values. Every one of the four is class_exists()-gated against its own package’s XxxQueueFactory::fromConfig() — kinetis/queue itself depends on none of them, redis/sql exactly as optional as sqs/rabbitmq; throws Exception\QueueUnavailableException naming the selector and the missing package when the selected one isn’t installed. The return type is QueueInterface; capabilities beyond it vary by backend, so a caller needing one checks the returned instance for it.

  • Kinetis\Queue\PackageBootstrap — declared via extra.kinetis; reads QUEUE_CONNECTION_NAME (unset or blank means 'default') as the connection to bind, validates any other value through QueueContract::assertValidConnectionName() before deriving the selector — a malformed name throws InvalidArgumentException naming QUEUE_CONNECTION_NAME instead of leaving the bootstrap inert — and, with that connection’s scoped QUEUE_CONNECTION selector set, binds three things. QueueInterface to a factory calling QueueFactory::fromConfig($config, $name), which also registers that backend’s dispose() on the AppScope when it declares DisposableQueueInterface, so the connection this binding opened is closed when the worker ends and an application’s own queue — which never reaches the factory at all — is left to whoever opened it; ClearableQueueInterface to a factory that resolves QueueInterface and returns it when it declares the capability, throwing Exception\QueueNotClearableException naming the backend when it does not; and core’s Kinetis\Events\ListenerInvokerInterface to a factory wrapping that same resolved queue in QueuedListenerInvoker, so a listener marked Kinetis\Events\ShouldQueue queues with no second registration — AppScope::boot() registers its own SynchronousListenerInvoker only where nothing is bound yet, and runs after every package bootstrap. All three are resolved on first use, so an application that never injects a queue builds no backend and an application whose own bootstrap.php binds QueueInterface (running after this and winning on the binding) never builds the one the selector names either — and both derived bindings always answer with the queue the application actually runs, not the one the factory would have produced. An application binding either derived interface itself wins on that binding too. Inert when that selector is unset, leaving core’s synchronous invoker in place; a named QUEUE_CONNECTION_NAME with only QUEUE_CONNECTION set stays inert too.

  • Kinetis\Queue\Console\WorkCommand — the queue:work [--queue=high,default] [--connection=<name>] command on vendor/bin/kinetis, registered through this package’s extra.kinetis scan root. Constructor-injects RequestScope and Config; reads QUEUE_POLL_TIMEOUT, QUEUE_MAX_ATTEMPTS (passed through as QueueWorker’s $defaultMaxAttempts) and QUEUE_RETRY_BASE_DELAY_SECONDS (its $retryBaseDelaySeconds), defaulting to 5/0/5 respectively and each validated through QueueWorker’s own shared assertion before any queue is resolved or built. Without --connection it resolves QueueInterface through the RequestScope (the PackageBootstrap binding, or the application’s override). --connection=<name> takes a value that passes QueueContract::assertValidConnectionName() — a bare or empty option throws InvalidArgumentException (--connection needs a value: --connection=<name>.) — and builds that connection through QueueFactory::fromConfig() without resolving the binding, so --connection=default bypasses both an application override and QUEUE_CONNECTION_NAME; a queue built that way that declares DisposableQueueInterface has its dispose() registered on the AppScope, which the CLI disposes on every exit path. Startup output follows option validation, setting validation and queue resolution. Warns on STDERR at startup when ext-pcntl is missing, since graceful shutdown is impossible without it.

  • Kinetis\Queue\Console\StatsCommand / ClearCommand — queue:stats [--queue=high,default] (waiting counts per queue, with a total) and queue:clear --queue=<name> --force (discards waiting jobs; refuses without --force, exit 1). Both drive the QueueInterface binding, so they report on whichever backend that binding resolves to. ClearCommand is the one runtime capability check in the package: a backend not declaring ClearableQueueInterface is named along with the missing interface and the command exits 1 having touched no queue. It then parses --queue into a complete list and runs QueueContract::assertValidQueueList() over the whole of it before the first clear(), so a malformed or repeated name anywhere in the list leaves every queue in it untouched rather than discarding the ones ahead of it first.

  • Depends on kinetis/framework (via a path repository to this monorepo’s root) plus psr/log (QueueWorker’s failure logging), psr/container (JobInvoker’s container parameter). No backend dependency at all — Redis, SQL, SQS, and RabbitMQ each live in their own separate package below. Own composer.json/phpunit.xml/phpstan.neon.

packages/queue-redis (kinetis/queue-redis)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\QueueRedis\RedisQueue implements Kinetis\Queue\ClearableQueueInterface, Kinetis\Queue\DisposableQueueInterface, Kinetis\Queue\RenewableQueueInterface — backed by Amp\Redis\RedisClient over kinetis/redis’s non-replaying link, so a command whose reply never arrived is reported as Kinetis\Redis\Exception\OutcomeUnknown rather than being sent a second time. Reserves under a finite lease rather than a plain destructive pop: each queue has a pending list, a delayed sorted set scored by ready-at time, and a leased sorted set scored by lease expiry, where the member is the exact envelope handed back as QueuedJob::$handle. pop()’s Lua script reads the pending tail, ZADDs that exact member to leased with an expiry of $visibilityTimeoutSeconds (second constructor argument, default 300, rejected below 1) from Redis’s own TIME, and only then LREMs it from pending — that order is what keeps a wrong-typed or erroring leased key from destroying the sole pending copy. Expiries are read from Redis’s TIME throughout, so every worker shares one lease clock. Each pop() promotes due delayed jobs and reclaims expired leases for every queue it is given, in priority order, in bounded batches (DELAYED_PROMOTION_BATCH_SIZE, LEASE_RECLAIM_BATCH_SIZE, both 100), so a large backlog can’t stall other Redis clients for one script’s whole duration; there is no reaper process. Between sweeps it paces with Amp\delay() capped at POLL_INTERVAL_SECONDS (1.0) and clipped to what is left of the caller’s deadline — no blocking Redis command, so the queue’s operation budget is plainly REDIS_TIMEOUT. A reclaim increments attempts and encodes a new envelope, so a crashed worker’s job is redelivered as a different member string; release() and reclaim share one conditional Lua script (ZSCORE the old member, write the replacement — LPUSH onto pending, or ZADD into the delayed set with a due score when release() carried a delay — then ZREM the old, returning whether it won), so two sweepers racing, or a sweep racing a settlement, yield one winner rather than a duplicate, and a delayed retry is as indivisible as an immediate one. A reclaim always calls it with no delay: a crashed delivery is not a handled failure. ack()/fail() ZREM only that exact member and read the count back, so a settlement for a delivery already settled or reclaimed raises Kinetis\Queue\Exception\StaleJobHandleException and writes nothing. An abandoned lease whose envelope no longer decodes is settled through QueueContract::settleIfMalformed() rather than reclaimed forever. renew() is one further Lua script: it reads Redis TIME and resets the exact leased member’s expiry to TIME + $visibilityTimeoutSeconds with ZADD ... XX, so a member the leased set no longer holds is never added back, and the changed count is ignored because ZADD answers 0 for a score it did not change — which a renewal inside the same second produces. QueueWorker drives it while a job runs, so the window sizes crash recovery rather than job duration; a job whose worker died, or whose handler never yields, still outlives its expiry and can execute alongside its replacement, and maxAttempts bounds a handler that throws, not a succession of processes that each die during execution. size() counts pending, delayed and expired leases but not live ones; clear() removes pending and delayed entries in one script and leaves the leased key alone. Every envelope carries a random id (and a pushedAt timestamp), generated fresh only on an independent push() — release() preserves the id/pushedAt it reads back off the envelope it’s replacing, keeping the job’s own logical identity and original enqueue time stable across retries. id is what keeps two byte-identical jobs from colliding into one member when both land in the delayed sorted set — sorted-set members are unique, plain strings are not. The envelope is exactly {id, pushedAt, class, args, attempts, maxAttempts, metadata} and all seven keys are required: the decoder checks each one against the shape RedisQueue itself writes rather than the widest shape a cross-backend coercer accepts — id is 32 lowercase hexadecimal characters, and pushedAt a positive Unix timestamp that json_decode() returned as a native integer, never a numeric string — all before a QueuedJob exists, so an envelope missing or corrupting any one of them settles through Kinetis\Queue\QueueContract::settleIfMalformed() rather than reaching ack()/release() as a partially accepted job.

  • Kinetis\QueueRedis\RedisQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): RedisQueue — reads the same REDIS_* convention the cache does, including REDIS_TLS*, and builds new Amp\Redis\RedisClient($client->link()) over a Kinetis\Redis\Client of its own, keeping that client and handing the queue its close() as the disposer — the Amp facade exposes no close, so the Kinetis\Redis\Client is the only object that can end the connection; first throws InvalidArgumentException naming the connection’s scoped REDIS_CLUSTER key when it is true, since queue scripts span keys with no shared hash tag and only standalone Redis is supported; throws when neither REDIS_URL nor REDIS_HOST is set, and rejects a QUEUE_VISIBILITY_TIMEOUT_SECONDS (read via Config::scopedKey(), default DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 300) below 1. The queue’s own connection is never shared with the cache, so its lifetime and its REDIS_TIMEOUT budget are its own.

  • kinetis/queue’s QueueFactory dispatches to this package for a connection whose selector is redis (QUEUE_CONNECTION=redis for the default connection).

  • Depends on kinetis/framework, kinetis/queue, and kinetis/redis (all via path repositories), plus amphp/redis and amphp/socket. Own composer.json/phpunit.xml/phpstan.neon.

packages/queue-sql (kinetis/queue-sql)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\QueueSql\SqlQueue implements Kinetis\Queue\ClearableQueueInterface, Kinetis\Queue\DisposableQueueInterface, Kinetis\Queue\RenewableQueueInterface — backed by the generic Kinetis\Persistence\Contract\SqlLink (dialect-agnostic SQL, including priority ordering via CASE queue WHEN ... END). Dequeues via SELECT ... FOR UPDATE SKIP LOCKED inside a transaction; pop()’s blocking contract is a poll loop suspended with Kinetis\Async\Timer::delay(), since SQL has no native blocking-wait primitive — bounded by whatever’s left of the overall deadline rather than always the full poll interval. It needs no per-queue sweep at all: its own single, priority-ordered SQL query already checks every queue as one atomic operation. Requires the kinetis_queue_jobs table (with queue, attempts, max_attempts, reserved_at, reserved_token columns and a composite (queue, available_at, reserved_at) index) — see resources/migrations/create_kinetis_queue_jobs_table.{mysql,pgsql}.php.stub, not auto-created. fail() deletes the row, the same as ack(). Its second constructor argument, $visibilityTimeoutSeconds (default 300, rejected below 1), reclaims a crashed worker’s reserved row after that many seconds, incrementing attempts at that point. clear() deletes only rows whose reserved_at is null, which is narrower than the predicate size() and pop() share: that one treats a reservation past $visibilityTimeoutSeconds as available again, and reclaiming is a per-row handover pop() makes under the row lock, while a clear has none to make and a slow worker still owns the row it is running. Every reservation and reclaim writes a fresh bin2hex(random_bytes(16)) reserved_token under the row lock, and QueuedJob::$handle is a Kinetis\QueueSql\Reservation carrying the row id and that token; release() also sets available_at to now + $delaySeconds, the same column and Y-m-d H:i:s format push() writes a delayed enqueue with, so a delayed retry needs no migration or schema change. ack()/release()/fail() match on both and require an affected-row count of exactly 1, so a settlement from a delivery a reclaim has superseded writes nothing and raises Kinetis\Queue\Exception\StaleJobHandleException instead of settling the reservation another worker now holds — the malformed-row cleanup included, which is fenced by the same predicate and surfaces that exception from pop() rather than deleting a live delivery’s row. renew() is one UPDATE setting reserved_at = ? under that same id/reserved_token predicate, leaving attempts and available_at alone; its affected-row count is not read, since MySQL reports 0 for an UPDATE writing the value already stored, which a renewal inside the same second does. reserved_at is written and compared against the worker process’s own time(), not the database’s clock — a renewal included — so skew between workers shifts when a reservation looks expired. The MySQL stub declares queue and reserved_token ascii_bin, since MySQL’s default collation compares case-insensitively and both are matched for exact equality; Postgres compares byte-exactly already.

  • Kinetis\QueueSql\SqlQueue::pushOn(Kinetis\Persistence\Contract\SqlTransaction $transaction, Kinetis\Queue\Job $job, int $delaySeconds = 0, string $queue = 'default', ?int $maxAttempts = null): void — enqueues through a transaction the caller already owns. It shares one private insertion path with push(), so argument validation, serialization, telemetry metadata, timestamps and the INSERT shape are the same on both routes. The row becomes visible and durable only if the caller commits; a throw before the commit rolls it back with the caller’s other work, and a COMMIT that fails leaves the unknown outcome SqlTransaction describes. Push telemetry closes when the INSERT statement completes, so the span reports the enqueue statement rather than the later commit. $transaction is the only thing SQL runs on: nothing is committed, rolled back, nested or retained, and the constructor SqlLink is untouched — so the caller’s transaction must address the database holding kinetis_queue_jobs, since the queue’s connection name scopes only the connection behind push(). Concrete on this class rather than a capability interface: there is one implementation, and QueueInterface carries only what every backend delivers identically, so QueueInterface::push() is not enlisted in a caller’s transaction on any backend. It takes a raw persistence transaction; a kinetis/orm transaction session hands its callback an EntityManager and does not expose a transaction.

  • Kinetis\QueueSql\SqlQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): SqlQueue — builds a SqlQueue from Kinetis\DatabaseBridge\ConnectionFactory::fromConfig()’s result, reading QUEUE_VISIBILITY_TIMEOUT_SECONDS (via Config::scopedKey()) for the second constructor argument — absent means DEFAULT_VISIBILITY_TIMEOUT_SECONDS (300), and a value below 1 is rejected. The link is opened here, so its close() becomes the queue’s disposer. The return type is the concrete class rather than ClearableQueueInterface: pushOn() sits on no interface, so this is what lets a caller who has already named the backend reach it, and lets an application bind SqlQueue::class to the result without a runtime narrowing check. SqlQueue implements QueueInterface and ClearableQueueInterface, and QueueFactory’s connection-driven dispatch still hands back QueueInterface.

  • kinetis/queue’s QueueFactory dispatches to this package for a connection whose selector is sql (QUEUE_CONNECTION=sql for the default connection).

  • Depends on kinetis/framework, kinetis/queue, kinetis/persistence (SqlQueue’s TransactionGuard use and the SqlTransaction in pushOn()’s signature), and kinetis/database-bridge (ConnectionFactory) — all via path repositories. Own composer.json/phpunit.xml/phpstan.neon.

packages/queue-sqs (kinetis/queue-sqs)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\QueueSqs\SqsQueue implements Kinetis\Queue\RenewableQueueInterface — backed by AsyncAws\Sqs\SqsClient. push()/pop() map onto SendMessage/ReceiveMessage; ack()/fail() onto DeleteMessage; release() onto ChangeMessageVisibility, carrying its own $delaySeconds as the new VisibilityTimeout — on a call SQS accepts, that timeout counts from the call, so 0 makes the message available again immediately rather than waiting out the normal timeout. The request field accepts 0 to 43200 seconds, a separate and wider limit from SendMessage’s 900-second DelaySeconds, and this backend raises against it before any transport. Being in range is not acceptance: SQS refuses a timeout longer than the time left in that received message’s own 12-hour maximum and documents that it does not recalculate down to that remaining time, which is service state this package cannot see — so it makes no local guess and the refusal propagates as SQS’s own error with nothing settled. A queue name resolves to an SQS queue of that name (optionally prefixed) via GetQueueUrl, cached per instance — never auto-created. delaySeconds uses SQS’s own native SendMessage delay, capped at 900 seconds — a longer value throws before any network call. QueuedJob::$attempts comes directly from SQS’s own ApproximateReceiveCount message attribute; $maxAttempts (no native SQS equivalent) travels as a custom maxAttempts message attribute; instrumentation propagation metadata travels the same way, as one JSON-encoded metadata attribute (see the telemetry package’s OtelTelemetry above). pop() sweeps every named queue in priority order with WaitTimeSeconds: 0, then long-polls the highest-priority queue for up to five seconds before sweeping again — 0 means “don’t block” on SQS, so one operation covers both, and the long poll is capped by what is left of the caller’s deadline, rounded up to a whole second because WaitTimeSeconds accepts no finer unit. pop() rechecks the deadline the moment that poll comes back empty, so no further receive is issued after it has passed. No Kinetis\Async\Timer::delay() or concurrently() wrapper, since the injected AmpHttpClient transport tolerates being called from plain top-level code. Standard SQS queues only; FIFO is not supported. Its third constructor argument, $visibilityTimeoutSeconds (default 300, admitted range 1 to 43200), is sent as VisibilityTimeout on every ReceiveMessage, so it overrides the queue’s own attribute for the messages this application takes, and renew() restores that same window with one ChangeMessageVisibility; SqsQueueFactory reads it from the scoped QUEUE_VISIBILITY_TIMEOUT_SECONDS and validates it before an AWS client is built. AWS counts a message’s own 12-hour maximum from the receive rather than from the last renewal, so a job running past it is redelivered whatever the worker sends. QueuedJob::$handle is the message’s ReceiptHandle, which SQS scopes to the receive that produced it; whatever SQS answers a settlement with propagates as its own error rather than as Kinetis\Queue\Exception\StaleJobHandleException. Does not declare Kinetis\Queue\ClearableQueueInterface, has no clear() at all, and never calls PurgeQueue: that operation deletes the messages a worker holds in flight along with the waiting ones, keeps deleting messages sent during the up-to-60-second window it takes to finish, and reports no count — and size(), which excludes in-flight work and is an estimate, could not report what it destroyed either. Emptying an SQS queue stays an infrastructure step; see Queue (SQS).

  • Kinetis\QueueSqs\SqsClientFactory::fromConfig(Config $config, string $connection = 'default'): SqsClient — builds SqsClient with Kinetis\RevoltHttpClient\AmpHttpClientFactory::create() injected as its transport. QUEUE_SQS_REGION required; QUEUE_SQS_ENDPOINT/QUEUE_SQS_PLAINTEXT/QUEUE_SQS_TIMEOUT/QUEUE_SQS_QUEUE_PREFIX optional, all via Config::scopedKey(). An explicit endpoint is validated down to one origin; without one, an ambient AWS_ENDPOINT_URL is refused. Credentials are never read from Kinetis\Config: the factory composes AsyncAws’s standard providers in its standard order behind Kinetis\QueueSqs\CredentialChain, handing that same transport to every provider in it, including the one that assumes an AWS_ROLE_ARN role through STS. See Queue (SQS) for what stays blocking.

  • Kinetis\QueueSqs\SqsQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): SqsQueue — the class_exists()-gated entry point kinetis/queue’s own QueueFactory calls: builds SqsQueue from SqsClientFactory::fromConfig() plus the optional QUEUE_SQS_QUEUE_PREFIX and the scoped QUEUE_VISIBILITY_TIMEOUT_SECONDS (default 300, admitted range 1 to 43200, read and validated before the client is built). Returns the concrete class, the narrowest type this backend declares, like every other backend’s own factory.

  • kinetis/queue’s QueueFactory dispatches to this package for a connection whose selector is sqs (QUEUE_CONNECTION=sqs for the default connection).

  • Depends on kinetis/framework, kinetis/queue, and kinetis/revolt-http-client (all via path repositories), plus async-aws/sqs. Own composer.json/phpunit.xml/phpstan.neon.

packages/queue-rabbitmq (kinetis/queue-rabbitmq)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\QueueRabbitMq\RabbitMqQueue implements Kinetis\Queue\ClearableQueueInterface, Kinetis\Queue\DisposableQueueInterface — backed by Thesis\Amqp\Client/Channel. A queue is declared durable on first touch by any method, never auto-created ahead of that. push() publishes to the queue directly; a delayed push() publishes into a ladder of internal holding queues — {queue}.delay.{2^i}s, each with an x-message-ttl of 2^i seconds and dead-lettering into the tier below, so a delay is spent as the binary sum of its tiers and every message in a tier owes that tier’s own wait rather than queueing behind a longer one (DelayLadder, @internal, carries the topology and the 4,194,303-second ceiling the client’s signed 32-bit encoding of an x-message-ttl imposes; Queue (RabbitMQ) documents what a caller sees). size()/clear() cover every tier, so a delayed job counts as waiting and is purged like any other, from any process — queue by queue, not as one atomic snapshot. attempts/maxAttempts travel as plain message headers (AMQP 0-9-1 has no native attempt count, only a boolean redelivered flag), and instrumentation propagation metadata as a JSON-encoded metadata header carried forward by release(). Every publish runs on a channel in confirm mode, mandatory, and waits for the broker’s acknowledgement, throwing Exception\PublishNotConfirmedException for anything else; release() republishes with an incremented attempts header (nack’s own requeue flag redelivers the message unchanged, so it cannot carry the new count) — through the same real-queue-or-ladder publication push() uses, so a release() carrying a delay enters the ladder and is subject to the same ceiling — waits for that acknowledgement, and only then discards the original delivery via nack(requeue: false) — an unconfirmed publish settles nothing and the job stays queued, while the crash window between a confirmed publish and the nack is the duplication window Queue’s own backend comparison records against this backend. QueuedJob::$handle is the Thesis\Amqp\DeliveryMessage itself; a delivery tag is scoped to the channel that produced it and the broker answers a second settlement of one with a channel-level error, so this backend raises no Kinetis\Queue\Exception\StaleJobHandleException either. pop() sweeps every named queue in priority order with basic.get, each a single immediate request (AMQP has no native blocking-wait-with-timeout primitive), and paces between sweeps with Amp\delay() — a one-second pause, cut short by whatever is left of the caller’s deadline. One channel per instance, opened lazily and reused. Kinetis\Async\concurrently() composes correctly with a still-open connection, confirmed against a real broker — ConcurrentBatch parks on a targeted Revolt suspension resumed once its own tasks finish, unaffected by Thesis\Amqp\Channel’s permanent background reader.

  • Kinetis\QueueRabbitMq\Exception\PublishNotConfirmedException — a publish RabbitMQ did not acknowledge (Nacked, Unrouted for a mandatory publish with nowhere to go, Canceled, Waiting, or a channel that is not in confirm mode at all). Raised before anything is settled, which is what keeps release() from discarding a job against a publish that never landed.

  • Kinetis\QueueRabbitMq\RabbitMqClientFactory::fromConfig(Config $config, string $connection = 'default'): Client — builds Thesis\Amqp\Client from Thesis\Amqp\Config::fromURI(), re-created with its username, password and vhost rawurldecode()d — the vendor parser leaves those three URI components percent-encoded, so /%2f would otherwise name a vhost literally called %2f (Queue (RabbitMQ) documents what a caller writes). QUEUE_RABBITMQ_URL required, via Config::scopedKey().

  • Kinetis\QueueRabbitMq\RabbitMqQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): RabbitMqQueue — the class_exists()-gated entry point kinetis/queue’s own QueueFactory calls: builds RabbitMqQueue from RabbitMqClientFactory::fromConfig() plus the optional QUEUE_RABBITMQ_QUEUE_PREFIX.

  • kinetis/queue’s QueueFactory dispatches to this package for a connection whose selector is rabbitmq (QUEUE_CONNECTION=rabbitmq for the default connection).

  • Depends on kinetis/framework and kinetis/queue (both via path repositories) plus thesis/amqp and amphp/amp. Own composer.json/phpunit.xml/phpstan.neon.

packages/storage (kinetis/storage)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\Storage\AmpFileAdapter — a League\Flysystem\FilesystemAdapter for local disk backed by Amp\File\Filesystem, so a driver call suspends the calling Fiber via Revolt rather than blocking the worker. write(), writeStream() and copy() publish through one primitive: a private 0700 directory beside the destination, an exclusive staged file, a post-close length check, the mode to publish under, and one rename. readStream() buffers the whole object into a php://temp resource, inside a boundary of its own that keeps a converted spill-disk warning an UnableToReadFile and closes the resource; writeStream() transfers the caller’s resource in bounded chunks on the calling thread, never holding the whole input, and restores its blocking mode without closing it. Both block the thread wherever their native stream calls reach a disk. copy() reads the source in the same bounded chunks. deleteDirectory() plans the whole subtree before deleting anything. Confinement, the root-destination refusal, the symlink check and every Amp\File call run inside one typed boundary per operation, so a driver failure arrives as that operation’s own UnableTo* while a policy outcome and a programmer error each keep their type. Requires a non-empty root, and refuses a publication whose destination names that root. See Appendix: Storage Reference for the confinement rules, the publication guarantees, the resource-method boundaries, and the symlink rules.

  • Kinetis\Storage\ConfinedPath — @internal; the value every operand of every AmpFileAdapter operation is admitted through before a location is built from it, and whose segments the symlink walk steps through. See Appendix: Storage Reference for the rules it enforces.

  • Kinetis\Storage\StagingName — @internal; the name grammar of the private directory AmpFileAdapter publishes through, matched whole, and the one definition publish()’s naming, listContents()’s hiding and ConfinedPath’s refusal (Kinetis\Storage\Exception\ReservedPathDetected) all read. See Appendix: Storage Reference.

  • Kinetis\Storage\PackageBootstrap — declared via extra.kinetis; with FILESYSTEM_DRIVER set, lazily binds League\Flysystem\FilesystemOperator to FilesystemFactory::fromConfig()’s result before the application’s own bootstrap.php runs (which wins on the same binding). Inert when FILESYSTEM_DRIVER is unset; named connections stay explicit app-side wiring.

  • Kinetis\Storage\FilesystemFactory::fromConfig(Config $config, string $connection = 'default'): League\Flysystem\Filesystem — FILESYSTEM_DRIVER (default 'local') and FILESYSTEM_ROOT (required and non-empty for the local driver), both via Config::scopedKey() for named connections. The local driver is Amp\File\createDefaultDriver(), not Amp\File\filesystem()’s status-caching wrapper, so each filesystem owns its driver — and, without ext-uv/ext-eio, that driver’s own worker-process pool — and reports what other threads, processes and external writers have done. One instance per process or thread, kept for the application’s lifetime; every named connection is another pool. FILESYSTEM_DRIVER=s3 dispatches to packages/storage-s3 (below) if installed, else throws Exception\StorageUnavailableException.

  • Depends on kinetis/framework (via a path repository), league/flysystem, league/mime-type-detection (FinfoMimeTypeDetector), amphp/file, amphp/byte-stream (AmpFileAdapter’s writeStream(), via Amp\ByteStream\ReadableResourceStream and Amp\ByteStream\pipe()). Suggests ext-uv/ext-eio: without either, amphp/file runs every call in a pool of worker processes, one pool per filesystem instance built. Own composer.json/phpunit.xml/phpstan.neon.

packages/revolt-http-client (kinetis/revolt-http-client)

Separate Composer package, not part of kinetis/framework core — and not dependent on it either: kinetis/framework appears only in require-dev (for tests and the NoStaticPropertiesRule dogfooding), never in require. Installable and usable with no Kinetis framework present at all — the same standing kinetis/redis and kinetis/aws-sigv4 have, and kinetis/mcp-protocol, which requires nothing from this monorepo at all.

  • Kinetis\RevoltHttpClient\Http — the client application code uses. Immutable: withBaseUrl()/withToken()/withBasicAuth()/withHeaders()/withQuery()/withTimeout()/withRetries()/withMaxResponseBytes()/asForm() each return a new instance. get()/post()/put()/patch()/delete() take arrays (query for get(), JSON body for the rest); send() is the general form. Constructed with no argument it defaults to AmpHttpClientFactory::create(), so it autowires; pass any HttpClientInterface (Symfony’s MockHttpClient, for one) to substitute the transport, which is then the caller’s to hold to one wire attempt per request and to no credentials or base URI of its own. Preflight validates what the package’s own guarantees rest on, before a transport object exists: an absolute http(s) base URL with no userinfo, query string or fragment, printable-ASCII and backslash-free, with a path free of ./.. segments and of percent-encoded separators and normalized into a prefix a relative target extends; the same URL rules on each request URL, which must be relative under a base URL and absolute without one; header names as RFC 9110 tokens given once per array, values as a string or a non-empty list of strings with no CR/LF/NUL, and none of Accept-Encoding, Host or Proxy-Authorization, which the client owns or refuses; a positive finite timeout and response-byte ceiling, and a retry count of 0 to 10; and send()’s options as a closed set of headers/query/json/body/timeout, everything else refused. Method, body and query value types are Symfony’s to validate: a transport that refuses to construct the request fails as a fixed InvalidRequest carrying neither the value nor the vendor message. Refused input performs no transport call. A client carrying an Authorization or Cookie header requires withBaseUrl(), which is what confines a credential to one origin; max_redirects is 0 on every request, so no credential is ever forwarded to a Location origin. withRetries() is the package’s own retry loop rather than a decorator, retrying transport failures and 429/500/502/503/504 for a method exactly GET/HEAD/OPTIONS/TRACE/PUT/DELETE with backoff doubling from 100 ms inside the one deadline, returning the last response when retries or budget run out, refusing a stream or Closure body on a method it could retry, and releasing every response it abandons — any other method makes one deferred attempt, as on a client without retries, because a Transport failure is acknowledgement-unknown and a retryable status does not make a POST safe to repeat; withTimeout() (30 s by default) is a single monotonic deadline covering every attempt, backoff, and response read, handed to each attempt as what remains of it; withMaxResponseBytes() (8 MiB by default) is the ceiling a response body may reach, made a bound on memory by the identity Accept-Encoding every request carries.

  • Kinetis\RevoltHttpClient\HttpResponse — status()/successful()/failed()/redirect()/clientError()/serverError()/body()/json()/jsonPath()/header()/headers()/discard(). An error status is returned rather than thrown; throw() opts into raising Exception\HttpRequestException and returns the response otherwise, so it chains. json() decodes with JSON_BIGINT_AS_STRING, so an integer too wide for PHP’s int type keeps its digits instead of becoming a float. The body is bounded by the client’s ceiling at three points — a declared Content-Length, the transfer itself through the transport’s progress hook, and the bytes that arrived — so no path buffers a complete untrusted reply first; the same progress hook enforces the operation’s deadline, which is rechecked after every read that answers. Which failure a raised read becomes is read from the budget’s state rather than the vendor exception’s type, so a progress-guard abort the transport wrapped is still reported as the ceiling or the deadline it was. The object owns the transport response: discard() releases it, never throws, never blocks, and is repeatable, after which every read fails with the Discarded category; a response nobody discards releases the same way when PHP collects it, and one read to its end has nothing left to release. The body is read once and cached, and reading is deferred until asked for, which is what lets requests started inside concurrently() overlap.

  • Kinetis\RevoltHttpClient\Exception\HttpRequestException — the only exception the package throws, over a fixed HttpFailure category (InvalidRequest, Conversion, Transport, Timeout, ResponseTooLarge, ErrorStatus, Discarded) that callers branch on. It carries the request method, the origin (scheme, host, non-default port), a status, and that category, and nothing else — no path, query string, userinfo, header, credential, or body. A vendor exception is never chained and its message never copied, since a lower-level HTTP or DNS client routinely names the full URI it failed on and an exception message is what a log pipeline records by default. #[\SensitiveParameter] on every forwarding parameter keeps caller input out of getMessage(), (string) $e, and getTraceAsString(); getTrace() and serialize() still reach the SensitiveParameterValue wrappers PHP puts in their place, so an argument-carrying trace is not a safe thing to forward.

  • Kinetis\RevoltHttpClient\Preflight and Kinetis\RevoltHttpClient\ResponseBudget are @internal. ResponseBudget owns one operation’s monotonic deadline and byte ceiling, produces the transport’s on_progress guard and per-attempt timeout/max_duration, and builds every failure naming the method and origin.

  • Kinetis\RevoltHttpClient\AmpHttpClientFactory::create(array $defaultOptions = [], ?callable $clientConfigurator = null, int $maxHostConnections = 6, int $maxPendingPushes = 50): Symfony\Contracts\HttpClient\HttpClientInterface — the package’s only construction method, taking Symfony\Component\HttpClient\AmpHttpClient’s own constructor parameters. $clientConfigurator defaults to one handing the pooled delegate back untouched, so one request is one wire attempt and no interceptor repeats it below the caller: what Http is built on, so its own retry loop is the only one in the stack, and what every Kinetis integration handed this transport runs on. A supplied configurator builds the delegate instead, and owns whatever it installs.

  • Depends on symfony/http-client (^8.0 — the first version whose AmpHttpClient targets the current, Revolt-based amphp/http-client generation rather than the old pre-Fiber one), symfony/http-client-contracts, amphp/http-client (^5.3, an optional peer dependency of symfony/http-client that isn’t auto-installed, so declared directly), and revolt/event-loop (used directly to await a read and to wait out a retry backoff, so declared rather than taken transitively). Own composer.json/phpunit.xml/phpstan.neon.

packages/aws-sigv4 (kinetis/aws-sigv4)

Separate Composer package, not part of kinetis/framework core — and, like kinetis/revolt-http-client, not dependent on it either: kinetis/framework appears only in require-dev.

  • Kinetis\AwsSigV4\SigV4SigningClient implements Psr\Http\Client\ClientInterface — the package’s main class. __construct(string $origin, string $region, string $service, ?CredentialProvider $credentialProvider = null, ?DateTimeImmutable $now = null, ?Kinetis\AwsSigV4\SignedTransport $transport = null). Owns the target, the credentials, the body and the failure boundary: normalizes and origin-checks the target, reads the request body once from its current position through EOF, resolves credentials, builds the outgoing request itself from the trusted origin and a Nyholm stream over those bytes, hands it to Signature, and sends it. $now exists solely for testability (sendRequest()’s signature is fixed by the PSR-18 interface, so there’s nowhere else to thread a fixed clock through) — real usage leaves it null. See AWS request signing (SigV4) for the normative statement of origin, target normalization, canonical form, credential, redirect, failure, and buffering behavior.

  • Kinetis\AwsSigV4\Signature — @internal. AWS Signature Version 4 over a request already in wire form: canonical request, string to sign, derived signing key, and the Authorization header. Owns and overwrites Host (from the URI’s own authority), X-Amz-Date, Authorization and X-Amz-Security-Token, and computes the payload hash from the buffered body bytes rather than from any caller header.

  • Kinetis\AwsSigV4\CredentialChain implements AsyncAws\Core\Credentials\CredentialProvider — @internal. AsyncAws’s five providers in AsyncAws’s order, each network-capable one holding the injected SignedTransport. Holds the first unexpired credentials a lookup resolves and reuses them until they expire; passes over a null or expired answer and continues the same lookup down the chain; holds nothing when a lookup resolves nothing, so a transient ECS/IMDS/token-file failure costs one lookup rather than the worker’s remaining lifetime.

  • Kinetis\AwsSigV4\SignedTransport implements Symfony\Contracts\HttpClient\HttpClientInterface — the only transport a signed request and the default credential chain travel on. create(array $defaultOptions = []): self builds a Symfony AmpHttpClient over a bare Amp\Http\Client\PooledHttpClient, pinning a client configurator that installs no AMPHP interceptor; request() forwards one delegate call per request with max_redirects => 0 written onto the request itself. The constructor is private and create() takes default options only, so no client and no configurator of a caller’s own reaches underneath a signature. answeredInProcess(callable $responder): self is the testing seam, answering from a function on the calling thread with no connection opened. See AWS request signing (SigV4) for the normative statement of what one send guarantees and why the transport is owned rather than configured.

  • Kinetis\AwsSigV4\TrustedOrigin — @internal. The parsed $origin, and the one place its grammar, its canonical form for comparison, its path joining, and its base-path containment check live. Package-owned rather than parse_url()-based: parse_url() accepts hosts and ports no HTTP origin may take and reports nothing about the components it discards.

  • Kinetis\AwsSigV4\WireTarget — @internal. The one rule turning a request target into the exact representation that leaves the process: unreserved percent escapes decoded, every other escape kept in uppercase hex, characters outside the safe set encoded, repeated / collapsed, ./.. segments removed after decoding, an empty path written as /. Applied before the origin and base-path checks and before signing, so an HTTP client resolving /a/../b or decoding /%7Efoo afterward has nothing left to change and the signature covers the bytes that go out.

  • Kinetis\AwsSigV4\Exception\ — SigningException for a rejected $origin/$region/$service at construction; ClientFailureException for what every per-request failure shares, split under it by PSR-18 category into UntrustedOriginException, UnsignableRequestException and TransportFailureException (via RequestFailureException, PSR-18’s RequestExceptionInterface) and NetworkFailureException (NetworkExceptionInterface). None of them is serializable: a stack trace holds #[SensitiveParameter] arguments and PHP refuses to serialize a SensitiveParameterValue.

  • The signature itself is held to AWS’s published SigV4 test vectors — a fixed date and the static AKIDEXAMPLE credentials — run end to end through the client, each published Authorization header asserted exactly. That is what makes the canonical form above a checked claim rather than a described one.

  • Depends on kinetis/revolt-http-client, async-aws/core, nyholm/psr7 (the PSR-17 factories the internal Psr18Client is built with, and the PSR-7 request and stream the outgoing request is built from), psr/http-client, psr/http-message, symfony/http-client, symfony/http-client-contracts, and amphp/http-client (named directly: SignedTransport pins the AMPHP client its own requests run through). async-aws/core supplies the credential provider interface, types and providers only; the signing algorithm is this package’s own. Own composer.json/phpunit.xml/phpstan.neon.

packages/storage-s3 (kinetis/storage-s3)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\StorageS3\S3FilesystemFactory::fromConfig(Config $config, string $connection = 'default'): League\Flysystem\Filesystem — builds Kinetis\StorageS3\S3Client with Kinetis\RevoltHttpClient\AmpHttpClientFactory::create() injected as its transport, Kinetis\StorageS3\DeclaredContentLength on that transport’s connection pool so a writeStream() body keeps the Content-Length AsyncAws declared, wraps it in Kinetis\StorageS3\S3Adapter with private visibility, ContentType as the only forwarded option and retain_visibility off. FILESYSTEM_S3_BUCKET/FILESYSTEM_S3_REGION required; FILESYSTEM_S3_PREFIX/FILESYSTEM_S3_ENDPOINT/FILESYSTEM_S3_PLAINTEXT/FILESYSTEM_S3_TIMEOUT optional, all via Config::scopedKey(). An explicit endpoint is validated down to one origin and addressed path-style; without one, an ambient AWS_ENDPOINT_URL is refused. Credentials are never read from Kinetis\Config: the factory composes AsyncAws’s standard providers in its standard order behind Kinetis\StorageS3\CredentialChain, handing that same transport to every provider in it, including the one that assumes an AWS_ROLE_ARN role through STS. See Storage (S3) for what stays blocking.

  • Kinetis\StorageS3\CredentialChain implements AsyncAws\Core\Credentials\CredentialProvider — the providers above, consulted in order until one answers with credentials whose isExpired() is false; an expired answer is skipped for the next provider in the same call, like a null one, and a round with no usable answer returns null. Holds only what it returns, so no clock is read here and nothing records a provider or a round as having failed — a role or token file appearing later is picked up on the next call.

  • Kinetis\StorageS3\S3Client extends AsyncAws\S3\S3Client — drops the adapter’s default private ACL from putObject/copyObject and refuses any other, resolves those two and deleteObjects instead of leaving the result to a destructor, and rejects a copy with no ETag or a batch delete with per-key errors, which S3 reports under HTTP 200.

  • Kinetis\StorageS3\S3Adapter extends League\Flysystem\AsyncAwsS3\AsyncAwsS3Adapter — replaces deleteDirectory() and nothing else: one listing page of at most 1,000 keys is deleted before the next is requested by continuation token, and an UnableToDeleteDirectory reason says whether a batch was confirmed complete, without ruling out a partial delete either way. See Storage (S3).

  • kinetis/storage’s own Kinetis\Storage\FilesystemFactory dispatches to this package for FILESYSTEM_DRIVER=s3, class_exists()-gated; throws Kinetis\Storage\Exception\StorageUnavailableException naming this package when it isn’t installed.

  • Depends on kinetis/framework and kinetis/revolt-http-client (both via path repositories), async-aws/core (the credential chain), async-aws/s3, league/flysystem-async-aws-s3, amphp/http-client (DeclaredContentLength). Own composer.json/phpunit.xml/phpstan.neon.

packages/mailer (kinetis/mailer)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\Mailer\PackageBootstrap — declared via extra.kinetis; with MAILER_DSN set, builds the mailer and binds the instance under Symfony\Component\Mailer\MailerInterface, so a malformed DSN, an unusable MAILER_TIMEOUT, or a missing bridge package fails at registration rather than on the first send. Inert when MAILER_DSN is unset. The application’s own bootstrap.php runs after this and still replaces the binding.

  • Kinetis\Mailer\MailerFactory::fromConfig(Config $config, string $connection = 'default'): Symfony\Component\Mailer\MailerInterface — reads MAILER_DSN and MAILER_TIMEOUT (Config::scopedKey() for named connections) and always passes Kinetis\RevoltHttpClient\AmpHttpClientFactory::create() into Symfony\Component\Mailer\Transport::fromDsn() as its HttpClientInterface. MAILER_TIMEOUT defaults to 30.0 seconds and becomes the client’s timeout and max_duration, with max_redirects at 0; a zero or negative value throws InvalidArgumentException naming the scoped key. Non-blocking for any API-based transport (Sendgrid, Mailgun, Postmark, SES, …) it resolves to; EsmtpTransport (SMTP) ignores the injected client and its options and opens a blocking socket. Its socket inherits PHP’s default_socket_timeout for the connection and each blocking stream operation, which is an inactivity bound rather than a total-send deadline — a disclosed limit, not a bug.

  • No Kinetis-owned MailerInterface — Symfony\Component\Mailer\MailerInterface is used directly, the same “don’t wrap an already-right abstraction” reasoning kinetis/storage already applies to League\Flysystem\FilesystemOperator.

  • Transport::fromDsn() discovers whichever bridge package (symfony/sendgrid-mailer, symfony/mailgun-mailer, …) is installed via its own class_exists()-gated factory list — MailerFactory has no dispatch logic of its own.

  • Mail is queueable with zero code in this package: a kinetis/queue Job’s own handle() method constructor-injects MailerInterface exactly like any other service, resolved through the same container QueueWorker/SyncQueue already autowire against.

  • Depends on kinetis/framework and kinetis/revolt-http-client (both via path repositories), symfony/mailer; symfony/sendgrid-mailer is a dev dependency, used by the tests as a real API bridge. Own composer.json/phpunit.xml/phpstan.neon.

packages/broadcasting (kinetis/broadcasting)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\Broadcasting\BroadcasterInterface (broadcast(string $channel, string $event, array $payload): void) is the one driver contract; Kinetis\Broadcasting\NullBroadcaster is the always-present default (a silent no-op); Kinetis\Broadcasting\Driver\PusherBroadcaster is the one real driver, speaking the Pusher Channels wire protocol — Soketi/Reverb/Pusher itself all implement it identically, so this one driver covers all three. Built on Kinetis\RevoltHttpClient\Http, so broadcast() suspends the calling Fiber rather than blocking; BROADCAST_TIMEOUT (default Http::DEFAULT_TIMEOUT_SECONDS, 30.0) is the whole budget for one trigger, applied by PusherBroadcaster::fromConfig() through Http::withTimeout() and therefore owned by the factory even for an injected client. A trigger returning means the broker answered 2xx, not that a subscriber received the event. A channel name outside the Pusher grammar raises Kinetis\Broadcasting\Exception\InvalidPusherProtocolValueException — PusherBroadcaster holds a trigger to the same Kinetis\Broadcasting\PusherProtocol grammar its signing methods enforce — and an unencodable payload raises JsonException, both before a request; an attempted request that does not produce 2xx raises Kinetis\RevoltHttpClient\Exception\HttpRequestException — Broadcasting’s “What a trigger outcome means” states which categories leave the outcome unknown. Kinetis\Broadcasting\Broadcaster is the service application code constructor-injects — autowires with nothing to register.

  • Kinetis\Broadcasting\ShouldBroadcast (broadcastOn(): list<string>, broadcastAs(): string, broadcastWith(): array) describes how an event class broadcasts, read by Broadcaster::event(). Not wired into Kinetis\Events\EventDispatcher automatically — unlike Kinetis\Events\ShouldQueue (checked per listener, inside a dispatch loop that already exists), broadcasting is a per-event concern with no natural hook in that loop; call Broadcaster::event() explicitly.

  • Kinetis\Broadcasting\Attributes\BroadcastChannel (TARGET_METHOD, one string $pattern) marks a private/presence channel authorization callback, discovered by Kinetis\Broadcasting\BroadcastChannelRegistry/BroadcastChannelDiscovery — the same “reflect a class for an attribute” shape Kinetis\Events\Listener/EventListenerRegistry already are, mirroring McpDiscovery’s exact three-source scan (project PSR-4 roots, the framework’s own Broadcasting segment, every installed package’s declared extra.kinetis scan roots) with the identical cross-pass $seen dedup every other Discovery class in this project carries. A pattern is dot-separated segments, each either one literal or exactly one whole {name} placeholder, with distinct placeholder names; a method’s signature — an optional leading CurrentUserInterface parameter, then one string parameter per placeholder, named and ordered to match — is validated at register() time, throwing Kinetis\Broadcasting\Exception\InvalidChannelAuthorizerException immediately rather than at the first real request, the same discipline EventListenerRegistry::register() already applies to a malformed #[Listener]. Registration is append-only and every overlap is a conflict: two patterns of the same segment count whose every position is an equal literal pair or holds a placeholder are rejected against each other, in either arrival order, so at most one authorizer claims a channel name and match() carries no precedence or ordering. BroadcastChannelRegistry itself implements Kinetis\Cache\CacheableDiscoveryInterface — declared as this package’s own extra.kinetis discovery class, so it’s part of the shared AOT cache (see Caching & AOT Compilation), not a separate mechanism. An artifact entry carries pattern/class/method/usesCurrentUser and nothing else; fromArray() checks those exact fields via Kinetis\Cache\Exception\ArtifactValidation, rebuilds each definition from pattern alone, and runs it through the same conflict check register() uses, throwing Kinetis\Cache\Exception\InvalidCacheArtifactException — the classified exception CacheableDiscoveryInterface::fromArray()’s own contract requires, and the one BootSequence recompiles on — for anything missing, extra, wrong-typed, malformed, or overlapping.

  • Kinetis\Broadcasting\Http\BroadcastAuthController (#[Post('/broadcasting/auth')]) is the endpoint a Pusher-protocol client library calls automatically before subscribing to a private-*/presence-* channel — discovered as an ordinary route via this package’s own extra.kinetis.scan, never hand-registered. Constructor-injects the bound BroadcasterInterface — the one id PackageBootstrap binds — so the route resolves under every BROADCAST_DRIVER. Signing an authorization response is Pusher-protocol-specific, so the endpoint requires the bound broadcaster to be PusherBroadcaster and throws Kinetis\Broadcasting\Exception\BroadcastingException::authNotSupported(), naming the bound driver class, for any other. Carries #[Middleware('@broadcasting')], and depends on neither auth package; Broadcasting’s “Securing the endpoint” states what that group admits and what a channel authorizer requires.

  • Http\BroadcastOriginMiddleware — the group’s one permanent member, at priority 100, which is also what guarantees the group the controller references always exists. Passes a request carrying no Origin header, one carrying the request URI’s own scheme://authority, or one listed exactly in BROADCAST_ALLOWED_ORIGINS (comma-separated, empty by default, not connection-scoped); anything else is 403 before the rest of the group or the controller runs. Not a CORS policy — a cross-origin browser request must also be admitted by the application’s global CorsMiddleware. Same-origin without configuration is the difference from kinetis/mcp’s McpOriginMiddleware, whose spec-mandated default rejects every Origin — a browser posting this endpoint from the page it was served by is the ordinary case here.

  • Kinetis\Broadcasting\PackageBootstrap — declared via extra.kinetis; BROADCAST_DRIVER (default "null") selects and eagerly builds the bound BroadcasterInterface at worker boot (not lazily, unlike kinetis/session’s own driver bindings — nothing here depends on a sibling package’s bootstrap having run first, so a misconfigured BROADCAST_DRIVER=pusher fails before the first request). BroadcastChannelRegistry is not bound here at all — the framework itself binds it, before this method ever runs, via extra.kinetis’s discovery key.

  • PusherBroadcaster’s signing algorithm — trigger-request signing (auth_key/auth_timestamp/auth_version/body_md5, lexically sorted, HMAC-SHA256 over "{method}\n{path}\n{params}") and channel authorization ("{socketId}:{channelName}", or "{socketId}:{channelName}:{channelDataJson}" for presence) — is held to pusher/pusher-php-server’s own source, and exercised against a real Soketi broker and a real WebSocket client: a public broadcast delivered, a private-channel subscription signed by this driver accepted with its triggered event delivered, and a presence-channel subscription signed by the real BroadcastAuthController accepted with the correct channel_data.

  • Depends on kinetis/framework and kinetis/revolt-http-client (both via path repositories), psr/http-message. Own composer.json/phpunit.xml/phpstan.neon.

packages/search-opensearch (kinetis/search-opensearch)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\SearchOpenSearch\PackageBootstrap — declared via extra.kinetis; with SEARCH_OPENSEARCH_HOST set, builds the client and binds the one instance as OpenSearch\Client — shared for the worker, which HttpTransport keeping nothing between calls and EndpointFactory building a fresh endpoint per call is what makes safe — plus Kinetis\Search\SearchClient as OpenSearchClient over it, so unusable configuration fails while registering rather than on the first search; construction opens no connection and the application’s own bootstrap.php can replace either binding. Inert when the key is unset; the concrete client is the binding id because opensearch-php exposes no interface for it. Installing both engine packages leaves them competing for the SearchClient id — the application binds it itself to decide.

  • Kinetis\SearchOpenSearch\OpenSearchClientFactory::fromConfig(Config $config, string $connection = 'default', ?Closure $transportDecorator = null): OpenSearch\Client — builds the client through OpenSearch\TransportFactory::setHttpClient() (a real PSR-18 injection point, part of the library’s own non-deprecated construction path — the older ClientBuilder/Transport/ConnectionPool stack is deprecated since 2.4.0 and has no such injection point) with SearchTransport’s client. $transportDecorator (Closure(ClientInterface): ClientInterface) wraps that fully-configured adapter right before TransportFactory receives it — the seam kinetis/telemetry’s TracingSearchTransport composes through, without duplicating the transport’s own config-reading logic. CONFIG_PREFIX is SEARCH_OPENSEARCH.

  • Kinetis\SearchOpenSearch\OpenSearchClient — AbstractSearchClient over the official client: each SearchCall goes to the client method of the same name, and OpenSearch\Exception\HttpExceptionInterface becomes a SearchRequestException carrying its status, which the shared half reads as null from get() and false from delete() for a 404. Response bodies are the cluster’s own, untouched.

  • The transport’s JSON Content-Type default exists for this engine: OpenSearch’s own request building never sets one, relying on the HTTP client to default a string body to JSON, while Symfony’s clients default an unmarked string body to application/x-www-form-urlencoded, which a node answers with 406. No OpenSearch request replaces that default, so a _bulk body travels as NDJSON lines under application/json, which the engine’s bulk handler accepts and this package’s real-cluster checks exercise.

  • Depends on kinetis/framework and kinetis/search (both via path repositories), opensearch-project/opensearch-php, psr/http-client. kinetis/revolt-http-client is a dev dependency only: the transport is kinetis/search’s to own and reaches an install transitively, and nothing in this package’s source names it. Own composer.json/phpunit.xml/phpstan.neon.

packages/search-elasticsearch (kinetis/search-elasticsearch)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\SearchElasticsearch\PackageBootstrap — declared via extra.kinetis; with SEARCH_ELASTICSEARCH_HOST set, builds the SearchTransport once and binds Elastic\Elasticsearch\Client and Kinetis\Search\SearchClient as non-shared bindings over it, so each resolution gets its own client. Elastic\Transport\Transport retains $lastRequest/$lastResponse and Client::setAsync() is a mutable mode, so a worker-lifetime client would hold one request’s documents and results until the next search displaced them; the transport underneath owns the pool, keeps nothing per call, and is the part that is shared. No client is built at registration; ElasticsearchClientFactory::assertUsableCredentials() is what makes a conflicting credential pair fail there. The concrete client is the binding id because Elastic\Elasticsearch\ClientInterface carries only the transport and mode accessors — none of search(), index() or get(), which the final Client picks up from its endpoint traits. Inert when the key is unset; same registration-time failure and same application override as the OpenSearch package.

  • Kinetis\SearchElasticsearch\ElasticsearchClientFactory::fromConfig(Config $config, string $connection = 'default', ?Closure $transportDecorator = null): Elastic\Elasticsearch\Client — builds the client through ClientBuilder::setHosts()/setNodePool()/setHttpClient() over SearchTransport’s origin and client. ::over(SearchTransport $transport, Config $config, string $connection = 'default') is the same assembly over a transport that already exists, which is how the bootstrap gives many clients one pool. CONFIG_PREFIX is SEARCH_ELASTICSEARCH. Accepts elasticsearch/elasticsearch ^8.19 || ^9.0; the client major must match the cluster’s, since a 9.x client sends compatible-with=9.

  • Retries are pinned to 0 on the built transport, not through ClientBuilder::setRetries(), which cannot express zero: build() replaces the value with the host count whenever empty() holds for it. Zero is required because Elastic\Transport\Transport catches PSR-18’s NetworkExceptionInterface and re-sends the request, which would replay an index or bulk whose dispatch outcome is unknown. A request that never completed therefore surfaces as Elastic\Transport\Exception\NoNodeAvailableException wrapping SearchNetworkException.

  • Kinetis\SearchElasticsearch\SingleNode — a NodePoolInterface answering the one configured origin with no liveness state. SimpleNodePool’s default NoResurrect strategy would mark this client’s only node dead on the first network failure and never revive it, ending every later request through that client, which for one built outside the container and kept is the rest of the worker’s life; keeping the node in service also lets the transport report the exception that actually happened rather than “no alive nodes”.

  • ClientBuilder::setBasicAuthentication() is never called: it reaches Transport::setUserInfo(), which puts credentials into the request URI’s userinfo where a transport error can quote them. Basic credentials stay in the HTTP client’s auth_basic option. SEARCH_ELASTICSEARCH_API_KEY (with optional SEARCH_ELASTICSEARCH_API_KEY_ID) travels as an Authorization: ApiKey header via setApiKey(); configuring it alongside SEARCH_ELASTICSEARCH_USERNAME raises a SearchConfigurationException rather than leaving header precedence to pick a credential. No setSSL*()/setCABundle() call is made — ClientBuilder::setOptions() throws for an HTTP client class it does not recognize, and TLS is SEARCH_ELASTICSEARCH_VERIFY_PEER’s.

  • Kinetis\SearchElasticsearch\ElasticsearchClient — AbstractSearchClient over the official client: each SearchCall goes to the client method of the same name, its Response\Elasticsearch objects are read with asArray(), and ClientResponseException/ServerResponseException become a SearchRequestException carrying the status both keep as their code, which the shared half reads as null from get() and false from delete() for a 404.

  • Depends on kinetis/framework and kinetis/search (both via path repositories), elastic/transport (named directly: SingleNode implements its NodePoolInterface and ElasticsearchClient maps its NoNodeAvailableException), elasticsearch/elasticsearch, psr/container, psr/http-client. kinetis/revolt-http-client is a dev dependency only, for the reason the OpenSearch package’s entry gives. Own composer.json/phpunit.xml/phpstan.neon.

packages/telemetry (kinetis/telemetry)

Separate Composer package, not part of kinetis/framework core. Participates in extra.kinetis through a PackageBootstrap.

Every decorator and hook below routes an operation’s own inputs through one @internal policy class, Kinetis\Telemetry\Redaction, with no configuration switch to bypass it: a fingerprint (scoped to a FingerprintDomain, so identical bytes in two contexts never share a digest), a count, a fixed-vocabulary descriptor, or the scheme, host and port naming which service was addressed crosses into a span, and the statement, key, path, URL userinfo, query string or fragment, session id, exception message or stack trace it stands for does not. Telemetry’s “What never reaches a span” states the rule and carries the table; the entries below name only what each decorator puts on its spans.

  • Kinetis\Telemetry\PackageBootstrap — binds OpenTelemetry\API\Trace\TracerProviderInterface on AppScope: the OTLP-exporting provider when OTEL_EXPORTER_OTLP_ENDPOINT is set, a NoopTracerProvider otherwise. Leaves OTel’s default Fiber-bound context storage in place, so a scope belongs to the Fiber that attached it. Registers the provider’s shutdown() via register_shutdown_function — request end under boot-and-die, worker exit under a persistent runtime, so both shapes flush.

  • Kinetis\Telemetry\TracerFactory::fromConfig(Config): ?TracerProvider — a BatchSpanProcessor over the OTLP/HTTP exporter, whose transport is Symfony\Component\HttpClient\Psr18Client wrapping AmpHttpClientFactory::create() with max_redirects 0 and timeout and max_duration both 10.0, and the transport is created with maxRetries: 0 — so each export request suspends rather than blocks and is one bounded wire attempt that is never replayed; see Appendix: Observability Reference. null when no endpoint is configured.

  • Kinetis\Telemetry\HttpClient\TracingHttpClient/TracingResponse — a client span per outgoing request, carrying http.request.method from the method vocabulary, url.scheme/server.address/server.port and kinetis.http.url_fingerprint — never the URL’s userinfo, path, query string or fragment, each of which routinely holds a credential or an identifier, while $inner is handed the URL and the method exactly as the caller wrote them. A stale traceparent/tracestate on the headers option is replaced, so the injected carrier reaches $inner exactly once; every unrelated entry of that iterable arrives with its own value and its own position, neither regrouped nor repaired here. The span ends when the response is consumed — getContent()/toArray(), an error, cancel(), or destruct as the safety net — never when request() returns, since requests through this transport complete later by design. stream() unwraps to the inner client’s own responses (Symfony clients only stream responses they created), so stream consumers get destruct-time span timing.

  • Kinetis\Telemetry\Logging\TraceAwareLogger — PSR-3 decorator adding trace_id/span_id to entry context when a span is recording; caller-supplied keys win.

  • Kinetis\Telemetry\SimpleCache\TracingSimpleCache — wraps any PSR-16 CacheInterface. A client span per method (get/set/delete/has/ clear/getMultiple/setMultiple/deleteMultiple), named by the operation, db.system.name: redis, a kinetis.cache.key_fingerprint over the operation’s ordered key list and db.operation.batch.size on the three multi-key methods. Neither the keys nor the values are recorded: a key is built from whatever identifies the cached thing, so it is as sensitive as the value.

  • Kinetis\Telemetry\Session\TracingSessionStore — wraps any SessionStoreInterface. A span per read/create/update/destroy; the session id never travels verbatim (it’s a bearer credential), only its fingerprint as kinetis.session.id_fingerprint. The payload is never recorded.

  • Kinetis\Telemetry\Search\TracingSearchTransport — wraps any PSR-18 ClientInterface, meant for either engine factory’s $transportDecorator parameter. A client span per call, named from the request’s method and the action its path performs (POST _search, GET _doc) rather than parsing the query DSL, both drawn from fixed vocabularies; a path naming no listed action gives request. Carries db.system.name from the SearchSystem case it was constructed with (opensearch or elasticsearch), db.operation.name (the action) and kinetis.search.path_fingerprint — index names, aliases and document ids identify the records a call touched, so no segment of the path names a span or reaches an attribute. PSR-18’s sendRequest() always returns a complete response, so unlike TracingHttpClient there’s no deferred span lifecycle to manage.

  • Kinetis\Telemetry\Instrumentation\OtelTelemetry — implements core’s Kinetis\Instrumentation\TelemetryInterface, turning the framework’s hooks into spans; PackageBootstrap swaps it into Telemetry::global() whenever the OTLP endpoint is configured. requestStarted()/requestEnded() produce the server span per request, around Kernel::handle()’s complete global pipeline: the method as the span name, http.response.status_code, php.memory.usage, error status on 5xx or an escaping exception, traceparent extraction for distributed traces. No form of the request target travels on it — a path’s segments are the identifiers a request is addressed by. The span is active until the response leaves the pipeline — the parent for everything below — and a streamed body is emitted after it ends. It is the whole of Kinetis-owned SQL, transaction and queue tracing: a client span per query named by the statement’s opening keyword and carrying db.system.name, db.operation.name and kinetis.db.query_fingerprint, with a server.started event marking the end of the wait for a pooled connection; a transaction span carrying db.transaction.outcome; a {queue} publish producer span and a {queue} process consumer span carrying messaging.destination.name, kinetis.job.class, kinetis.job.attempt and kinetis.job.outcome. A hook pair ends by recording the failure’s type alone. Its route.match span carries the method and, once the router answers, the matched template as http.route — never the request target the hook is handed. The MCP tool name and resource URI it does export are registry-resolved definitions rather than caller-supplied text. Which hooks activate their span (parenting whatever starts next) is the load-bearing choice. The nested, same-Fiber pairs activate on the context their Fiber already carries — middleware, controller, event/listener, the concurrently() batch, MCP tool calls. requestStarted(), taskStarted() and jobStarted() activate on a parent context they name themselves — the propagated or root context for a request or a job, the batch span reached through the batch token taskStarted() is handed — because each begins on a Fiber that may carry no context of its own. Query spans never activate: they overlap within one Fiber, and activating them would interleave that Fiber’s own stack. jobPushMetadata() injects a traceparent carrier the backend stores with the job; jobStarted() extracts it, parenting the consumer span into the producer’s trace — one trace across processes. A failure in any of its own methods — an unreachable collector, a bad export — never reaches application code: core’s Kinetis\Instrumentation\Telemetry is the no-throw boundary every hook call goes through before this backend is ever invoked, so OtelTelemetry is free to let a real export error propagate rather than swallowing it itself. See Appendix: System Layout’s Kinetis\Instrumentation entry.

  • Depends on kinetis/framework, kinetis/revolt-http-client, open-telemetry/sdk, open-telemetry/exporter-otlp, symfony/http-client, nyholm/psr7, psr/log; kinetis/persistence/kinetis/cache-redis/kinetis/session only in require-dev — every decorator class loads lazily, so none of them is forced on an install that only wants request spans, and the search decorator needs no engine package at all, only PSR-18. Own composer.json/phpunit.xml/phpstan.neon.

packages/authorization (kinetis/authorization)

Separate Composer package, not part of kinetis/framework core. Unopinionated: no attribute, no registry, no runtime type-based dispatch of any kind. A Policy check is a plain first-class callable the caller already holds, so nothing here resolves a Policy off a subject argument’s own class.

  • Kinetis\Authorization\Gate — authorize(CurrentUserInterface $user, callable $check, mixed ...$arguments): void (throws Exception\AuthorizationException on denial), allows()/denies() (both bool, reporting a denial rather than raising one). None of the three catches what $check itself throws: a check that fails outright is a failure, not a denial, and propagates as whatever it already is. All three are @template TUser of CurrentUserInterface, $check typed callable(TUser, mixed...): bool|AuthorizationResponse — a first-class callable reference to any method ($policy->update(...)) or a plain closure; the template lets a Policy method type-hint a concrete CurrentUserInterface implementation richer than the interface itself (kinetis/auth-jwt’s JwtUser, reading its already-decoded claims with no query) and still pass PHPStan level 8 — the un-templated signature rejects exactly this case as a contravariance violation. Gate resolves nothing itself and holds no state, so it’s safe as a worker-lifetime autowired instance with no explicit binding needed.

  • Kinetis\Authorization\AuthorizationResponse — allow()/deny(string $message = 'This action is unauthorized.')/fromBool(bool). A check returning a bare bool is normalized via fromBool(); one returning this directly can carry a denial-specific message instead of the generic default.

  • Kinetis\Authorization\Exception\AuthorizationException — thrown by authorize(), carrying whichever message the denying AuthorizationResponse resolved to. Implements core’s Kinetis\Http\Exception\HttpStatusExceptionInterface with httpStatus(): 403, so Kernel’s own unconditionally included ExceptionHandlerMiddleware (inside SecurityHeadersMiddleware and a registered CorsMiddleware) returns the denial as a 403 carrying that message, rather than the generic 500 it falls back to when no valid HttpStatusExceptionInterface status contract applies — see Appendix: System Layout.

  • This package declares no extra.kinetis bootstrap, registers no middleware, and has no scan/discovery key — installing it wires nothing, since the 403 is the exception’s own declared status and Gate needs no binding.

  • Depends on kinetis/framework only (via a path repository to this monorepo’s root) — no dependency on kinetis/auth, kinetis/auth-jwt, or kinetis/session, since Gate only needs core’s own CurrentUserInterface. No third-party dependency. Own composer.json/phpunit.xml/phpstan.neon.

packages/auth (kinetis/auth)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\Auth\BearerAuthMiddleware — PSR-15 route middleware (never global) validating an Authorization: Bearer <token> header (parsed by core’s Kinetis\Http\Auth\AuthorizationToken68Parser with the Bearer scheme — see Appendix: System Layout) against an app-supplied UserProviderInterface, registering the resolved user on the current RequestScope as CurrentUserInterface on success, or returning 401 with a WWW-Authenticate: Bearer header on failure — the same generic 401 whether the header itself failed to parse or a well-formed token was rejected by the provider. Resolved fresh per request from the route’s own RequestScope, so it constructor-injects RequestScope directly.

  • Kinetis\Auth\UserProviderInterface — one method, findByToken(string $token): ?CurrentUserInterface. Storage-agnostic; the app implements it.

  • Kinetis\Auth\TokenGenerator — generate(int $bytes = 32): string, a random_bytes() wrapper, hex-encoded.

  • Depends on kinetis/framework (via a path repository to this monorepo’s root), nyholm/psr7 (BearerAuthMiddleware’s 401 response), psr/http-message, psr/http-server-middleware. Own composer.json/phpunit.xml/phpstan.neon.

packages/auth-jwt (kinetis/auth-jwt)

Separate Composer package, not part of kinetis/framework core.

  • Kinetis\AuthJwt\JwtAuthenticator — the request-neutral authentication decision, authenticate(string $token): ?JwtUser; constructed with a JwtVerificationKeys plus optional $revocationStore, $expectedIssuer, $acceptedAudiences, all validated there. Immutable and safe for AppScope lifetime — token, claims and the returned JwtUser stay method-local. Every unusable credential answers the same null; a revocation lookup that throws propagates instead. Validation, security, limits, and failure rules: Appendix: Authentication.

  • Kinetis\AuthJwt\JwtAuthMiddleware — PSR-15 route middleware (never global) parsing an Authorization: Bearer <token> header, handing the credential to the JwtAuthenticator it is constructed with, and registering the returned JwtUser under both CurrentUserInterface::class and JwtUser::class, or answering 401 with a WWW-Authenticate: Bearer header. Its other constructor parameter is the RequestScope, so an application registers one JwtAuthenticator on AppScope and references #[Middleware(JwtAuthMiddleware::class)] directly. Not final, so an otherwise empty subclass can carry a class-level middleware-group attribute.

  • Kinetis\AuthJwt\JwtUser — wraps the decoded claims (stdClass). id(): string reads sub, narrowing CurrentUserInterface’s string|int to the canonical subject string and throwing if the claim is anything else; claim(string)/claims() expose the rest.

  • Kinetis\AuthJwt\JwtIssuer — signs the tokens JwtAuthenticator verifies, through issue(string|int $subject, array $claims = [], ?int $ttlSeconds = 3600): string, which converts $subject to the canonical non-empty subject string once and rejects an empty one, from a constructor taking a JwtSigningKey plus optional $issuer and $audience. Which claims outrank a same-named $claims entry, and every Exception\JwtIssuerException: Appendix: Authentication.

  • Kinetis\AuthJwt\JwtSigningKey — the immutable signing configuration, built by hmacSecret(string $secret, string $algorithm = 'HS256', ?string $kid = null) or rsaPrivateKey(string $privateKeyPem, string $algorithm = 'RS256', ?string $kid = null), each validating its algorithm, key material and kid on construction and throwing Exception\JwtConfigurationException otherwise. The key material never leaves the value: sign(array $payload): string is the only reader.

  • Kinetis\AuthJwt\JwtVerificationKeys — the immutable verification configuration, built by hmacSecret(string $secret, string $algorithm = 'HS256'), rsaPublicKey(string $publicKeyPem, string $algorithm = 'RS256'), or jwks(string $jwksJson) for the multi-key form, validated the same way. requiresKid(): bool states whether a token must carry a kid this value knows; decode(string $token, ?string $kid): ?stdClass resolves that kid to one pinned key and returns the verified claims, or null for every unusable token.

  • Kinetis\AuthJwt\JwtKeyValidator — the shared validator every other class here defers to, so the supported algorithms, the key-material rules, and the rule for what may name a key exist once. Public constants SUPPORTED_ALGORITHMS, RSA_MINIMUM_BITS and MAXIMUM_KID_LENGTH; predicates isHmacAlgorithm(), isRsaAlgorithm(), isUsableKid(string) and its isUsableKidValue(mixed) form, for a kid read out of a decoded document or an array key and so not yet known to be a string; assertions assertUsableKid(), assertHmacSecret(), assertRsaPublicKey() and assertRsaPrivateKey(), each throwing Exception\JwtConfigurationException. The rules they enforce: Appendix: Authentication.

  • Kinetis\AuthJwt\RevocationStore — a Psr\SimpleCache\CacheInterface-backed denylist. Per-token: revoke(string $jti, ?int $ttlSeconds) is the primitive — null revokes with no expiry at all (a permanent denylist entry, routed straight through to the cache’s own null-TTL semantics), a positive value is the entry’s own remaining lifetime, and zero/negative throws Exception\RevocationUnavailableException::nonPositiveRevokeTtl(). revokeToken(JwtUser $user) derives this from the token’s own exp claim: absent means indefinite (revoke($jti, null)), already in the past skips the write entirely, present-but-non-integer throws invalidExp(), and a missing/empty jti throws missingJti() rather than silently doing nothing. isRevoked(string $jti) is the lookup JwtAuthenticator runs. Revocation is per token only — “log out everywhere” is application-owned credential-generation policy (see JWT Authentication). Requires a real cache — construction over NullSimpleCache throws Exception\RevocationUnavailableException, since a denylist that never stores anything would let every revoked token stay valid until natural expiry. Fail-loud at the individual-write level too: a conforming PSR-16 cache may report a failed set() by returning false rather than throwing, and revoke() checks for it and throws Exception\RevocationUnavailableException::revokeFailed() rather than silently treating the failed write as a successful revocation — the message names neither the jti nor the cache key involved.

  • Kinetis\AuthJwt\RefreshTokenStore — a Psr\SimpleCache\CacheInterface-backed, single-use opaque refresh token, independent of RevocationStore. issue(string|int $subject, array $claims = [], int $ttlSeconds = 1_209_600): string converts $subject to the same canonical non-empty subject string JwtIssuer::issue() writes and stores sha256(token) => {subject, claims}, rejecting an empty subject or a non-positive $ttlSeconds; redeem(string $token): ?array atomically reads and deletes the entry the moment it’s looked up (valid or not, via Kinetis\SimpleCache\AtomicConsumeInterface::consume()) and returns {subject: string, claims} or null — a record whose stored subject is not that canonical form is unredeemable rather than reinterpreted. revoke(string $token): void invalidates one token directly. Construction throws Exception\RefreshTokenUnavailableException over NullSimpleCache (same as RevocationStore) or over any cache not implementing AtomicConsumeInterface — a get() then a separate delete() would let two concurrent redeems of the same token both succeed. The same fail-loud discipline applies to every mutation: issue() throws issueFailed() (or nonPositiveIssueTtl()) rather than returning a token that was never actually stored, and revoke() throws revokeFailed() on a failed delete — neither failure message names the token, subject, or cache key.

  • Kinetis\AuthJwt\PublishedRsaKey — one RSA public key and the kid it is published under (public private(set) string $kid/$publicKey), the input JwkSet::fromRsaPublicKeys() takes. What each field must be, and why a kid travels as a value rather than an array key: Appendix: Authentication.

  • Kinetis\AuthJwt\JwkSet::fromRsaPublicKeys(array $keys, string $algorithm = 'RS256'): array — builds an RFC 7517 JWK Set ({"keys": [...]}) from a list<PublishedRsaKey>, to publish at a .well-known/jwks.json-style route. RSA only; the accepted input and every failure are in Appendix: Authentication.

  • Kinetis\AuthJwt\JwkSetParser::parse(string $jwksJson): array — the consuming half of JwkSet, used by JwtVerificationKeys::jwks(): raw JWKS JSON parsed into array<string, Firebase\JWT\Key> keyed by LOOKUP_PREFIX . kid, so a kid PHP would read as a number stays the exact string the document published; Exception\JwtConfigurationException otherwise. Public bounds MAXIMUM_JSON_BYTES and MAXIMUM_KEYS. Accepted shapes, limits, and failure rules: Appendix: Authentication.

  • Kinetis\AuthJwt\JoseHeader::parse(string $token, bool $kidRequired): ?self — reads a compact JWT’s own unsigned header into the alg/kid pair (public private(set)) before JWT::decode() sees the token, answering null for every rejection, which JwtAuthenticator turns into its own null and JwtAuthMiddleware into a generic 401. The MAXIMUM_* bounds, what a header must be, and why crit/b64 are refused: Appendix: Authentication.

  • Kinetis\AuthJwt\StrictJson::decodeObject(string $json, int $maximumBytes, int $maximumDepth): ?array — @internal. Decodes a JSON object under caller-supplied byte and depth limits, answering null for a document that names one member twice at any depth.

  • Kinetis\AuthJwt\Base64Url — @internal. encode(string): string is RFC 4648 §5 base64url without padding; decode(string): ?string accepts the one spelling of any byte string and answers null for every other.

  • Depends on kinetis/framework (via a path repository to this monorepo’s root), firebase/php-jwt (^7.1 — 6.10/6.11 are excluded by an open security advisory), psr/simple-cache (RevocationStore/RefreshTokenStore), ext-openssl (JwkSet), nyholm/psr7, psr/http-message, psr/http-server-middleware. Own composer.json/phpunit.xml/phpstan.neon.

packages/session (kinetis/session)

Separate Composer package, not part of kinetis/framework core. Participates in extra.kinetis with a PackageBootstrap plus a scan root covering Kinetis\Session\Console (the session:gc command); both middlewares are explicit per-route opt-ins.

  • Kinetis\Session\SessionStoreInterface — read(id): ?array / create(id, data, lifetimeSeconds) / update(id, data, lifetimeSeconds): bool / destroy(id). Payloads are JSON-serializable array<string, mixed> application data, projected to JSON by every store; expiry is the store’s own job. update() writes only over a record that is still stored and still live, returning false when it is not — Appendix: Sessions Reference states that terminal rule. Concurrent updates of one live id are last-write-wins — no locking, deliberately, since serializing a browser’s parallel requests would fight the concurrent-worker model.

  • Kinetis\Session\GarbageCollectableStoreInterface — gc(): int, deleting every expired session and returning the count. Implemented by the file and sql stores; the Redis store leaves it out because the key’s own TTL expires it.

  • Kinetis\Session\Console\GcCommand — the session:gc command on vendor/bin/kinetis. Calls gc() on the bound store and prints the count; for a store without GarbageCollectableStoreInterface it reports that the backend expires entries on its own and exits 0; with no store bound at all it exits 1 naming SESSION_DRIVER. Nothing schedules it — cron or an equivalent does.

  • Kinetis\Session\Session — what a controller constructor-injects (registered on the RequestScope by the middleware). get/set/has/remove/all, flash()/flashed() (survives exactly one following request, aged at commit), csrfToken() (generates one on first use, stored in the session — a real write, so call it only where the response actually carries the token), verifyCsrfToken() (constant-time hash_equals() against whatever token the session already has, returning false with no such token rather than generating one to compare against — the method CsrfMiddleware uses instead of csrfToken(), specifically so checking a wrong token can never itself be what creates one, or allocates a session for a cookie the store has never heard of), regenerate() (fresh id and fresh CSRF token, same application data, old payload destroyed — the fixation defense for a known, previously-issued id, so a token issued before a privilege change no longer verifies after it), destroy(). Lazy: the store isn’t read until first access, and commit() writes only when something changed — an untouched session costs no round trip and no cookie. id() participates in the same lazy load, not a bare property read: on first access, a presented id is verified against the store, and one the store has never heard of (wellformed but nonexistent — the id SessionMiddleware’s own shape check cannot catch) is rotated to a fresh id before any state is exposed or written; that rotation is itself lazy (a read-only access persists nothing), and only id() itself, a mutation, or csrfToken() marks the fresh id for persistence at commit() — the session-fixation closure this package’s own README and Appendix: Sessions Reference describe.

  • Kinetis\Session\Store\FileSessionStore — one JSON file per session (sess_{id}), expiry embedded as a timestamp; an expired file is deleted when next read, and gc() sweeps the rest for session:gc. Validates ids against ^[a-f0-9]{32}$ before building any path — defense in depth against traversal even though the middleware validates first.

  • Kinetis\Session\Store\RedisSessionStore — over the RedisSimpleCache the container already holds (the concrete class, not PSR-16: update() needs its conditional replace()), the key’s own TTL as expiry, single node and cluster alike. Payloads are stored as the package’s own JSON text rather than handed to the cache’s native serializer.

  • Kinetis\Session\Store\SqlSessionStore — kinetis_sessions (id/payload/expires_at) over the generic SqlLink contract; dialect-agnostic SQL. create() is a plain INSERT; update() is one UPDATE constrained to a live row, falling back to a single existence check when the server reports zero changed rows, which MySQL does for a byte-identical write. Expired rows stay until gc() (the session:gc command) deletes them. Migration stubs in resources/migrations/, never auto-created.

  • Kinetis\Session\Middleware\SessionMiddleware — route middleware only (the BearerAuthMiddleware structural rule): rejects any cookie value that is not a wellformed 32-hex id (a malformed value is treated as no cookie), registers a lazy Session on the scope, and afterwards commits + sets the cookie only when needed. Shape-only: a wellformed id the store has never issued is not this class’s job to catch, since verifying existence would mean reading the store eagerly on every request and breaking the lazy-until-accessed contract above — Session::load() is what actually checks a presented id against the store and rotates a miss, on the one request that ever touches the session. Cookie: HttpOnly always, Path=/, no Domain, Secure/SameSite/name/lifetime from SESSION_* config. Validates the configured name at construction: it must be a legal cookie token, and a __Host-/__Secure- prefix (matched case-sensitively) requires SESSION_SECURE — a browser drops such a cookie silently, which presents as sessions that never persist. SESSION_SAMESITE is validated there too: Strict, Lax, or None matched case-insensitively and normalised to that casing in the header, with None requiring SESSION_SECURE for the same reason. Reads the cookie from getCookieParams() and nowhere else — the PSR-7 form every runtime adapter fills from the incoming Cookie header, so parsing that header belongs to whatever builds the request, not here.

  • Kinetis\Session\Middleware\CsrfMiddleware — synchronizer-token check on non-GET/HEAD/OPTIONS, via X-CSRF-Token header or a form body’s _token, checked through Session::verifyCsrfToken() (constant-time, never generates a token), 403 on mismatch; a missing upstream SessionMiddleware is a distinct 500 naming the declaration-order mistake. JSON bodies use the header — the dispatcher decodes JSON itself, so getParsedBody() never carries _token for them.

  • Kinetis\Session\PackageBootstrap — with SESSION_DRIVER set, binds SessionStoreInterface as a lazy factory (resolved on first use, after boot() and every sibling bootstrap have run — which is what lets the redis driver consume boot()’s own CacheInterface binding and the sql driver the link kinetis/database-bridge or the application’s bootstrap.php binds, regardless of bootstrap order). redis takes that bound cache and requires it to be a RedisSimpleCache, so an application keeps one Redis client and its own binding wins. Unknown driver throws naming the valid set; redis without kinetis/cache-redis installed or with no Redis cache bound (REDIS_URL, REDIS_HOST, or REDIS_CLUSTER with REDIS_CLUSTER_SEEDS), and sql without kinetis/persistence installed or with no link bound, throw naming the fix.

  • Depends on kinetis/framework, psr/http-message, psr/http-server-middleware; kinetis/persistence/kinetis/cache-redis only in require-dev — store classes load lazily. Own composer.json/phpunit.xml/phpstan.neon.

packages/views and view adapters

Four separate Composer packages make view rendering optional and keep template vendor dependencies out of core.

  • kinetis/views owns Views, ViewEngineInterface, logical ViewName validation, ViewDirectory containment and deterministic recursive template discovery, the deterministic AssetUrl, and the common ViewNotFoundException/ViewRenderException vocabulary. ViewRuntime maps the application project root and AppEnvironment to isolated .kinetis-cache/views/<engine> storage; ViewCacheDirectory creates and empties only that derived directory without following links. Console\WarmCommand / ClearCommand expose views:warm / views:clear through this package’s scan root and delegate to the bound engine. Views::response() returns core’s UTF-8 HtmlResponse; render() returns the same complete body as a string. Data is supplied anew on every call and the asset key is reserved.

  • kinetis/views-php owns PhpViewEngine. It extracts one call’s data into a private include scope, exposes $asset, captures output, and restores every buffer opened above the caller’s level when a template throws. Cache warm and clear are successful zero-work operations.

  • kinetis/views-latte owns LatteViewEngine, appends .latte, registers the asset() function, and exposes the underlying Latte engine for bootstrap-time extensions. In production it clears and warms every canonical .latte template through Latte’s warmupCache() into .kinetis-cache/views/latte; development writes no cache.

  • kinetis/views-twig owns TwigViewEngine, a FilesystemLoader whose path and cache-key root are both the configured directory, the asset() function, Twig environment options, and bootstrap-time access to that environment. In production it clears and loads every logical .twig template into .kinetis-cache/views/twig; development writes no cache.

  • The three adapters conflict pairwise in Composer, so an application selects exactly one. Each depends on kinetis/views; only the Latte/Twig adapters add their corresponding vendor engine. See Views for wiring, escaping, persistent-worker state, and local-filesystem constraints.

See also