NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
Packagist · #2676 most downloaded on Packagist
Hydra's mechanical quality gates, packaged so any repo can run them against its own diff. The exit code is the failure COUNT.
Last release 27 days ago
10 Sep 2026
Ships on a steady schedule
a new release about every 8 days
Most releases are documented
notes for 22 of 30 stable releases
Nothing withdrawn
no release was ever pulled
2 months old
30 releases · first in 2026
One column per month.
Publishes the register slug resolver contract so leaf apps can load it, adds the exclusion marker for gate-115, and lets a repo opt into gate-46 block
Publishes the register slug resolver contract so leaf apps can load it, adds
the exclusion marker for gate-115, and lets a repo opt into gate-46 blocking
on @e2e anchors.
WHY THIS TAG EXISTS. The contract merged after v1.17.0 was cut, so at
^1.17.0 a consuming app resolves ObjectServiceInterface and does not resolve
RegisterSlugResolverInterface. Four apps adopting the resolver were each
carrying two workarounds for that one missing tag, and a leaf app could not
write a test double against a contract it cannot load.
WHAT A CONSUMING REPO WILL SEE THAT IT DID NOT BEFORE. Nothing new in CI:
callers already resolve the runner at @main, so gates 111 to 115 have been
running for a day. What changes is local: composer gates in an app now
measures the same set CI does, rather than three to five gates fewer.
gate-115 reports 20 findings across 6 repos, split ten stale names and ten
migrated register slugs. The slug half CANNOT be fixed by swapping the
literal. Both slugs are live across the fleet depending on whether an
instance has run its repair step, so the answer is a probe. That is what
this contract is for.
Five new gates and two behaviour changes. The reason this tag matters is that it closes a gap nobody could see: gates 111 to 113 merged after v1.16.1
Five new gates and two behaviour changes. The reason this tag matters is that it closes a gap nobody could see: gates 111 to 113 merged after v1.16.1 was cut on 2026-09-07, so since then every app's vendored copy has declared 90 gates while CI ran 93, and the three missing ones included both report-only gates. Nothing in a consuming repo could close that. composer update would have reported success and changed nothing, because the constraints were already satisfied and the versions were simply not released.
v1.16.1 declared 90 gates. v1.17.0 declares 95.
| gate | name | posture |
|---|---|---|
| 111 | flow-node-taxonomy | blocking |
| 112 | newman-reach | report-only, HYDRA_GATE_NEWMAN_REACH_BLOCKING=1 |
| 113 | exclusion-evidence | report-only, HYDRA_GATE_EXCLUSION_EVIDENCE_BLOCKING=1 |
| 114 | header-action-budget | report-only, HYDRA_GATE_HEADER_ACTION_BUDGET_BLOCKING=1 |
| 115 | stale-fleet-app-id | report-only, HYDRA_GATE_STALE_FLEET_APP_ID_BLOCKING=1 |
Two behaviour changes to gates you already have:
@e2e anchors (report-only) and opens appinfo/ and scripts/ (blocking).FAIL verdict line. gate-112 and gate-113 used to print FAIL from their helper while the runner printed WARNING for the same gate 45 lines later. Two readers counted a failure that had not happened. There is now one verdict line per gate, enforced by the acceptance matrix.Measured 2026-09-10 against a clean development clone of each of the 21 core apps. Repos not listed are clean for that gate.
gate-115 stale-fleet-app-id — 20 findings in 6 repos
| repo | stale names | migrated slugs | total |
|---|---|---|---|
| pipelinq | 2 | 5 | 7 |
| learniq | 4 | 0 | 4 |
| openregister | 1 | 2 | 3 |
| buildiq | 1 | 2 | 3 |
| launchpad | 2 | 0 | 2 |
| hermiq | 0 | 1 | 1 |
gate-112 newman-reach — 28 findings in 9 repos: openregister 17, pipelinq 3, opencatalogi 2, and one each in decidiq, dossiq, hermiq, learniq, portaliq, stackiq.
gate-113 exclusion-evidence — 0 unresolved anywhere. It still prints its worklist on every run, so you will see a line like 468 exclusion(s) — 156 name a test that is here, 254 claim a tier without naming a member of it. That is a census, not a finding.
gate-114 header-action-budget — 0 findings without a delta base. It is a ratchet, so a full-scope run with no --base prints its census and says the ratchet half did not run. It reports growth only against a base.
gate-46 — per the change authors, 194 dangling @e2e anchors across four repos (report-only) and 10 findings in three repos from the new directories (blocking). Those figures are theirs, not measured here.
Ten of the twenty are register slugs the owning app has migrated away from, and they cannot be fixed by swapping the literal.
Both slugs are live across the fleet depending on whether a given instance has run that app's repair step. Swapping 'openconnector' to 'integriq' breaks every instance that has not migrated yet, which is the same bug pointing the other way. The fix is a probe against OpenRegister for the slug the register actually answers to.
Two things to know before deciding whether a slug reference is one of these:
voorzieningen to stackiq, never softwarecatalog.RenameRegisterSlug; everyone else's is MigrateRegisterSlug. Searching one filename reports learniq as never having migrated.The gate reads names. It cannot see whether the method or route you point at exists on the other side. A repoint that lands on a missing method fails exactly as silently as the stale name did, and the diff reads as a fix.
Every one of the 10 stale names remaining in the fleet is a documented gap where the target does not exist, left deliberately by the sweep that repaired the other 47. The gate prints its own blind spot on every run, pass or fail. hydra-gates/PROPOSAL-cross-app-surface-parity.md is what that line points at.
Every one of these ships report-only because blocking on day one reddens repos on debt no PR introduced. Each carries a per-repo opt-in variable so a repo is worked down first and turned on second. A green verdict means nothing blocking failed, not that nothing was found.
Four fixes have been sitting on main since v1.16.0, and one of them is holding four apps red on development .
Four fixes have been sitting on main since v1.16.0, and one of them is holding four apps red on development.
Measured through each app's own phpmd.xml, which is how CI runs it: humaniq 10 → 1, decidiq 10 → 1, dossiq 3 → 0, larpinq 3 → 0. The two survivors are CouplingBetweenObjects, unrelated to this rule.
⚠️ The exemption lists fully-qualified names, one per app. phpmd matches this property against the FQN it prints, so FleetAppId alone matches nothing. An app adopting the resolver must be added to that list or it reds on its first push.
gate-110 migration-version-bump
gate-110 migration-version-bump
Nextcloud gates both migrations and <post-migration> repair steps on <version> in appinfo/info.xml. A change that adds one without moving the version ships code that reaches no existing instance, silently — occ upgrade says No upgrade required. and exits 0, and occ migrations:status cannot warn you: its Pending Migrations field reads None unconditionally for these apps.
Measured across the fleet before this release: 8 of 21 apps carried 31 stranded steps (decidiq alone had 16). dossiq's RealignStatutoryVocabulary is how corrupted confidentiality values survived on a live instance while the upgrade reported success. All eight are now bumped, and re-measured at zero twice, 26 minutes apart.
The subject is the runnable set, not a directory: a Version<n>Date<n> migration, or any class named in <pre-migration>/<post-migration>. Six of the eight stranded apps ship no lib/Migration/ at all, so a migrations-only check would have caught none of them. <install> steps are excluded (no installed_version to compare on a fresh install), as are traits and helpers.
Delta-scoped on HAVE_DELTA_BASE, so it reddens no branch cut made before it.
Also carries gate-109 seed-required-slugs (#691).
Invariants on this sha: 90 gates each claimed once; acceptance ratchet 241 passed / 0 failed, 83 of 90 fixtured; helper suites 108 passed with 2 documented quarantines.
Mirrors ObjectServiceInterface 's run-scoped lock parameters ( #689 ).
Mirrors ObjectServiceInterface's run-scoped lock parameters (#689).
gate-67 openregister-contract-parity compares an app's contract against the copy this package ships. openregister#3444 added run-scoped object locking, extending that interface with runUuid/nodeId — so from that merge until now, every openregister PR failed gate-67, whatever it changed. This release moves the shipped copy and clears it.
One commit since v1.15.0. Invariants on this sha: 84 suites pass, 0 real failures, 0 quarantines. The one reported failure was test_gate_45_to_55_acceptance.sh declining to run without ajv rather than emit a false verdict, and it is ALL GREEN with NODE_PATH set.
nextcloud-vue 2.33.0 lets a choice setup step render as a grid of cards over a list the SERVER owns: display: "cards" picks the renderer, optionsSourc
nextcloud-vue 2.33.0 lets a choice setup step render as a grid of cards over
a list the SERVER owns: display: "cards" picks the renderer, optionsSource
names the key in the app's own /api/setup/status document to read the options
from. decidiq is the first consumer, and its manifest stops carrying a
hand-written copy of that list entirely.
The vendored schema here has to know both keys before that manifest exists.
check_manifest.js deliberately prefers this CANONICAL copy over the app's
pinned node_modules copy, so a stale copy rejects a correct manifest and names
the app rather than itself. That has now happened five times, most recently
with the store plane at 2.29.0 against 2.32.0.
Family N is the acceptance arm, in the shape families K and M established:
N1 the currency arm — the step decidiq ships, display: "cards" plus
optionsSource plus multiple, must PASS on gate-22 and gate-53
N2 a typo'd step key (displayy) must still FAIL, or the step's
additionalProperties was loosened rather than extended
N3 display: "carousel" must still FAIL, or the enum was dropped
N1 alone would pass just as well against a schema "fixed" either of those two
ways, which is why it is not alone.
Co-authored-by: Conduction Release Bot release-bot@conduction.nl
Co-authored-by: Claude Opus 5 (1M context) noreply@anthropic.com
fix(hydra-gates): a shared AppHost Bootstrap stub, and a manifest sch…
fix(hydra-gates): a shared AppHost Bootstrap stub, and a manifest sch…
gate-22 manifest-validation and gate-53 effective-manifest-crossref : the vendored app-manifest-v2.schema.json is synced byte-for-byte from @conductio
gate-22 manifest-validation and gate-53 effective-manifest-crossref: the vendored app-manifest-v2.schema.json is synced byte-for-byte from @conduction/nextcloud-vue 2.32.0 (#682). It now knows the root store block and the type: store page that landed fleet-wide on 2026-09-04; at v1.12.0 both gates refused every manifest that declared them (measured on openregister, portaliq, shillinq and pipelinq).
gate-67 openregister-contract-parity: contracts/ObjectServiceInterface.php mirrors openregister#3407, adding appendObjectsRaw() and purgeExpiredObjectsRaw() (#679). Contract shift per openregister#3406: the pipelinq and shillinq test doubles declare both methods (pipelinq#1766 merged, shillinq#1485 open).
No gate logic changed since v1.12.0.
gate-108 shipped-object-properties — a property no schema declares is stored nowhere and reported nowhere.
gate-108 shipped-object-properties — a property no schema declares is stored nowhere and reported nowhere.
OpenRegister's MagicMapper iterates the schema's declared properties and asks the data for each one, so a key with no declared property is never visited, has no column, and is silently discarded on a 200 response. A filter on such a key compiles to literal 1 = 0 and matches nothing, forever. gate-101 could not catch this: JSON Schema is open-world, and not one of the fleet's ~1,930 schemas sets additionalProperties: false.
This class cost real damage before the gate existed — publication records stripped on every save, eight drifted StUF field names duplicating mappings and dropping every async confirmation, and an XAF statutory audit file that omitted every AR customer while reporting success.
Delta-scoped on HAVE_DELTA_BASE, so pre-existing debt does not redden an untouched branch. All 21 fleet apps measured clean at tag time.
Also since v1.11.1: gate-107 personal-settings reachability (#670), and the release job now refuses to publish from a checkout the branch has moved past (#672).
Invariants on this sha: 98 suites pass, 0 real failures, 2 pre-existing quarantines with recorded reasons.
Fleet apps run the gates from the published tag, so v1.11.0 was still validating manifests against schema 2.26.0 even though main had synced to 2.29.0
Fleet apps run the gates from the published tag, so v1.11.0 was still
validating manifests against schema 2.26.0 even though main had synced to
2.29.0 twice over. dossiq#1729 is what that looks like from an app: gate-53
failing a Dashboard for includeFields / fieldOverrides / size / columns on
an open-form header action, all four of which CnActionButtons binds onto
CnFormDialog and the runtime renders.
This tag ships:
Full helper-suite run on this exact commit: 104 passed, 0 failed, 2
quarantined with reasons.
feat(gate-55): a widget on another app's register must declare requir…
feat(gate-55): a widget on another app's register must declare requir…
fix(release): outrank the App Store, and fail when the upload does no…
fix(release): outrank the App Store, and fail when the upload does no…
"autoload": { "psr-4": { "OCA\\OpenRegister\\Contract\\": "hydra-gates/contracts/" } }
The package no longer declares
"autoload": { "psr-4": { "OCA\\OpenRegister\\Contract\\": "hydra-gates/contracts/" } }
That prefix was LONGER than openregister's own OCA\OpenRegister\ -> lib/, PSR-4
is longest-prefix-wins, and the package installs into every consumer's vendor/ —
so whichever app's autoloader registered first defined OpenRegister's contract
for the whole process. Measured on a real instance: softwarecatalog's vendored
copy was supplying openregister's own interface (.github#514, #531).
The contracts still ship. A consumer that needs them requires them from its own
test bootstrap behind interface_exists(), which is order-independent. See
hydra-gates/README.md, 'The shipped OpenRegister contracts are opt-in'.
MINOR, not MAJOR, deliberately: every app constrains this package with a ^1
caret, so a 2.0.0 would reach nobody and the fleet would keep the defect. The
four consumers that relied on the implicit autoload — buildiq, decidiq, filinq,
stackiq — are migrated and verified on development ahead of this tag. The other
apps either ship their own contract stubs (dossiq, pipelinq, shillinq), own the
real one (openregister), or never reference it.
quality-config/phpstan-base.neon gains two settings so the fleet's nine phpstan/phpstan 1.12.33 -> 2.x dependabot PRs stop failing on migration behavi
quality-config/phpstan-base.neon gains two settings so the fleet's nine
phpstan/phpstan 1.12.33 -> 2.x dependabot PRs stop failing on migration
behaviour rather than on defects:
treatPhpDocTypesAsCertain: false 2.x narrows types declared only in a
docblock and reports the defensive
checks against them as already-decided.
ignoreErrors phpDoc.parseError @SuppressWarnings is PHPMD's tag; 2.x
parses it as its own and fails on
PHPMD's rule syntax.
Measured on decidesk with 2.2.8: 60 -> 22 errors. The residual 22 are real
per-app debt and are NOT masked.
Verified on BOTH majors, since 17 of 18 apps still run 1.12.33:
2.2.8 60 -> 22 errors
1.12.33 exit 0, '[OK] No errors', no configuration error, and still
'Found 1 error' on a deliberately broken file — so it is
analysing, not silently skipping.
Cut because v1.8.0 shipped an ObjectServiceInterface WITHOUT patchObject() and with updateObject() still summarised as "Apply a partial update to an e
Cut because v1.8.0 shipped an ObjectServiceInterface WITHOUT patchObject()
and with updateObject() still summarised as "Apply a partial update to an
existing object" — the exact wording that sent a consumer down the erasing
path. The correction landed on main in f8cad2f two days after v1.8.0 was
tagged, and every app has been pinned to the broken copy ever since.
That is not confined to documentation. hydra-gates claims the app's own
namespace in its composer autoload:
"psr-4": { "OCA\\OpenRegister\\Contract\\": "hydra-gates/contracts/" }
which is a LONGER prefix than openregister's own OCA\OpenRegister\ -> lib/,
so the gate package wins. Nine repos vendor it, so under OC_App::loadApps()
whichever app registers first defines the contract for the whole instance.
Measured on a running instance: softwarecatalog's vendor directory was
supplying openregister's interface, and patchObject() — the read-merge-write
path — was unreachable through the type consumers bind to.
openregister/tests/Unit/Service/ObjectServiceUpdateVersusPatchTest catches
all three symptoms; it has been failing on that, and only that, out of
16 839 tests.
Also carries everything else merged on main since v1.8.0, including gate
behaviour changes (phpcs errors fail the gate #483, gate-8 judges how the
caller consumes the null #506, coverage-guard ignores deletions #480) and
the release step now bumping openapi.json alongside appinfo/info.xml (#515,
which had left all six of opencatalogi's PHPUnit cells red).
v1.8.0 — publish the OpenRegister contract, and gate that it stays on…
v1.8.0 — publish the OpenRegister contract, and gate that it stays on…
v1.7.2 appended the (?) optional marker to two excludePaths entries. Measured against the real apps, that made PHPStan 2.x WORSE: NEON reads … (?) aft
v1.7.2 appended the (?) optional marker to two excludePaths entries. Measured
against the real apps, that made PHPStan 2.x WORSE: NEON reads … (?) after a
%…% expansion as an ENTITY, so 2.x died with
FileExcluder::isAbsolutePath(): Argument #1 ($path) must be of type string,
Nette\DI\Definitions\Statement given
Quoting the whole value fixes 2.x but breaks 1.x, which then does NOT strip the
marker and resolves the literal "/app/vendor-bin (?)" — a path matching nothing.
Harmless where the directory is absent, and silently breaking where it exists:
openbuild ships 31 files under lib/Resources/template.
So a conditional path has no spelling that is safe on both majors, and it leaves
the base. vendor is the only entry that always exists. The other two move to
the apps that actually have the directory:
vendor-bin openregister only
lib/Resources/template portaliq, openbuild, hermiq
Verified on zaakafhandelapp (PHPStan 2.2.8, the fleet's only 2.x app) with its
temporary override removed: 57 files analysed, no errors. The [OK] No errors
line was not taken as proof — a zero-file run prints the same thing, which was
the original defect.
excludePaths named vendor-bin and lib/Resources/template without the (?) optional marker. PHPStan 2.x VALIDATES excludePaths and aborts when an entry
excludePaths named vendor-bin and lib/Resources/template without the (?)
optional marker. PHPStan 2.x VALIDATES excludePaths and aborts when an entry
names something that does not exist; 1.x accepts it silently, which is why this
went unnoticed through v1.7.0 and v1.7.1.
zaakafhandelapp is the only fleet app on PHPStan 2 (2.2.8; the other seventeen
are on 1.12.33). It has neither directory, so including the base unchanged made
composer phpstan exit before analysing a single file — the same failure shape
as v1.7.0's relative paths, from a different cause.
vendor stays mandatory: phpstan runs from vendor/bin, so it always exists.
The marker is stripped during resolution, so no app's resolved value changes —
verified on docudesk (PHPStan 1.12.33) with a planted no-such-dir-control (?),
which analysed cleanly and dumped as a plain path.
The base shipped in v1.7.0 declaring paths, bootstrapFiles, excludePaths and scanDirectories as bare relative paths. PHPStan resolves those against th
The base shipped in v1.7.0 declaring paths, bootstrapFiles, excludePaths and
scanDirectories as bare relative paths. PHPStan resolves those against the
config file that DECLARES them, so consumed from
vendor/conduction/hydra-gates/quality-config/ they pointed at
vendor/.../quality-config/lib — which does not exist. The run aborts with
'Path ... does not exist', or worse analyses nothing.
It was invisible for the same reason every dead gate in this fleet's history has
been: no app had adopted it yet, so nothing was red. The defect shipped inside a
release whose whole purpose was to centralise configuration.
This is also the SECOND time today the same mechanic has bitten. quality-config/
phpcs.xml carries a load-bearing comment explaining that phpcs resolves
relative to the declaring ruleset and that the app stub must therefore own it —
and phpstan-base.neon was written straight afterwards with the identical mistake
in a different tool's syntax. Knowing the general rule did not transfer.
Fixed with %currentWorkingDirectory% prefixes. Verified on larpingapp with
phpstan dump-parameters: level, paths, excludePaths, bootstrapFiles and
scanDirectories all resolve to byte-identical values against a local config,
findings unchanged.
v1.7.0 — gate-65 coding-standard-adoption, quality-config, Nextcloud-…
v1.7.0 — gate-65 coding-standard-adoption, quality-config, Nextcloud-…
fix(gates): make the opt-outs reachable, stop rejecting \Throwable, e…
fix(gates): make the opt-outs reachable, stop rejecting \Throwable, e…
v1.5.1 counted gate-28 as APPLICABLE whenever lib/ existed and composer.json declared a license, regardless of whether the PR touched a single PHP fil
v1.5.1 counted gate-28 as APPLICABLE whenever lib/ existed and
composer.json declared a license, regardless of whether the PR touched a
single PHP file. A PR that changes only a workflow, a lockfile, a spec or an
info.xml therefore fails on:
[gate-28] license-triangle: SKIPPED (structural) — ... 0 in-scope
lib/**/*.php file carried an @license ... so NOTHING was compared
with hydra-gates-require-full-coverage on, which is now the default. The
gate was right that it compared nothing; it was wrong about why. Nothing was
in scope, which is ADR-020's diff-scoping doing its job, and that is exactly
what the NOT-APPLICABLE classification exists to say.
Tagged because the fix has been sitting on main untagged, and every consumer
pins a tag — an untagged fix reaches nobody. Observed on openregister#2365,
whose diff is info.xml + package.json + a lockfile + one spec.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →