Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.6.0] - 2026-08-29

### Added
- **`Kernel::withWorkerSecret()` — the queue can finally be an authenticated
channel.** `WorkerLoop` has always carried a signature check, but the kernel
had no way to give it a key: `$signingSecret` defaulted to `''`, was never
passed at construction, and there was no builder method. In every deployment
that has ever run, the check was dead code and the worker executed whatever it
was handed. A queue is an input channel — whoever can write to it is calling
into the application — so this closes a hole, not a nicety. Defaults to
`JOB_SIGNING_SECRET` and stays OFF when that is unset, preserving today's
behaviour. It deliberately does **not** fall back to `APP_KEY`: that would
switch verification on for every existing application at once and reject every
job already in flight, since no `QueuePort` adapter signs by default. Turning
it on is a two-sided change — roll it out producer-first, teaching the adapter
to stamp `JobPayload::signatureFor()` at `push()` time.
- **Graceful worker shutdown, a memory ceiling, and per-job timeouts.** There
was no `pcntl` anywhere in the kernel, so SIGTERM — what every process
supervisor and container runtime sends to stop a worker — killed PHP outright,
including in the window between `handle()` returning and `ack()` removing the
message. A job that had already run its side effects came back on the next
boot and ran them **again**. The loop now traps SIGTERM/SIGINT/SIGQUIT,
finishes the job it is on, resolves its ack/release/fail, and exits.
`run()` takes a `memoryLimitMb` so a supervised worker exits between jobs
rather than being OOM-killed inside one, and a job's declared `timeout` is
enforced with `pcntl_alarm` — best effort, since SIGALRM is dispatched between
opcodes and cannot preempt a job blocked inside one long query.
- **`Request::withAttributes()`** — set several attributes in a single new
instance. Every `with*()` deep-clones all seven parameter bags, so a chain of
them pays that price once per link. `ResolveStage` attaching `route_entry`,
`route_params` and `target_service` is one logical step that cost three full
clones of a request nothing had read yet: **10.02 µs → 3.57 µs, 64% less**.
- **`SecurityVerdict::allowWithIdentity()`** — allow while carrying an identity
that is not yet attached to a request. `allow()` reads the identity back *off*
a request, forcing a layer that has just resolved one to clone the entire
request so the constructor can read a single property.

### Fixed
- **`BOOT_CACHE` never hit for the essentials shape the docs recommend.**
`Kernel::build()` computed `buildHash()` twice — before and after
`resolveEssentialModules()`, which rewrites `essentials` from proj.json's
DOMAINS (`tenancy.routing`) into provider CLASSES. So the stamp was written
under one hash and read under another, and every request recompiled all ten
manifests **and** rewrote the stamp on top of the recompile it had failed to
skip — measurably *worse* than leaving the flag off. Measured on a three-route
application: **2604 µs → 39 µs per request under PHP-FPM.** The hash is now
taken once, from the raw builder inputs; the derived class list rides in the
stamp's payload, never its key. `BootStampTest` tests the stamp in isolation
and could not see this, so `KernelBootCacheTest` builds twice through the real
`Kernel::build()` and watches the manifest inode.
- **A job payload that failed verification was silently deleted.** The check
returned `skipped()`, which `processWithPort` then **acked** — removing the one
piece of evidence that something is writing to your queue. A misconfigured
producer and an active attacker were indistinguishable, and both looked like
nothing happening at all. An unverifiable payload now raises
`RejectedJobException`, goes through the `ErrorPipeline`, and is dead-lettered
via `fail()`. It is never retried: a signature that does not verify will not
verify on the second attempt.
- **`retry` and `timeout` in `module.json` compiled to nothing.**
`CompileJobManifestStage` read `handler`, `queue`, `module` and `solves` and
dropped the other two, so every job in every application shared one hardcoded
exponential strategy and ran unbounded — while its manifest said otherwise. A
declaration that compiles to nothing is worse than no declaration: it reads as
a guarantee. Both are compiled through now and honoured per job, including the
`"retry": 5` shorthand; an unknown strategy falls back rather than failing the
boot, and `"max": 0` is raised to 1 (a job that can never run is never what it
meant).
- **`hkm run` ignored its own documented default of `./`.** An `args.len <= 2`
guard printed usage and exited 2 before the resolver ever ran, contradicting
the command's module docblock, its help text, and the `resolveRoot()` call
below it — which already handled an empty target. It bit `hkm run --dev`
hardest: `--dev` is stripped before command parsing, so that invocation
arrived as exactly `["hkm", "run"]` and failed, while adding any unrelated flag
(`--port=8000`) got past the count and worked perfectly — making the failure
look like it was about `--dev`, or about the directory, rather than about how
many words were typed. Resolution now belongs entirely to `resolveRoot()`, and
a bare `hkm run` outside a project names the actual problem instead of dumping
a usage screen that does not mention it.
- **The Homebrew bump job failed a release that had already published.** Its PR
fallback pushed the bump branch, then called `gh pr create` — which the API
refuses unless *Settings → Actions → General → "Allow GitHub Actions to create
Expand All @@ -22,6 +99,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
… from untrusted tap". `brew trust alfacode-team/hkm` is now part of the
documented sequence, in the README and in the formula's own header.

