Changelog
All notable changes to HKM Kernel are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
For the full changelog with every detail, see the repository.
[1.18.0] - 2026-10-10
Added
hkm install --checkreports whether the web server can use the files a deploy does not ship, and changes nothing. A tree pushed by git-ftp or rsync only ever receives what git tracks. The gitignored paths (.envand its per-domain siblings such as.env.ekkula.ateiug,var/,userdata/) are created on the server by whoever happened to create them, and their ownership drifts. The failure is quiet:LoadEnvironmentskips an unreadable.env.*without a word.- What it checks, for the account PHP-FPM runs as (
--as=<user>,HKM_POOL_USER, defaultwww-data):- that the account can reach the project through every parent directory;
- that it can read each env file;
- that no env file is world-readable or group-writable;
- that every runtime directory exists;
- that everything under
var/anduserdata/is writable by it.
- Access is evaluated the way the kernel evaluates it (owner class, then group membership, then other) from the file's real owner and group, not by comparing mode bits:
0640 root:rootand0640 deploy:www-datalook identical in a mode table and only one of them works. --owner=adds an ownership comparison, and directories without setgid are reported as a warning. The command exits 1 when anything is wrong and nameshkm install --productionas the fix.TRUSTED_PROXIESin the generated entry points.app/public/index.phpandapp/swoole/index.phpregister the listed proxies (IPs/CIDRs,PRIVATE_SUBNETS, or under FPMREMOTE_ADDR) withRequest::setTrustedProxies(), soRequest::ip()andisSecure()reflect the real client behind nginx or a load balancer. Empty, the default, trusts nobody, so a forgedX-Forwarded-Foris still ignored.
Changed
- A kernel
DomainExceptionnow answers 422, not 500. It is the client-correctable business-rule failure the exception hierarchy documents as 422, butErrorStagehad no rule for it, so it fell through to 500. OptimisticLockExceptionandLockTimeoutExceptionnow answer 409 and are classified warning. A lost optimistic-lock race and a lock wait that timed out are concurrency outcomes the client can retry, not faults.OptimisticLockExceptionextendsRepositoryException, so it was answered 500 and classified critical, paging someone over a race.LockTimeoutExceptionhad no rule at all, despite its own docblock describing it as warning-level. The 409 carries a fixed message ("The resource is busy…" / "This record was changed by someone else…"), not the exception's own: a lock timeout's names the internal lock key. The original message still reaches the log, andAPP_DEBUGstill shows it.ERROR_STATUS_LEGACY=truekeeps the previous mapping for both changes above: the three exceptions answer 500 again and the lock exceptions are critical again. Status and severity are switched together, so a 500 is never logged as a warning. It exists for clients and alert rules that branch on the old codes and will be removed in 2.0.
Fixed
--helpno longer runs the command. php-io-cli'sCLIApplicationhandled<command> --help,-handhelp <command>by callingexecute()(the only way to give the command an IO) and printing help afterwards, somigrate:fresh --helpdropped every table andmigrate:run --helpapplied pending migrations.printHelp()now takes the IO directly and nothing executes. Requires the php-io-cli submodule bump that carries the fix.after.executehooks run. They were appended behindExecuteStage, which is terminal and never calls$next, so a stage registered there was never reached. They now sit directly in front of it, call$next(), and receive the controller's response on the way out. A hook already registered atafter.executestarts running with this release.- Commands declared in
module.jsoncommands[]are registered.CompileCommandManifestStagecompiled them intocommand-manifest.php, but nothing read it, so a command declared there and not also registered inProvider::boot()did not exist.CliPipelinenow adds every declaredAbstractCommandhandler whose constructor takes no parameters, not even optional ones, and that is not already registered by class or name; registration inboot()still wins. A command with constructor parameters is still left toboot(), because autowiring it from the core container could build it against the wrongDatabasePort. The core container fills an optional typed parameter from its bindings rather than leaving the default. The manifest is read only forlist/helpor a command nameboot()did not register, so running an ordinary command costs what it did before. Request::ip()works under OpenSwoole. The generatedapp/swoole/index.phpbuilt itsRequestwithout the peer address, soip()was null andclient.ipwas never bound.
Security
composer.lockpinsleague/flysystem3.36.0 (was 3.35.2), fixing CVE-2026-102601: the whitespace path normaliser's control-character check could be bypassed with malformed UTF-8 in a path, on every adapter.
[1.17.1] - 2026-09-25
Fixed
- An optional constructor dependency now receives its default when nothing binds it. A parameter written
?SomeContract $x = nullthrewEntryNotFoundExceptionfromModuleContainerwhenever the module providingSomeContractwas not in the request's dependency graph. bind-it falls back to a parameter's default only on its ownBindingResolutionException, andModuleContainersignals "unbound" withEntryNotFoundException, so the fallback never fired.ModuleContainer::resolveClass()now applies the same fallback to its own exception. A parameter with no default still fails loudly, unchanged.
[1.17.0] - 2026-09-17
Added
Pluginsandplugin_installed()— is a module actually installed in this application? Answered from the compiledservice-manifest.php, whose keys are thesolvesdomains of everything the project passed towithModules([...]), so it needs no knowledge of any particular plugin and no directory scan.plugin_installed('tenancy.routing')takes a domain or a module.jsonname, and several at once;installed_plugins()returns the whole map;Plugins::ensure(...)throws aKernelExceptionnaming what is missing AND what IS registered, because the usual cause is a domain spelled differently from the plugin'ssolvesrather than a plugin that is really absent.A module that cannot work without another one should still declare it in its own
module.jsonrequires[]—CompileServiceManifestStagefails the BOOT there, which an operator sees at deploy time instead of when a request happens to reach the feature. This is for whatrequires[]cannot cover: an OPTIONAL integration that should light up when a plugin is present and degrade quietly when it is not, and code running outside the module graph — a standalone CLI entry point, a bootstrap file, a template — which has nomodule.jsonto declare anything in. An absent plugin is therefore afalse, never a throw, unlessensure()is the call.
[1.16.0] - 2026-09-15
Added
RangeReadableStorage— an optional interface aStoragePortimplements to report an object'ssize()and open part of it withreadRange().readStream()was the only read the port declared, and on S3 it returns a network body that can be neither stat'ed nor seeked, so a consumer honouringRange(a PDF reader paging a large document) served ranges on the local driver and silently sent whole objects on S3. Checked withinstanceof, likeDriverAware, so existingStoragePortfakes need no change; a consumer falls back toreadStream()when the bound port does not implement it.
[1.15.0] - 2026-09-14
Added
- Scheduled tasks, declared in
module.json. A module listsschedule[]entries — aname, a cron expression or alias such as@hourlyinat, and EITHER ajobor acommand, with optionalqueue,timezoneandwithoutOverlapping.CompileScheduleManifestStageparses every expression at boot, so an impossible schedule ("0 25 * * *") fails the boot naming the field instead of silently never running; naming both a job and a command, or neither, fails it too. One crontab line drives the application:hkm cli schedule:runevery minute dispatches whatever is due — ajobis pushed onto the queue, so it gets the worker's retries and timeout, and acommandruns through the CLI pipeline exactly as if typed.schedule:listshows what is declared.withoutOverlappingtakes aCachePortlock keyed by task name, which is what makes the scheduler safe on several servers at once; with no cache bound, a guarded task is skipped and logged rather than run unguarded. MetricsPortandTracerPort, and anObservabilityStageon every request. The kernel already propagated a correlation id; it had no seam for the aggregate numbers or for where a request's time went. Both ports are StatsD/OpenTelemetry-shaped and dependency-free. The stage sits insideCorrelationIdStageand outside security and routing, so a denied request and an unmatched one (labelledunmatched) are still measured, and it labels by route TEMPLATE, never by path, so a metric cannot mint one series per id. With neither port bound, shared no-op implementations cost onehrtime()pair per request. An adapter must not throw.DriverAware— an optional interface aDatabasePortimplements to name the SQL dialect behind it. Checked withinstanceof, likeRequestAware, so the many in-memory test fakes of the port need no change; a consumer falls back to configuration when the bound port does not implement it.hkm ppkg— the native package manager. The Composer-compatible package manager frommodules/hkm-ppkg, now a submodule, runs under the launcher:install,update,require,remove,autoload,audit,outdatedand the rest, writing the samecomposer.lockandvendor/Composer does. Composer PLUGINS are not run;hkm ppkg compatnames each one a project uses and what it would have done.hkm ppkg test-env <dir>builds a plugin's testvendor/from the kernel's own and the checkouts already on disk.- A feature test bench.
tests/Featureboots a real kernel and pushes real Requests through the realHttpPipeline— manifests, matcher, dependency graph, scoped containers and stages all real, only the database, cache and queue faked — and runs as its ownFeaturesuite. It exists for the class of defect a unit test cannot see: a controller signature a stage no longer matches, or a route that resolves to nothing.
Changed
- The route compiler is split into
Kernel\Boot\Routing.CompileRouteManifestStagewas 1,200 lines doing six jobs and could only be tested by compiling a whole manifest. The jobs now live inRouteNormalizer,DomainComposer,GroupExpander,RouteCompilerandRoutePolicy, each tested on its own, and the stage keeps only their order. The public shape ofroute-manifest.phpis unchanged; each dynamic route inroute-index.phpnow also carries its route key, which is what lets a metric be labelled by template. An index compiled by an older kernel still serves. - A fetched plugin is verified without Composer when that is possible.
hkm plugins installused to runcomposer installinside every plugin to reach phpunit — a dependency resolution across a few dozenvcsrepositories, throttled by GitHub's anonymous rate limit. It now first builds the test environment from the kernel's ownvendor/plus the sibling checkouts on disk, and falls back to Composer whenever that cannot be trusted: no phpunit, or a test dependency with no checkout.HKM_PLUGIN_TESTS_COMPOSER=1forces the Composer route. RichGrapharticles takeinLanguage— the language of the CONTENT, which can differ from the page's — and an article with noauthorNamenames the publishing Organization as its author, as Google recommends for content that is contributed rather than bylined.
Fixed
- Error responses had no correlation id.
ErrorStagewrappedCorrelationIdStage, so the request it caught an exception on was the one from before the id was attached: every error envelope'srequestIdwas empty, every notifier received an empty correlation id, and error responses carried noX-Correlation-IDheader — the id was on every response except the ones anyone would need it for. The two stages now run the other way round. hkm plugins updateoverwrote files a project had edited. It replaced every published file whose bytes differed from the plugin's copy, with no way to tell a project's customisation from a stale copy — a customised layout, view override or translation was lost on the next update — and when two plugins published the same path, each update overwrote the other's. The installer now records a SHA-256 of every file it publishes invar/plugin-assets.json. A file still exactly as published is refreshed; one that was edited, never recorded, or written by another plugin is KEPT, and the plugin's version is written beside it as<file>.plugin-new, listed with the other plugin named.--overwriterestores the old behaviour for one run, andenablenever overwrites. A manifest written before this release has no hashes, so its first update keeps every file that differs rather than guessing.- Every page head carried two
<title>elements and two descriptions whenSeoHeadembedded a SiteSEO Open Graph document, which renders its own. Only that document'sog:andtwitter:tags are kept. - Ctrl+C left the Vite dev server running. The frontend template's hot-file cleanup installed
SIGINT/SIGHUPlisteners, and Node removes its default terminate the moment one exists — so the shell got its prompt back while the orphaned server kept the port, and its readline failed withError: read EIOon top of the next command typed. The handler now cleans up, restores the default and re-raises, and registers once per process across dev-server restarts.
Security
composer/composer2.10.2 → 2.10.3 in the kernel's development dependencies, for CVE-2026-84361 — arbitrary command execution through a malicious package's Perforce source URL. It was never in a release bundle, which installs without development dependencies, butcomposer auditfailed every build until it moved.
Removed
templates/app/apache.conf.exampleandtemplates/app/nginx.conf.example, andhkm newno longer writes them into a new project. The nginx or Apache configuration a deployment runs is generated by the Edge plugin (hkm edge:apply).
[1.14.0] - 2026-09-05
Added
hkm service— run a project's queue worker as a supervised service.hkm worker --queue=mailsis a foreground process: it dies with the terminal, it does not come back after a crash or a reboot, and nothing collects its output. Every deployment therefore hand-wrote the same unit file. The command generates it for whichever supervisor the host runs — systemd or launchd, with--platformto override so a Mac can produce the Linux unit it will deploy — in four verbs: preview (the default, which writes nothing),write,install [--start]andremove. Scope is--systemor--user, defaulting to system on Linux and to a user agent on macOS, where a LaunchDaemon running as root is the wrong answer on a developer machine.--dry-run(-n) reports every write and every command for the three mutating verbs and performs none of them.Three things the generated unit gets right that a hand-written one usually does not:
ExecStartruns the LAUNCHER —hkm worker -p <root>, notphpplus an absolutevendor/autoload.php. The launcher self-locates the kernel, so a kernel upgrade that moves a version-stamped install directory cannot silently break the queue.--exec=phpemits the direct form for a server with no launcher installed, and states the pinned autoload's cost in the unit itself.TimeoutStopSec/ExitTimeOutis 90s. The worker traps SIGTERM and finishes the job in flight before exiting — that is what makes a redeploy safe — and launchd's 20s default SIGKILLs it mid-transaction instead.PATHandHKM_PHP_BINare pinned. A service inherits none of a login shell's PATH, and/opt/homebrew/binis on neither manager's default. The entire diagnostic without the pin iserror: FileNotFound, with nothing anywhere naming php. Found by running the generated unit, not by reading it.
Values reaching the unit are validated rather than interpolated: a queue name may hold only
[A-Za-z0-9._:-], so nothing can append an argument or a directive;ExecStarttokens containing whitespace are quoted; plist strings are XML-escaped.
Fixed
- The worker entry point ignored every command-line flag.
hkm workerandhkm run --workerforward their arguments verbatim toapp/worker/run.php, which read onlyWORKER_QUEUEfrom the environment — sohkm worker --queue=mailswas accepted in silence and draineddefaultinstead. That is the failure mode with no signal at all: no error, no warning, a running worker, and the wrong queue. The entry point (and the scaffolding template new projects get) now parses-q/--queue,-n/--max-iterations,--memoryand-h/--help, each overriding the matching environment variable, and rejects an argument it does not recognise rather than ignoring it. The environment fallbacks moved fromgetenv()toenv()at the same time: the loader injects.envinto$_ENVand deliberately skipsputenv(), sogetenv('WORKER_QUEUE')could not see a value set in the project's.env.
[1.13.1] - 2026-09-04
Changed
EventBus::dispatch()now returns the listener failures it isolated (array<class-string, \Throwable>, empty on full success). Isolation was right — one broken subscriber must not stop the others — but callers had no way to tell it apart from success, and one of them was a transactional outbox. A mis-scoped listener threw, the bus swallowed it exactly as designed, the outbox marked the row dispatched becausedispatch()had returned normally, and the row was consumed and never retried: a tenant membership was lost permanently while the table recordedstatus=1, attempts=1, last_error=NULL. Adding the value is backward compatible — every existing$bus->dispatch($e);ignores it and behaves exactly as before — but anything that RECORDS delivery should now check it and re-queue rather than consume.
Fixed
- A listener that could not be resolved was reported as a broken constructor.
resolveListener()caught every container failure and fell back tonew $listenerClass(). For a listener with constructor arguments that threwArgumentCountError, so abindInternal()binding — which the container had already refused with aScopeViolationExceptionnaming the scope, the class and the fix — was logged as "Too few arguments to function …::__construct()". Every reader then went to the listener's constructor, which was correct, instead of to the binding, which was not; the same shape had already been misdiagnosed twice before.newis now attempted only when it can actually succeed (a constructor with no required parameters); otherwise the container's own exception is rethrown untouched.
[1.13.0] - 2026-09-03
Fixed
hkm install --owner=left every plugin file owned by the deploying user. A project's plugins are not in the project:hkm plugins installkeeps one copy per (plugin, version, origin) in the global store and links the project at it, soplugins/Loggeris a symlink out of the tree. Both halves of the hardening pass stopped at that boundary by design —hardenTreeskips symlinks because a chmod would follow one and rewrite a target outside the project, and the chown only walked the project root. The result was a project that verified clean and could not serve: every file the pool has to read first, every Provider and every controller a route resolves to, still belonged to whoever ran the command, under a report that saidProject owned by deploy:www-data.--ownernow also chowns the store versions the project links to, plus the directories between them and the store root so the trees it just chowned can be reached. Only the versions THIS project links to: the store is shared by every project on the machine, and claiming all of it for one project's web account is not that command's call.--productionreported a reachable project while the plugins were unreachable. The traversal check walked the parents of the project root only. Since the store moved out of the project it defaults to$HOME/.cache, which a deploy under sudo resolves to/root/.cache— 0700 on every mainstream distro — so the chown succeeded on every entry and the site still could not read one of them. The check now covers the store's own parents, with its own remedy: relocate the store (hkm plugins store --set=, orHKM_PLUGIN_STORE) rather than widen a home directory to reach a cache.- A plugin that gained an env var never got it.
hkm plugins enablereturns early when the plugin and its dependencies are already wired, so a plugin declaring a newconfig[]entry in a later version left an.envblock that was now incomplete — and the boot failed on the missing key with nothing pointing at the cause. Enabling an already-enabled plugin now tops up its block. Safe by construction: the seeder only ever ADDS keys the file does not already mention, in any form, so a real secret is never rewritten. .env.exampledocumented the Tenancy control-plane switch as a hostname.TENANCY_CONTROL_PLANE=admin.example.comreads as "the control plane lives here"; the plugin declares the key astype: bool, where any non-empty string is truthy — so the example value silently turned tenant routing OFF for anyone who uncommented it. Corrected to a bool, withTENANCY_CENTRAL_DOMAINS(a real declared key that was missing) added beside it and the mode values named. The plugin's ownmodule.jsonstays the authority; this is the example catching up to it.- Re-seeding wrote a second block for the same plugin. The append was unconditional, so a plugin seeded twice got two
# ─── Auth ───headings, and three after that. Every key was still present exactly once, so nothing broke — the grouping the block exists to provide just quietly stopped being true. New keys are now merged into the block the plugin already owns, keeping the blank line that separates it from the next one.
Added
hkm env— audit and tidy a project's.env. A dotenv file accumulates: a plugin seeds its block on enable, someone appends a key at the bottom to try something, a second plugin declares a variable the first one already did. None of that is an error anywhere. The loader resolves a repeated key silently, the boot succeeds, and the value in effect is whichever line happens to be last — a file that works and does not say what it is doing.hkm envreports duplicates with every occurrence's line number and marks which one is live. That marker is the point:LoadEnvironment::setVaroverwrites on each call and the cascade reads a file top to bottom, so the LAST active assignment wins — the opposite of what most people assume when they append a key to the bottom of a .env.hkm env dedupeasks per key rather than choosing. The right survivor is not derivable:DB_HOST=localhoston line 12 andDB_HOST=10.0.0.4on line 88 are both plausible, and the one in effect is as likely to be the accident as the intent.--keep=effectiveis the scriptable form that cannot change behaviour;--keep=first/--keep=lastare positional.hkm env groupreorders the file into blocks: a key a plugin declares in itsmodule.jsonconfig[]goes under that plugin, otherwise under the feature its prefix names, otherwiseUngrouped. Comments attached to a key move with it, comments attached to nothing are rescued into aNotesblock rather than dropped, and the pass refuses to write unless every key AND every informational comment that went in comes out again.- Every write leaves the previous file beside it as
.env.bak, at 0600.
- A project is found from anywhere inside it.
resolveRootchecked the exact working directory, sohkm envin<project>/appanswered "'.' is neither a project folder (with proj.json) nor a registered name" about a project one directory up. It now walks up to the filesystem root, the way git, composer and npm all find theirs — for every command that takes a[path|name], not justenv. An EXPLICIT path stays exact: the same resolver backshkm install --owner, and a command that chowns a tree must never quietly retarget itself above where it was pointed.
[1.12.1] - 2026-09-02
Fixed
- A plugin fetch asked for a GitHub account, for a repo that is public.
FileManagerwas the one hyphenated plugin missing from the slug override table, so it resolved tohkm-plugin-filemanager— a repository that does not exist. GitHub answers 404 for "does not exist" and "not yours" alike; it will not confirm a private repo to an anonymous request. Git cannot tell those apart, assumed the second, and stopped to ask for a username and password that no account could have satisfied. Added the override, plus tests pinning every multi-word folder to its real hyphenated slug (and round-tripping back to the PSR-4 folder name) so the next repo added with a hyphen cannot drift the same way. - Git could block a deploy on a credential prompt. The plugin fetch inherited the terminal, so an unreachable remote hung
hkm installon a password box until somebody killed it — on a deploy box or in CI, indefinitely. Every git invocation now runs withGIT_TERMINAL_PROMPT=0and SSHBatchMode=yes: a bad remote fails immediately and the call site names the plugin and URL, which is the information actually needed.HKM_GIT_INTERACTIVE=1restores the prompt for a genuinely private remote you intend to authenticate against by hand.
[1.12.0] - 2026-09-02
Fixed
A project installed with
--production --owner=still could not be served by PHP-FPM. The pass only ever touchedvar/anduserdata/, so every directory a request actually reads —app/public_html,src/,vendor/,plugins/— kept the deploying user's ownership and whatever mode the clone arrived with. The pool could write logs it was never going to reach the code to produce. Three separate reasons a boot failed, each fixed:- the pass now covers the WHOLE project tree, and runs LAST — after
composer installand the plugin fetch, both of which createvendor/andplugins/as whoever ran the command. Running at step 3, as it did, meant the two largest directories in the project were created after the permissions were "fixed". .envwas chmod'd0600. PHP-FPM running as another account cannot readAPP_KEYthrough that, and the boot fails on a file whose mode bits look deliberate. It is now0640— group-readable, never group-writable, never world-anything.- nothing reported that the pool could not TRAVERSE to the project. Reaching
app/public_html/index.phpneeds execute on every parent directory, and a home directory is0700on a stock Debian install — unfixable from inside the project, so the offending parents are now named (reported only, never changed).
- the pass now covers the WHOLE project tree, and runs LAST — after
The installed kernel was left at whatever the installing account's umask produced (
tools/install.sh)./opt/hkm-kernelis shared infrastructure — every PHP-FPM pool on the box loads its PHP out of that one tree, and none of those pools runs as the account that installed it. Withumask 027or077the whole tree landed 0750/0700 and every site died with "Permission denied" on a kernel file, while the install reported success because the installer could obviously read what it had just written. The installer now normalises the tree it lays down: directories traversable, files readable, and anything that WAS executable still executable.
Changed
hkm install --production/--owner=now apply a split-ownership model: code owned by the deploy user and only READABLE through the web server's group (2750/0640),var/anduserdata/group-writable (2770/0660). EVERY directory carries setgid, code included: the group is the only thing granting the pool access, so a file created later — a log written at 3am, a file agit pulllands — would otherwise take the creating account's primary group and drop out of the share, and each deploy would silently un-share whatever it touched. Code is never group-writable in either profile — an FPM pool that can rewrite the PHP it executes turns any file-write bug into code execution. An already-executable file keeps its exec bit (re-granted only where the profile grants read, sobin/pspandvendor/bin/*survive at0750, not0751), and.gitis skipped by both the chown and the chmod.- The pass re-stats what it changed and reports any mode the filesystem refused, instead of reporting success for a chmod the kernel rejected.
[1.11.0] - 2026-09-02
Fixed
- The migration engine only worked on MySQL (
modules/let-migrate). A commit titled "refactor: remove deprecated methods" had restoredsrc/,tests/and the README byte-for-byte to their state before a day of merged PR work — undoing four driver fixes and a Laravel-parity alias, and deleting the eight tests that covered them. Nothing was failing that the deletion fixed. Restored and carried forward:ALTER TABLEcompiled MySQL syntax for every driver — additions batched into one comma-separated statement, indexes added withADD KEY, and drops running columns BEFORE the indexes over them. A rollback written in the correct order was reordered by the compiler into one that could not run anywhere but MySQL, in the one direction nobody exercises until they uninstall a plugin.- PostgreSQL rejected
BOOLEAN DEFAULT 1, andmodifyColumn()emitted three;-joined statements into a clause the extended query protocol refuses. - SQLite could not add a foreign key to an existing table, and
modifyColumn()compiledCREATE TABLE "__tmp_users" ()— it was broken outright, because the rebuild SQLite requires was driven from a blueprint holding only the delta. It now reconstructs the full table from theSchemaInspector, carrying existing indexes across and unwrapping defaults so a literal is not re-quoted on every rebuild. - Seeding a second database in one run died with Cannot redeclare class — reachable the moment one run seeds once per driver, which is what
hkm ground migratedoes.
- PostgreSQL: every schema lookup silently matched nothing. The driver and inspector used libpq's
$1placeholders, which PDO neither understands nor rejects — sotableExists()answeredfalsefor a table with seven columns, and the inspector reported no columns, indexes or foreign keys. Anything guarded byhasTable(), and everything built on schema diffing or dumping, was quietly wrong on that driver. Found only by executing against a live server. - SQL Server emitted invalid T-SQL for every column addition and every foreign key —
ALTER TABLE … ADD COLUMN(T-SQL has noCOLUMNkeyword there) andON DELETE RESTRICT(unimplemented; its actions areNO ACTION,CASCADE,SET NULL,SET DEFAULT). Both are argued from the T-SQL specification and are not verified against a live server — none was reachable — but each replaces SQL the server rejects outright. MigrationConfig: singularpathoverrode pluralpathsinstead of acting as its fallback, so a config carrying both silently ran one directory and ignored the array — failing by doing less work rather than by erroring.Blueprint::dropColumn()accepted one column, sodropColumn('a', 'b')silently dropped onlya. Now variadic, and thedrop*methods chain.
Added
useCurrent()/useCurrentOnUpdate()/bigIncrements()— Laravel parity, so a ported migration compiles unchanged. Without them the failure is a fatal Call to undefined method raised the moment the migration runs, during a deploy.StatusRenderer—migrate:statusas normalised data, aligned table lines and JSON from one source, so the human and--jsonviews cannot disagree.tests/Live— the migration compiler executed against every reachable engine. Configured withLETMIGRATE_DB_MYSQL/_PGSQL/_SQLSRV(GROUND_DB_*honoured); each run uses its own scratch database and drops it. A driver that is unconfigured or not answering SKIPS with the reason, never counted as a pass. This release was verified on SQLite, MariaDB 12.3 and PostgreSQL 18; SQL Server skipped, and says so.
Changed
docs/guides/18_MIGRATIONS.md—->useCurrent()and->useCurrentOnUpdate()now exist, so the anti-pattern entry saying they do not is corrected. The->index()half stands: an index is declared on the Blueprint, not the column.
[1.10.1] - 2026-09-01
Fixed
hkm ground initgenerated a CI workflow that called a binary nothing installs. The generated.github/workflows/ground.ymlranvendor/bin/hkm-ground check --hereandvendor/bin/hkm-ground migrate --strict, but this package declares"bin": ["bin/hkm-cli", "modules/ground/bin/ground"]— so what a plugin actually gets in itsvendor/binisground. No package has ever shipped ahkm-groundexecutable, andalfacode-team/groundis not published separately at all: it is a path repo inside this repository, reachable only because the kernel autoloads it.So every plugin that ran
ground initgot a workflow whose first step could only ever exit 127,No such file or directory— and it failed at the step AFTERcomposer installsucceeded, which reads like the plugin is broken rather than like the workflow named the wrong file. Five plugin repositories had already been scaffolded with it.The generator now emits
vendor/bin/ground. Existing checkouts need the two lines changed by hand orground initre-run; nothing else in the workflow moves.
[1.10.0] - 2026-09-01
Added
ground devrenders a plugin's pages on a BENCH rather than bare. The generated entry handed Pageflow's<App>nochildren, so a page that declares no.layout— three of the eight plugin admin pages on disk — opened as unstyled markup on a white document, which reads like the page is broken rather than like a one-line assignment has not been written yet.The entry now frames every page in
GroundFrame(@ground/dev, new, in this package's ownui/). It is a FRAME, not a replacement layout: the production tree renders untouched inside it, becauseground serverunning the real pipeline is the only reason to trust what it shows. The layout therefore becomes a control rather than a decision —page(the page's own.layout, else the admin shell for anadmin/Pagespage and nothing for asite/Pagesone),bare,admin,auth.The bench also carries what only ground knows: every page from the glob with the route that renders it (a page no route reaches was previously unreachable in a browser at all), the plugin's routes with prefixes and filters resolved, the Pageflow page object, and an
adminShellseeder.PluginManifest::expandedRoutes()— every route with its module and group prefixes, names, filters andrequiresresolved.allRoutes()deliberately leaves entries as written, which is right for checking declarations and wrong for anything that navigates: a route declared/{id}inside{"prefix": "/admin"}under"routePrefix": "/api"is served at/api/admin/{id}.The
--sidebar-*design tokens, in the shared frontend theme. The admin shell in@pageflow/adminnames eleven of them (bg-sidebar-bg,text-sidebar-fg-muted,w-[var(--sidebar-width)], …) and not one was defined anywhere — the shell was ported out of HKM 0.3 and its stylesheet was left behind, so the sidebar rendered transparent with no width in every project. An undefined custom property is not an error, so nothing reported it.
Fixed
ground devmarked only ONE surface hot, so pages on any other surface got no scripts at all. PHP looks for{surface}-hotfor whichever surface the CONTROLLER named;--modewrote one file. A plugin whose pages render onsitetherefore served a bare shell with an empty#appwhileyarn devsat there reporting itself ready onadmin— no error in the PHP log, nothing in the console. One server serves every entry under the same root, so every declared surface is now marked hot.A dev server that failed to start deleted a running one's hot file. The cleanup was armed in
configureServer, which runs before the port is bound, so withstrictPorta secondyarn devexited during startup and removed the hot file belonging to the server that was working. Ownership is now claimed inside thelisteninghandler, and the teardown hooks are armed there too.ground devloaded no CSS at all. The generated config had no Tailwind plugin and the entry imported no stylesheet, while the pages, the shared@uikit and the admin shell are all Tailwind-only. Every page rendered unstyled. The workspace now generates a stylesheet — the kernel theme inlined, so@import "tailwindcss"resolves from a location that has it — with explicit@sourcedirectives, since Tailwind's automatic scan stops at gitignored directories and every file that matters is outside it.The scaffolded vitest tests never applied a page's
.layout. They rendered a bare<Page />, so the shell around it — the thing most likely to break — was never exercised. They now applyPage.layout ?? AdminLayout, which immediately surfaced that jsdom implements neithermatchMedianorResizeObserver, both reached byAdminLayouton its first render; the generatedsetup.tsnow supplies both.@testing-library/domwas also missing from the generatedpackage.json— a peer of@testing-library/reactsince v16, without which the whole suite dies on import.The shared
ThemeProviderdid not implement the API its own consumers call. It exposed{ theme, toggle }withtheme: "light" | "dark", while@pageflow/admin'sThemeToggledestructuressetThemeand calls it with"light" | "dark" | "system"— so every click on the theme switch calledundefined— and the sharedsonner.tsxdoesconst { theme = 'system' }. Neither failure is visible to a type-check or a build. The provider now exposessetThemeandresolvedTheme, treatssystemas a real third state that keeps tracking the OS, and guardslocalStoragein both directions (the unguarded read in the state initializer took the whole app down before first paint in a private window).
Changed
- The default theme is now
systemrather thanlight. Anyone who has never chosen one follows their OS — which is whatsonner.tsxalready assumed.
[1.9.0] - 2026-08-31
Added
ground migrateruns the whole dependency CHAIN, seeds it, and separates the CENTRAL and TENANT databases. Three related gaps, all of which let a green tick stand for a schema that could not deploy:- The chain. Only the target plugin's migrations ran. A plugin's schema does not stop at its own tables — tenancy's
user_tenantshas a foreign key ontousers, which the User plugin owns — so running one plugin alone rehearsed something no project ever does. The chain is the same transitiverequires[]walk that decides what a REQUEST loads, so it cannot disagree with whatground serveboots.--withnames an undeclared dependency,--alonerestores the old behaviour. - Seeding. Seeders now run against the freshly built schema, before the rollback. A seeder is the first thing to notice a column a migration renamed, and it exercises the schema the way the application will — real INSERTs, real constraints, real defaults.
- Central vs tenant.
database/migrationsbuilds the central database anddatabase/tenant-templatebuilds ONE tenant's. Running both into a single scratch database made ground the only place those tables coexist: a tenant-template migration declaringforeign('user_id')->on('users')applied happily, while in productionuserslives in a different database where no engine can point a key. Each layer now gets its own database, created and dropped independently, with its own seeders (database/seeders,database/tenant-seeders).
Because SQLite records a foreign key to a missing table without complaint — and
PRAGMA foreign_key_checkreturns nothing on empty tables — the harness reads the declared keys and fails a layer whose parent table it did not build. That caughtsocial_identities.user_id → usersin a tenant schema.- The chain. Only the target plugin's migrations ran. A plugin's schema does not stop at its own tables — tenancy's
Ground reads the plugin's own
.env, andground initwrites it. Ground already synthesised an environment —APP_KEY,APP_ENV=testing, and eachconfig[]var's default or a type-correct placeholder — which is what lets a plugin boot with no configuration at all. What it could not know was a REAL value: a sandbox API key, a local MailHog host,APP_DEBUG=falsefor an afternoon. Those are properties of one machine.initnow writes a.envseeded from theconfig[]declarations of the plugin AND every plugin it depends on, grouped by which one declared each var, and merges on re-run so a key someone pasted in survives. Precedence isconfig[] default/placeholder→.env→PluginGround::env(): a test naming a value still wins, because that value is a precondition of the test and must not depend on a file the test never mentions.Vars WITHOUT a default are written commented out — deliberately unlike
hkm plugins enable, which writes them as an active emptyKEY=so a project boot fails until the secret is supplied. That is right for a project and exactly wrong here: an empty string is a value, it would override ground's placeholder, and the bench would stop booting. The.envis gitignored.Seeding a second database in one run died with "Cannot redeclare class". LetMigrate's
SeederRunnerrequired every seeder file unconditionally, andrequireEXECUTES it — so a seeder declaring a named class could be loaded only once per process. Nothing hit that untilground migratebegan seeding once per driver in a single run; with SQLite alone there was one target and one load. It now skips the require when the class is already in memory and instantiates it directly, whilereturn new class {...}files — which declare no named class — are still required every time, as they must be.ground init --ignore— refresh a plugin's .gitignore and nothing else. The ignore list grows as ground learns to generate more (a dev workspace, a lockfile from a different package manager), and an existing plugin then needs only that one step. Running the whole ofinitto get it would also scaffold a CI workflow and reinstall dependencies the plugin never asked for.The list itself now covers everything ground or its toolchain writes:
/vendor/,/node_modules/,/ui/node_modules/, the generatedui/package.jsonand all three lockfiles,ui/vitest.config.ts,ui/tsconfig.plugins.json,ui/.ground/,ui/dist/,ui/.vite/,composer.local.*,ground.databases.json,docker-compose.ground.ymland the phpunit caches. Tests, fixtures,phpunit.xmland migrations stay source and are deliberately NOT ignored.ground drop— remove scratch databases a killed run left behind.migratedrops its own in afinally, so normally there is nothing to do;GROUND_KEEP_DATABASE=1, a hard kill, or a crash inside the drop itself each leave a whole database sitting on a server under an unrecognisable name. It only ever touches theground_prefix, refuses anything else by name, and--listshows what it would do.hkm ground dev—yarn devinside a plugin, with HMR, against the real kernel. A plugin's pages import@pageflow/react,@ui/button,@providers/theme— aliases that only existed afterhkm ui synchad mirrored the plugin into a PROJECT. So "let me see this page in a browser" answered "first build a project", which is the wrong answer while the plugin is the thing being written.The command generates a gitignored Vite workspace at
ui/.ground/(config, one entry per surface declared inui.json, adevscript inui/package.json), reusing the alias mapUiWorkspacealready derives for vitest.ground servethen setsVITE_PUBLIC_PATHso ViteManifest finds the dev server's hot file, andPAGEFLOW_ROOT_VIEWso the responder renders Pageflow's real layout instead of its minimal fallback shell.There is no proxy: PHP renders the page and points the browser straight at Vite for the modules, which is what the hot file has always been for. Run
hkm ground serve .andyarn devside by side, browse the PHP port, and a saved.tsxhot-updates.Two details are load-bearing. The entries are generated at exactly
src/surfaces/{surface}/index.tsx— the path the Pageflow layout requests by default — so nothing has to inject aviteEntryprop. And EVERY surface's pages are registered in EVERY entry, because the server may render a component authored undersite/Pagesonto the admin surface, and the component key carries no surface in it.
Fixed
ground servealone 500'd every Pageflow page once the layout was wired. The real layout callsvite(), which THROWS when there is neither a hot file nor a production manifest — so choosing that layout at startup turned "noyarn devrunning" from a bare-but-valid shell intoViteManifestNotFoundException. The layout is now chosen PER REQUEST, from whether a hot file exists at that moment. Starting or stoppingyarn devtherefore needs no restart of the PHP server either: the next reload just takes the other path.- The generated vitest setup left
localStorageundefined on Node 24+. Node ships its own, which shadows the one jsdom provides and isundefinedunless the process was started with--localstorage-file. Any component reading a stored preference then died ongetItemof undefined — the sharedThemeProviderdoes exactly that, so a page wrapped in it failed to render for a reason having nothing to do with the page. The setup now installs a working in-memory stand-in, because in a browser localStorage always exists. ground serverendered Pageflow pages with no assets at all. Pageflow's Provider resolves a relativePAGEFLOW_ROOT_VIEWagainst the active project root, which under ground is a throwaway workspace containing no layout — so the responder fell back to its minimal built-in shell: correct page object, correct root element, and not one script tag. The page rendered, the React never booted, and nothing reported an error, because an empty shell is a legitimate thing to render.
[1.8.1] - 2026-08-31
Fixed
ground servehanded its router an autoloader path that exists in no layout, so every request died before reaching the kernel:Failed opening required '.../modules/ground/src/vendor/autoload.php'. The routerphp -Sexecutes runs in a fresh process per request and so has torequirea composer autoloader itself; the path was built by counting directories up from the command's own source file, which named avendor/insidesrc/. The command still started and printedListening— the fatal was in a different process, on the first request, which is why nothing caught it. It now asks where the autoloader ALREADY IN EFFECT lives (two levels above the loadedComposer\Autoload\ClassLoader), correct by construction in a kernel checkout, an installed bundle and a plugin's ownvendor/alike.ServeTestnow reads the emitted router and asserts the paths it names are real — a generated file is executed by something other than the test runner, so nothing about it is checked unless it is checked deliberately.
[1.8.0] - 2026-08-31
Added
hkm ground— the plugin developer's bench, as a kernel module. Developing a plugin previously required a project to test it from, which is backwards: the plugin is the thing being written and the project does not exist yet.modules/groundboots ONE plugin on the real kernel — real BootPipeline, real route compiler, real dependency graph, real scope isolation — with every port bound to a fake and the compiled manifests written to a throwaway workspace. Six verbs, no arguments: the plugin is the one you are standing in (.says so explicitly; a name reaches a different one), resolved by walking up from the working directory so it works fromui/andtests/too.ground init— everything a plugin needs to be testable, once:.gitignoreentries first,phpunit.xml, a CI workflow, dependencies, a scaffolded test, the UI setup, and the database harness. Idempotent, and it reports whether each file was WRITTEN or KEPT rather than overwriting an author's.ground check— static conformance: manifest drift, undeclaredenv(), unbound contracts, access-rule violations. Exits 1 on any error.ground probe— boot it and report what compiled.ground serve— the realHttpPipelinebehindphp -S, against fakes.ground test— phpunit, then vitest when the plugin ships page tests.ground migrate— see below.
It is a MODULE, not a plugin: it owns no business domain and extends no project. Because every plugin already requires the kernel, every plugin now gets
PluginGroundTestCasewith no dev dependency at all, andvendor/bin/groundwithouthkmon PATH.ground migrate— migrations against every database, for real. A migration is the one thing in a plugin that cannot be tested against a fake: a fake records SQL without parsing it, and--pretendcompiles without executing, so a statement MySQL accepts and PostgreSQL rejects passes both. This connects to actual servers, CREATES its own scratch database per run, applies every migration, inspects the schema, thenreset()s to exercise everydown()— the half thathkm plugins disabledepends on — and drops the scratch database afterwards. It never touches a database anyone configured. Drivers that are unconfigured or unreachable are reported as SKIPPED with the reason and never counted as passes;--strictfails the run when any supported database went untested, which is what CI should use.Connections come from
ground.databases.json— written by--initstraight into.gitignore, because it holds credentials for servers that exist on one machine — or fromGROUND_DB_MYSQL/GROUND_DB_PGSQL/GROUND_DB_SQLSRV, which override the file so CI needs no file at all.
Changed
pspis gone from every name a user sees.bin/pspis nowbin/hkm-cli; the bundler and the upgrade path still install it asbin/hkm, so installed layouts are unchanged.[psp]output prefixes are[hkm], and the global CLI calls itself "HKM Kernel CLI".PSP_GLOBAL_AUTOLOAD/PSP_PROJECTS_DIRare nowHKM_*. The old names are still READ as a fallback everywhere, and the launcher EXPORTS both — a project generated before this release readsPSP_, one generated after readsHKM_, and nothing can tell which it is about to run.- Generated project glue is
hkm_*.psp_require_kernel_autoload(),psp_kernel_home()andpsp_register_project_autoload()becomehkm_*in new projects; the managed marker is[hkm-support:<Folder>]. The tooling reads BOTH spellings, andhkm pluginsemits whichever name the target project actually defines — writing the new name into a project generated before this release would produce a config that fatals on an undefined function.
Fixed
hkm --devhanded PHP the launcher binary. The CLI path was hardcoded to<root>/bin/hkm, which in a BUNDLE is the PHP CLI but in the dev monorepo is this launcher's own compiled executable — so every--devpassthrough died with a parse error thousands of lines into a Mach-O file. Resolution now takesbin/hkmwhen it IS a PHP script and falls back tobin/hkm-cli, reading the first bytes rather than trusting the name.ALTER TABLEcompiled MySQL syntax for every driver (modules/let-migrate). Additions were batched into one statement and indexes added withADD KEY, neither of which SQLite, PostgreSQL or SQL Server accept; and drops ran columns BEFORE the indexes over them, which only MySQL tolerates. A rollback written in the correct order was reordered by the compiler into one that could not work anywhere but MySQL. Found byground migrateon its first run.
[1.7.0] - 2026-08-30
Security
- A
Host:header could make one project load another project's.env.DomainResolvermatches the request host against the MACHINE-GLOBAL registry (HKM_USERDATA_DIR/projects.json), which lists every project on the box by absolute path — so a host owned by a different project resolved to that project's directory, and tier 3 of the cascade read its.envwith no check that it was the application being served. The result was a foreignAPP_KEY,DB_*andSESSION_*spliced over ours, selected by a header the caller controls; withENV_CACHE=1the merged result — our secrets included — was then written under that project'svar/cache. Tier 3 is now confined to the application root, and a refusal is announced viaerror_lograther than silently skipped. The test is CONTAINMENT, not equality, because both layouts are legitimate: flat, where the project path is the root, and nested, where it is<root>/projects/<name>. Comparison is done afterrealpath()and with an explicit separator on the prefix test, so a sibling sharing a name prefix (/srv/app-backupagainst/srv/app) is not treated as inside. When the environment loads is unchanged — still beforeKernel::build(); only which directory tier 3 will read has changed.
Added
routePolicy.only— an allowlist for the routes your plugins publish. A plugin owns and declares its routes, and onehkm plugins installcan add thirty of them at once; the only existing control,routePolicy.disable, SUBTRACTS, so it helps only once you already know a route exists. You cannot veto what you were never shown.Kernel::withRouteAllowPolicy()(andproj.json"routePolicy": {"only": [...]}) inverts it: when the list is non-empty, a plugin route must match a spec or it is never exposed. Two asymmetries are deliberate, both so the safer posture is not the harder one to adopt — an EMPTY list means "no allowlist" rather than "allow nothing" (which would empty an application on upgrade), and an allow spec matching nothing does NOT fail the boot (naming routes from a plugin this deployment has not enabled is normal in shared configuration), unlike a disable spec. Allow is applied before disable, so the two compose: allow a module's whole domain, then subtract the handful of its routes you do not want.- Route-policy PREFIX specs —
"GET /mail/demo/*". The exact form fails OPEN on upgrade: veto five demo routes by exact key and the plugin's next release adds a sixth, the five still match, the anti-typo guard is satisfied, and the surface grows with nothing to announce it. A prefix keeps covering what arrives later, which is the only form that survives a dependency bump. Prefixes are method-specific (GET /admin/*does not silently also drop the POST that mutates) and segment-bounded (/mail/demo/*never swallows/mail/demos). Available to bothdisableandonly; the exact and module-domain forms are unchanged, and a prefix matching nothing still fails the boot. module.json"files"— plain PHP files a module needs loaded. A plugin is loaded by the KERNEL, not by Composer: plugins are symlinked intoplugins/and reached through the PSR-4Plugins\map, so no plugin's owncomposer.jsonis ever read. That is fine for classes and fatal for FUNCTIONS — a plugin declaring"autoload": {"files": [...]}has declared it in the one place nothing looks, and nothing complains until something calls one and dies with "Call to undefined function". Projects were hand-patching this with arequire_onceinbootstrap/app.php, which works exactly once, in the one project that noticed. Declared files are compiled intofiles-manifest.phpand required at boot; a declared file that does not exist now FAILS THE BOOT instead of becoming a runtime fatal inside a plugin. Whenmodule.jsondeclares none, the module's owncomposer.jsonautoload.filesis honoured as a fallback — so existing plugins work with no plugin change at all.
Fixed
- Plugin event listeners with dependencies were silently dropped. The
EventBusis constructed once, at materialize, with theCoreContainer, while listener DEPENDENCIES are bound per request byProvider::register()into theModuleContainer. Dispatch also gated resolution onhas(), which reports only what is EXPLICITLY BOUND — so an ordinary listener class was reported absent and built withnew $listenerClass(), which throwsArgumentCountErrorfor anything with constructor arguments, which the catch logged as a failed listener. The event was dropped, the cause read like a bug in the listener, and projects worked around it by hand-assembling listeners (and four levels of a plugin's internals) intowithPorts()so they would be in the core container after all.EventBus::forContainer()now gives each request and job a view that resolves against its own container, and dispatch asks the container before falling back tonew. Resolution is a strict SUPERSET: a listener already bound in core resolves exactly as it did. BOOT_CACHEcould silently skip a manifest a newer kernel added. The cached-boot check gated on one sentinel manifest, so a cache written by an OLDER kernel — whose stamp still matches, because the builder inputs did not change — was accepted while a manifest that version never compiled was simply absent, and the stage reading it did nothing. The sentinel is now a list, so an older cache invalidates and recompiles instead of leaving a new feature inert until someone clearsvar/cacheby hand. The route allowlist is also part of the stamp key: without it, TIGHTENING the allowlist would leave the previous, wider route manifest cached — the worst way for a security control to fail.route:listgained--unfilteredand--plugin(in the Commands plugin): the inverse of--filter, and the one question an audit actually asks — what did enabling these plugins expose with no filter in front of it? An unfiltered route is not automatically unsafe (a login form,robots.txt, or a page shell whose data sits behind a filtered endpoint are all legitimately unfiltered); it is the set that has to be justified one by one.hkm plugins enablenow reports the HTTP surface it activates — how many routes the plugin publishes and how many of those run no filter — instead of reporting none of them.
Templates
- Removed a duplicate
HashingPortbinding (the second silently overwrote the first) and a deadPdoDatabaseimport. - The connection pool is no longer built and
warmup()-ed at bootstrap. Under PHP-FPM the bootstrap re-runs on EVERY request, so a pool there opened its connections, served one request and was thrown away — strictly more expensive than not pooling. It is now a lazy factory, gated on the CLI SAPI (which is what OpenSwoole and the queue worker run under). app/worker/run.phpreadWORKER_QUEUEandWORKER_MAX_ITERATIONSwithgetenv(), which cannot see a.envvalue because the environment loader deliberately skipsputenv()— so both were silently ignored and every worker draineddefault. Nowenv().app/public/index.phpresolves the domain EXPLICITLY instead of reading the$domainthe bootstrap happened to leave in scope.requireshares scope, so the old form worked until the bootstrap returned early or renamed the variable — at which pointResolveStagefalls back to the RAWHostheader forroute_host, the value the client controls. This matches what the OpenSwoole entry point already had to do per request.- The process timezone is pinned explicitly (
APP_TIMEZONE, defaultUTC).
[1.6.1] - 2026-08-30
Fixed
- Essential modules never reached a queued job.
HttpPipelinepassed its essentials intoOnDemandLoader;WorkerLoopbuilt its loader with none, andKernel::materialize()had no way to hand them over. So a module the project declared app-wide inproj.json"essentials"was app-wide for requests and absent from every job — and for an essential that rebinds a port per scope (tenancy rebindingDatabasePort) the failure is silent rather than loud: the binding still resolves, just to the wrong connection. The worker now registers essentials into every job container and seeds their domains into the job's graph, so their transitiverequires[]come with them — the same two stepsLoadStageperforms for a request. A job whose class the manifest does not know now also gets a container rather than the bareCoreContainer, since "essential" means every unit of work; an application that declares no essentials keeps its exact previous behaviour, including that fallback. The class→domain mapping both surfaces need moved toDependencyGraphCalculator::domainsFor(); a private copy in each pipeline is how they drifted apart in the first place. APP_DEBUGmeant two different things in one file.ErrorStage::isDebug()parsed the value withFILTER_VALIDATE_BOOLwhilepublicError()compared it=== 'true'. WithAPP_DEBUG=1the HTML debug page — stack trace and source excerpt — was served to anything sendingAccept: text/html, while every JSON response still masked its message as "An internal error occurred.". One flag, two behaviours, and the more revealing of the two was the one that engaged. There is now oneisDebug(), used by both;FILTER_VALIDATE_BOOLis the surviving parse because it is what every other kernel flag uses (HttpPipeline::flag()), so1,on,yesandtruemean the same thing throughout. It also now reads throughenv()rather than$_ENV/getenv(): the environment loader deliberately skipsputenv(), sogetenv()is not the source of truth for a.envvalue. Note the direction of the change — withAPP_DEBUG=1the JSON path now reveals exception messages, which is what the flag was asked for;APP_DEBUGunset or falsy masks them exactly as before.
[1.6.0] - 2026-08-29
Added
Kernel::withWorkerSecret()— the queue can finally be an authenticated channel.WorkerLoophas always carried a signature check, but the kernel had no way to give it a key:$signingSecretdefaulted 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 toJOB_SIGNING_SECRETand stays OFF when that is unset, preserving today's behaviour. It deliberately does not fall back toAPP_KEY: that would switch verification on for every existing application at once and reject every job already in flight, since noQueuePortadapter signs by default. Turning it on is a two-sided change — roll it out producer-first, teaching the adapter to stampJobPayload::signatureFor()atpush()time.- Graceful worker shutdown, a memory ceiling, and per-job timeouts. There was no
pcntlanywhere in the kernel, so SIGTERM — what every process supervisor and container runtime sends to stop a worker — killed PHP outright, including in the window betweenhandle()returning andack()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 amemoryLimitMbso a supervised worker exits between jobs rather than being OOM-killed inside one, and a job's declaredtimeoutis enforced withpcntl_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. Everywith*()deep-clones all seven parameter bags, so a chain of them pays that price once per link.ResolveStageattachingroute_entry,route_paramsandtarget_serviceis 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_CACHEnever hit for the essentials shape the docs recommend.Kernel::build()computedbuildHash()twice — before and afterresolveEssentialModules(), which rewritesessentialsfrom 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.BootStampTesttests the stamp in isolation and could not see this, soKernelBootCacheTestbuilds twice through the realKernel::build()and watches the manifest inode.- A job payload that failed verification was silently deleted. The check returned
skipped(), whichprocessWithPortthen 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 raisesRejectedJobException, goes through theErrorPipeline, and is dead-lettered viafail(). It is never retried: a signature that does not verify will not verify on the second attempt. retryandtimeoutinmodule.jsoncompiled to nothing.CompileJobManifestStagereadhandler,queue,moduleandsolvesand 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": 5shorthand; an unknown strategy falls back rather than failing the boot, and"max": 0is raised to 1 (a job that can never run is never what it meant).hkm runignored its own documented default of./. Anargs.len <= 2guard printed usage and exited 2 before the resolver ever ran, contradicting the command's module docblock, its help text, and theresolveRoot()call below it — which already handled an empty target. It bithkm run --devhardest:--devis 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 toresolveRoot(), and a barehkm runoutside 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 and approve pull requests" is on, and it is off by default. The step exited non-zero, so v1.5.0 shipped correctly with every asset in place while the run was marked failed. Both blocked paths now degrade to warnings that name the branch, a ready-made compare link, and the two settings that make the bump fully automatic. The release itself was never at risk; only the report was. - The documented Homebrew install did not work as written. Homebrew 6 refuses to load a formula from a third-party tap until it is trusted, and it refuses at
brew installrather than atbrew tap— so the two-line instruction appeared to succeed and then failed with "Refusing to load formula … from untrusted tap".brew trust alfacode-team/hkmis 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. Signingdataalone leftjobClass— the field that decides WHICH CODE RUNS — unauthenticated. Capturing one legitimately signed envelope and swapping its class for any otherJobContractwas enough; nothing about that required forging a signature, only reusing one. The material is nowjobId | jobClass | queue | maxAttempts | canonical(data).attemptsis deliberately excluded: the driver increments it on everyrelease(), so covering it would invalidate a job on its first retry —maxAttemptsis 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. SecurityGatewayno longer clones the request on its final layer. The clone existed soSecurityVerdict::allow()could read the identity back off it, andSecurityStagethen 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
- Homebrew formula (
HomebrewFormula/hkm.rb).brew tap alfacode-team/hkm https://github.com/AlfaCode-Team/hkm-kernel && brew install hkm—phpandcomposerarrive as formula dependencies, and the Gatekeeper quarantine dance disappears entirely. This repository doubles as its own tap (Homebrew reads aHomebrewFormula/directory in any tapped repo), so there is no secondhomebrew-hkmrepo to keep in sync. The formula consumes the same universal Mach-O the other macOS paths get rather than building from source: the launcher is pinned to a Zig dev toolchain that Homebrew's stablezigcannot compile. It reshapes the.appinto thelibexec/bin+libexec/lib/hkm-kernelpairing the launcher already self-locates against, and resolvesvendor/by running the kernel's owninstall.shagainst the PHP Homebrew just installed.- The
bin/entry points are wrapper scripts, not symlinks — the same_NSGetExecutablePathreasoning asinstall-macos.sh, plus they defaultHKM_USERDATA_DIRto~/.local/share/hkm. Without that the project registry defaults to<kernel>/projects/projects.jsoninside the Cellar, whichbrew upgradedeletes wholesale. tools/homebrew-bump.shrepoints the formula at a release, run by a newhomebrewjob in the release workflow. A tap formula pins one tarball by digest, so a stale one does not fail loudly — it silently installs the previous version. The digest is only knowable after the assets exist, so the job runs post-publish and lands its commit onmaindirectly where the token allows it, else as an automatic pull request —mainhere requires reviews, and without the fallback the bump would simply be dropped. It never fails the workflow: the release is already out by then, and failing would only misreport a good release as broken.
- The
Changed
- The macOS install is user-local by default — no root.
install-macos.shputHKM.appin/Applicationsand escalated tosudowhenever that was not writable, which inverted on macOS the promisetools/install.shmakes in its own header on Linux: no root, nothing written outside your home. It now defaults to~/Applications— a first-class macOS location that Finder and Launchpad both show — and--systemopts into/Applicationsas the only path that ever asks for a password.--userforces the home location.- An existing install is updated where it is. Without that, a plain re-run on a machine with
/Applications/HKM.appwould have left it in place and shadowed it with a second copy in~/Applications, leaving two launchers andPATHorder alone to decide whichhkmruns. --uninstallsweeps both locations unless a scope is named. Removing only the one a given run resolved would leave a machine still answeringhkmafter printing "Removed."
- An existing install is updated where it is. Without that, a plain re-run on a machine with
Fixed
hkm doctorandhkm versiondescribed a machine that does not exist on macOS.install_scopemodelled only the Debian/tarball pair, so both commands listed/opt/hkm-kerneland~/.local/lib/hkm-kernelas "not installed" beside a perfectly good bundle, andhkm versionreportedscope: neither — a checkout or a custom prefixfor a stock install on the line below a table that had just marked it as the user scope. The module now models the scopes each platform's installer actually writes — on macOS~/Applications/HKM.appand/Applications/HKM.app— sodetect,scopeOfandScope.how()are right for every command that asks. The pre-1.4 legacy user root is no longer probed on macOS, where nothing ever wrote it.hkm upgrade --user/--systemnow select those two locations, while a barehkm upgradestill updates the bundle it is running from — so a customHKM_APPDIRinstall is updated rather than shadowed by a second copy in the canonical spot. The chosen root is passed down to the installer instead of being resolved a second time, which is what previously let the announced target and the written one differ.hkm versioncalled the wrapper that runs it a shadowing install. Same wrapper blindness already fixed inhkm doctor, and the check now shares the same helper (util.leadsTo) rather than a second copy of the comparison.- A macOS upgrade could destroy the project registry.
install-macos.sh's wrappers did not pinHKM_USERDATA_DIR, so the registry defaulted to<kernel>/projects/projects.json— inside the bundle thathkm upgradereplaces from an archive shipping its own defaultprojects.json. Every registered project would have gone on the next update, warned about only by adoctorhint suggesting you pin it by hand. The wrappers now default it to~/.local/share/hkmand create it, matching the Homebrew formula. hkm upgrade --useron macOS installed system-wide. The macOS branch ignored the requested scope entirely and fell back to/Applications, so a command asking for a user-local install wrote outside$HOME— while the "Target" section above it named~/.local/lib/hkm-kernel, a third path that was neither. macOS installs are.appbundles, soinstall_scope's Debian/tarball pair describes nothing that exists there; upgrade now reports and acts on the bundle it actually self-locates into, defaulting a fresh install to~/Applications. It also creates the destination (absent on a fresh account) and refuses on an unwritable directory before downloading, naming the no-root alternative — discovering that halfway throughtarleaves a partly replaced bundle.- macOS: every child process the launcher spawns failed with
error.FileNotFound.hkmandhkm-configbuilt theirstd.Io.Threadedinstance with.init(page_allocator, .{}), leavingInitOptions.environat its.emptydefault. Zig resolves a bare command name against thePATHheld by the Io instance — not theenviron_maphanded to each spawn — so with no environ it fell back toThreaded.default_PATH(/usr/local/bin:/bin/:/usr/bin). On Linux that works by accident, since a distrophplands in/usr/bin; on Apple Silicon nothing Homebrew installs is on that list, sophp,composer,git, andtarwere all unreachable. The symptom was maximally confusing:hkm doctorprintedphp /opt/homebrew/bin/phpand then, one line later, "could not execute the PHP binary". Every PHP passthrough command (hkm list,hkm new,hkm run) was equally dead, making a macOS install non-functional even when it reported success. Both entry points now pass.{ .environ = init.environ }, which also fixes TTY/colour detection, andHKM_PHP_BIN=/full/path/to/phpis no longer needed as a workaround. install-macos.shaborting on the first download withREPO: unbound variable. The progress line interpolated$REPO…— a bare variable followed directly by a multi-byte…. macOS/bin/shis bash 3.2, whose parser is not multi-byte-aware and swallowed the…bytes into the variable name, producing an unbound-variable abort underset -u. This hit every macOS user of the default (auto-download) path, since it fires before anything is fetched. Braced as${REPO}.install.shcarried the identical line and is fixed too — it survives only because/bin/shis dash on most Linux distros.- Every
sudo hkm …on macOS silently lost its configuration.SUDO_USERwas resolved to a hardcoded/home/<user>in two places (lib/userconfig.zig,lib/install_scope.zig), which is not merely non-native on macOS:/homethere is an autofs automount (auto_home,nobrowse), so the path cannot even be created. Reads found nothing, sosudo hkm --devlostHKM_DEV_HOME,HKM_KERNEL_HOMEandHKM_USERDATA_DIR;sudo hkm versionreported no user install on a machine that had one;sudo hkm uninstalllooked for the user's files under/homeand left them behind; andsudo hkm-config set-kernel-home …failed with a bareerror: Unexpected. Home is now reconstructed per platform (util.sudoUserHome), and the tests assert the platform's convention rather than the Linux spelling that let this look covered.hkm-config's setters also report an unwritable config file with the path and the key instead of a raw Zig error. hkm-config checkundersudopinned root's registry into your config.ensureUserdatareadenv.HOMEwhileuserconfig.pathhonouredSUDO_USER, so the two disagreed about whose home this was — and the value that got written was root's. Every later plainhkmrun then pointed at a registry under/root(or/var/root) it cannot read. Both now resolve home the same way. This half was wrong on Linux too.hkm upgradeon macOS was a silent no-op that nested a second bundle. The extraction target was threedirname()calls off the kernel root, which lands onHKM.app/Contents— but the tarball's top-level entry isHKM.app/, so tar unpacked a whole second bundle atHKM.app/Contents/HKM.appand left the real kernel untouched.install.shthen re-resolved the OLD tree and the command printed "updated." The target is now found by walking up to the.appcomponent (appContainerDir), which is independent of nesting depth and install prefix, and only theHKM.appmember is extracted so the archive'sinstall-macos.shno longer lands in/Applications.hkm upgradedid not know Homebrew, or its own limits, on macOS. A Cellar install is replaced wholesale bybrew upgrade, so unpacking a bundle into it is undone at best; it is now detected and refused up front — before any download, and before the scope machinery prints a plan for a kernel that is not the one running. A layout that is neither a.appnor a Cellar (a portable tree, or anHKM_KERNEL_HOMEpin) is refused too rather than unpacked somewhere invented.isSystemBinDirclaimed Intel Homebrew's bin as the system install. It is a.debconcept — "a launcher here belongs to the system package, whose kernel is/opt/hkm-kernel" — and/usr/local/binis exactly where Homebrew installs on an Intel Mac. It now returns false on macOS, where no.debexists for it to be true of.- Downloads went to a hardcoded
/tmp. They now honour$TMPDIR, falling back to/tmp. macOS gives each user a private, auto-cleanedTMPDIR; the artifact name is entirely predictable, so a world-writable/tmpis a file another user on a shared machine can pre-create. hkm doctorgiving advice that breaks a wrapper-based install. Both macOS install paths reach the launcher through a wrapper script, so the binary's own directory is deliberately never onPATH. Doctor judged that by directory alone and reported "on PATH: NO" on an install that worked, then advised adding the real directory — which for Homebrew is the version-scoped Cellar path, so following it breaks at the nextbrew upgrade. It now recognises a wrapper that execs this launcher, which also stops it calling that wrapper a shadowing copy. Separately, a self-contained install (Homebrew,HKM.app, a portable tarball) no longer draws "no kernel installed in either scope —hkm upgrade --userinstalls one": that would build a second, competing kernel arbitrated by nothing butPATHorder. Finally, the userdata dir is now read from the environment rather than fromconfig.envalone —registry.zigconsults the environment first anduserconfig.load()folds the file into it, so the file was only ever part of the answer, and an exportedHKM_USERDATA_DIRwas reported "not pinned" while actively working. The row now also says which of the two pinned it.--helpon both installers printing past the end of the help text. Thesed -nranges inusage()overran their comment blocks:install-macos.shprinted a danglingWHY A WRAPPER SCRIPT…heading and its underline, andinstall.shprinted a literalset -euas if it were help.
[1.4.3] - 2026-08-28
Fixed
install.sh/install-macos.shcrashing withunbound variable. Both installers' PATH hint used${SHELL##*/}, which throws underset -uwhenever$SHELLisn't exported in the invoking environment — unlike$HOME, POSIX doesn't guarantee it, and it's commonly absent undercurl | sh, cron, and some IDE task runners. Guarded with${SHELL:-}and matched by suffix instead, so a missing value now just falls through to the generic PATH-hint case instead of aborting the whole install.
[1.4.2] - 2026-08-28
Added
tools/install-macos.sh— a macOS-specific installer. Previouslytools/install.sh(Linux-only auto-download) simply refused to run on macOS with no working alternative documented anywhere, leavingHKM.appusers to hand-discover that it needs the Gatekeeper quarantine flag cleared and aPATHentry beforehkmruns at all. The new installer downloads (or accepts a local)hkm-kernel-<version>-macos-universal.tar.gz, swapsHKM.appinto/Applications, clearscom.apple.quarantine, and wireshkm/hkm-configontoPATHvia a tinyexecwrapper script rather than a symlink —_NSGetExecutablePathis not guaranteed to resolve through a symlink the way Linux's/proc/self/exedoes, and the launcher self-locates its kernel relative to its own executable path, so a symlink risked it silently finding the wrong kernel (or none). Shipped as its own release asset (install-macos.sh) and bundled inside the macOS tarball itself, mirroring howinstall.shships with the Linux one.
[1.4.1] - 2026-08-28
Fixed
hkm upgrade --user/--systemcrashing on a self-upgrade — the tarball'stools/install.shreplacedbin/hkmandbin/hkm-configwith a plaincp, which truncates and writes INTO the existing file. Since the process running the upgrade IS that exact binary, the kernel it's running under refuses withcp: cannot create regular file '.../hkm': Text file busy— the same failurehkm upgrade --localwas fixed for previously, just never carried over to this installer. Now stages each binary beside its target andmvs it over, so the running process keeps its old (unlinked) inode and the next invocation picks up the new build.
[1.4.0] - 2026-08-28
A project's plugins/, var/* and userdata/storage/* are gitignored on purpose — plugin source is fetched from its own git remote, and var// userdata/ are runtime state, not source. That leaves a project pulled onto a new machine (a teammate's clone, a fresh server, CI) missing all three, unable to boot until someone reconstructs them by hand.
Added
hkm install [path|name]— brings a cloned/pulled project up to a runnable state in one command: registers it in the kernel registry; recreatesvar/logs,var/cache/manifests,var/tmp,var/locks,var/sessions,var/queueanduserdata/storage; creates.envfrom.env.exampleand generatesAPP_KEYif either is missing or empty (never touches a key that is already set); runscomposer install; then fetches every plugin the project's ownapp/bootstrap/app.phpwires — the same fetch-and-lock stephkm newruns right after scaffolding, now shared vialib/plugin_provision.ziginstead of duplicated. Every step past directory creation has a--no-*flag.hkm install --production/--owner=<user>[:<group>]— correct permissions AND ownership on a server, not just "writable".--productiontightensvar//userdata/to0750/0640(no "other" access) instead of the dev defaults0775/0664; it deliberately does not guess an owner.--ownerrecursivelychowns both directories to the account your web server / PHP-FPM pool actually runs as (user,user:groupand:groupall work, passed straight through to the systemchown) and reports a failed chown per-directory rather than swallowing it — unlike chmod, a production ownership fix that silently didn't happen is worse than one that says so.HKM_PROD_OWNERsets a default so a deploy environment does not have to repeat--owner=on every run.
[1.3.3] - 2026-08-18
Follows 1.3.2 within a day, and the theme is narrower: 1.3.2 fixed which kernel a command acts on, this one fixes how commands talk to the shell around them, plus a full uninstall. An audit of the Zig toolchain drove it — every item below was reproduced against the shipped --release=small binary.
Added
hkm uninstall— removes every hkm install on the machine (both kernels, both pairs of launchers, the pre-1.4 user kernel,~/.config/hkmand the shared plugin store) and deregisters the.debfrom dpkg, while keeping your projects and the project registry. Those two are protected by construction, not by a filter: every path it can delete is computed from the install layout, so a project directory cannot enter the plan at all; andprojects.json+platform.jsonare rescued out of a kernel tree into the userdata directory before anything is deleted, so the registry survives even when its only copy was inside the tree being removed.--dry-runprints the plan and exits; system paths are reported rather than silently skipped when not root.
Changed
hkm doctorandhkm versionshare one PATH lookup with the launcher passthrough (util.findOnPath). Three private copies of "which binary would actually run" is three chances to disagree.- CI actions moved off the deprecated Node 20 runtime.
upload-artifactv5→v7,download-artifactv5→v8,codeql-action/upload-sarifv3→v4 andaction-gh-releasev2→v3 all declarednode20, which the runners were already forcing onto Node 24. A.github/dependabot.ymlnow watches thegithub-actionsecosystem weekly and groups the bumps into one PR, so the next runtime deprecation arrives as a reviewable change rather than a notice in the log of a workflow that still passes.
Fixed
- Command output went to stderr, so nothing could be piped. The whole
promptrenderer usedstd.debug.print, which writes to stderr — sohkm list > projects.txtproduced an empty file and a command's results were indistinguishable from its errors. Results (intro/section/item/ok/muted/note/table/outro) now go to stdout;err/warnand every interactive prompt stay on stderr. This was found once before and fixed a single function wide (banner.printShort); the cause was in the shared renderer all along. - ANSI escapes were emitted unconditionally and
NO_COLORwas ignored, so colour codes landed in redirected output, log files and CI transcripts. Colour is now decided per stream from the ruletools/install.shalready applied: off whenNO_COLORis set, whenTERM=dumb, or when that stream is not a terminal. - Tables truncated to 80 columns when redirected.
termCols()falls back to 80 whenever theioctlfails — exactly the non-TTY case — so piping cut the end off every long path, with the…as the only clue. Truncation now applies only when stdout really is a terminal. - A mistyped command printed a raw Zig error. Anything not handled natively is forwarded to the PHP CLI, and a spawn failure propagated out of
mainaserror: FileNotFound— no filename, no mention of PHP, no pointer tohkm doctor. The three causes (no PHP, no kernel CLI, an unknown command) are now told apart and each names its own fix. - Unknown flags were silently ignored, which inverted a destructive command. Every command parsed the flags it knew and dropped the rest. The token most likely to be misspelled is the one that makes a command safe, so
hkm uninstall --dryrun --yesparsed as "no dry run, and don't ask" and deleted the install without a prompt.uninstallandupgradenow reject anything they do not recognise before acting on anything they do. projects.jsonandplugins.lock.jsonwere written non-atomically.writeFiletruncates before writing, so a process killed part way through — or a full disk — left a truncated registry rather than the previous one. Both now write a sibling temp file andrename()over the target, the same patterninstall.shand the launcher install already used.- Every unknown long option crashed the CLI parser.
php-io-cli's long-option branch recorded a different array shape than its two siblings, andrejectUnknownOptions()reads the key it omitted — so the feature meant to suggest a correction raisedTypeError: suggestOption(): Argument #1 ($name) must be of type string, null givenon every unknown--flag. Fixed upstream (php-io-clib1dd657) rather than pinned back, so the handling stays in. hkm uninstallcould destroy the registry it promises to keep (found in review). Two holes:HKM_USERDATA_DIRmay point INSIDE a deletion target —/opt/hkm-kernel/projectsis the obvious case — so the plan listed it under "Will KEEP" and deleted its parent moments later; andrescueRegistryswallowed every write failure, so a failed rescue was followed by the delete anyway while the command reported success. It now refuses the first layout outright and aborts before removing anything if the rescue fails. Rescued files are written atomically.- Output fixes that had gaps of their own (found in review):
hkm-config printstill wrote the config to stderr; a line longer than 8 KiB fell back tostd.debug.printand silently changed stream; remediation text printed after an error went to stdout, splitting one message across two streams;writeFileAtomicused a fixed temp name two processes could collide on;findOnPathskipped emptyPATHentries, which POSIX defines as the current directory; the passthrough blamed a missing PHP for a missing kernel CLI; and--ended flag VALIDATION but not flag PARSING, sohkm upgrade -- --systemstill selected the system scope. - A failed
.debinstall could still report success. The fallback path treatedapt-get -f installexiting 0 as evidence the package had landed, but it exits 0 whenever it finds nothing to repair — so adpkg -ithat failed for any non-dependency reason (a truncated download, a corrupt.deb) was reported as "updated" with the previous kernel still installed. The dependency repair is now followed by a seconddpkg -i, and that result alone is the verdict. prompt.itempadded by byte count, so a key containing any multi-byte glyph shifted its description column left — a single→misaligned the row by two. It now measures display width using the helper already written fortable().
[1.3.2] - 2026-08-17
Fixes a class of failure that made installing or upgrading on a machine with an existing install appear to do nothing. A machine can hold BOTH a system install (.deb → /opt/hkm-kernel + /usr/bin) and a user install (tarball → ~/.local); the CLI did not model that, and every symptom below followed from the same gap.
If you are upgrading from 1.3.1 or earlier, the old launcher cannot install the user scope. Install it from the release instead — the fixed hkm upgrade takes over from there:
curl -fsSL https://github.com/AlfaCode-Team/hkm-kernel/releases/latest/download/install.sh | sh
hkm version # shows every install and which one your PATH runsAdded
hkm versionreports every install on the machine, not just the launcher's own compile-time stamp: the kernel version in each scope (read from that kernel'scomposer.json), the launcher serving it and the version IT was built as, and an arrow on the one this invocation resolves. It also names the states that make a later "my upgrade did nothing" report inevitable — anotherhkmearlier onPATH, a kernel with novendor/, a stale config pin.hkm --versionis unchanged and still prints one line for scripts.hkm upgrade --user/--systemto force a scope. Without either, the target is chosen from privilege — root → system, otherwise → user — sosudo hkm upgradeandhkm upgradeare two predictable commands rather than one command whose target depends on machine state.hkm-config unset <KEY>, for clearing a staleHKM_KERNEL_HOME.hkm doctorgained an Installs table: both scopes, their versions and whether each has resolved dependencies.
Changed
- Kernel resolution ranks sources by how specific they are to the invocation (
tools/src/lib/kernel.zig): an exportedHKM_CLI_PATH/HKM_KERNEL_HOME, then self-location relative to the launcher's own binary, then aconfig.envpin, then/opt/hkm-kernel. The pin was previously checked first. It still applies wherever self-location genuinely fails — a custom prefix — but no longer overrides an install sitting next to the binary. A launcher in a system bin directory (/usr/bin) claims/opt/hkm-kernelat the self-location step, since no relative probe can reach it from there. hkm upgrade --userinstalls to~/.local/lib/hkm-kernel, matchinginstall.sh, instead of~/.local/share/hkm/kernel. The old path sits outside every self-location probe, so it could only ever be reached through a machine-wide pin — which is what created the cross-scope hijack below. An install left at the old location is detected and reported, not silently used.install.shremoves a redundant or supersededHKM_KERNEL_HOMEpin rather than repointing it. A repointed pin is still read by every launcher on the machine; no pin lets each one find its own kernel. A pin aimed at a genuine custom layout is reported and left alone. It also lists the installs already present with their versions, and printsVersion: old -> newwhen it finishes.hkm-config checkno longer pinsHKM_KERNEL_HOMEfor a self-locating layout — writing one on behalf of whichever install ran it last is how the shared pin came to exist. It removes one that has become redundant.hkm upgrade --localobeys the same scope rule (non-root installs to the user scope, creating it if absent) and installs the launcher into that scope'sbindirectory rather than always/usr/bin.- The scaffolded
kernel-autoload.phptries~/.local/lib/hkm-kernelbefore/opt/hkm-kernel, so a project run under PHP-FPM or systemd resolves the kernel its owner actually manages. The pre-1.4 user path is still tried.
Fixed
- One install silently ran the other's kernel.
~/.config/hkm/config.envis read by everyhkmon the machine, andHKM_KERNEL_HOMEwas checked before self-location — so whichever installer wrote that pin last redirected the other install too. A.deblauncher would report its own version while running a kernel out of the user's home, and upgrading either scope could not move the number on screen. hkm upgradecould not update a user install on Linux. It only ever fetched the.deband shelled out tosudo apt-get, despite the user-local tarball being the documented default since 1.3.1. BecausePATHusually resolves~/.local/binbefore/usr/bin, the command reported success and the very next invocation ran the old launcher unchanged. The user scope now installs from the tarball via its owninstall.sh, with nosudoanywhere in that path.- Upgrade decisions used the wrong version.
hkm upgradecompared the LAUNCHER's compile-time stamp against the latest release tag, then went on to replace a KERNEL somewhere else — two numbers that differ exactly when the launcher onPATHbelongs to the other scope. Versions are now read from the kernel being replaced, and the command names the other scope when it is also behind instead of reporting an unqualified "you are on the latest version". - A
--localinstall could never report what it was. It copied the checkout'scomposer.json, which carries noversionfield by design, sohkm versionread "unstamped" forever and the next upgrade had nothing to compare. Thegit describeversion is now recorded as semver build metadata (1.3.1-2-g34abb2c→1.3.1+2.g34abb2c), which Composer accepts and which semver excludes from precedence — a change of spelling, not of meaning. A release build still stamps the exact tag or nothing. - A
--systemupgrade run without root now says so once, up front, with the command that works, instead of failing one permission error at a time. The system path no longer prefixessudounconditionally, which broke on the containers and CI images where a system install is most useful andsudois frequently absent.
[1.3.1] - 2026-08-12
Supersedes 1.3.0, which was tagged from a commit that never reached master (the branch had advanced remotely between the build and the push). Tags are immutable in this repository, so 1.3.0 was left in place rather than moved — it builds, but it predates the php-io-cli pin below. Use 1.3.1.
Added
- Domain lists.
domain/subdomainnow take either a string or a LIST, at all three levels — module-wide (routeDomain/routeSubdomain), group, and route. A project serving several hosts can pin a group to "these three and not that one" instead of duplicating the group per host. The domain is still part of the route KEY, and a route grouped under a host the project does not serve is still rejected at boot. - Plugin env seeding. Enabling a plugin writes the environment it declares in
module.jsonconfig[]straight into.env, in three shapes: a documented default is written ACTIVE, a required key with no default is written active but EMPTY (so the boot failure points at a line you can see), and an optional key with no default is written COMMENTED. Previously that list was discoverable only from a boot stack trace, one variable per attempt. - A user-local install that needs no root. Linux releases now ship a portable tarball alongside the
.deb;tools/install.shunpacks kernel and launcher entirely inside$HOMEand writes nothing outside it. Published with the release assets, socurl … | shworks without a checkout. The.debremains for multi-user machines and CI images. - Scaffold support for
@pageflow/admin(Pageflow v1.1.0): a three-state theme provider ({ theme, resolvedTheme, setTheme, toggle }with a "system" default that keeps following the OS), the sidebar CSS variables the shell consumes, and a globbedui/admin/nav.tsnavigation registry. Both scaffold surfaces now wrap their tree inAppErrorBoundary.
Fixed
modules/php-io-clipinned back to its last loadable commit. The newer pointer merged two parallel implementations of unknown-option handling and kept both, declaringAbstractCommand::$unknownOptionstwice — a fatal at class load, so every command built onAbstractCommanddied, not just the test that surfaced it. Only the pointer is reverted; which implementation is canonical is php-io-cli's call.
Changed
hkm doctorreports which install is actually in use, and whether a staleHKM_KERNEL_HOMEpin in~/.config/hkm/config.envis overriding it — the failure that otherwise presents as "my changes do nothing".
[1.2.0] - 2026-08-12
Added
- Route groups.
groups[]inmodule.json/proj.jsonstates aprefix,filters,requires,nameprefix anddomainonce for every route inside; groups nest (max depth 16). Module-wideroutePrefix/routeFilters/routeRequires/routeName/routeDomain/routeSubdomaindo the same for a whole file. Expanded at BOOT into ordinary flat routes — zero request-time cost. - Domain grouping. A route may declare the host it answers on (
"domain": "africavoting.local","domain": "*.example.com", or a bare"subdomain": "api"). The domain is part of the route KEY, so one project can answerGET /differently per host. Ungrouped routes stay global; a bare subdomain answers on that label of every domain. A declared host is validated againstproj.json"domains". - Parameter types
pathandenum(a|b), and optional{id?}.pathis a traversal-safe catch-all (anyis unchanged and still has no guard);enummembers arepreg_quoted, so no regex can be injected from JSON. HEADrequests are served by theGETroute (ROUTE_HEAD_FALLBACK), with the body stripped. Opt-in405 Method Not Allowed+Allow(ROUTE_METHOD_NOT_ALLOWED) and trailing-slash policy (ROUTE_TRAILING_SLASH).BOOT_CACHE—Kernel::build()skips recompiling manifests that are already current. Under PHP-FPM the boot pipeline previously ran on every request (~2 ms, ~150 KB of writes for ~130 routes); with the cache that becomes ~0.02 ms. Off by default; clearvar/cache/manifests/on deploy.route(),signed_route()andurl()global helpers;UrlGeneratorbound in theCoreContainer. Absolute URLs follow the route's own domain group.signedroute filter (SecurityFilters) — enforces asigned_route()link declaratively, the URL counterpart tohmac.- Two derived manifests beside
route-manifest.php:route-index.php(the matcher-ready index) androute-names.php(the name indexUrlGeneratorreads). Both optional at runtime — every consumer falls back to the flat manifest.
Fixed
- Captured route parameters are percent-decoded and re-validated against their type.
/files/..%2F..%2Fetc%2Fpasswdno longer satisfies{name}, and/users/Jos%C3%A9now reaches the controller asJosérather thanJos%C3%A9. - Route patterns are anchored with the
Dmodifier — a trailing newline in the request path no longer satisfies$. - Literal path text is
preg_quoted, so/feed.xml/{id}no longer matches/feedXxml/1. - Signed-URL verification compares the query byte-for-byte instead of round-tripping it through
parse_str(), which rewrote.,and[in parameter names and made some legitimately signed URLs impossible to verify. UrlGeneratorsupports a repeated placeholder (/a/{id}/b/{id}), which previously reported the second occurrence as a missing parameter.resolveEssentialModules()no longer re-reads everymodule.jsona second time duringbuild().
Changed
- Route filter stages are resolved once per worker instead of being reconstructed on every request; filter specs, the handler split and the dependency-graph key are precompiled into the manifest.
- Dynamic routes are bucketed by their first literal path segment, so a request tests only the patterns that could match its prefix.
- These now FAIL THE BOOT instead of compiling into a route that silently never matched: a path not starting with
/, a duplicated or PCRE-invalid capture name, a handler without exactly one@, a filter alias noProvider::boot()registered, and a route domain absent fromproj.json"domains". RouteCatalog::publicPaths()takes an optional$domain— the default is unchanged (shared routes only).
Docs
docs/Sentinel-Routing-Guide.pdf— a practical, example-driven routing manual.
[1.1.0-beta.1] - 2026-08-07
First installable pre-release of the 1.1.0 line. 1.1.0-dev.2 and 1.1.0-dev.3 are withdrawn — see below.
Fixed
composer installaborted on every machine that took1.1.0-dev.2or-dev.3. The build stamps its version intocomposer.json, and1.1.0-dev.Nis not a valid Composer version: Composer'sdevsuffix takes no counter.composer installrefuses to run at all on an unparseable version, so the package unpacked and then failed to resolve its dependencies. The stamper now validates and skips rather than writing something Composer rejects, and this release is named-beta.1, which Composer accepts — so the version marker the native distribution needs is actually present again.- The stamper trimmed
vfrom both ends of the version, so any version ending invlost it —1.1.0-devbecame1.1.0-de, the one pre-release form Composer does accept.
Note on upgrading from 1.0.21
A 1.0.21 client has no pre-release filter: it strips the suffix, sees 1.1.0 > 1.0.21 and offers this automatically. That filter ships in this release, so the behaviour self-corrects after one upgrade. If you took 1.1.0-dev.2 or -dev.3 and the install reported a composer schema error, upgrading to this release repairs it.
[1.1.0-dev.3] - 2026-08-07
Re-cut of 1.1.0-dev.2 from main rather than master, so the artefacts include the PHPStan work that landed with #107. Contents are otherwise identical — see [1.1.0-dev.2] below for the full list.
Fixed
ProcessLocalLockcould not write its own lock table. The registry was typed as an anonymousobject{locks: ...}shape, whose properties PHPStan treats as read-only, so every write was an error against a type that described the shape but never named the one class satisfying it.- PHPStan is green again: the project scaffolding that binds to plugin contracts is scoped out of analysis here, since those plugins are deliberately not dependencies of the kernel. It is analysed in a project that has installed them.
[1.1.0-dev.2] - 2026-08-07
Development pre-release. Published so the new tooling can be exercised against real projects before a stable 1.1.0; hkm upgrade will NOT offer it unless you ask for it with --pre.
Added
- Plugins install from git.
hkm plugins install|uninstall|versions|outdated|lockfetch a plugin from its own repository, andhkm plugins enablenow installs a missing plugin instead of wiring it into the bootstrap by name and failing at boot with a class-not-found. Installs resolve to a TAG, never a branch. plugins.lock.jsonrecords the remote, tag, commit and kernel version for every installed plugin, so an install is reproducible and reviewable.hkm plugins lockrestores a project to exactly what it records.- Kernel compatibility gate. A plugin declares
"kernel": "^1.0"in its module.json and an incompatible pairing is refused at install time rather than surfacing at request time as a missing method on a contract. - Translation catalogue cascade.
CompileLangManifestStagecompiles every plugin'slangdeclaration intolang-manifest.phpusing the same project-first priority model as views, so plugins can finally ship messages. Groups MERGE across the cascade, so overriding one key does not require copying the rest. - English + French catalogues for every plugin with user-facing text (validation, auth emails, OAuth consent/device/admin, tenancy admin, user screens and the verification email).
hkm upgrade --localinstalls the local checkout over the installed kernel, for testing a kernel change against real projects without cutting a release.- Memory inspector wired into the CLI:
--memprints per-command allocation stats and leak backtraces,HKM_MEM_STRICTturns a leak into a non-zero exit for CI. zig build teststep (there was none) andzig build stampto write the build version into composer.json for the native distribution.
Fixed
- Releases published with no binaries attached. This repository has immutable releases enabled, which forbid attaching assets after publishing; the workflow published first and uploaded second, so every artifact it built had nowhere to go. Assets now attach while the release is a draft, which is published afterwards. (v1.1.0-dev.1 was withdrawn for this reason — it exists as a burned version number and was never installable.)
**array-repeat was removed in Zig 0.17, so the memory inspector's border drawing failed to compile under the pinned toolchain that every release is built with, while compiling fine on 0.16.- PHPStan had been unable to run since the plugin decoupling — it still analysed a
pluginspath the kernel no longer has, and died before reading a single file. - Deferred cleanup never ran. Every command exited via
std.process.exit, which skips defers, sothreaded.deinit()and the arena teardown never executed and no end-of-run reporting was possible. --helpwas broken in five commands.hkm discover --helpignored the flag and ran a full registering scan;cli/workerprinted help and exited 2;runcould not distinguish--helpfrom bad arguments;new/updatehad no handling at all.hkm --versionwrote to stderr, soVERSION=$(hkm --version)returned an empty string despite the function documenting itself as being for scripted use.- A
git describebuild sorted below its own tag. "1.0.21-138-gbdbbf34" was read as a pre-release of 1.0.21, so a build made after v1.0.21 was treated as older than it and refused plugins it could run. - A pre-release tag was treated as the latest release, which would have pushed a dev build to every stable user on
hkm upgrade. hkm upgradequeried the pre-rename repository name and only worked via GitHub's redirect.- Release-mode builds inherited the debug allocator's
never_unmapandretain_metadata, neither of which belongs in a shipped binary.
[1.0.21] - 2026-07-22
Changed
- Rebranded to HKM Kernel. The CLI banner (
hkm version) now renders the HKM block-letter art and reads "HKM Kernel · Gated Demand Architecture"; the debug/error page and CLI exception header are branded HKM (was "Sentinel"); the global-kernel autoload error prefix is now[HKM]. - README rewritten as a guided document — leads with Purpose, project goals, and an honest "done vs. cooking" status map, followed by install and usage. Adds the HKM hero banner and points at the new public guides.
Added
- Public architecture guides under
docs/guides/— a curated, reader-facing set of layer-by-layer guides (kernel, modules, plugins, security, data access, and more), with an index. The internal AI-context source stays private.
Fixed
- Security-layer docs corrected to match the code. The guides no longer describe a kernel
FirewallLayer/RateLimiterLayer(which do not exist) — the kernel ships onlyCsrfTokenLayer; authentication comes from the Auth plugin (JwtAuthLayer/PersonalAccessTokenLayer), and rate-limiting / IP-filtering are SecurityFilters route filters (throttle/shield).
Merged
- Integrates edge features, CLI commands, and security updates from #36.
[1.0.20] - 2026-07-22
Added
hkm modulecommand for managing first-party kernel packages (themodules/submodules: bind-it, php-io-cli, let-migrate, http) — inspect, and update the pinned package set from one CLI entry point.alfacode-team/httpas a first-party package dependency (^1.0; dev-master inside the monorepo,v1.0.0for stable releases). The http submodule is pinned at its latest master.
Changed
- Pageflow stages refactored and consolidated — the SPA-bridge pipeline stages are simplified into fewer, clearer units.
- Open-source readiness — license, composer package metadata, and a
.env.exampleadded; issue/PR templates, CODEOWNERS, and required-reviewer configuration formain.
Fixed
MigrateListCommandparent wiring repaired and theOutboxWriterport contract corrected.- CI analysis gates — PHPStan level-5 config + baseline made a blocking gate (optional Swoole/OpenSwoole coroutine calls ignored); CodeQL, Semgrep, and
composer auditwired in.
[1.0.19] - 2026-07-21
Added
- Edge reuses & updates an existing nginx SNI stream splitter. When both nginx and Apache run and the host already declares a
map $ssl_preread_server_namesplitter (located vianginx -T), Edge no longer writes a second, conflictingstream {}block. It emits only the internal backend vhosts AND merges the platform's public domains into the existingmapin place — inside a marked, idempotent sub-block, leaving hand-written entries untouched and never duplicating a domain. NewStreamConfigWriter;EDGE_REUSE_STREAM(default on),EDGE_STREAM_BACKEND(defaultnginx_backend). - Force a single-server strategy with no fallback.
edge:apply --nginx-only/--apache-only(andedge:statuspreview) bypass host auto-detection;EDGE_FORCE_STRATEGYsets a deploy default. - Behind-SNI-router awareness. The nginx-only vhost now listens on the internal backend port (e.g. 444) instead of
:443when the host runs an SNI stream router that already owns:443— auto-detected, or forced viaEDGE_BEHIND_SNI_ROUTER/ pinned withEDGE_NGINX_SSL_PORT. Prevents nginx failing to start with "Address already in use". The:80→HTTPSredirect still targets the public port. - Configurable CORS, TLS pinning, method guard and deny lists for generated vhosts:
EDGE_CORS(off/allowlist/wildcard — wildcard opt-in, allowlist echoed via a$http_originmap),EDGE_SSL_PROTOCOLS/EDGE_SSL_CIPHERS/EDGE_SSL_STAPLING,EDGE_ALLOWED_METHODS,EDGE_DENY_DIRS. plugins/Edge/USAGE.md— full command + environment reference.
Fixed
- CLI parser rejects unknown/misspelled options instead of silently ignoring them (e.g. a typo'd
--tsl=bothno longer produces the wrong config with a zero exit). In a script/CI it exits non-zero with a Damerau-Levenshtein "did you mean?" suggestion; on an interactive terminal it auto-applies the obvious correction with a visible notice. Launcher-injected globals stay tolerated. --tls=bothemits the port-80 redirect block (with ACME/Let's-Encrypt HTTP-01 passthrough before the redirect) alongside the:443block.- Security/CORS headers are no longer dropped inside location blocks. Header emission is centralised so every location that declares an
add_headerre-emits the full set — headers now land on real app/API responses, not just static paths. - DEVELOPMENT profile emits short-lived HSTS (
max-age=300, noincludeSubDomains, neverpreload); production keeps long-form HSTS withpreloadopt-in. /nginx-statusis dev-only — removed from production, where the SNI stream proxy makesallow 127.0.0.1world-open.- Production denies source maps.
.mapis added to the deny list (not merely dropped from the static-asset rule, whichlocation /would still serve viatry_files); development keeps serving maps for debugging. - Deny rules are ordered before the static-asset regex and directories use
^~prefix locations, so a denied path (e.g.vendor/composer/installed.json) can no longer be served through a whitelisted extension. - Per-site access/error logs are emitted in production (previously dev-only, silently falling back to the global log).
- IPv4/IPv6 listeners are consistent —
listen [::]:443 sslnow mirrors the:80block. - Production static-asset regex drops
map/json, and explicit TLS protocol/cipher pinning + session settings are emitted for every TLS listener;error_log … debugis opt-in (EDGE_NGINX_DEBUG_LOG), defaultwarn.
[1.0.18] - 2026-07-20
Added
hkm plugins recover [proj]— rebuild a lost/driftedvar/plugin-assets.json(aliasesrebuild/reindex). Reconstructs the plugin-assets manifest from ground truth: for every plugin ENABLED in the project bootstrap it records the published assets that actually exist on disk, healing a manifest that was deleted, truncated, or fell out of sync. It copies nothing (usehkm plugins updateto re-publish physically-missing assets) and preserves any migrationbatchalready recorded, since batch numbers cannot be derived from the filesystem.--dry-run(-n) previews the rebuild; unresolvable enabled plugins are reported and skipped. Implemented natively in Zig.
Changed
hkm discovernow restores each project's gitignored runtime folders. After locating a project it ensuresvar/logs,var/cache/manifests,var/tmp,var/locks,var/sessions,var/queueanduserdata/storageexist — a freshly cloned or moved project is usually missing them, which would otherwise fail at boot.--dry-runreports how many are missing without creating them; a real run creates them (idempotent — an already-complete project reports nothing).
[1.0.17] - 2026-07-20
Added
hkm discover [root]— find projects on disk and register them (aliashkm scan). Walks a directory tree, finds every folder holding aproj.json, and upserts each into the kernel registry (projects.json) with its name, version, ABSOLUTE path, and domains read straight from that project's ownproj.json. The bulk counterpart tohkm update <path>(one project): use it to adopt projects scaffolded with--no-register, cloned from git, or moved on disk. Reports each match asnew/moved/up-to-dateagainst the current registry;--dry-run(-n) previews without writing;--depth=N(default 4) caps descent. Skipsvendor,node_modules,var,.git,dist,zig-out,.zig-cacheand dotfolders, and stops descending once a folder is identified as a project root. Implemented natively in Zig (no PHP required), reusing the same registry resolver asnew/update/list.TENANCY_CONTROL_PLANE— serve a super-admin host with Tenancy enabled.Tenancy::boot()previously registeredTenantContextStageunconditionally, which made a central control-plane deployment unservable: every request either 500'd (route did not load Tenancy, soTenantIdentifierwas unbound and the stage threw) or 404'd (loaded, but no tenant resolves on an admin host). SetTENANCY_CONTROL_PLANE=trueand theafter.loadhook is skipped, soDatabasePortstays on central. Everything else the plugin publishes — the registry, connection resolver, admin/membership/invitation services and thetenant:*provisioning commands — is unaffected. Defaults to false, so a tenant-serving deployment cannot lose tenant isolation by omission.
Changed
tenant:migrateis scoped to the calling project by default. It now migrates only the tenants recorded in that project'svar/tenants.json, instead of every active tenant in the registry. Several projects may share one central registry, and a sibling's tenant is encrypted with that project'sAPP_KEY— so it surfaced on every run as a spurious "Could not decrypt payload (invalid key or tampered data)" failure. Pass--allfor the previous fleet-wide behaviour; a project with novar/tenants.jsonstill migrates every active tenant, so single-project deployments are unchanged. Skipped tenants are reported rather than silently dropped.
[1.0.16] - 2026-07-18
Added
- TLS modes for
edge:apply.--tls=ssl|none|both(plus--no-sslas an alias fornone) picks how each vhost terminates TLS: HTTPS only, plain HTTP on:80, or:80that 301-redirects to:443.--ssl-cert/--ssl-keyoverride the certificate paths per run. Default comes fromEDGE_TLS_MODE(ssl), so existing behaviour is unchanged. - Cache profiles derived from
APP_ENV.local/development→ DEVELOPMENT (nothing is browser-cached: HTML, the front controller and every asset areno-store, so a rebuild is picked up without clearing the browser cache);production→ PRODUCTION (dynamic responses stay uncached, fingerprinted assets getexpires 1y+public, immutable). Anything unrecognised falls back to DEVELOPMENT — never production. The profile is written into the file as a# HKM Edge cache profile: …banner. - Environment flags on
edge:apply/edge:service.--local(alias--dev),--development/-d, and--productionsetAPP_ENVfor the run. They are command-scoped, not launcher-global. - OpenSwoole runtime. A project can now set
"edge": { "runtime": "openswoole" }in itsproj.jsonand Edge renders nginx as a reverse proxy instead of a PHP-FPM vhost: a dedicatedupstream(least_conn,max_fails/fail_timeout, keepalive pool, multiple workers via"ports": [9501, 9502]), a$connection_upgrademap, a separate/wsWebSocket location with long timeouts, an optional/healthendpoint, and Cloudflare'sCF-Connecting-IPforwarded upstream. Static assets are still served straight off disk. edge:servicecommand. Generates the systemd unit (or supervisor program block with--supervisor) that keeps a project's OpenSwoole server alive.--write[=dir]writes it out; PHP-FPM projects are skipped since php-fpm already supervises those workers. PHP binary, entry script, port and worker count are configurable.- Response compression.
EDGE_COMPRESSION=auto(default) prefers Brotli when the server actually supports it and falls back to gzip — resolved per server from nginx'sngx_brotlibuild and Apache's loadedmod_brotli, so an Apache-only host no longer inherits nginx's answer. Brotli mode also emits a gzip block for clients withoutbr. - HSTS. Emitted only for the TLS modes (
ssl/both), never for plain HTTP, with configurablemax-age,includeSubDomainsandpreload. - Optional http-context prelude (
EDGE_HTTP_PRELUDE=1, off by default): thelog_format,limit_req_zone/limit_conn_zoneand Cloudflareset_real_ip_fromranges that the vhost directives depend on. Off by default because re-declaring a zone that already exists innginx.confis a duplicate-definition error.
Changed
- Cache mode is no longer inferred from the kernel mode. Nothing in vhost generation reads
HKM_DEVany more — the cache profile comes fromAPP_ENValone, so choosing which kernel to run against (hkm … --dev) and choosing how assets are cached are independent.dev_vhostdefaults to "follow the cache profile"; setEDGE_DEV_VHOSTto force it either way. - All generated paths derive from the project root. The vhost records its provenance (
# HKM Edge project root: …/public root: …/swoole root: …) and the OpenSwoole entry script now defaults toapp/swoole/index.phprelative to the project root — matching whathkm run <project> --swooleactually executes (it previously defaulted to abin/server.phpthat no HKM project has). - Security headers are repeated inside
locationblocks that set their ownadd_header. nginx drops every inheritedadd_headeras soon as a location adds one, which silently strippednosniff/X-Frame-Options/Referrer-Policy/ HSTS from static assets and the front controller. - Static assets resolve only under the public root; a miss is a hard
404and is never forwarded to the application.
Fixed
- Generated nginx failed
nginx -t. The PHP-FPM vhost nestedlocation = /index.phpinsidelocation ~ \.php$, which nginx rejects ("location … is outside location …"), soedge:applycould never pass its own config test. Replaced with the flat front-controller pair (location = /index.phpfor FastCGI,location ~ \.php$ { return 404; }for everything else). .well-knownwas denied, breaking ACME/Let's Encrypt. A blanketlocation ~ /\.shadowed the later negative-lookahead rule, so HTTP-01 challenges 404'd and certificates could not be issued or renewed.- Apache vhosts failed
apachectl configtest.ServerTokensis a server-level directive and is rejected inside<VirtualHost>; it is no longer emitted (ServerSignature/LimitRequestBodyare valid there and remain). - Apache no longer emits directives for modules that are not loaded. The loaded module set is probed from
apachectl -M, and HSTS (mod_headers) and compression (mod_filter+mod_brotli/mod_deflate) degrade to whatever the host supports instead of failing the config test.
[1.0.15] - 2026-07-17
Fixed
- Edge now serves local (
.local/.test) domains in dev. TheEDGE_LOCAL_IN_SERVERflag was defined but never read, so a project whose domains are all local rendered an empty vhost (header comment only). Dev mode (hkm … --dev, which exportsHKM_DEV=1) now folds local domains into the generated nginx/Apache vhost automatically —hkm cli -p <project> --dev edge:applyproduces a working local site with no extra flag. A production (non--dev) run still keeps local domains out of the server config (they resolve through DNS);EDGE_LOCAL_IN_SERVER=trueforces local-in-server outside dev. Local domains continue to sync to/etc/hostsin both cases.
[1.0.13] - 2026-07-17
Added
- Edge plugin (
Plugins\Edge, solvesedge.routing). Generates this host's web-server front config from the platform's registered domains and adapts to what is actually running: nginx SNI stream splitter (raw-TLSssl_prereadrouting to nginx:444/ Apache:8443) when both run, else a plain nginx-only or Apache-only vhost. Project-aware: one vhost per project (docroot<project>/app/public), served via PHP-FPM or OpenSwoole (configurable per project inproj.json"edge"). The run-env the launcher exports (APP_ENV,HKM_KERNEL_HOME,HKM_DEV_HOME,HKM_USERDATA_DIR,PSP_GLOBAL_AUTOLOAD,PSP_PROJECTS_DIR) is passed through into each vhost so FPM workers boot the correct kernel. The PHP-FPM socket is auto-resolved to the CLI PHP version (multi-PHP hosts). Local (.local/.test) domains are excluded from the server config and synced to/etc/hosts(dev only,--devrequired; never duplicates an existing entry). CLI:edge:status,edge:apply,edge:hosts(default-scoped to the current project,--allfor the whole registry). Seeplugins/Edge/README.md. PSP_PROJECTS_DIRis now exported byhkm run/hkm cli— resolved to the same project registry the launcher uses (HKM_USERDATA_DIR→PSP_PROJECTS_DIR→HKM_KERNEL_HOME/projects), so the kernel and plugins read one registry without re-deriving it.--devalso exportsHKM_DEV=1as an explicit marker.
Fixed
- Frontend build output path. Vite wrote hashed assets + manifests to
<project>/public_html/build/, but the docroot andViteManifestboth use<project>/app/public— so built assets landed outside the web root and were never found. The frontend template now builds toapp/public/build/. hkm … --devundersudo. The launcher read its config from root's home (/root/.config/hkm/config.env) and lostHKM_DEV_HOME; it now honoursSUDO_USERand reads the invoking user'sconfig.env, sosudo hkm … --devresolves the dev kernel.
[1.0.12] - 2026-07-16
Changed
- Bundle dependencies pinned to the PHP 8.4 series (not
>= 8.4). The Debian.debDepends/Recommendsnow use the versionedphp8.4-*packages instead of the unversionedphp-cli (>= 8.4)meta-package — which would also let PHP 8.5+ satisfy the dependency. The docstring and WindowsINSTALL.txtwording changed from "PHP >= 8.4" to "PHP 8.4". The runtime is now locked to the 8.4 line, not "8.4 or newer".
[1.0.11] - 2026-07-16
Added
- proj.json
"essentials"— project-declared global modules.Kernel::withEssentialModules()now accepts module DOMAINS as well as provider class-strings; domains resolve to providers atbuild()and an unknown domain fails the boot.EntryHelpers::projectEssentials()reads the new key and the scaffold bootstrap appends it — which plugins are global is now a per-project deployment decision, not a code edit. Session-cookie apps declareauth.identity+user.managementhere soSessionAuthStageresolves logins on every page. - Boot-time
requires[]validation.CompileServiceManifestStagenow FAILS the boot on any module.jsonrequiresentry that matches no registered module'ssolves(previously dropped silently — a typo or a plugin missing fromwithModulessurfaced only as an unbound-contract error at request time). Port/contract class names no longer belong inrequires[]. - let-migrate tenant resolver support classes (ported to scaffolded projects):
DatabaseTenantResolver+CentralTenantRegistry+Dsnread the tenant fleet from the centraltenantstable —tenant:status/tenant:migrateand request routing share ONE registry. - Display identity in the
pageflow_authprop.PageflowAuth::resolve()now shares the non-sensitive display fields off theIdentity—username,fullName,email,avatarUrl— so the browseruseAuth()renders the real name/email/avatar instead of the raw user id. ThePageflowAuthTS type + guest default gain the fields.
Changed
- STRICT tenant routing — no unscoped passthrough (BREAKING).
TenantContextStagenow 404s any request that resolves no tenant (cookie hint first — principal-bound, guests included — then theTENANCY_MODEidentifier). Every served host must be assigned to a tenant (tenant:host:add); control-plane code pins the central connection explicitly. The remembered-tenant cookie's user binding is now actually enforced (no cross-user replay). - Essential modules load their transitive
requires[]. Essential domains are seeded into every request's dependency graph (previously an essential registered alone and its dependencies were silently missing). Each module still registers exactly once per request. - Tenancy module
requirestrimmed to["database.management"]— the always-on stage path. Its selection/admin/invitation/host routes now carryauth.identity/user.management/audit.trailas route-levelrequires[], cutting the every-request graph from 13 modules (~135µs) to 2 (~15µs) in a Tenancy-essential project. - One
Provider::requires()convention. All plugin providers now mirror module.json domains (the single source of truth the kernel reads); theModuleContractdocblock documents the convention. - Per-worker loading caches:
LoadStagememoizes resolved dependency graphs;OnDemandLoadercaches provider instances (providers are stateless by contract). - Auth-required browser navigations redirect to login.
RequireAuthStagenow sends a full page load OR a Pageflow SPA navigation (detected via theX-Pageflowheader) to/login?redirectTo=…instead of a raw JSON 401; genuine API/fetch callers (JSON expected, noX-Pageflow) still get the machine-readable 401. The original path rides along asredirectTo. - MySQL sessions pinned to UTC.
MySQLConfigurationsetstime_zone = '+00:00'(viaMYSQL_ATTR_INIT_COMMAND+initStatements, so it survives auto-reconnect) —NOW()/CURRENT_TIMESTAMPandTIMESTAMPread-back are now unambiguously UTC, matching the PHP-side UTC clock.
Fixed
- Settings plugin required the non-existent
database.querydomain (nowdatabase.management) — the Database module was silently absent from its graph; caught by the new boot-time validation. - Scaffold template comments taught wrong
solvesvalues (database.query,crypto,i18n,commands).
Security
- SiteSEO JSON-LD stored XSS.
Schema.phpnow encodes structured data withJSON_HEX_TAGso a</script>in user-controlled content can't break out of the<script type="application/ld+json">block.
[1.0.10] - 2026-07-14
Added
- Display identity on the kernel
Identity— new best-effort fieldsusername,email,fullName,avatarUrl.AuthServicefills username/email from the central user store at issuance when the caller doesn't supply them; they ride as OIDC claims (preferred_username,email,name) on JWTs — rebuilt statelessly byJwtAuthLayer— and as session keys (auth.username/email/name/avatar) for session logins, remember-me resurrection andGET /auth/me.fullNamecomes from the TENANTuser_profilestable, so only tenant-scoped credentials carry it. - Post-login "previous page" redirect. The Session plugin's
StartSessionStagenow records the last eligible page view (GET + 2xx, HTML navigation or Pageflow page object; auth/OAuth/API/asset paths exempt — extend with the newSESSION_PREVIOUS_EXEMPTenv) underStartSessionStage::PREVIOUS_URL. On successfulPOST /auth/loginthe redirect target is: an explicitredirectToon the request (query/body) → the recorded page (pulled one-time) →/. Browser POSTs get a 302; AJAX callers getredirectToin the JSON payload. Every candidate passes an open-redirect guard (relative paths only). SocialAuth's web callback honours the same recorded page beforeSOCIAL_AUTH_SUCCESS_REDIRECT. - User: published
TenantProfileReaderContract— tenantuser_profilesdisplay reads (fullName(userId, tenantId)), implemented byTenantProfileProvisionerin pinned-repository or per-call resolver mode; best-effort, never throws.UserDTOgainsfullName,avatarUrlandpermissions;UserProfile::fullName()composes first + last. - Base controllers (
ApiController,ViewController) now composeInteractsWithSession, as documented —sessionGet/put/pull,flash,csrfTokenand friends are available on every controller.
Changed
- Tenant selection decomposed (tenancy ≠ authentication).
MembershipServiceis control plane only:selectTenant()re-verifies the seat, audits, and returns the verifiedTenantSummary— it no longer mints tokens and lost its Auth dependency.TenantControlleris the composition point: it mints thetnttoken viaAuthServiceContract(withrolesand thenameclaim viaTenantProfileReaderContract) and builds theTenantSelectionresponse. Response shape is unchanged. - Tenant-scoped auth data now rides the per-request
DatabasePort. Auth's personal-access-token + device-session repositories, Audit'saudit_log, OAuth2's server tables and SocialAuth'ssocial_identitiesresolve the request connection (tenant-rebound byTenantContextStage) instead of pinning the central connection; their migrations moved to each plugin'sdatabase/tenant-template/. User, Tenancy control plane and Auth refresh tokens stay pinned to central. UserServiceContract::find()gainsbool $isAuth = false— issuance-time lookups by Auth skip the self-or-permission check (the request Identity is still guest during login).
Fixed
- Login hang (30s
max_execution_time) — a container resolution cycleAuthService → UserService → MembershipService → AuthServicerecursed forever. Fixed twice over: the Auth provider resolves the user store through a lazy closure, and the selection refactor removes the cycle's closing edge. - Remember-me resurrection fataled when the user had no tenant (nullable
tenantIdpassed tostartSession()). UserDTOdeclared a readonly property with a default value (PHP fatal on every load);permissionsis now a promoted constructor parameter.
[1.0.9] - 2026-07-13
Added
- Audit plugin (
audit.trail) — the single owner of the shared centralaudit_logtable. User, Feedback and Tenancy no longer write the table themselves; they requireaudit.trailand record through the publishedAuditServiceContract(actor/tenant auto-filled, JSON log line + best-effort persistence — an audit write never breaks the action it records).AuditReaderContractadds keyset-paginated queries + retention purge. - Auth: device sessions, mobile auth and OTP password reset. New routes:
GET/DELETE /auth/sessions[/{id}]+POST /auth/logout-other-devices(device-session listing/revocation backed by the new tenantauth_sessionstable),POST /auth/mobile/{login,register,logout}(token-first mobile flow), andPOST /auth/password/{forgot,verify-otp,reset}(OTP reset via the CachePort-backed broker, mail optional). New config keys:AUTH_SESSION_TTL/REFRESH,AUTH_FINGERPRINT_HEADER,AUTH_MOBILE_ACCESS_TTL/AUTOVERIFY,AUTH_OTP_TTL; namespacedauth::views. - SocialAuth: end-to-end social sign-in.
GET /auth/social/{driver}→ provider redirect,/callbackmaps the profile onto a central user (linked identity → email match → create) opening a platform session or returning a JWT+refresh pair (?mode=token);POST /auth/social/{driver}/tokenverifies native-SDK tokens (Google access/id token, Apple identity token vs JWKS) for mobile. Links persist in centralsocial_identities. - Authorization: policy seeding + enforcement surfaces.
SeedPolicyCommand(CSV policy seed viaconfig/policy.seed.csv), HTTP pipeline stages, and globally autoloadedEngine/functions.phphelpers. Auth now requiresauthorization.policyand resolves roles through the newRoleResolver. - Tenancy:
var/tenants.jsondefault tenant for the CLI.tenant:createrecords the provisioned tenant (last created = default) sotenant:delete/tenant:host:addwork without--tenant/--slug; newtenant:rememberbackfills pre-existing tenants (--slug,--tenant,--all, or interactive). Hints are re-validated against the registry and stale entries self-drop. hkm plugins update— full analyse + sync. Update now compares every publishable surface (config, database migrations/tenant-template/seeders/ factories, resources, ui) byte-for-byte against the project: publishes NEW files, refreshes content-drifted files (plugin wins), re-syncs a drifted plugin ui mirror (+ glue regen), and runs migrations when a central OR tenant migration changed. Dry-run previews the full analysis.- Kernel: request-scoped
client.ipbinding.OnDemandLoader::load()exposes the client IP in the request container so request-scoped services (e.g. the audit trail) can attribute an action's origin without threading it through controllers.
Changed
- Tenant-membership is now part of the user fetch.
UserServiceContractid-based operations take acheckMembershipflag;ModelUserProviderfetches with membership enforced, so on a tenant-scoped request a user without an active seat is indistinguishable from a non-existent user. - Tenant-scoped tables moved to
database/tenant-template/. Auth (personal access tokens, refresh tokens, auth_sessions), Authorization (casbin_rule) and OAuth2 (oauth_* tables) migrations are now provisioned per tenant database instead of the central DB. - User outbox refactored into GDA layers (
OutboxRelayService+OutboxRepositoryreplace the Infrastructure outbox writer/relay pair) and the email-verification flow gained a full page path (VerifyEmailResult,account/verifyview,VerifyEmail.tsxsite page). hkmtenant migrate passes are now independent. A plugin shipping only tenant-template migrations still gets its tenant pass, andtenant:migrateis triggered by shipped tenant-template files (registry-driven) instead of atenantskey inconfig/let-migrate.php.modules/let-migratebumped: safe transaction handling around implicit DDL commits; unsigned integers, CHECK constraints and table options in the schema builder.
Fixed
AuthUserProxy::withSecurity()/withAccessToken()droppedjoinedAt, shifting constructor arguments and throwing aTypeErroron every session / JWT guard resolution.
[1.0.8] - 2026-07-11
Fixed
tenant:migratecommand collision. The kernel's generic LetMigratetenant:*commands (registered via theCommandsplugin's migration factory) were overwriting the Tenancy plugin's registry-basedtenant:migrateunder the CLI's last-wins registration, so the wrong command ran and demanded atenantsresolver config the project does not use. The generic factory now yields to any command a plugin already claimed via the newCliPipeline::hasQueued()— so the Tenancy command wins when Tenancy is enabled, and the kernel commands still register normally when it is not.
Changed
- Tenancy
tenant:migratetemplate path is now project-relative. The default template migrations path resolves under the active project root (projects/<name>/database/tenant-template) viaPaths::project(). TheTENANCY_TEMPLATE_PATHoverride is honoured as-is when absolute, or resolved under the project root when relative. The previous plugin-directory fallback was removed.
Tenant migrations against MySQL / SQL Server also required a companion fix in the
let-migratemodule (DDL implicitly commits, closing the open transaction) — released separately in that package.
1.0.7 - 2026-07-11
Added
hkm plugins upgrade [project]— split-safe project upgrade. A new command (aliases:reconcile,migrate) that upgrades a project after the plugins it depends on have changed, in three idempotent phases: (1) dependency healing — auto-enable the provider of any domain a plugin newlyrequires; (2) assets + migrations — publish each enabled plugin's NEW assets and run its pending migrations (delegates toupdate; already-applied migrations skip by name); (3) split reconciliation — when a plugin SPLITS and a migration file moves to a new plugin (e.g. Feedback extracted from User), the migration's ownership invar/plugin-assets.jsontransfers to the new owner WITHOUT touching the database. The sharedlet_migrationsrow is keyed by filename and stays applied, so the table AND its data are preserved — and a laterdisableof the OLD plugin can no longer roll back (drop) a table the NEW plugin owns.
Fixed
- Plugin asset publishing now includes
database/tenant-templatemigrations (previously never copied into projects), so tenant-scoped tables ship on enable/update and tenant-template splits reconcile correctly.
1.0.6 - 2026-07-09
Added
- Project route policy — disable plugin routes without forking. A plugin still owns and declares its routes, but the deploying project is now the final authority:
proj.jsongains"routePolicy": { "disable": [...] }(wired via the newKernel::withRoutePolicy()+EntryHelpers::projectRoutePolicy()). Each spec is either"METHOD /path"(one plugin route) or a module domain like"oauth.server"(every route that module solves). Applied at boot to plugin routes BEFORE project routes compile, so a project can veto a plugin route and re-declare its own on the freed key. A spec matching nothing fails the build with a descriptive error — typos never pass silently. hkm <command> --dev— pin a single invocation to the DEVELOPMENT kernel instead of the installed stable copy. Resolves via the newHKM_DEV_HOMEconfig key (set once withhkm-config set-dev-home <checkout>, validated), or by walking up from a repo-built launcher to the nearestcomposer.json. ExportsHKM_KERNEL_HOME+HKM_CLI_PATHfor the child process only — nothing persistent changes, and the flag is stripped before downstream arg parsing. Fails loudly when no dev kernel is found (never silently falls back to stable).hkm-config set-dev-home <path>subcommand +HKM_DEV_HOMEinhkm help; contributor "Dev environment" guide intools/README.md.- New-project templates updated: scaffolded
proj.jsonships aroutePolicy.disablestub, the bootstrap wireswithRoutePolicy(...), and the project README documents the three route verbs (add / override / disable).
1.0.5 - 2026-07-08
Added
- New projects also ship a full Apache virtual-host sample (
app/apache.conf.example) alongside the nginx one — DocumentRoot pinned toapp/public, dotfiles denied, onlyindex.phpexecutable, security headers.
1.0.4 - 2026-07-08
Security
- Scaffolded
.env(which holds the generatedAPP_KEY) is now writtenchmod 600(owner-only);~/.config/hkm/config.envtoo. - Debug output is force-disabled when
APP_ENV=production, even ifAPP_DEBUGwas lefttruein a mis-set.env— production never leaks internals. - New projects ship an
app/public/.htaccess(Apache) and anapp/nginx.conf.example(nginx): deny dotfiles (.env,.git), disable directory listing, dropX-Powered-By, add baseline security headers, and route through the single front controller with docroot pinned toapp/public.
Added
HKM_USERDATA_DIR— relocate the persistent registry (projects.json+platform.json) outside the kernel install so a kernel update never overwrites it. Honoured by thehkmCLI (registry) and the PHPDomainResolver; falls back to<kernel>/projectswhen unset.- The
.debmarksprojects.json+platform.jsonas dpkg conffiles, so an in-place upgrade preserves a user's registrations even without relocating.
Changed
hkm-confignow sets up the FULL required environment in one run: it resolves + pinsHKM_KERNEL_HOME, and creates a persistent userdata dir (XDG_DATA_HOME/hkmor~/.local/share/hkm), migrates any existing registry into it, and pinsHKM_USERDATA_DIR.
1.0.3 - 2026-07-08
Changed
- Scaffolding templates moved from
tools/src/templates/to a top-leveltemplates/directory so they ship inside the kernel payload.tools/is not bundled, which previously brokehkm new/hkm ui initon packaged installs.
Fixed
hkm run/hkm run --pick/ the registry now self-locate the installed kernel (/opt/hkm-kernel, or the dir relative to the launcher) instead of only using env vars or a dev tree found by walking up from the CWD. Fixes "Kernel registry not found" on packaged installs and stops an installed launcher from silently using a development kernel.hkm-configis now a real config checker: it resolves the kernel, verifiesvendor/autoload.php+ the projects registry, and writes/repairsHKM_KERNEL_HOMEin~/.config/hkm/config.env.- The launcher now loads
~/.config/hkm/config.envat startup (real environment variables still win), sohkm-configsettings actually apply.
1.0.2 - 2026-07-07
Changed
hkm upgradenow performs the update automatically for packaged installs: it detects the OS, downloads the matching release artifact (.deb/.tar.gz/.zip), and installs it (Linux:apt; macOS: extract +install.sh; Windows: downloads and points atinstall.bat). Previously it only printed manual instructions.
1.0.1 - 2026-07-07
Changed
- The Sentinel ASCII banner + version now shows as the header of
hkmandhkm help(previously only onhkm version).
1.0.0 - 2026-07-07
First native release — the framework ships as OS-native bundles for Linux, macOS, and Windows, built and published automatically from a v* tag.
Added
- Native
hkmlauncher (Zig) for Linux, macOS (universal arm64 + x86_64), and Windows, cross-compiled from a single Linux host. hkm doctor— verifies PHP ≥ 8.4.1, required extensions (json, mbstring, ctype, tokenizer, filter, pdo, openssl, curl, fileinfo), a PDO driver, and reports the resolved kernel path.hkm version/--version/-vwith a Sentinel ASCII banner.hkm upgrade [--check]— checks the repo'sv*tags for a newer release and updates a git-checkout install (packaged installs get reinstall guidance).- Self-locating kernel — the launcher finds the kernel relative to its own executable, so
.app/zip/portable installs need no environment variable. tools/bundle.sh— assembles the.deb, macOS.apptarball, and Windows.zip; ships source (src,plugins,projects,modules) and resolves Composer dependencies on the target at install time (vendor/not bundled).MODULES=gitmode fetches path-repo modules from pinned commits instead.- CI (
.github/workflows/ci.yml) — PHPUnit + Zig cross-builds on push/PR tomain. - Release (
.github/workflows/release.yml) — test-gated; builds all three OS bundles on Linux and publishes the GitHub release automatically. - Self-hosted Zig toolchain fetch (
tools/ci/setup-zig.sh) so CI is immune to upstream purging the pinned dev build. - Kernel unit test suite (
tests/Unit/Kernel/) — Identity, SecurityVerdict, FrameworkException/ValidationException, DependencyGraphCalculator, Request, Response (427 tests total, all green).
Changed
- Minimum PHP is now 8.4.1 (aligned with the actual Symfony 8 dependency); previously advertised as 8.2+.
- The kernel is packaged under
/opt/hkm-kernelwithhkmonPATH.
Fixed
- PSR-4 autoloading violations: split
SeedCommands.phpinto one class per file and excluded path-loadeddatabase/{seeders,migrations,factories}from the classmap. - Registered the Cookie and Pageflow support helpers in Composer's
filesautoload so their global functions resolve. - Added a tracked
phpunit.xml.distso CI finds the test configuration (phpunit.xmlis gitignored). - Windows cross-compilation: guarded POSIX-only raw-mode TTY code.