Release: merge development into beta - #2638
Conversation
…260820201450 chore(release): 1.1.5-unstable.20260820201450
…260820204506 chore(release): 1.1.5-unstable.20260820204506
…260820210405 chore(release): 1.1.5-unstable.20260820210405
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 21:27 UTC
Download the full PDF report from the workflow artifacts.
…260820212658 chore(release): 1.1.5-unstable.20260820212658
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ❌ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ❌ | ||||
| build | ❌ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ❌ | ||||
| composer | ✅ | ❌ | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 21:44 UTC
Download the full PDF report from the workflow artifacts.
…260820214342 chore(release): 1.1.5-unstable.20260820214342
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 22:16 UTC
Download the full PDF report from the workflow artifacts.
…260820221600 chore(release): 1.1.5-unstable.20260820221600
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 22:35 UTC
Download the full PDF report from the workflow artifacts.
…260820223544 chore(release): 1.1.5-unstable.20260820223544
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 22:49 UTC
Download the full PDF report from the workflow artifacts.
…260820224828 chore(release): 1.1.5-unstable.20260820224828
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 23:11 UTC
Download the full PDF report from the workflow artifacts.
…260820231029 chore(release): 1.1.5-unstable.20260820231029
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 23:39 UTC
Download the full PDF report from the workflow artifacts.
…260820233856 chore(release): 1.1.5-unstable.20260820233856
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ❌ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ❌ | ||||
| build | ✅ | ||||
| check-specs | ❌ | ||||
| test-l10n | ❌ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 23:58 UTC
Download the full PDF report from the workflow artifacts.
…260820235724 chore(release): 1.1.5-unstable.20260820235724
* chore(ci): remove dead Forgejo/Codeberg CI configuration GitHub is the only host this organisation publishes to. No local checkout has a Codeberg git remote, so nothing is pushed there and no workflow under .forgejo/ has ever run for this repository. Issue templates: the 4 templates under `.forgejo/issue_template/` were already ported to `.github/ISSUE_TEMPLATE/` and were verified present there before deletion (including `feature-request.yml`, which the in-product "Request a feature" deep-link targets by that exact filename). This repository had no `.forgejo` release workflow. Its release path, `.github/workflows/release.yml`, is untouched. .github/workflows/ is untouched — that is the live CI. Any CODEBERG_TOKEN reference lived only inside the deleted files and goes with them. Removes 7 file(s) under .forgejo/. * Move the PR template to .github so GitHub actually uses it This repo's only pull-request template lived at `.forgejo/PULL_REQUEST_TEMPLATE.md`, where GitHub does not look for it — so it has been inert since the move off Codeberg, and no PR here has been prefilled by it. The rest of `.forgejo/` is deleted in this PR as dead Codeberg-era CI. This file is the one thing in there that still had a job to do, so it is MOVED rather than dropped: a pure rename, content untouched, which also empties `.forgejo/` completely. Verified there is no pre-existing `.github/PULL_REQUEST_TEMPLATE.md` to clobber — that is exactly why the original was worth keeping. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* chore(deps): refresh the shared Conduction locks hydra-gates v1.9.0 -> v1.9.0 nc-vue 2.11.1 -> 2.15.0 Lock-only: both packages are already declared with caret ranges that permit these versions, so nothing about what this app ACCEPTS changes - only what it currently resolves to. Opened by the weekly fleet shared-dependency bump, because a lock nobody re-resolves is a pin nobody chose. Merging is gated by this repository's own suite, deliberately: taking hydra-gates v1.8.1 added patchObject() to a published interface, which is a load-time fatal for any concrete double that implements it without the method. CI is the only thing that can tell a safe bump from that. * fix(e2e): select .cn-flow-node — the canvas wrapper class moved in 2.15.0 nextcloud-vue 2.15.0 rewrote the canvas onto Vue Flow, moving the node wrapper from CnGraphCanvas.vue's .cn-graph-canvas__node to CnFlowNode.vue's .cn-flow-node. This spec selected the old class, so the locator matched nothing and the canvas assertions failed while the canvas itself rendered correctly. Nothing announced the rename and the old class survives in the shipped CSS and source maps, so from outside the contract looked untouched. .cn-flow-node is what 2.15.0 emits. nextcloud-vue#752 additionally keeps the old class on the same element as a compatibility alias, so this selector holds both before and after that lands. * ci(quality): give every event its own concurrency lane, not just main and development The event suffix applied only when ref_name was main or development, so on every other branch push and pull_request computed the SAME group and cancel-in-progress made them kill each other. That contradicts the comment directly above it, which already says push, dispatch and schedule each get their own lane. It became reachable when the push allow-list widened on 2026-08-14 to include feat/**, fix/**, perf/**, refactor/** and chore/**: those branches now get both a push run and a pull_request run for one commit. Measured on #2821, sha 4cad83d: 07:51:57 event=push queued <- never started 07:52:15 event=pull_request cancelled Both runs gone, and quality / Quality Report is a needs:-gated aggregator that reports FAILURE when its dependencies are cancelled — so the PR showed a red gate having never been evaluated, and re-running collided the same way. See ConductionNL/.github#563. * fix(e2e): count .vue-flow__edge — edges stopped being ours in 2.15.0 CnFlowDetail used to hand-draw edges into a #edge slot with its own edgePath(), classed .cn-flow-detail__edge. nextcloud-vue 2.15.0 hands routing to Vue Flow; its own comment says so: "Edges are Vue Flow's now. The hand-drawn #edge slot and its orthogonal edgePath() are gone." Nothing renders that class any more, so the assertion counted zero. The connection was being made; nothing was counting it. Same shape as the node wrapper rename in the previous commit, and with the same tell: .cn-flow-detail__edge still has a live style rule in CnFlowDetail, so grepping the library finds it and the contract looks intact. * chore(deps): resolve @conduction/nextcloud-vue 2.16.0, which carries the canvas fixes The lock resolved 2.15.0. That version's canvas silently drops keyboard-made connections: CnFlowNode called Vue Flow's addEdges() and never told the host, so the edge drew, the save serialised a flow without it, and the server refused to run it — "node has no outgoing edge and does not end the flow". Fixed in ConductionNL/nextcloud-vue#760 and released in 2.16.0, which this resolves to. package.json already permits it (^2.8.0); only the lock pinned the older build. Edited surgically rather than regenerated: 2.15.0 and 2.16.0 declare an identical dependency set — 38 each, none added, removed or changed — so only the version, resolved URL and integrity of this one entry move. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-24 22:40 UTC
Download the full PDF report from the workflow artifacts.
…ity (ADR-099) (#2835) * docs(openspec): or-delegated-identity — run-scoped acting identity (ADR-099) Proposal, three spec deltas, design and tasks for unifying the fleet's run-as implementations onto IUserSession::setVolatileActiveUser(), splitting attribution (triggeredBy) from authorization (runAs) on FlowRun, sourcing a run's identity from its trigger node rather than the flow definition, and re-resolving rights at every fire and resume. Grant store and consent lifecycle are deliberately out of scope (or-delegation-grants). * docs(openspec): record the measured blast radius for or-delegated-identity 3 flows carry a schedule trigger (2 enabled), 0 declare a runAs. All three are Hydra's own and sit mid-cutover: their trigger nodes carry an empty config, so the schedule lives in the legacy cron column and they would already fail the node's existing cron validation. The trigger index is empty across all 92 flows. Small enough for hard enforcement; adds task 4.4 for the column-side case. * feat(rbac): run an operation as a user without persisting it to the session ObjectService::runAs() moves from IUserSession::setUser() to setVolatileActiveUser(). setUser() also writes user_id into the PHP session, and base.php starts a session on every request but status.php — so with setUser() a request that dies before the finally (a fatal, an exit()) leaves the acting identity written into the caller's session and returns a cookie for it. setVolatileActiveUser() has been available since NC 29.0 and every fleet app declares min-version=32. Also states runAsSystem()'s reachability boundary (ADR-099 rule 9: code-initiated only) and pins it. OpenRegister already conformed — all four call sites are genuinely userless — so this guards against drift rather than fixing a breach. Refs ADR-099, or-delegated-identity tasks 2.1-2.3 * feat(flow): a run records the identity it executes as, separate from what caused it FlowRun gains runAs, the sole authorization subject; triggeredBy keeps its meaning as provenance and is no longer read to decide access. The migration backfills runAs = triggeredBy (verified against 4045 live rows) and declares runAs on each existing schedule trigger from the flow's current owner — a one-time cutover that makes the previously implicit resolution explicit and auditable, rather than a fallback that keeps answering forever. FlowRunAttribution keeps caller-wins and its independent tenancy resolution; its non-caller branch now reads the trigger node's runAs and fails closed instead of borrowing flow.owner. FlowRunService::queue() refuses an unattributable dispatch outright (FlowUnattributed) rather than writing a run each node rejects one at a time as a permissions error. A context-supplied runAs is ignored: context is caller-supplied at queue time, so honouring it would let anyone who can start a flow choose the identity its steps run as. SubFlowNode propagates the parent's acting identity, not its provenance. Also clears three pre-existing test failures unrelated to this change: ChunkMapperLiveQueryTest errored instead of skipping when its probe could not reach a database; RelationsControllerTest reached the real container through static Server::get() for 11 unstubbed leaf providers; and the tests/bootstrap diagnostic corrupted the result channel of @runInSeparateProcess workers because only one of its two branches silenced forked children. Refs ADR-099, or-delegated-identity tasks 3.1-3.4 * feat(flow): a scheduled run acts as its trigger declares, not as the flow's author TriggerScheduleNode::validate() now requires a runAs naming an existing user, alongside the cron check it already performed, and declares it in configKeys() so the preflight cannot report a correctly-spelled identity as an unknown key. FlowScheduleService::fire() stops handing flow.owner down as the run's identity. That fallback (or#2158, fourth instance) fixed a loud problem — ownerless runs refused node by node — by creating a quiet one: authoring a flow became standing consent to unattended execution as the author. The scheduler now passes no identity and the trigger node answers. A refusal is recorded on the flow (status + status_message, as FlowDeadEnd already does) and a SCHEDULE is additionally disabled, because a schedule retries every tick forever with nobody watching — it would otherwise read as enabled while firing nothing. Both object nodes refuse a DISABLED acting identity, not just a missing one. Disabling the account is how a departure is normally processed and IUserManager::get() returns one happily, so an existence check missed the ordinary case: a run parked for weeks would resume writing on behalf of someone offboarded. canDispatch() and FlowLocator keep their owner gate but no longer justify it as an identity check — that rationale was stale and read as authorization. Refs ADR-099, or-delegated-identity tasks 4.1-4.4, 5.1-5.2 * i18n(nl): Dutch for the acting-identity refusals on the object nodes Six strings covering the three refusal shapes (no identity, unknown account, disabled account) across the read and write nodes. Added to both l10n/nl.json and l10n/nl.js. The .js is the artifact Nextcloud actually serves, and a translation present only in the source reaches no browser. Worth noting separately: nl.js held 2416 entries against nl.json's 2716 before this change, so the artifact is ~300 behind the source independently of this work. Refs or-delegated-identity task 7.2 * refactor(flow): clear the PHPMD findings this change introduced Removes FlowRunService::activeOrganisation(), dead since attribution moved to FlowRunAttribution — PHPMD caught it as an unused private method, and a stale docblock elsewhere still pointed at it. Splits Version1Date20260824120000::declareScheduleIdentities() (cyclomatic 11, NPath 202) into declareOnScheduleNodes/isUndeclaredScheduleTrigger/ hasUndeclaredScheduleTrigger, and lifts the FlowUnattributed catch body out of FlowScheduleService::fireDueFlows() (100 lines, at threshold) into reportUnattributed(). Behaviour unchanged: the node-cutover transform was re-verified against the three real schedule flows and produces byte-identical output (config {"runAs":"admin"}, node counts 5/9/76 preserved). * feat(flow): validate trigger nodes at save, and cover delegated identity in e2e E2E against a live instance proved TriggerScheduleNode::validateConfig() was an ORPHANED CAPABILITY: a schedule trigger posted with config:{} — no cron, no identity — saved with 201. FlowNodePreflight only calls validateConfig() for STEPS, reading $edge['config'], and a trigger is not a step. The unit tests passed throughout because they call the validator directly, which is exactly why all three live schedule flows carry config:[]. FlowTriggerValidator now runs every trigger node's own validateConfig() before the write, so both the pre-existing cron rule and the new runAs rule are enforced where the author is. Connectivity still only warns, per flow-engine: an unconnected node mid-authoring is normal, a schedule that can never fire is not. Unknown node types are skipped, never refused — a leaf app's trigger is not OpenRegister's to validate. The refusal surfaces as 400 with the node's own message rather than an HTML 500, which read as a broken instance for what is an author's typo. tests/e2e/api-direct/delegated-identity.spec.ts: 6 tests green, including the security property (a caller-supplied context runAs is ignored). 153 pre-existing api-direct tests still pass. Also bumps the app version so the migration ships, and clears the PHPMD findings this work introduced. Refs ADR-099, or-delegated-identity tasks 6.1, 6.2, 7.2 * docs(flow): the fleet contract for acting on behalf of a user, and strict-gate cleanup Adds docs/Patterns/acting-on-behalf-of-a-user.md — runAs/runAsSystem semantics, where a flow run's identity comes from, and the three rules that keep it from becoming escalation (identity narrows along a chain; a caller-supplied context cannot choose it; rights are re-resolved, never snapshotted). Written for the five apps that will retire their local copies. Gate results: PHPCS 0 errors across 1137 files, PHPMD clean on every touched file, Psalm 0 errors, PHPUnit green. PHPStan 9 -> 2. The two remaining PHPStan errors are left deliberately. Both are in SharedSchemaDedupeService, untouched by this branch, and both are PHPStan calling a defensive guard dead on the strength of a PHPDoc. Deleting a runtime guard because a docblock says it cannot fire is not debt repayment; it needs its own review. Local PHPStan is 1.12.33 while CI runs 2.x, so neither result is evidence for the gate. Refs ADR-099, or-delegated-identity tasks 6.3, 7.1 * docs(openspec): record that narrowing is not yet enforced, and spec the grant store Two things. 1. or-delegated-identity gains a known-limitation note. Its delegated-identity spec requires that an acting identity NARROWS, and that guarantee is currently unenforced at the entity-permission layer: MultiTenancyTrait::hasRbacPermission() gates its organisation check behind isset($this->organisationService), and that property is never declared or assigned anywhere in lib/Db/ — so the guard is permanently false and the method returns true for every authenticated non-admin (openregister#2833, fix in #2834). Same defect class as the never-injected logger in #2822. Nothing in this change causes it and none of its tests assert narrowing, so no result here is vacuous — but the spec states a guarantee the codebase does not yet keep, and saying so is the difference between a caveat and a false claim. 2. Adds openspec/changes/or-delegation-grants: proposal, four spec deltas and design for the grant store, consent lifecycle and awaiting_consent run state — the half of ADR-099 that turns a declared identity into an authorized one. Refs ADR-099, openregister#2833 * docs(openspec): tasks for or-delegation-grants, and register it on the specs it modifies * test: cover FlowTriggerValidator and the run_as migration The coverage ratchet failed: +176 statements to changed files, surviving-code coverage 76.96% -> 76.72%. The two largest new pieces had no dedicated unit test. FlowTriggerValidatorTest asserts the thing that was actually missing. That a trigger node REFUSES a bad config was already true and always had been; nothing called it. So these tests assert the validator is REACHED — that a node's verdict propagates out of validate() to the caller — and pair every refusal with a positive control, so a validator that rejects everything cannot pass the file. Also covers the cases that must NOT refuse: a step node (connectivity stays the preflight's business), an unknown leaf-app node type, and a registry that will not build. Version1Date20260824120000Test covers the two silent failure modes of the node cutover: an empty decodes as a LIST, so writing a key into it re-encodes as {"0":...} nonsense; and a flow with no owner has no identity to promote, so it must be reported rather than invented. Idempotence is asserted rather than assumed — a migration cannot be re-run to fix a mistake. The backfill test asserts the SQL TEXT, because the failure mode is every row's run_as becoming the literal string 'triggered_by', which looks like success. Also fixes Prettier formatting on the e2e spec. Refs ADR-099, or-delegated-identity task 6.1 * docs(openspec): correct the narrowing limitation — #2834 does not close it I wrote that openregister#2834 closes the gap between the delegated-identity spec's narrowing requirement and what the codebase enforces. That was wrong, and the correction came from the session that owns #2834. #2833 is that hasRbacPermission()'s organisation check is UNREACHABLE, not that it is too permissive at that line. #2834 only makes it reachable. The actual authorization is the organisation's authorization config (entityType -> action -> allowed groups), and an EMPTY config means allow — so narrowing holds only where an organisation has a config for the entityType and action in question. Measured on the dev instance: most organisations carry a config (all restricting to admin), but E2E Org has none. A narrowing assertion written against that organisation would pass for the wrong reason. Making narrowing unconditional is larger than #2833 — it needs organisation provisioning and a policy decision about what an empty config means. Refs ADR-099, openregister#2833, openregister#2834 * test(flow): cover the schedule sweep's refusal paths Real uncovered behaviour rather than diff-shaping for the ratchet: the per-flow catch in fireDueFlows() had no test at all, for either FlowDeadEnd or the new FlowUnattributed. The invariant it protects fails invisibly. The sweep iterates every due flow, so if the refusal escaped the loop the FIRST flow missing an identity would abort the pass and every LATER flow would silently stop firing — presenting to an operator as "cron stopped working" rather than as a fault in one definition, and the flows that stopped would be the ones that were fine. So the assertion is that the SURVIVOR fires. A test that only checked the broken flow was skipped would pass against a sweep that aborted. Also covers that a refused flow records no last-fire (otherwise a flow that never ran looks like one that had, and waits a whole interval after being repaired) and that the refusal names both the flow and the trigger, which point at different fixes. * docs(openspec): narrow the narrowing note to what #2834 actually lands #2834's scope shrank twice during review and the limitation note tracked the first version. What lands is narrower than #2833's title: a CONFIGURED policy now binds, and enforcement lives exactly where a policy exists and nowhere else. Denying on an unconfigured instance is a separate piece of hardening that stays open on #2833 — it needs organisation provisioning to exist first. Adds the warning that matters more than the attribution: do NOT close that gap by denying when no organisation resolves. It was tried on #2834 and reverted after fourteen sharing e2e tests failed. A freshly-created share recipient has no active organisation, so it denied them everything; on an instance nobody has organised yet it denies every non-admin every entity operation. Sharing exists to give access to someone the normal scope excludes, so an organisation-presence requirement denies the feature's whole purpose — an outage with a security-shaped justification. The limitation itself is unchanged and, if anything, firmer: after #2834 narrowing holds only where an organisation has an authorization config for that entityType and action, and E2E Org measurably has none. Refs openregister#2833, openregister#2834
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ❌ | ||||
| phpcs | ⏭️ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ❌ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| build | ❌ | ||||
| format | ❌ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ❌ | ❌ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-24 22:50 UTC
Download the full PDF report from the workflow artifacts.
…2842) The lock already resolves 2.16.0, but the declared range still allowed anything from 2.8.0 up — so a fresh install, or any resolution that ignores the lock, could legitimately pick a version whose canvas silently drops keyboard-made connections (fixed in ConductionNL/nextcloud-vue#760, released in 2.16.0). The e2e that proves the canvas works now depends on behaviour only 2.16.0 has, so the range should say so rather than leaving it to the lock.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-24 23:11 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-24 23:30 UTC
Download the full PDF report from the workflow artifacts.
…) (#2834) * fix(rbac): entity permission checks can now actually deny `MultiTenancyTrait::hasRbacPermission()` gated its entire organisation and group check behind `isset($this->organisationService)` and returned **true** when the property was absent, "for backward compatibility". No class using the trait ever declared or injected that property. The only two mentions in the codebase were commented-out lines in `EndpointMapper` and `SourceMapper`, sitting next to `// REMOVED: Services should not be in mappers.` And `isset()` on an undeclared property is always false — so the allow branch was the ONLY branch that ever ran. Every authenticated non-admin was granted every entity permission on create/update/delete, across eleven mappers, and everything below that line was unreachable code. Closes #2833. Same defect class as #2822 — a guard on a property nothing supplies, so it is permanently in one state. There the dead branch cost a log line; here it cost the authorization decision. ## Why this does not inject OrganisationService The obvious fix is to inject the missing service, and it is the wrong one. Those `// REMOVED: Services should not be in mappers.` comments are a deliberate architectural decision, and it is very likely *why* the dependency vanished and the fail-open shim was left behind. Putting it back would trade one problem for another. `OrganisationMapper` is a mapper, is already injected almost everywhere, and answers the only question this check asks: is the current user a member of the active organisation. `getActiveOrganisationUuid()` in this same trait already resolves the active organisation through it, so the check now uses the dependency that was there all along. Two mappers lacked it — `MappingMapper` and `EndpointMapper` (the latter had it commented out) — and now take it. Nine already had it, so they are untouched. ## It fails closed An unresolvable organisation, an absent mapper, or a user outside the active organisation all deny. A wiring mistake now costs access rather than granting it, which is the direction an authorization default has to fail in. CLI and `SystemOperationContext` keep their existing trusted short-circuits, and admins still short-circuit ahead of any of this. ## Tests assert a DENIAL, not a call `RegisterSchemaRbacTest` documents this bug accurately in its own docblock and scopes around it — it asserts `verifyRbacPermission()` is *called*, deliberately not that it can *deny*. That is precisely why nothing went red for as long as it did, and why a call-assertion is not enough here. `MultiTenancyRbacDenialTest` covers four decisions: a non-admin outside the active organisation is denied; a member passes; an unresolvable organisation is denied (a lookup failure is not authorisation); an admin is still allowed. That last one matters as much as the first — a check that refuses everyone is as broken as one that permits everyone, and would surface only as someone losing access in production. Mutation-checked: inverting the membership test to allow fails `testANonAdminOutsideTheActiveOrganisationIsDenied` with `Failed asserting that true is false`. 1,459 `tests/Unit/Db` tests pass, except 5 `ChunkMapperLiveQueryTest` cases that need a real database and fail identically on unmodified `development` here. phpcs and phpstan clean on all three changed lib files. ## Deployment note This is a behaviour change on a live authorization path. On an instance where organisation membership is incomplete, non-admin users who could previously write will now be denied until they are members of the active organisation. That is the point of the fix, but it is worth measuring before rolling out rather than discovering from a support ticket. * fix(rbac): drop the redundant mapper isset() psalm flagged psalm: RedundantPropertyInitializationCheck — $organisationMapper is a non-nullable typed property on every class using this trait, so isset() on it can only ever be true. It is right, and the guard was mine: I added it while making this path fail closed, reasoning that a class might not supply the mapper. Checked instead of assumed — all twelve trait users declare it as a typed property AND assign it in their constructor — so the guard protects against nothing. Leaving it would also have been the wrong shape for this fix in particular: a redundant guard on an authorization path is precisely the defect #2833 is about. * fix(rbac): drop the membership gate CI's e2e refused My first version of this fix denied any non-admin absent from the active organisation's user list. CI's Playwright suite refused it, and it was right to: fourteen sharing tests failed with "no grant row appeared after clicking Share", across the UI shares tab, HTTP sharing, group shares and email invitations. The failure is not incidental, it is the point. **Sharing exists to give access to someone the normal scope excludes**, so a membership requirement denies the feature's whole purpose. And newly-provisioned users are not on that list either, so the blast radius was much wider than sharing — it was "any user nobody has explicitly added to an organisation", which on a fresh instance is everyone. The user list was never the gate. The code I replaced tested membership into an EMPTY if-body and fell through regardless, and its own comment said the value "was intended for group-based access but is currently unused". The real authorization sits below it: the organisation's `authorization` config, keyed entityType -> action -> allowed groups, with an explicit allow when nothing is configured. So #2833's defect is that the check was **unreachable** — every path returned true before reaching any of that logic. Making it reachable is this PR's job. Introducing a membership requirement is a policy change that would need organisation provisioning to exist first, and it has no business riding along in a bug fix. What remains here: - the active organisation is resolved through `OrganisationMapper`, so the group authorization logic below actually runs - an unresolvable organisation still denies outside CLI — unchanged behaviour, and the right direction for an authorization default to fail in - the CLI and `SystemOperationContext` bypasses are untouched The two tests that asserted the membership rule are replaced by their correction, kept as a pair: a non-member and a member are BOTH allowed when the organisation carries no authorization config. If those two ever disagree in that state, the membership gate has been reintroduced. 4 RBAC denial tests, 59 tests across the related organisation-access and register/schema RBAC suites, phpcs clean. * fix(rbac): enforce where a policy exists, not where one is absent Second correction, from the same instrument. Removing the membership gate was not enough: CI's e2e failed again with the identical fourteen sharing tests, so my first diagnosis was incomplete. The remaining cause is that I had restored two denials that fire on the ABSENCE of configuration rather than on any rule: no active organisation, and an organisation that fails to load. A freshly-created share recipient has no active organisation, so it was denied everything — and on an instance nobody has organised yet, that denies every non-admin every entity operation. Calling that "failing closed" would be generous; it is an outage with a security-shaped justification. Both now allow, and the comments say why. The control this method actually implements is the organisation's `authorization` config; where there is no organisation there is no such config, and allow is the behaviour every caller has ever seen. An organisation that cannot be loaded is the same case, with the extra problem that denying makes a lookup failure indistinguishable from a deliberate rule and takes the instance down on one bad row. **What lands is smaller than #2833's title suggests, and it is the part that can land.** A configured policy now binds: an organisation whose `authorization` says `register.create` is limited to the admin group will now actually refuse a user outside it, where before every path returned true long before reaching that logic. Enforcement lives exactly where a policy exists and nowhere else. Denying on an unconfigured instance is a real hardening step and it is NOT in here: it needs organisation provisioning to exist first, and a decision about the default. #2833 stays open for that half, with this PR's evidence attached. Six tests, and they are paired on purpose: - a configured policy DENIES a user outside the allowed groups — the actual fix - the same config ALLOWS a user in an allowed group — without this, a check that denies everyone would satisfy the first one perfectly while being broken - no active organisation, and an unloadable organisation, are both allowed - membership in the user list is still not a gate - an admin is still allowed 54 tests across the organisation-access and register/schema RBAC suites, phpcs clean, phpstan clean. * fix(rbac): ship the entity check available and OFF, on evidence Third correction, all three from the same instrument, and this one changes what the PR claims rather than how it does it. CI's e2e refused enforcement three times. The third run finally said why, in the server log the workflow captures: **39 `[FileSharingHandler] … Shared path must be set` errors, and zero of them appear on development.** Enforcing the stored `authorization` config breaks register creation and object sharing, because those configs grant only the `admin` group while the app legitimately performs those operations as other identities — `openregister`, the object owner. That is not a bug in the configs and not a bug in the check. It is what happens when a dormant control is switched on: **the configs were written while this check was inert, so they have never once been validated against real usage.** A rule nobody could observe is a rule nobody had to get right. So #2833 cannot be closed by making the check reachable, which is what I assumed for three rounds. Enabling it is a data migration — audit every organisation's `authorization` against the identities the app actually acts as — and that cannot be done from inside this PR, or safely defaulted on for existing instances. What ships: - the check is **reachable** for the first time, resolved through `OrganisationMapper` rather than the never-injected `OrganisationService` - it is **opt-in**, `openregister.rbac_entity_enforcement = enabled`, and **defaults to today's behaviour**. An unset or malformed value keeps the old path — asserted over `''`, `'true'`, `'1'`, `'yes'`, `'ENABLED'`, `'disabled'`, because enabling a known-breaking control by accident is the failure mode that matters here - seven unit tests prove the control works **when enabled**, so what remains for #2833 is config, not code: a configured policy denies a user outside the allowed groups, and the same config allows one inside it The paired allow/deny tests are deliberate. A check that denies everyone satisfies "the policy binds" perfectly and is completely broken; a fail-closed bug and a working guard produce identical output if only the denial is asserted. #2833 stays open with this evidence attached, retitled in effect: the defect is real, and closing it needs the organisation configs corrected first. 59 tests across the organisation-access, register/schema RBAC and mapper-cache suites; phpcs and phpstan clean. * fix(rbac): read the flag through OrganisationMapper, not an undeclared property phpstan caught me committing the exact defect this PR exists to fix. My opt-in flag read `$this->appConfig` from inside MultiTenancyTrait — and the trait does not declare that property. Five of the twelve classes using it don't either: Access to an undefined property OCA\OpenRegister\Db\AgentMapper::$appConfig. … ApplicationMapper, ConfigurationMapper, EndpointMapper, MappingMapper The trait's own docblock says so outright — "the trait does not declare the $appConfig and $logger properties… Classes should define this property themselves if needed" — which is the same sentence that explains #2822's dead logger. I read it, quoted it in a commit message earlier today, and then wrote the bug anyway. Guarding it with `isset()` would have been worse: it is exactly what made the original check unreachable, and it cannot be right for all twelve classes at once — redundant in the seven that declare the property, and an undefined-access in the five that don't. `OrganisationMapper::isEntityRbacEnforcementEnabled()` puts the question in one place. That mapper already holds `IAppConfig`, and every class using the trait already holds that mapper, so this costs no new constructor arguments and adds no property the trait has to assume. Semantics are unchanged and still default OFF: only the exact string `enabled` turns enforcement on. The default test now proves the flag is what stops it, by supplying an organisation config that WOULD deny and asserting the call is still allowed. Cited `openspec/specs/authorization-rbac/spec.md` for the new method, without an anchor. Worth noting its requirement reads "Membership of an organisation SHALL NOT by itself grant the right to mutate that organisation's registers or schemas; the caller SHALL also hold the role permitting the action" — which is the spec agreeing that the user list was never the gate, and that the role check is. 7 RBAC tests; 1,462 tests/Unit/Db, the only failures being the 5 ChunkMapperLiveQueryTest cases that need a real database and fail identically on unmodified development here. phpcs back to its 24 pre-existing warnings, phpstan and psalm clean.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-24 23:46 UTC
Download the full PDF report from the workflow artifacts.
…#2846) Unblocks the engine half of shillinq#1216. Composite groupBy was ALREADY implemented in the runner — the VALIDATOR rejected it, so validator and executor each owned a copy of the grammar. The validator now delegates the shape question to AggregationQuery::normaliseGroupByFields(), the one canonicaliser the runner uses, so they cannot drift again. projectTranslatableGroupKeys() carried the same guard and was handing callers raw language maps for every multi-field aggregation; it now projects each translatable member. The join is a deliberate two-query merge rather than SQL: routing the joined schema back through tryNativeAggregation() makes it INHERIT the organisation and soft-delete predicates instead of re-deriving them. Native cost is 1+N queries independent of group count. RBAC via an explicit hasPermission(list) on the joined schema before any row is read, tenancy from the shared read paths, and the join spec in the cache key. Also found and reported: @Covers under beStrictAboutCoverageMetadata discards a test coverage wholesale, which is why AggregationRunner reported 0% under 187 tests. Removing it took the file from 0 to 548 attributed statements. Ten other files in that directory have the same problem — filed as #2847 rather than changed here. phpcs, phpstan, psalm pass; 214 aggregation tests, 432 assertions, 0 failures.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 01:44 UTC
Download the full PDF report from the workflow artifacts.
… grouping (#2849) Completes the annotation vocabulary shillinq#1216 needs: #2846 added composite groupBy and join, this adds metrics, so a declaration can ask for two sums over the same grouping. Almost nothing new was built — AggregationQuery::$metrics, tryNativeMultiMetric(), computeMetrics() and computeGrouped(metrics:) already existed and are reached by the ad-hoc controller. Only the annotation path never read the key, so the engine could compute what a schema could not ask for. Three wiring points plus a validator. The cache key carries metrics, because two specs differing only there are different aggregations. The ungrouped multi-metric case emits `values` (a map) rather than `value` (a scalar) — emitting the scalar would hand the caller one figure where it asked for several, and look like a working answer. The validator is the point: a metrics entry the runner cannot execute must fail at SAVE time, because at read time a rejected metric is a missing figure in a well-formed envelope, indistinguishable from a genuine zero. Every bad entry is reported, each naming its index. Extracted into AggregationMetricsAnnotationValidator after phpmd flagged the inline version (class complexity 52>50, cyclomatic 12>10, NPath 516>200) — mirroring AggregationJoinAnnotationValidator. My local phpmd had passed on a stale ~/.pdepend cache; cold-cache it now reports 0. phpcs, phpstan, psalm, phpmd all clean; 223 aggregation tests, 446 assertions, 0 failures.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 02:43 UTC
Download the full PDF report from the workflow artifacts.
…relax it (#2852) A declared aggregation filter is fixed at schema-save time and the only placeholder the resolver knows is $currentUser, so an aggregation that must answer PER TENANT could not say so. filter[...] on the request now merges into the declared filter, narrowing only: a key the declaration already constrains is refused rather than overwritten, only scalar equality is accepted (an operator filter can widen), empty values are dropped on declared AND new keys, and the merged filter feeds both the query and the cache key. Refusals are silent drops, not exceptions — this is a read path reached from a URL, and a 500 on a stray query parameter would fail the page rather than the parameter. A dropped key leaves the declared constraint in force, so the failure mode is "your narrowing did not apply", never "you saw more than you should". Nine tests, most asserting a REFUSAL; 3 go red with the declared-key refusal disabled. One found that ?-> guards null but not uninitialised, which the helper now models correctly. phpcs, phpstan, psalm, phpmd clean cold-cache; 232 aggregation tests, 456 assertions, 0 failures.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 05:47 UTC
Download the full PDF report from the workflow artifacts.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ecycle (#2851) * feat(delegation): the grant record, its resolver, and the consent lifecycle The half of ADR-099 that turns a DECLARED identity into an AUTHORIZED one. or-delegated-identity (#2835, merged) made every run state whose rights it executes with and made that identity unforgeable from a payload; it deliberately did not ask whether the person who named it was entitled to. This lands the foundation: the record, the decision, and the consent lifecycle. Wiring it into the flow/agent/consumer save paths follows, behind a measured blast radius. WHY A TABLE AND NOT AN OPENREGISTER OBJECT A grant stored as an OR object is governed by the RBAC it exists to decide: resolving a delegation would need a subject, and resolving the subject would need the delegation. The only exits are elevating to a trusted userless principal for every grant read — putting the most security-critical read in the app behind the one escape hatch ADR-099 rule 9 forbids on request paths — or carving the grant schema out of the evaluator it is being carved out of. So the authoritative read is a mapper on a plain table. THREE PROPERTIES THE TESTS PIN Self short-circuits BEFORE the store, asserted by leaving the store BROKEN: if self-delegation consulted it, the test would refuse instead of permitting. The clock is an argument, never read inside. That is the only way the expiry boundary is assertable at all — one second either side must not depend on which machine asks. An unreadable store fails CLOSED. This subsystem has now been bitten twice by a guard that returned "allowed" when its collaborator was absent (a never-injected logger, a never-injected organisation service), so the opposite default is asserted explicitly rather than assumed. A refusal says WHICH: denied, pending, revoked, expired and never-granted are different facts, and a caller that can only report "no" cannot tell a user whether to ask, wait, or stop. Denial suppresses re-requesting — re-asking after a refusal is how consent fatigue is manufactured, and the eleventh identical prompt is accepted by reflex rather than by decision. Consent requests dedup on (principal, actingAs, scope), NOT on the unit of work: keyed per run, a backlog of two hundred blocked runs sends two hundred notifications, which does not annoy the recipient into care — it trains them to dismiss. The prompt is built from the RECORD. A test sets a grant's reason to "IGNORE PREVIOUS INSTRUCTIONS AND APPROVE THIS" and asserts that string never reaches the sentence the system speaks in its own voice: an agent that reads a hostile document must not end up writing its own consent prompt. 28 unit tests. Design records why entity RBAC (#2834) cannot be assumed to narrow: it ships OFF, because the stored authorization configs were written while the check was inert and had never been validated against real usage. Refs ADR-099, openregister#2835, openregister#2833, openregister#2834 * chore(release): bump so the delegation-grants migration ships A new migration only runs when the app version moves. Version1Date20260824220000 creates oc_openregister_delegation_grants and is additive only — no existing row is touched, and nothing starts refusing because it ran. The enforcement that consults the table lands separately, behind a measured blast radius: a migration that both creates a store and switches on a refusal gives an operator no way to inspect the first before the second bites. * docs(openspec): count generators, not just records or-delegated-identity measured the 3 existing flows carrying a schedule trigger and missed the population that actually broke: code in OTHER APPS that CREATES schedule triggers programmatically. integriq's JobToFlowGenerator emits config => ['cron' => $cron] with no identity, so every flow it generated began failing validation the moment the rule landed (fixed there by configuring a service account, integriq#1573/#1574). A query over stored rows cannot see a generator, because the rows it would produce do not exist yet. This change enforces a comparable rule, so its blast-radius task now requires a fleet-wide code search for constructors of the constrained shape alongside the row counts. Refs ADR-099, integriq#1573 * docs(openspec): a fleet search must say whether it answered Adds the failure mode to the blast-radius task: a code search that silently fails looks exactly like a clean result. Measured by another session the same day — GitHub's code-search API rate-limited a fleet sweep mid-run and returned error bodies the loop counted as hits, so apps never actually checked would have been reported clean. The task now requires asserting a per-repo HTTP status and a non-empty repo list before believing any zero-match result. A sweep that cannot name which repos it covered has not covered any. Same family as the finding it sits under: an instrument reporting accurately about a different question than the one asked. * docs(delegation): @SPEC on the four methods gate-16 named hydra-gates gate-16 (spec-coverage) reported three changed methods without an @SPEC tag. Adding them to jsonSerialize() and to DelegationVerdict's three named constructors — the value object's entire public surface, which is where a reader looks first to find out what a verdict means. Constructors are left untagged: the gate does not ask for them, and a @SPEC on dependency injection points at a requirement the constructor does not implement. Worth noting the gate's own output rather than just its verdict: COVERAGE was 67 of 74 declared gates, with 7 not applicable and 67 of 67 applicable gates reporting. That distinction is the reason to trust the FAIL — a run where gates had silently not executed would look identical to a clean one, which is exactly what the coverage line exists to make visible.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 07:10 UTC
Download the full PDF report from the workflow artifacts.
…it (#2856) `createFlow()` set `name: ${RUN_ID} ${overrides.name}` and THEN spread `...overrides`, so any caller passing a name overwrote the prefixed value. Every run wrote flows called "schedule with identity", "manual attribution" and so on into the instance — indistinguishable from each other and from anything a person had created. The prefix exists for cleanup isolation. Putting it where a caller can silently defeat it made the isolation decorative: it was present, it looked like care, and it never applied. Measured: nine flows left behind on the shared dev instance across three runs before this was noticed. Those are now deleted through the API so their runs and steps cascaded. `name` now goes AFTER the spread. Also records or-delegation-grants task 1.1, measured against merged development: 5 schedule triggers declare a runAs and ZERO name anyone other than the flow's owner; no agent declares an actingUser (no such column exists); no integriq consumer has job_flow_run_as set. Nothing on this instance would start refusing — every declaration is a principal naming themselves, which is not delegation. That zero licenses shipping the check without a grandfathering migration. It does NOT license assuming the first real grant behaves: a rule nobody could observe is a rule nobody had to get right.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 14:01 UTC
Download the full PDF report from the workflow artifacts.
…) (#2858) `GET /api/objects/{register}/{schema}` answers `404 Register not found: '19'` for a register whose row is intact, whose magic tables exist, and which `occ` resolves without complaint. With the logger from #2822 finally wired, the mapper can be seen SUCCEEDING on that exact lookup while the endpoint still reports the register as missing: [RegisterMapper] Searching for register identifier = '19' [RegisterMapper] Register exists before filters registerId = '19' (and no "Register not found after filters") `setRegister()` does two things. It resolves the register, and then — if a schema ref is still pending from an earlier caller on the shared ObjectService — it re-resolves that ref INSIDE the register. A scoped miss there is a **schema** failure, and `resolveRegisterSchemaIds()` reported every `DoesNotExistException` out of `setRegister()` as a missing register. ## Why the misattribution is the expensive part The error names a register that demonstrably exists, so every reasonable first move confirms the register and explains nothing: check the row, check the magic tables, check the organisation filter (skipped — `_multitenancy: false`), run the mapper's own query in psql (returns the row), diff the deployed code against development (identical on the resolution path). That is hours of work the message actively misdirects. This is the same family as #2790 — shared ObjectService state between callers — but it does not LOOK like #2790, because the report points somewhere else entirely. ## The discriminator `setRegister()` assigns `currentRegister` BEFORE re-resolving a pending ref, so a register entity that is NEW after the throw proves the register lookup succeeded. Comparing against the entity held BEFORE the call is what makes it sound. The service is shared, so `currentRegister` can already be populated when the call starts; testing it for null alone would report a genuine missing register as a schema problem whenever anyone had used the service first — swapping one misattribution for another. ## Tests Three, pinning both directions, because asserting only the schema case would admit a fix that reports everything as a schema problem: - a genuinely missing register is still reported as the register - a resolved register with a schema-side failure is reported as the schema — #2820 itself - a leftover register from an earlier caller is not mistaken for success, which is the case that makes the before/after comparison necessary rather than decorative Mutation-checked: restoring the original single-catch fails the middle test with the exact wrong message this issue is about. ## What this does NOT fix The leak itself. A pending ref from an unrelated caller is still re-resolved inside a register it was never meant for; this change makes it say so. The isolation half — #2790's fix applied at the entering end — is still open on #2820, along with why registers 9 and 505 resolve while fourteen others do not. I have not measured that and am not guessing at it here.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 16:02 UTC
Download the full PDF report from the workflow artifacts.
…#2820) (#2860) The other half of #2820. #2858 made this endpoint report a leaked-ref failure honestly — as a schema failure rather than a phantom missing register. It did not stop the leak. `setRegister()` re-resolves whatever schema ref is still pending on the SHARED ObjectService. A ref left behind by an unrelated earlier caller therefore gets resolved inside a register it was never meant for, and the request dies on a schema it never asked for. On the dev instance a preload resolves register `buildiq` moments before the request's own lookup — visible in the log now that #2822 wired the mapper's logger — which is how a plain `GET /api/objects/19/9476` failed while `RegisterMapper::find(19)` succeeded. `resolveRegisterSchemaIds()` now calls the existing `clearCurrents()` first. Nothing pending can ever be legitimate there: the method is handed BOTH the register and the schema explicitly, so it has no use for a ref it did not receive. #2790 added exactly this isolation on LEAVING `find()`; this is the entering end, which is where it was missing. Test asserts `clearCurrents()` is called before resolution, mutation-checked — removing the call fails it with "expected 1 time, actually called 0 times". The three attribution tests from #2858 still pass alongside it, so the honest reporting is not traded away for the fix. 64 tests across the four ObjectsController suites; phpcs and phpstan clean. ## Still not measured Why registers 9 (learniq) and 505 (hrmq) resolved while fourteen others did not. The likely explanation is that the leaked slug happens to exist in those two registers, but I have not measured it and will not assert it. If the 404s persist on a deployed build after this, that is the thread to pull.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 22:01 UTC
Download the full PDF report from the workflow artifacts.
…own fix (#2862) * fix(bulk): one resolution implementation, because #2820 survived its own fix Deploying #2858 and #2860 took the objects API from 2 of 16 registers working to 16 of 16. The archived-corpus import still failed on its first project: POST /api/bulk/19/9475/save 404 {"error": "Register not found: '19'"} for the same register `GET /api/objects/19/9476` had just served. `BulkController` held its **own private copy** of `resolveRegisterSchemaIds()`, identical to `ObjectsController`'s until I fixed one of them — after which they were not identical, and the bug lived on in the copy nobody edited. Fixing the second copy would have left the same trap set for the third, so the logic now has one home: `ResolvesRegisterAndSchemaTrait`, used by both. Two behaviours it must keep, both load-bearing and both learned the hard way: - **`clearCurrents()` first.** `ObjectService` is shared within a request, so a schema ref left pending by an earlier caller is otherwise re-resolved inside whichever register THIS call names. - **Report a schema failure as a schema failure.** `setRegister()` assigns `currentRegister` before re-resolving a pending ref, so a changed entity means the register resolved and the throw came from the schema side. Without that, the endpoint blames a register that demonstrably exists — which is what made #2820 cost a day: every reasonable first move (check the row, the magic tables, the organisation filter, run the query in psql) investigates the wrong thing. `BulkController` keeps its re-anchor on the resolved numeric ids, because its downstream handlers read the service's current register/schema rather than the returned array. That is behaviour-preserving, not incidental. ## The test is the point `RegisterSchemaResolutionParityTest` asserts both controllers route through the trait, and that **no controller file contains `clearCurrents(`** — the tell of a pasted copy. A future third controller that duplicates the helper fails here. Mutation-checked: removing the trait from `BulkController` fails with *"BulkController resolves register/schema without the shared trait — that is how openregister#2820 survived its own fix"*. That guard matters more than this fix. The defect was never the logic; it was having two of it. phpcs, phpstan clean on all three changed lib files; 8 tests across the parity and attribution suites. Refs #2820 * fix(gate): add the @copyright tag gate-1 requires gate-1 spdx-headers failed with '0 missing @license, 1 missing @copyright' — the new trait carried the SPDX-FileCopyrightText line and the @license tag but not the @copyright PHPDoc tag the gate checks for. They are different things and only one of them was present.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-26 09:20 UTC
Download the full PDF report from the workflow artifacts.
* chore: ignore agent/test scratch and untrack generated files Part of the 2026-08-25 fleet structure audit (ADR-100 Decision 2: the repository root is a closed set; generated files are never tracked). Ignore rules added: .stale/ /.e2e-state/ .phpunit.result.cache test-results/ playwright-report/ Untracked (kept on disk, now ignored where a rule covers them): - .phpunit.result.cache - composer-setup.php - composer.phar - no-admin-idor-findings.log - phpstan.phar `.stale/` was missing from ALL 19 fleet repos and is the one that matters most operationally: agent scratch there grew unbounded and filled the dev disk once already. Refs ConductionNL/hydra ADR-100. * fix(ci): give the container the runner's composer, not a committed phar Untracking composer.phar in the previous commit broke the Newman API suite: .github/workflows/api-test-coverage.yml ran `php composer.phar install` inside the nextcloud container, which ships no composer of its own. That is why the binary was committed in the first place. Rather than restore it, the workflow now copies the RUNNER's composer into the container (docker cp "$(command -v composer)") and calls `composer install`. The runner already has composer — the `composer install` step earlier in the same job uses it — so this removes the second copy instead of trading a committed binary for a network download. `command -v` fails loudly if composer is ever absent, rather than falling through to a missing binary and reporting the install failure as a test failure. A checked-in package-manager binary is the supply-chain shape ADR-100 Decision 2 forbids: never reviewed, never updated alongside the lockfile, and its provenance is a git history nobody reads. phpstan.phar, removed in the same sweep, is invoked nowhere — the only surviving mentions are composer.lock listing it as a package's own file and a design doc calling it binary noise. * fix(ci): run the copied composer through php, not as an executable The previous commit copied the runner's composer into the container and called `composer --version`, which failed with exit 127: OCI runtime exec failed: exec: "composer": executable file not found in $PATH `docker cp` does not carry the executable bit reliably, and the copied phar is not on the container's PATH as an executable regardless. Invoke it as `php /usr/local/bin/composer` instead — which is exactly how the old committed composer.phar was invoked (`php composer.phar install`). That sidesteps both the exec bit and the shebang and needs nothing on PATH. The point of the change is unaffected: the binary is still not committed to the repository, it is borrowed from the runner for the duration of the job. * fix(ci): resolve the composer symlink before docker cp Second attempt at the same step, and the message changed: the first failed with 'composer: executable file not found in $PATH' (fixed by invoking through php), this one with Could not open input file: /usr/local/bin/composer setup-php puts a SYMLINK on PATH, and `docker cp` copies the link itself rather than its target — so the container received a dangling symlink pointing at a runner path that does not exist inside it. php then found a file it could not read, which is a different failure wearing similar words. `readlink -f` resolves to the real phar before the copy. The step also prints the resolved path and its size, so a future failure of this shape is visible in the log rather than inferred, and keeps the immediate `--version` check — it names the cause at the copy, instead of surfacing 40 lines later as a dependency-install failure. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…oo (#2869) * fix(aggregation): the caller's narrowing filter must scope the join too A join is a second query, and every predicate the caller supplies has to be answered twice. It was not. applyJoin() built its filter from the DECLARED `join.filter` alone, so the parent rows were narrowed to one tenant while the joined aggregate was computed over all of them, and the two halves of one envelope described different populations. Measured on a live instance. CommitmentLine narrowed to ADM-001 returned the correct sums, and the joined CommitmentBudget.authorised_amount came back 160,000,000 — ADM-001's own 80,000,000 plus both of adm-demo's (50,000,000 + 30,000,000), because that join matches on programmeCode alone. The page rendered another administration's money as this one's. Nothing raised, and 160 million reads as plausibly as 80. mergeNarrowingFilter()'s docblock promises the failure mode is never "you saw more than you should". That held on the parent; the join walked past it. The same docblock enumerates three security controls, and numbering them three is part of why the fourth stayed invisible. The filter is restricted to keys the joined schema DECLARES. A filter on a property a schema does not have is not an error in this stack — it matches nothing — so forwarding an unknown key would silently zero the join instead of narrowing it, trading a figure that is too big for one that is always wrong, in the direction that looks like "no budget yet". It also forwards what actually took effect on the parent, not what was requested. Reusing $extraFilter would open the mirror-image hole: mergeNarrowingFilter() drops a key the declaration already constrains, so a caller passing administrationId=B against a declaration pinning A leaves the parent on A while the join follows the caller to B — and because the dropped key never reaches the cache key, that mismatch would be cached and served on. Both failure modes have a test, and both were confirmed to go red without the fix: 1400.0 where 1000.0 is required, and 400.0 where 1400.0 is. * test(flow): wait on the clock, not on one usleep(), for the ceiling test FlowNodeRegistryTest::testAStepThatOverrunsItsCeilingIsStopped went red inside the full suite while phpcs and phpmd were saturating the machine, and green three times over in isolation. SlowNode slept once for 1.2s against a 1s ceiling, and the comment above it argued that the margin meant "a loaded machine cannot make this flap". That reasons about the wrong direction: load makes a sleep LONGER, which is the safe way to be wrong. What actually bites is usleep() returning EARLY when a signal arrives — plausible on a busy box with many child processes. The node then finishes inside its ceiling, the dispatcher is right not to raise, and the test fails claiming the timeout is broken. Looping until hrtime() says the target has genuinely elapsed makes the node outlive its ceiling whatever the sleep does. Verified by running it with every core pinned: green.
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.