### Changed
- **A job signature now covers the whole envelope, not just `data`.** Signing
`data` alone left `jobClass` — the field that decides WHICH CODE RUNS —
unauthenticated. Capturing one legitimately signed envelope and swapping its
class for any other `JobContract` was enough; nothing about that required
forging a signature, only reusing one. The material is now
`jobId | jobClass | queue | maxAttempts | canonical(data)`. `attempts` is
deliberately excluded: the driver increments it on every `release()`, so
covering it would invalidate a job on its first retry — `maxAttempts` is signed
instead, so the retry budget cannot be widened in transit. The payload is
canonicalised (associative keys sorted at every depth, list order preserved)
because a driver round-tripping the envelope through JSON is under no
obligation to keep key order, and an unstable input makes an HMAC reject its
own legitimate messages. **No migration is required**: verification was
unreachable before this release, so no deployment has signed payloads in
flight. `JobPayload::signatureFor()` is the one implementation both producer
and verifier use.
- **`SecurityGateway` no longer clones the request on its final layer.** The
clone existed so `SecurityVerdict::allow()` could read the identity back off
it, and `SecurityStage` then cloned a second time to put that identity on the
request the pipeline actually carries — so for the documented CSRF-then-Auth
stack, where the last layer is the one that authenticates, a whole request copy
was built and read once. A later layer still sees an earlier layer's identity;
only the final layer takes the shortcut. **4.17 µs → 0.87 µs, 79% less.**

## [1.5.0] - 2026-08-28

### Added
Expand Down
2 changes: 1 addition & 1 deletion modules/http
Submodule http updated 1 files
+28 −0 src/Request.php
83 changes: 78 additions & 5 deletions src/Kernel/Boot/Stages/CompileJobManifestStage.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,22 @@

use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{ManifestReader, ManifestWriter};

/** Reads jobs[] from every module.json -> job-manifest.php. */
/**
* Reads jobs[] from every module.json -> job-manifest.php.
*
* RETRY AND TIMEOUT ARE PART OF THE DECLARATION.
*
* module.json has always documented them —
*
* { "type": "job", "queue": "emails", "timeout": 30,
* "retry": { "max": 3, "strategy": "exponential", "jitter": true } }
*
* — but this stage used to drop both on the floor, so every job in every
* application silently shared one hardcoded exponential strategy and no timeout
* at all. A declaration that compiles to nothing is worse than no declaration:
* it reads as a guarantee. They are compiled through now, and WorkerLoop honours
* them per job.
*/
final class CompileJobManifestStage implements BootStageContract
{
/** @param list<class-string> $moduleClasses */
Expand All @@ -23,15 +38,73 @@ public function run(): void
if ($name === null) {
continue;
}
$spec = is_array($job) ? $job : [];

$jobs[$name] = [
'handler' => is_array($job) ? ($job['handler'] ?? $name) : $name,
'queue' => is_array($job) ? ($job['queue'] ?? 'default') : 'default',
'module' => $moduleClass,
'solves' => $manifest['solves'],
'handler' => $spec['handler'] ?? $name,
'queue' => $spec['queue'] ?? 'default',
'module' => $moduleClass,
'solves' => $manifest['solves'] ?? '',
// Fall back to the module-wide declaration: a module.json
// whose "type" is "job" states retry/timeout at the top
// level, which is the shape the docs show.
'retry' => self::retry($spec['retry'] ?? $manifest['retry'] ?? null),
'timeout' => self::timeout($spec['timeout'] ?? $manifest['timeout'] ?? null),
];
}
}

ManifestWriter::write('job-manifest.php', $jobs);
}

/**
* Normalise a `retry` declaration into a shape WorkerLoop can act on without
* re-parsing JSON per job.
*
* An UNDECLARED retry compiles to null, not to a default: that keeps "this
* job says nothing" distinguishable from "this job asked for the defaults",
* so the loop's own fallback stays the single place the default lives.
*
* @return array{max: int, strategy: string, base: int, jitter: bool}|null
*/
private static function retry(mixed $spec): ?array
{
if ($spec === null) {
return null;
}

// "retry": 5 — the shorthand for "just give me five attempts".
if (is_int($spec) || (is_string($spec) && ctype_digit($spec))) {
$spec = ['max' => (int) $spec];
}

if (!is_array($spec)) {
return null;
}

$strategy = strtolower((string) ($spec['strategy'] ?? 'exponential'));

return [
// A job may not declare fewer than one attempt — "retry": {"max": 0}
// would mean the job can never run, which is never what it means.
'max' => max(1, (int) ($spec['max'] ?? 3)),
'strategy' => in_array($strategy, ['exponential', 'linear', 'fixed'], true)
? $strategy
: 'exponential',
'base' => max(1, (int) ($spec['base'] ?? $spec['delay'] ?? 1)),
'jitter' => (bool) ($spec['jitter'] ?? false),
];
}

/** Seconds a single attempt may run, or null when the job declares none. */
private static function timeout(mixed $spec): ?int
{
if ($spec === null || !is_numeric($spec)) {
return null;
}

$seconds = (int) $spec;

return $seconds > 0 ? $seconds : null;
}
}
58 changes: 54 additions & 4 deletions src/Kernel/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ final class Kernel
private array $projectGroups = [];
/** @var list<string> hosts this project serves (proj.json "domains") */
private array $projectDomains = [];
/** HMAC key every dequeued job payload must carry; null = read the env. */
private ?string $workerSecret = null;
private ?ErrorPipeline $errorPipeline = null;
private ?\Closure $errorPipelineFun = null;
private ?string $basePath = null;
Expand Down Expand Up @@ -122,6 +124,32 @@ public function withSecurity(array $layers): self
return $this;
}

/**
* Require every dequeued job payload to be HMAC-signed with this key.
*
* A queue is an input channel: whoever can write to it is calling into the
* application. WorkerLoop has always had the check — but the kernel never
* had a way to give it a key, so in every deployment it was dead code and
* the worker ran whatever it was handed.
*
* Defaults to `JOB_SIGNING_SECRET`, and stays OFF when that is unset, which
* is the historical behaviour. It deliberately does NOT fall back to
* APP_KEY: that would switch verification on for every existing application
* at once and reject every job already in flight, since no QueuePort adapter
* signs by default.
*
* TURNING IT ON IS A TWO-SIDED CHANGE. The producing adapter must stamp
* {@see \AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\JobPayload::signatureFor()}
* onto the envelope at push() time. Until it does, every payload is rejected
* as unsigned and dead-lettered — correct, but not a discovery to make in
* production. Roll it out producer-first.
*/
public function withWorkerSecret(string $secret): self
{
$this->workerSecret = $secret;
return $this;
}

public function withErrorPipeline(ErrorPipeline|callable|\Closure $pipeline): self
{
if (is_callable($pipeline)) {
Expand Down Expand Up @@ -357,7 +385,16 @@ public function build(): self
// When BOOT_CACHE is on and nothing the compile read has changed, skip
// the compilation and keep only the validation stages, which touch no
// disk and must still catch a missing port or an unusable layer.
$cached = BootStamp::enabled() ? BootStamp::read($this->buildHash()) : null;
// Computed ONCE, from the RAW builder inputs, and reused for the write
// below. It must NOT be recomputed after resolveEssentialModules():
// that turns proj.json's "essentials" domains into provider classes, so
// a hash taken afterwards would never equal the one the next build looks
// the stamp up with — the cache would miss on every request, and pay for
// a stamp rewrite on top of the recompile it failed to skip.
$cacheEnabled = BootStamp::enabled();
$stampHash = $cacheEnabled ? $this->buildHash() : '';

$cached = $cacheEnabled ? BootStamp::read($stampHash) : null;

if ($cached !== null) {
$pipeline->runValidationOnly();
Expand All @@ -377,8 +414,9 @@ public function build(): self
// pipeline's reader, whose module.json cache is already warm.
$this->essentialModules = $this->resolveEssentialModules($reader);

if (BootStamp::enabled()) {
BootStamp::write($this->buildHash(), $reader->files(), $this->essentialModules);
if ($cacheEnabled) {
// $stampHash, not a fresh buildHash() — see the note above.
BootStamp::write($stampHash, $reader->files(), $this->essentialModules);
}

$this->built = true;
Expand All @@ -392,6 +430,13 @@ public function build(): self
* essentials, disable policy), so hashing these covers a proj.json edit
* without stat'ing it — and covers an edit to bootstrap/app.php itself,
* which no file-mtime check would catch.
*
* CALL THIS BEFORE resolveEssentialModules(), AND ONLY ONCE PER BUILD.
* `essentials` here is a BUILDER INPUT — the raw list the project passed,
* which for proj.json is domains ('tenancy.routing'). resolveEssentialModules()
* replaces it with the DERIVED provider classes, so a hash taken afterwards
* describes a different array and can never match the one the next build
* reads with. The derived list belongs in the stamp's payload, not its key.
*/
private function buildHash(): string
{
Expand Down Expand Up @@ -489,7 +534,12 @@ private function materialize(RuntimeMode $mode): void
essentialModules: $this->essentialModules,
);
$this->cli = new CliPipeline($this->core, $errorPipeline);
$this->workerLoop = new WorkerLoop($this->core, $errorPipeline, $this->workerPipe);
$this->workerLoop = new WorkerLoop(
$this->core,
$errorPipeline,
$this->workerPipe,
$this->workerSecret ?? (string) (env('JOB_SIGNING_SECRET') ?: ''),
);

// Configuration compiled by CompileConfigManifestStage during build().
// Bound BEFORE module boot() so a Provider can read config while wiring.
Expand Down
14 changes: 8 additions & 6 deletions src/Kernel/Pipelines/Http/Stages/ResolveStage.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ public function handle(Request $request, callable $next): Response
return Response::notFound();
}

$request = $request
->withAttribute('route_entry', $match['entry'])
->withAttribute('route_params', $match['params'])
->withAttribute('target_service', $match['entry']['solves']);

return $next($request);
// One clone, not three: chaining withAttribute() would build two
// intermediate requests — each a deep clone of all seven parameter bags
// — that nothing ever reads.
return $next($request->withAttributes([
'route_entry' => $match['entry'],
'route_params' => $match['params'],
'target_service' => $match['entry']['solves'],
]));
}

/**
Expand Down
Loading