Skip to content

Release: merge development into beta for the fleet-wide beta checkup - #295

Merged
rubenvdlinde merged 429 commits into
betafrom
sync/dev-to-beta-20260820
Aug 20, 2026
Merged

Release: merge development into beta for the fleet-wide beta checkup#295
rubenvdlinde merged 429 commits into
betafrom
sync/dev-to-beta-20260820

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Brings beta up to date with development for tonight's fleet-wide beta checkup (team decision, 2026-08-20), so tomorrow's review runs against current code on the online beta environment.

Why this PR and not the auto sync PR: the automatic development -> beta PR was CONFLICTING and could not merge. This branch is that same merge, with the conflict resolved.

Resolution policy: for files development actively maintains, development's version wins — that is what a release-channel sync means. Beta-only files that did not conflict are preserved untouched.

The per-conflict reasoning is in the merge commit message.

Also carried to beta: the converged release.yml (beta's pre-convergence workflow has no workflow_dispatch trigger), and the app signing path that started working today once the NEXTCLOUD_SIGNING_KEY/CERT secrets landed — the first successful releases since 2026-08-17.

remko48 and others added 30 commits August 3, 2026 11:51
…issions

Point the wrapper at ConductionNL/.github (the org "Conduction" does
not exist), cancel superseded runs per ref, and declare least-privilege
permissions (CodeQL actions/missing-workflow-permissions).
…-token/jwt-library (#135)

The lockfile pinned roave/security-advisories to a 2026-04-02 commit, freezing
its conflict rules — and all protection against CVEs published since — at that date.

Refreshing it was blocked by an aliasing artifact: roave conflicts with
web-token/jwt-experimental <4.1.7, and web-token/jwt-framework 3.4.10 replaces
jwt-experimental at self.version, so the patched 3.4.10 monolith trips a rule whose
range was written without the <3.4.10 carve-out the same advisory carries against
jwt-framework itself. Every upstream web-token advisory is fixed in 3.4.10, and
composer audit --locked reports 0 here — this was never a live vulnerability.

Rather than force a major upgrade to ^4, depend directly on the split package we
actually use:

  -  "web-token/jwt-framework": "^3"
  +  "web-token/jwt-library": "^3.4.10"

Same version, 3.4.10. All 24 distinct Jose\\Component classes used across
openregister, doriath and openconnector were checked to resolve inside the
jwt-library 3.4.10 dist (with a positive control); all 24 resolve. No usage of
Jose\\Bundle, JoseFramework or Jose\\Experimental anywhere in lib/ or tests/.

This reduces attack surface: jwt-experimental, the package that genuinely carries
an unbounded advisory range, is no longer installed.

composer audit --locked: 0 advisories before -> 0 after.
The caller's permissions block is a static ceiling for every job in
the called quality.yml — including disabled ones — so it must cover
the widest declared grant (journeydoc/update-baseline/features-extract
need contents/actions write; the Quality Report comment needs
issues/pull-requests write). Also satisfies CodeQL
actions/missing-workflow-permissions and cancels superseded runs.
* ci(e2e): enable the shared E2E Tests (Playwright) job

Adds a CI-only Playwright config, an explicit provisioning script, and the
caller wiring needed for ConductionNL/.github's `E2E Tests (Playwright)` job
to run Doriath's 56-test regression suite on a fresh Nextcloud install.

- .github/workflows/code-quality.yml: enable-playwright, playwright-test-path,
  playwright-seed-command, plus additional-apps for OpenRegister (doriath
  adopts its AppHost engine in lib/AppInfo/Application.php).
- tests/e2e/playwright.config.ts: CI regression config declaring ONLY the
  chromium project. The shared workflow passes no --project, so the root
  config would also run `visual`, whose PNG baselines cannot byte-match a CI
  Linux runner. Report/output land at the app root, where the workflow's
  upload-artifact steps look.
- tests/e2e/ci-seed.sh: turns on `debug` and re-runs the repair steps, so
  SeedDevelopmentData actually seeds the dev vault the nine workflow specs
  unlock; imports the register through OpenRegister's admin API; then verifies
  secret types (positive control), the active suite, the seeded secrets and the
  register/schema slugs, and gates on the bundle serving as JavaScript.
- tests/e2e/base-url.ts: accept NC_BASE_URL too (the workflow exports it).
- .gitignore: the app-root playwright-report/ and test-results/ the CI config
  writes to.

* fix(migration): doriath_ca_certs.is_active must not be BOOLEAN NOT NULL

Nextcloud 31's MigrationService::ensureOracleConstraints() throws for any
new BOOLEAN NOT NULL column on every platform, so 'occ app:enable doriath'
aborted on a fresh NC 31 install with

  Column "oc_doriath_ca_certs"."is_active" is type Bool and also NotNull,
  so it can not store "false".

i.e. the app could not be installed at all on the minimum server version its
own appinfo/info.xml declares. NC 32 narrowed the same check to Oracle, which
is why newer servers never showed it, and doriath's PHPUnit legs have been
skipped behind a red php-quality job, so nothing had ever installed the app
on 31. Every other boolean column in lib/Migration already uses
notnull => false; this brings the first one into line.

Also pins the E2E / Newman / Journeydoc server (nextcloud-test-refs[0]) to
stable32, because OpenRegister — installed as an additional-app and required
by doriath's AppHost integration — implements OCP\ContextChat\IContentProvider,
which does not exist before NC 32. The PHPUnit matrix still covers stable31.

* ci(e2e): do not assert an OpenRegister register doriath never declares

lib/Settings/doriath_register.json is still the scaffold descriptor: it
carries a single `example` schema and no `components.registers` at all, and
OpenRegister's ImportHandler only creates registers from that key. Report the
register list for diagnosis, assert on the schema slug the config actually
describes.

* fix(appinfo): declare supported databases so the app can install at all

Nextcloud's MigrationService turns on `checkOracle` for any app that declares
no <database> dependency, and then validates every migration against
ensureOracleConstraints(). Doriath's schema does not satisfy those
Oracle-derived identifier limits:

  Table name "oc_doriath_certificate_metadata" is too long.

plus the companion rule that rejects a default-named primary key on any table
whose unprefixed name is >= 23 characters, which covers a dozen Doriath tables.

The consequence is not cosmetic: 'occ app:enable doriath' aborted on a fresh
Nextcloud, so the app was not installable from scratch on a stock server. Long-
lived dev instances hid it because the checks only apply to tables that do not
exist yet, and CI never exercised it (the PHPUnit legs are skipped behind a red
php-quality job and the E2E job was not enabled until now).

Declaring pgsql/sqlite/mysql is Nextcloud's own mechanism for this and is
simply true — Doriath has never supported Oracle. openregister and opencatalogi
already ship exactly these three entries.

* fix(offline): the service worker broke every controlled request

Once `clients.claim()` put a page under the offline app-shell worker, every
same-origin `/apps/doriath/` GET it intercepted started failing with
net::ERR_FAILED. Verified from the CI Playwright traces of run 30798827764:

  - the first, still-uncontrolled GET of /index.php/apps/doriath/lock returned
    200 text/html; …/serviceworker.js was then fetched and claimed the client;
    the very next GET of that same URL came back with status -1 / x-unknown.
  - the lazily-loaded argon2-browser WASM chunk, requested only after the
    worker took control, failed identically — the Share dialog showed
    "Loading chunk … failed" and password-protected link shares could not be
    created at all.

For a user: a reload, a bookmark, or any second Doriath link lands on the
browser's network-error page, and link sharing is broken. 16 of 56 e2e specs
failed on these two symptoms.

The offline cache is a resilience feature, not a performance one — interposing
on the online path bought nothing and broke the app. The worker now:

  - precaches the shell AND the doriath JS/CSS the shell references, at install
    time, which is where the "app shell loads offline" requirement is actually
    satisfied (nothing was precached before — entries only appeared as a side
    effect of serving live traffic, i.e. the broken path);
  - returns without calling respondWith() while the browser is online, making
    it a no-op and leaving the online path byte-identical to having no worker;
  - serves from cache only when the browser reports itself offline, with a
    navigation falling back to the one precached shell document and a cache
    miss still falling through to the network.

No spec scenario changes: offline shell load, the no-secret-material rule and
the version-keyed cache eviction all still hold.

* fix(ui): NcSelect dropdowns rendered behind NcDialog, making them unclickable

NcSelect defaults to appendToBody, so vue-select teleports its dropdown to
<body> and nc-vue tags it .vs__dropdown-menu--floating — a class that carries
position: absolute and NO z-index (@nextcloud/vue 9.9.0), while NcModal (which
NcDialog is built on) sits at z-index 9998/10001. Any select opened from inside
a dialog is therefore painted behind that dialog.

It fails silently and reads as a dead control: the options are in the DOM,
visible and enabled, but every click lands on the dialog body. On the
Move-secret dialog's "Destination folder" select, picking a folder was simply
impossible — Playwright retried the click 100 times over 60 seconds and each
attempt hit <div class="dialog"> instead of the option.

Raises the teleported menu above the modal layer. The real fix belongs upstream
in nc-vue's NcSelect stylesheet. Per-select :append-to-body="false" was
rejected: .dialog__content is overflow:auto, so an inline menu is clipped by
the dialog instead of overflowing it.

Also fixes the 3 pre-existing stylelint string-quotes errors in this file.

* fix(ui): raise the NcSelect dropdown above NcDialog with sufficient specificity

The previous attempt used .vs__dropdown-menu--floating { z-index: 10002 },
which is 0-1-0 — the same specificity as vue-select's own
.vs__dropdown-menu { z-index: var(--vs-dropdown-z-index) } — and vue-select's
stylesheet is injected later (it rides the NcSelect chunk; this file is
imported at the top of main.js), so source order handed the win to vue-select
and the dropdown stayed behind the dialog.

Root cause, precisely: nc-vue 9.9.0 sets --vs-dropdown-z-index: 9999 on body
while its own NcModal wrapper sits at 10001, so every NcSelect opened inside an
NcDialog is unreachable. Overriding :root does not help either — the variable
is declared on body itself, and an element's own declaration beats a value
inherited from :root.

Uses a two-class selector (0-2-0) plus the body > child form, which win
regardless of injection order.

* fix(ui): keep the Move-secret folder select's list inside its dialog

NcSelect defaults `appendToBody` to true, so vue-select TELEPORTS the options
list to <body>. A teleported list opened from inside an NcDialog is painted
behind that dialog and the control is simply dead: the options are in the DOM,
visible and enabled, but every click lands on the dialog. Choosing a
destination folder was impossible — Playwright retried the click ~100 times
over 60 seconds and each attempt hit <div class="dialog"> instead of the
option.

Raising the list's z-index does NOT fix it. Two attempts are recorded in the
history of this branch (10002 via .vs__dropdown-menu--floating, then via a
0-2-0 selector to beat vue-select's own rule); neither changed the outcome,
because the teleported list and the dialog end up in different stacking
contexts, so the list's z-index never competes with the dialog's at all. Those
CSS rules are removed again rather than left in place claiming a fix they do
not deliver; the stylelint string-quotes cleanups in the same file are kept.

Keeping the list inside the dialog removes the question entirely.

The underlying defect is nc-vue's — NcSelect teleports by default while
NcModal, which NcDialog builds on, creates a stacking context the teleported
node cannot rise above. Doriath renders 13 more NcSelects inside dialogs with
the same exposure; only this one is proven broken by a test, so only this one
is changed here.

* TEMPORARY: truncate the frontend bundle after the gate (positive control)

Proves the e2e suite genuinely exercises the Doriath frontend. Reverted in the
next commit — must NOT be merged.

* Revert "TEMPORARY: truncate the frontend bundle after the gate (positive control)"

This reverts commit bf2bcf6.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Jobs without timeout-minutes fall back to GitHub's 360-minute default, so
a hung runner burns six hours of Actions minutes before it is reaped.

Bounds are derived from observed run durations and left deliberately loose:
a timeout that fires under normal contention is worse than no timeout,
because it turns a slow run into a phantom defect.

Jobs that only call a reusable workflow (job-level `uses:`) are untouched --
they inherit their bound from the called workflow.
… hook

Fleet convention (ConductionNL/.github CONVENTIONS.md § features.json):
CI only verifies and blocks — generation happens on the developer's
machine, before the checks run.

- .githooks/pre-commit: regenerates docs/features.json whenever staged
  changes touch openspec/specs/ or the features overlay, and stages the
  result. Best-effort: it warns but never blocks the commit; the CI
  gate (features-check / features-extract -> Quality Report) enforces.
- package.json "prepare" + composer.json "post-install-cmd" set
  core.hooksPath to .githooks, so any npm install or composer install
  activates the hook automatically. Existing clones activate once with
  `git config core.hooksPath .githooks`.

Works from any client that runs real git (CLI, IDEs, Claude,
GitKraken 9.5+); a bypassed or broken hook is caught by CI, which
hard-fails the merge on a stale features.json.
ci: align workflows with fleet standard (ConductionNL/.github)
…#139)

All four gates were failing on development against real findings, not
tooling noise. Fixed the violations rather than baselining them.

psalm (2 -> 0) and phpstan (34 -> 0)
- Add tests/stubs/openregister-apphost.stub.php, an analysis-only stub
  for OpenRegister's AppHost Settings engine, wired via phpstan.neon
  scanFiles and psalm.xml <stubs>. Sections\SettingsSection and
  Settings\AdminSettings extend GenericSettingsSection /
  GenericAdminSettings, which live in the openregister sibling app and
  are absent from the CI analysis path. PHPStan refuses to ignore
  "extends unknown class" at all ("use excludePaths instead"), so a
  stub is the only non-destructive fix. Declaring GenericAdminSettings
  as IDelegatedSettings also resolves the 12 AuthorizedAdminSetting
  class-string errors. Mirrors the signatures in
  openregister/lib/AppHost/Settings/ and the openbuild precedent.

Real defects fixed
- CertificateAuthorityService::getStatus() docblock omitted the
  'issued' key it actually returns, so DashboardService's CA-health
  card read an offset PHPStan proved could not exist.
- Two dead guards on phpseclib saveX509(), declared ": string", where
  is_string($pem) === false can never be true.
- EncryptionSuite::getCreatedAt() was annotated non-nullable while the
  backing property is ?DateTime and jsonSerialize() uses ?->.
- TeamFolderService::registerFanOutShares() declared @return int but
  returns array{created, rows}.
- ShareService::createBatchShares() declared a precise array shape for
  rows that arrive verbatim from an untrusted NoAdminRequired request
  body; the docblock invited a future reader to delete the runtime
  guard that is the actual validation.
- Removed four injected-but-never-read LoggerInterface dependencies
  (AttachmentService, AuditService, CertificateLifecycleService,
  DelegationService) and their test wiring.
- Removed ComplianceReportService::FORBIDDEN_KEY_PARTS: unused, and
  unusable as written since it would reject the legitimately
  allowlisted ciphertextAgeBands and linkSharesPasswordProtected keys.
  SECTION_ALLOWLIST is a closed set and strictly stronger; corrected
  assertAllowlisted()'s docblock, which claimed a check that was never
  implemented.
- Removed SettingsService::ADMIN_CONFIG_KEYS: unused and a stale,
  incomplete duplicate of the keys updateAdminSettings() handles.

phpcs (54 -> 0)
- 14 auto-fixed with phpcbf; 25 missing named parameters; doc-comment
  capitalisation; two inline IFs expanded; missing @PARAM tags on
  EncryptionSuiteController and DashboardService; one over-long
  notification string shortened (msgid untranslated in every locale).

stylelint (28 -> 0)
- 111 rule-empty-line-before violations auto-fixed (blank-line
  insertions only, zero content changes).
- Quoted the stylelint globs in package.json. Unquoted, src/**/*.vue is
  shell-expanded without globstar, so it matched ONE directory level
  and silently skipped src/components/settings, src/components/share
  and every other nested dir: CI saw 28 of the 111 real violations.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Pin the exact version (no caret). The 3.0.0 major removes CnFlowCanvas,
CnEditFlowsModal and CnFlowCanvasModal; this app imports none of them.
Peer set is unchanged from 2.1.0-vue3.16, so no new peers are declared.

Verified from the lockfile: @conduction/nextcloud-vue 3.0.0-vue3.4 exact,
vue3-apexcharts 1.8.0 (below the proprietary 1.9.0 line).
Built with USE_LOCAL_LIB=false; built js/ carries CnFlowEditModal and
CnAppNav and none of the three removed symbols.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ro) (#144)

* style(phpmd): import classes used with `new` instead of FQCN (MissingImport 18 -> 0)

Adds the missing `use` statements for DateTime, ReflectionClass,
RuntimeException, X509, ShareTarget, Secret, SettingsService,
SettingsController and InitializeSettings, and switches the
instantiation sites to the short names. No behaviour change.

* refactor(phpmd): drop the error-control operator and count() in loop conditions

ErrorControlOperator 6 -> 0, CountInLoopExpression 2 -> 0.

- ServiceWorkerController: guard the shell read with is_file()/is_readable()
  instead of suppressing file_get_contents().
- New Support\SuppressesDiagnostics trait: one named, greppable, strictly
  scoped set_error_handler()/restore_error_handler() pair replacing the five
  scattered `@` marks on openssl_csr_get_public_key(), openssl_csr_new(),
  openssl_x509_parse() and stream_socket_client(). Those functions report
  malformed user input both as an E_WARNING and as a false return; the
  reason now lives in one place instead of being restated per site.
- Background scan jobs: hoist count($page) into $pageCount before the
  do/while condition.

No behaviour change: every call site still branches on the same return value.

* refactor(phpmd): fix short/long variable names and unused formal parameters

ShortVariable 12 -> 0, LongVariable 7 -> 0, UnusedFormalParameter 3 -> 0.

Renames (behaviour-neutral; named-argument call sites and their tests
updated in lockstep):
- $s -> $secret, $p -> $params, $d -> $days, $fn -> $counter,
  $ca -> $caRows, $dn -> $dnParts, $ip -> $remoteIp
- $encryptionSuiteService -> $suiteService
- $rotationPolicyService -> $rotationService (also in SecretService, whose
  class-level LongVariable suppression already covered it, so the injected
  name stays uniform across all three consumers)
- $attachmentGrantMapper -> $grantMapper
- $encryptedAdditionalFields -> $encryptedExtras (the wire key
  'encryptedAdditionalFields' is untouched — it is API contract)

Two documented inline suppressions, not fixes:
- HoneyAlert::$ip — the property name IS the column name QBMapper derives;
  renaming would retarget reads/writes at a non-existent ip_address column.
  Class-level because PHPMD does not read @SuppressWarnings off a field.
- JwtAuthMiddleware::afterException($methodName) — overrides
  OCP\AppFramework\Middleware; narrowing the signature is a fatal error.
- GroupShareController::denyNewMember($id, $newMemberId) — $id is the {id}
  URL segment the router binds; denial is a specified server-side no-op.

* refactor(phpmd): remove boolean flag arguments (BooleanArgumentFlag 6 -> 0)

Two real decompositions:
- DelegationService::createDelegation($isAdminPath) split into
  createDelegation() (owner self-delegation) and createAdminHandover()
  (vault-admin power grab), sharing loadDelegableSecret() and
  persistDelegation(). The flag selected an AUTHORIZATION branch, which is
  exactly the case that should be two named entry points. Guard order,
  messages and persisted shape are unchanged; the five admin-path tests now
  call createAdminHandover().
- AttachmentService::unlinkBlobIfOrphaned($keepWhileRowExists) dropped. The
  single true call site was unconditionally a no-op: the callee returned on
  the flag before any unlink could run, so removing the call changes nothing
  observable. Replaced with a comment stating the invariant it encoded.

Three documented inline suppressions, not fixes — in all three the bool is
payload bound by name out of the HTTP request body, not a switch the method
branches on:
- SiemSinkController::create($tls, $enabled) — sink fields, passed straight
  through to the params array.
- SecretRequestController::create($isReRequest) — a body discriminator;
  splitting it would split the route and change the HTTP contract.
- SuiteMigrationCompletedEvent::__construct($hasErrors) — event payload,
  stored and read back via hasErrors().

* refactor(phpmd): remove every else branch (ElseExpression 31 -> 0)

No suppressions — all 31 are decomposed. Four shapes were used:

1. Default-then-override for a value assigned in both arms ($status,
   $resultMessage, $band, $separator, entity setters). Safe because the
   overridden value is a pure literal or an in-memory setter.
2. A named predicate variable for QueryBuilder null-vs-equals filters
   (ExpiryPolicyMapper, FolderMapper, HoneyAlertMapper) — the predicate is
   built first, then applied once.
3. Extracted early-return helpers where both arms did real work:
   ApplicationSecretsController::fetchOwnedSecrets(),
   DashboardSettingsController::applyUpdate(),
   SecretController::createOwnedSecret(),
   SecretRequestController::dispatchCreate(),
   ApplicationService::registrationAuditEvent(),
   SiemService::deliverTo() (now the single syslog/webhook decision, shared
   by deliverOne() and testSink()).
4. match() where the branch picks between two side-effecting calls
   (insert vs update) or resolves a three-way discriminator, so the
   original single decision point is preserved.

Guard against the obvious hazard of shape 1: SettingsService encodes the
bool into a separate $encoded variable, because $value is both the
condition and the target and a naive rewrite would have made every boolean
preference store '0'.

Verified no complexity was traded away: the CyclomaticComplexity and
NPathComplexity finding sets are byte-identical to before this change.

* refactor(phpmd): inject an AuditEventFactory instead of calling AuditEvent statically

StaticAccess 63 -> 8. New lib/Event/Audit/AuditEventFactory.php exposes the
four named constructors as instance methods; 22 classes now take it through
constructor DI and call $this->auditEvents->forUser(...) etc.

The factory is stateless, so each consumer defaults it to a fresh instance
(`= new AuditEventFactory()`, PHP 8.1 new-in-initializers). Wiring it
explicitly still works and is what the Nextcloud container does, but no
existing construction site — production or test — had to change. AuditEvent
keeps its static named constructors: they are the value object's own API and
tests exercise them directly; the factory delegates rather than duplicating
the ACTOR_* mapping, and carries the one documented StaticAccess suppression
for those four delegations.

SecretService's class-level @SuppressWarnings(PHPMD.StaticAccess) is REMOVED
— its stated reason ("AuditEvent::forUser / forApplication ARE the audit
API") no longer holds now that there is an injectable seam.

Measured trade-off, reported rather than hidden: adding the dependency
raises CouplingBetweenObjects by 1 in each consumer, which pushes
LinkShareService from 12 to 13 and thus adds ONE new CouplingBetweenObjects
finding (16 -> 17). Every one of its twelve imports is used; reducing it
belongs to the coupling pass, not here. Net phpmd 143 -> 85.

phpcs 0, psalm 0, phpstan 0, phpunit 707 pass.

* refactor(phpmd): clear the last 8 StaticAccess findings (63 -> 0)

Real fixes (5):
- AuditEventTypes::whitelist() becomes `public const WHITELIST`. The table
  was already a compile-time literal, so a static accessor bought nothing;
  constant access is a value read, not a static call. (2 findings)
- phpseclib3\\Crypt\\PublicKeyLoader is now reached through the injectable
  Support\\PublicKeyLoaderAdapter, wired into CertificateAuthorityService.
  (3 findings)
- Jose JWKFactory is now reached through Support\\JwkFactoryAdapter, wired
  into JwtAuthService. (1 finding)

Both adapters are thin and stateless, default-constructed in the consumer
like AuditEventFactory, and each carries ONE documented suppression naming
the library and why no instance API exists — these libraries expose key
construction exclusively as static factories with nothing to instantiate.

Documented inline suppressions (2), where no injectable alternative exists:
- ApplicationSecretsController::parseIso8601 — DateTime::createFromFormat is
  a named constructor on a PHP built-in; ITimeFactory only produces "now".
- Application::register — Bootstrap::register is a cross-app static entry
  point in a SIBLING app that may be absent (hence the class_exists guard
  and catch(\\Throwable)); this method IS the composition root, and a typed
  dependency on a possibly-absent foreign class would 500 every route.

Turning the whitelist into a const gave PHPStan literal types and it then
proved SiemService::buildPayload's forbidden-key check unreachable. The
check is NOT removed: it is what stops secret material reaching a SIEM sink
if a whitelist row is ever widened. The two predicates are now evaluated up
front instead of short-circuited, which keeps the guard live and breaks the
narrowing that made it look dead. Comment records why.

phpmd 77 (from 225). phpcs 0, psalm 0, phpstan 0, phpunit 707 pass.
…145)

* refactor(phpmd): decompose the notifier and honey tripwire dispatchers

- DoriathNotifier::prepare() split into four subject-family renderers
  (sharing, secret lifecycle, admin, vault access) plus a shared
  withAdminSectionLink() helper; CC 20 -> 7, 193 lines -> 33.
- HoneyTripwireListener::handle() split into resolveRemoteIp(),
  resolveUserAgent() and trip(); CC 12 -> 3, NPath 290 -> 3.
- AuditController::me() renamed to mine() (ShortMethodName). The route
  name becomes audit#mine; the public URL /api/v1/audit/me is unchanged.

phpmd 77 -> 72.

* refactor(phpmd): decompose ShareService bulk registration and sync

- registerDirectShares() delegates each row to registerDirectShare()
  (validation) and createDirectShare() (owner guard, idempotency,
  copy + ShareTarget insert); CC 13 -> 4, NPath 514 -> 4, 118 lines -> 20.
- syncUpdate() delegates to resolveSyncWriter(), assertSyncSourceUnchanged(),
  collectSyncTargets(), applySyncUpdates() and applyRecipientBlob();
  CC 22 -> 3, NPath 46800 -> 3, 124 lines -> 32.

Behaviour is unchanged; the transaction boundary, the membership guard and
the skip-not-fail statuses all move intact. phpmd 72 -> 66.

* refactor(phpmd): decompose TeamFolderService membership, fan-out and offboarding

- addMember() delegates to assertMemberAddable() and
  findOrCreateMembership(); CC 10 -> 1, NPath 216 -> 1. The user/group
  validation is regrouped by member type, which removes the repeated
  $memberType comparisons without changing which input is rejected.
- registerFanOutShares() delegates each row to createFanOutShare();
  CC 12 -> 6, NPath 200 -> 6. The 'created' count now derives from the
  created rows it already collected.
- offboard() delegates to revokeOffboardedShares() and
  transferTeamSecrets(); CC 10 -> 4.

phpmd 66 -> 61.

* refactor(phpmd): group admin settings by domain, split the JWT assertion exchange

- SettingsService::updateAdminSettings() now delegates to
  updateAuthenticationSettings(), updateInstanceSettings(),
  updateExpirySettings(), updateLeaseSettings() and
  updateRetentionSettings() alongside the existing updatePolicySettings().
  CC 31 -> 1, NPath 20155392 -> 1, 159 lines -> 13. The write order of
  every key is preserved exactly, so partial-write behaviour on a
  mid-payload rejection is unchanged.
- JwtAuthService::exchangeAssertion() now delegates to
  deserializeAssertion(), readAssertionClaims(), assertClaimsAcceptable(),
  loadActiveIssuer(), loadIssuerKey() and issueAccessToken().
  CC 18 -> 4, NPath 73728 -> 4, 124 lines -> 26. The jti replay check
  stays in the caller, between claim validation and signature
  verification, exactly where it was.

phpmd 61 -> 55.

* refactor(phpmd): decompose the remaining oversized create/register paths

Controllers:
- SecretController::create() moves its param validation into
  assertCreatable(), which throws InvalidArgumentException — the same
  400 the caller already produced for those two messages. CC 12 -> 5.
- SecretRequestController::create() moves expiry parsing into
  parseExpiry(), likewise throwing into the existing 400 mapping.
  CC 10 -> 7.
- SiemSinkController::update() moves the six optional-field guards into
  collectSinkChanges(). NPath 256 -> 4.

Services:
- EphemeralSendService::create() delegates to resolvePayloadType(),
  resolveMaxViews(), resolveExpiry() and assertPasswordFieldsConsistent().
  CC 13 -> 2, NPath 576 -> 2.
- CertificateAuthorityService::resignPreservingPublicKey() delegates to
  rsaModulusOf() (used for both the old and the new certificate) and
  signPreservingSubject(). CC 12 -> 6, NPath 720 -> 6.
- ApplicationService::register() delegates to assertRegistrable(),
  buildApplicationRow() and runPostRegistrationHooks(). CC 13 -> 1,
  NPath 480 -> 1.
- HoneyCredentialService::raiseAlert() delegates to recordHoneyAccess()
  and shouldCollapseAlert(). CC 12 -> 3.

phpmd 55 -> 45.

* refactor(phpmd): clear the last CyclomaticComplexity and NPathComplexity findings

- RotationPolicyService::resolveEffectiveExpiry() delegates candidate
  collection to policyExpiryCandidates() and configuredExpiryCandidates();
  CC 10 -> 4.
- RotationPolicyService::upsertPolicy() delegates to assertPolicyInput(),
  encodeReminderDays() and findScopedPolicy(); CC 10 -> 2.
- SecretRequestService::fill() moves the requested-field checks into
  assertRequestedFieldsFilled(); CC 10 -> 5. An unparseable
  requestedFields JSON still means 'nothing to check', as before.
- SecretService::fuzzyMatch() moves the per-page Levenshtein scan into
  fuzzyPageHits() and unions the result in; CC 11 -> 8, NPath 244 -> 44.
  The union keeps left-hand keys and the helper already skips ids that
  are matched, so the merge is exactly the previous assignment.

CyclomaticComplexity 19 -> 0 and NPathComplexity 12 -> 0. phpmd 45 -> 40.

* refactor(phpmd): shorten the composition root and the CA-certs migration

- Application::register() moves the service overrides into
  registerDomainOverrides() and every listener binding into
  registerDomainEventListeners(); 194 lines -> 74. The AppHost
  class_exists() guard and the Bootstrap::register() call deliberately
  stay in register(): that guard is also the assertion psalm relies on
  to accept the call, and psalm does not carry the narrowing across a
  method boundary (extracting it produced a real UndefinedClass error).
- Version000002Date20260331000001::changeSchema() moves the column
  definitions into createCaCertsTable() and the 25-line rationale for the
  nullable is_active BOOLEAN into the class docblock; 107 lines -> 8.

ExcessiveMethodLength 7 -> 0. phpmd 40 -> 38.
…t gate (#148)

`quality / Frontend Tests (unit)` went red on development (run 30884131373)
with a single failure:

  tests/store/import.spec.js > useImportStore > round-trips an export
  .doriath-backup into committed, decryptable secrets
  Error: Test timed out in 5000ms.   (1 failed | 424 passed)

The test was not wrong and nothing it asserts had regressed - it passed
locally on the same commit. The cause is cost, not correctness.
`generateKeyPair()` is a real WebCrypto RSA-4096 keygen, i.e. a random
prime search with unbounded, high-variance runtime: 12 local samples
spread 154-850ms (median 464ms). import.spec.js called it NINE times,
once per `unlockSession()`, and paid that variance nine times over
against vitest's 5000ms per-test default while 69 spec files competed for
a two-core runner. That is a coin flip, not a gate.

Fix: cache the key pair per spec file and reuse it. The keys stay real
and every encrypt/decrypt assertion still runs RSA-OAEP against a genuine
key - only the redundant regeneration goes. Vitest isolates module state
per file, so the first test pays for one keygen and the rest reuse it.
The specs assert the crypto round-trip, never key uniqueness, so a shared
pair is equivalent. `beforeEach` still recreates pinia, so no store state
leaks between cases.

Applied to the three RSA-heaviest specs via a shared `sharedKeyPair()` /
`secondaryKeyPair()` helper in the existing rsa-fixtures module.
`secondaryKeyPair()` keeps the emergency-envelope wrong-key negative
control honest - it must be a genuinely different pair - and
rsa.spec.js's 'generateKeyPair emits a valid SPKI PEM' case deliberately
keeps its own fresh call, because there key generation IS the subject.

Measured, whole suite, same machine:

  tests/store/import.spec.js         4218ms -> 887ms
  tests/vitest/rsa.spec.js           7817ms -> 2095ms
  tests/vitest/emergencyEnvelope     4360ms -> 2794ms
  total 'tests' time                50.83s  -> 33.93s
  69 files / 425 tests passed, exit 0

No timeout was raised, no test skipped, no assertion weakened.

Positive controls (each reverted after):
  - pointing the envelope's 'attacker' at sharedKeyPair() instead of
    secondaryKeyPair() -> 'cannot be opened by a non-grantee' FAILS
    (1 failed | 3 passed), proving the two pairs really differ and the
    negative control still discriminates.
  - changing the import round-trip's expected plaintext to a wrong value
    -> FAILS (1 failed | 9 passed), proving the assertion is live.

No PHP file is touched by this commit.
…all/psalm/phpstan (#149)

The guard made the scripts always exit 0. Measured: 707 tests now run and the suite fails when an assertion is broken. Cost stated: 'PHP Quality (phpmd)' and the dependent 'Quality Report' fail on this PR and fail identically on development (run 30893158203) - pre-existing, not caused by this change. All four PHPUnit legs are green.
…150)

DashboardService carried two unrelated responsibilities behind one
constructor:

  - per-user preference storage (DashboardSettingMapper + logger),
    consumed by DashboardSettingsController;
  - a cross-domain read-only summary aggregation with seven counter
    dependencies (SecretMapper, FolderMapper, ShareTargetMapper,
    ApplicationMapper, RotationFlagMapper, CertificateAuthorityService,
    HoneyAlertMapper), consumed by DashboardController.

The two halves shared no state and no caller, so the split is a pure
move: fetchSummary(), caHealthCard() and safeCount() move verbatim to
the new DashboardSummaryService, which grows its own copy of the
two-line validateUserId() guard. DashboardController now injects the
new service; both services are autowired, so no DI registration changes.

Clears CouplingBetweenObjects on DashboardService (16 -> under 13).
No @SuppressWarnings, no baseline, no threshold change.

The four fetchSummary test cases move verbatim to a new
DashboardSummaryServiceTest; the only edit is the constructor call
losing the DashboardSettingMapper the aggregator never used. Suite
total is unchanged at 707 tests / 2281 assertions.

Verified on PHP 8.4 (container; host PHP 8.2 cannot run this tree):
  phpmd    38 -> 37 findings, exit 2 (Dashboard finding gone)
  phpunit  707 tests / 2281 assertions, exit 0 on PHP 8.3 and 8.4
  vitest   425 tests / 69 files, exit 0
  phpcs    exit 0 (clean before and after)
  psalm    exit 0
  phpstan  exit 0
chore(deps): @conduction/nextcloud-vue 3.0.0-vue3.6
…b (37 -> 35) (#153)

lib/Db is exclusively OCP\AppFramework\Db\Entity subclasses and their
QBMappers. In an Entity one protected property IS one database column --
Nextcloud derives the column list, the magic getters/setters and the
dirty-field tracking from the declared properties -- so TooManyFields is
measuring the width of a table, not a design defect.

  Db\Secret    19 fields -> doriath_secrets
  Db\SiemSink  18 fields -> doriath_siem_sinks

Cross-checked against the migrations that own those tables:
Version000008Date20260604000002 creates doriath_secrets with 15
addColumn() calls and Versions 16, 17 and 22 each getTable() it and add
more. The only change that takes Secret under the threshold of 15 is
splitting the app's core secrets table in a destructive migration, i.e.
the rule is unreachable by refactoring PHP.

Mechanism: phpmd-db.xml refs phpmd.xml and excludes exactly the two
rules; composer phpmd runs the full ruleset over lib with lib/Db excluded
and phpmd-db.xml over lib/Db, taking the worst exit code so neither leg
can be short-circuited away.

Neither rule offers a per-class exception surface (TooManyFields has only
maxfields, ExcessiveClassLength only minimum/ignore-whitespace), so a
threshold bump would have stopped reporting a 19-field service too. A
top-level <exclude-pattern> was rejected for the opposite reason: it
filters in PDepend, dropping lib/Db from EVERY rule, which would have
silently hidden the real ShareTargetMapper TooManyPublicMethods finding.
No @SuppressWarnings, no baseline file.

Probe (two byte-identical 20-field, 1137-line, 11-public-method classes,
one in lib/Db and one in lib/Service):

  rule                    lib/Service      lib/Db
  TooManyFields           REPORTED         not reported
  ExcessiveClassLength    REPORTED         not reported
  TooManyPublicMethods    REPORTED         REPORTED
  ExcessiveMethodLength   REPORTED         REPORTED
  ShortVariable x10       REPORTED         REPORTED
  ShortMethodName x10     REPORTED         REPORTED

Also removes a nested <exclude-pattern>*Migration*</exclude-pattern> from
the UnusedFormalParameter rule. It was dead: PHPMD reads exclude-patterns
in RuleSetFactory::getIgnorePattern(), which walks only elements directly
under <ruleset>, so a nested one parses and does nothing. Verified that
removing it changes neither the finding list nor the exit code.

phpmd 37 -> 35, exit 2 -> 2. The gate is still red on 35 complexity and
coupling findings, 22 of them on security-critical classes; see the
follow-up issue. Refs #152.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…pping (#155)

A skipped job and a passing job are indistinguishable in the Quality
Report. Every gate turned on here reported 'skipped' in every run.

Each newly-enabled leg was measured against this tree BEFORE being
enabled; the results are in the PR description. Legs that were measured
failing are enabled anyway - the defects are pre-existing, and the only
thing that changed is that CI can now see them.

Journeydoc Capture and enable-axe are deliberately NOT enabled.
… 32 migration suppressions (#157)

lib/Migration was carrying 32 @SuppressWarnings(PHPMD.UnusedFormalParameter)
docblock tags. They existed because the exclusion that was supposed to replace
them never worked: the rule used to carry a NESTED
    <exclude-pattern>*Migration*</exclude-pattern>
and a nested exclude-pattern is INERT - PHPMD only reads them as direct
children of <ruleset>. The element was later removed with a note claiming that
a top-level pattern was the only alternative and that "nothing in lib/Migration
is currently reported" anyway. Both halves of that note were wrong, and it has
been replaced with an accurate one.

Wrong half 1: a ruleset of its own gives the pattern a top-level home whose
blast radius is that single rule. phpmd-unusedparams.xml now holds
UnusedFormalParameter alone with a top-level */Migration/* exclude-pattern, and
runs as a third leg alongside the existing phpmd.xml and phpmd-db.xml legs. The
lib/Db leg is untouched. The new leg runs over all of lib, including lib/Db,
because phpmd-db.xml imports phpmd.xml and would otherwise lose the rule
entirely.

Wrong half 2: "nothing in lib/Migration is currently reported" was an artefact
of the 32 suppressions. Strip them and lib/Migration reports 64
UnusedFormalParameter findings - every one on a changeSchema / preSchemaChange /
postSchemaChange signature mandated by OCP\Migration\IMigrationStep, which a
step cannot alter even when it uses none of the three parameters. The scoped
ruleset replaces all 32 tags, which are deleted here.

The pattern is */Migration/* and not *Migration*: the latter would also have
swallowed lib/Service/MigrationService.php, lib/Controller/MigrationController.php,
lib/Db/SuiteMigration.php and the SuiteMigration events and listeners.

MEASURED - PHPMD 2.15.0 / PHP 8.4.22, three legs, worst exit code:

  suppression-free true count   205 -> 141
  retired                       64 UnusedFormalParameter, all in lib/Migration
  newly appearing               0
  lib/Migration still reports   2 ExcessiveMethodLength + 1 StaticAccess

  shipped reported count        35 -> 35, finding list byte-identical

Positive-controlled with probe classes in lib/Migration, lib/Db and lib/Probe:
the Migration probe's UnusedFormalParameter is dropped while its ElseExpression
is still reported by leg 1, and the lib/Db and lib/Probe probes report from
leg 3 only - so the exclusion is real, it is scoped to the one rule, and no
finding is double-counted across legs.

Also fixes the tag-value indentation PHPCS error that deleting the tag above
@SuppressWarnings(PHPMD.StaticAccess) exposed in Version000004.

No phpmd.baseline.xml exists in this repo; none was added or removed.
No @SuppressWarnings was added. The 5 ExcessiveMethodLength and 1 StaticAccess
suppressions in lib/Migration, and the 18 UnusedFormalParameter suppressions
elsewhere in lib, are untouched.

phpunit -c phpunit-unit.xml: 707 tests / 2281 assertions before and after.

Refs #156
…ad deleted, 79 kept with a written reason (#159)

* refactor(phpmd): adjudicate all 106 @SuppressWarnings — 27 deleted as dead, 79 kept with a written reason

doriath's SHIPPED phpmd count (35) and its TRUE count (141, measured with every
@SuppressWarnings stripped on a throwaway copy) differ by 106. This change
audits every one of those 106 tags. It does not move either number, and that is
the honest result — but the repo now states its debt truthfully instead of
carrying 27 tags that suppressed nothing and 28 that gave no reason.

| measure                          | before | after |
|----------------------------------|--------|-------|
| SHIPPED findings                 |     35 |    35 |
| TRUE findings                    |    141 |   141 |
| @SuppressWarnings tags in lib/   |    106 |    79 |
| dead (misleading) tags           |     27 |     0 |
| kept tags with NO written reason |     28 |     0 |
| unit suite                       | 707/2281/1 skipped | 707/2281/1 skipped |

There is no phpmd.baseline.xml in this repo and there must not be: PHPMD
AUTO-DISCOVERS that filename, so a baseline would stay active even after the
`baseline` CLI flag is removed. phpmd.xml, phpmd-db.xml, phpmd-unusedparams.xml,
phpcs.xml, phpunit*.xml, composer.json and tests/ are all byte-unchanged.

Method
------
Adjudicating a tag requires knowing which finding it hides, and `mktrue.sh`-style
tag DELETION shifts every line below it, so triples stop being comparable across
the two trees. The audit therefore used a LINE-PRESERVING true tree — each tag
line replaced by ` *` rather than removed. `TRUE − SHIPPED` then yields exactly
the 106 findings the tags actually suppress, each mappable to one tag.

The 27 dead tags
----------------
14 LongVariable (12 lib/Db entities + 2 SecretController), 3 migration
ExcessiveMethodLength, 3 CertificateAuthorityService StaticAccess, plus
RotationController/SiemService TooManyPublicMethods, ShareRequestService
CouplingBetweenObjects, SecretController CyclomaticComplexity, and 3
within-file misplacements. VERIFIED rather than asserted: after deleting all 27
the shipped set was unchanged — 35 findings, same triples. A live deletion would
have shown up as a new finding.

Two mechanism findings worth recording:
* CertificateAuthorityService carried 3 StaticAccess tags citing
  `Uuid::uuid4()`, which IS called at 3 sites — yet PHPMD reports ZERO
  StaticAccess in that file. The call is nested inside an argument list
  (`setId(Uuid::uuid4()->toString())`). The same run reports StaticAccess at 14
  other places, so the absence is real, not a dead scan.
* `KeyGeneratorService::generateFromCharset`'s `bool $includeSpecial` has NO
  default, so BooleanArgumentFlag cannot fire on it — the rule keys on the
  DEFAULT VALUE, not the call site. Confirmed before deleting.

Why the shipped 35 did not move
-------------------------------
Real cleanups were made (12 genuinely dead imports removed across 12 files, e.g.
SecretTypeService importing a Repair\ class), but none of them moved a finding,
and saying otherwise would be false. PDepend's CouplingAnalyzer counts AST
references — @throws types, property/param/return types — not `use` lines. That
was read first and then confirmed empirically: SecretVersionService stayed at 13,
LeaseService at 14. Only line numbers moved.

All 35 remaining are large-class / coupling metrics whose only honest fix is
architectural decomposition: 16 CouplingBetweenObjects (13-37 vs 13; Application
at 37 is the DI composition root), 10 ExcessiveClassComplexity (52-95 vs 50),
4 TooManyPublicMethods, 3 ExcessiveParameterList (12-15 ctor DI params vs 10),
1 TooManyMethods, 1 ExcessiveClassLength (1466 vs 1000).

Four of the coupling findings sit at exactly 13 and could be dropped under the
line by deleting a single type reference (e.g. `new DateTime()` -> `date()`).
That is rule-dodging, not debt reduction, so it was not done.

@SPEC anchors
-------------
28 `@spec` tags in the touched files carried a `#requirement-…` fragment that
exists in NO version of its target — neither the archived change spec nor the
canonical `openspec/specs/` one. `#requirement-bounded-delivery` against a spec
whose headings are "Admin-configured syslog and webhook sinks", "Forwarded
payload carries no secret material", "Reliable background delivery",
"Backpressure and observability"; `#requirement-mark-rotated` against "Proven
mark-rotated flow"; and so on. They are shorthand invented by an earlier
annotation pass.

gate-46 (spec-anchor-existence) is diff-scoped, so these only surface once the
file is edited — which this change does. The fragments are DROPPED rather than
re-guessed: which Requirement each method implements is exactly what no version
of the spec substantiates, and wrong traceability is worse than none. The path
itself resolves and is kept, so each tag still says truthfully "this code
implements something in this spec".

Pre-existing issue fixed
------------------------
The docblock edits exposed the repo's column-alignment sniff: two
@SuppressWarnings in one docblock must have column-aligned tag values. Fixed in
KeyGeneratorController.php and KeyGeneratorService.php (8 spaces, matching the
existing convention in SiemService).

Not done, with reasons
----------------------
* Splitting TeamFolderService (1466 lines / 37 methods / 15 ctor deps) would
  alone clear 3 findings and is the highest-value follow-up. Deliberately not
  attempted here: it changes public service boundaries, the extracted
  collaborator would inherit most of the same mappers (so the ctor list may not
  even drop below 10), and a speculative split of that size needs design review,
  not a debt-burndown pass.
* Removing `bool $x = false` defaults to silence 7 BooleanArgumentFlags moves
  code out of the rule's detection window without fixing the SRP smell. Most are
  Nextcloud router request-param bindings where an optional field MUST carry a
  default; that constraint is now written into each reason instead. Each claim
  was verified (e.g. SettingsService::$force appears exactly once, passed
  straight through to OpenRegister's ADR-022 signature).
* Splitting Version000001's changeSchema (109 vs 100 lines) — it is the
  initial-schema migration every fresh install runs; 9 lines is not worth that.
* Repointing the remaining `openspec/changes/…` @SPEC paths to canonical
  `openspec/specs/…` is a repo-wide policy cleanup of its own; the paths all
  resolve today via the archive index.

Verified inside nextcloud:latest (PHP 8.4.22) with `composer install` run IN
this worktree — host PHP 8.2 kills vendor/bin/phpmd in platform_check.php with
exit 255, which reads exactly like a clean run. phpcs 0 errors, psalm exit 0,
phpstan exit 0, PHPUnit 707/2281/1 skipped exit 0, phpmd shipped set identical
to development's.

* test(phpmd): lock in the two suppression-hygiene invariants this audit established

gate-47 (security-change-has-tests) went red on this PR, and correctly by its
own contract: the diff touches 13 security-classified files under lib/ (they
match on `IUserSession`, `#[NoAdminRequired]`, `getSecureRandom`, …) and changed
no test file. The gate offers an `exclude` opt-out; that is a waiver, so it was
not used.

Instead, the invariants this audit created are now asserted. Both are real
guarantees that did not exist before and can regress silently:

1. testEverySuppressionCarriesAWrittenReason — every @SuppressWarnings under
   lib/ has a reason of at least 12 characters on the tag's own line. Before
   this PR, 28 of 106 had none. Without the assertion the next unexplained tag
   reads exactly like an adjudicated one.
2. testNoAutoDiscoveredPhpmdBaselineExists — no phpmd.baseline.xml anywhere.
   PHPMD AUTO-DISCOVERS that filename, so a baseline stays active even after
   the `baseline` CLI flag is removed from composer.json; its mere presence
   would silently subtract findings from every leg of `composer phpmd` while
   the run still looked clean.
3. testScanReachesTheSourceTree — a positive control on the scan itself. A
   wrong path would make both assertions above pass vacuously; an empty scan
   and a clean one are otherwise indistinguishable.

Source-scanning tests are an established shape in this repo — see
KeyGeneratorServiceTest::testSourceUsesNoWeakRandomness().

NEGATIVE CONTROL, because a passing assertion is only evidence about the
assertion until it has been shown to fail. With a bare
`@SuppressWarnings(PHPMD.UnusedFormalParameter)` dropped into a throwaway
lib/ file and an empty phpmd.baseline.xml touched at the repo root:

    Tests: 710, Assertions: 2285, Failures: 2, Skipped: 1
    1) …::testEverySuppressionCarriesAWrittenReason
       @SuppressWarnings without a written reason (>= 12 chars …)
    2) …::testNoAutoDiscoveredPhpmdBaselineExists
       PHPMD auto-discovers phpmd.baseline.xml; none may exist in this repo

Both throwaways were removed; the tree is clean.

Suite: 707/2281/1 skipped -> 710/2285/1 skipped, exit 0. phpcs 0 errors, psalm
exit 0, phpstan exit 0, phpmd shipped count unchanged at 35.

* fix(phpmd): narrow the UnusedFormalParameter exclusion from */Migration/* to */lib/Migration/*

`*/Migration/*` is a path-SEGMENT pattern, not a directory anchor. PDepend
compiles an exclude-pattern into an UNANCHORED regex — Input\ExcludePathFilter
preg_quote()s the pattern and then turns `\*` into `.*` — so it matches ANY path
containing a `/Migration/` segment: `lib/Service/Migration/`,
`lib/Command/Migration/`, any future `lib/*/Migration/`. Those are ordinary
classes with no interface-mandated signature, and a genuine unused parameter in
one would never be reported while the run still looked clean.

doriath is the repo where this was closest to biting: it already carries
MigrationController, MigrationService, SuiteMigration, SuiteMigrationMapper and
two SuiteMigration listeners. None of them sits under a `/Migration/` DIRECTORY
today — `Migration` is part of the class NAME, and the compiled regex requires
the slashes — so nothing was actually being swallowed. One `lib/Service/
Migration/` refactor away, it would have been.

PROBE MATRIX, PHPMD 2.15.0 / PHP 8.4.22, three probe classes on a throwaway copy:

  probe                                   */Migration/*   */lib/Migration/*
  lib/Migration/…            UFP          not reported    not reported  (intended)
  lib/Migration/…            Else (leg 1) reported        reported      (leg 1 unaffected)
  lib/Service/…              UFP          reported        reported
  lib/Service/Migration/…    UFP          NOT REPORTED    reported      (the leak)

The IMigrationStep exclusion still does exactly its job; leg 1 still analyses
lib/Migration for every other rule; and the leak is closed.

Real numbers: UNCHANGED. Shipped 35 -> 35, TRUE 141 -> 141. Nothing was being
hidden, which is what the grep for `/Migration/` directories outside
lib/Migration/ already predicted — but the trap is gone regardless.

One process note worth recording: the first draft of this comment contained a
literal `<-` inside the <description>, which made the ruleset UNPARSEABLE. PHPMD
then exited 1 and reported NOTHING — and "no findings printed" is exactly what a
clean run looks like to a grep. It was caught only by reading the exit code
directly. The XML is now validated as part of the check.
…ot-applicable (#160)

hydra-gates-require-full-coverage (default ON) fails the run when a gate
whose subject matter exists did not report, and its contract is that a
legitimately not-applicable gate must DECLARE itself: 'A gate that emits
nothing at all still counts against coverage.'

v1.0.1 contains ZERO _skip calls — it has no na/structural/wiring vocabulary
whatsoever, so any gate whose prerequisite is absent emits nothing and is
counted as DID NOT RUN. v1.3.0 contains 36 such declarations.

Measured here, run 31019022177 on development:
  COVERAGE: 58 of 61 declared gates reported a result.
  GATES THAT DID NOT RUN: 4 24 33

None of the three is starved by a skipped producer job: gate-4 is diff-scoped
out, gate-24's input is scripts/check-integration-parity.sh in this repo, and
gate-33's input needs enable-axe. In that run Playwright PASSED and Newman RAN,
and 24/33 still did not report.
Two files still declared `SPDX-License-Identifier: AGPL-3.0-or-later` while
every other licence signal in the repo (LICENSE, composer.json, package.json,
appinfo/info.xml and all 241 lib/**.php @license tags) says EUPL-1.2.

- tests/e2e/visual/_visual-helpers.ts
- tests/integration/run-newman.sh

Header-only change; PHP (710 tests) and vitest (425 tests) suites are
byte-identical before and after. gate-28 license-triangle: PASS -> PASS.
…ation-2026-08-05

chore(license): normalise 2 stray AGPL-3.0 SPDX declarations to EUPL-1.2
…its path (#162)

* fix(apphost): register OpenRegister's autoloader instead of guessing its path

Apps register in sorted order (OC_App::getEnabledApps() sort()s the list, and
Coordinator::registerApps() calls registerAutoloading() then register() one app
at a time), so doriath's register() runs before OCA\OpenRegister\ is
autoloadable. The previous workaround include_once'd
../../../openregister/vendor/autoload.php, which assumes both apps share one
apps directory and silently does nothing on a multi-apps_paths install.

Resolve the path through IAppManager::getAppPath() and hand it to
OC_App::registerAutoloading(), which touches only the autoloader and is
idempotent. Deliberately NOT IAppManager::loadApp(), which would mark
OpenRegister loaded and boot it before its own register() had run.

The class_exists(Bootstrap::class) guard now wraps the call so an absent
OpenRegister degrades instead of aborting the rest of register().

* fix(apphost): make the autoload prelude testable and teach the analysers OC_App

CI found three real problems with the first version of this change:

- psalm and phpstan both fail on \OC_App: it is Nextcloud's legacy bootstrap
  class, server-private and absent from nextcloud/ocp. There is no OCP
  interface for registering another app's autoloader. Added to the same
  ignore lists that already carry OC, OCA\OpenRegister\, Doctrine and Guzzle.
- the coverage ratchet dropped 55.78% -> 55.74%, because an inline prelude in
  Application::register() cannot be reached by a unit test — Application
  needs a Nextcloud DI container to construct.

Moving the prelude into AppInfo\OpenRegisterAutoloader fixes the second
properly rather than by lowering the baseline: the contract that actually
matters — this NEVER throws, so it can never abort the caller's register(),
which is the very defect the prelude exists to prevent — is now directly
asserted, along with its idempotence.

Verified in the container (PHP 8.4): phpstan OK, psalm no errors, PHPCS clean,
PHPMD clean on the new file, and both new tests pass.

* fix(spec): point the prelude's @SPEC anchor at doriath's own spec

gate-46 (spec-anchor-existence) resolves @SPEC targets inside THIS repo. The
anchor named openspec/specs/apphost-boilerplate/spec.md, which is
OpenRegister's canonical spec file and does not exist here, so it could never
resolve. It also used a ' — Requirement: ...' suffix that no other @SPEC in
this repo uses.

Points at doriath's own openspec/specs/apphost-adoption/spec.md instead.
…ne (#164)

The standing 'Release: merge development into beta' PR has head_ref
'development', so its pull_request run rendered the same concurrency group as a
push to development. cancel-in-progress killed the push run, which is the only
carrier of the push-only jobs (Coverage Baseline Check, SBOM, Features
Extract). Those jobs report 'skipped' on the surviving PR run, which renders
like a pass, so the gate never produced a verdict.

Suffixes -push on the group for main/development pushes only; feature-branch
dedup is unchanged. No gate weakened: no waiver, baseline, threshold or
continue-on-error.

Same fix as openconnector#1158.
…#166)

.coverage-baseline was read as a floor by the phpunit guard and as an exact
target by the push-side staleness check. Together they demand equality with a
checked-in constant, which against a moving base branch is not satisfiable:
closing "stale" means committing the value the tree will measure after the PR
lands. Measured on openregister — committed 58.93, development advanced
16030->16038 tests, merge result measured 58.88, guard reported a 0.05% drop.

coverage-guard.php gains --against=<clover.xml>, naming a report measured at
the merge base. When present it is the only floor; the committed constant is
reported but not enforced. Both numbers then come from one driver in one job,
so the xdebug/pcov statement-counting difference cancels rather than being
baked in, and the merge base cannot go stale.

Ratios are compared as exact integer cross-products, not rounded percentages:
at two decimals a one-statement regression read as "unchanged" and exited 0.
An empty or zero-statement report is now a hard error rather than 0%, which as
the merge-base side would set the floor to zero and pass every drop.

Verified on real CI clover artifacts: a genuine 1.44% drop fails, an unchanged
tree passes, and adding untested code fails while adding tested code passes.
…bles, not OpenRegister

Written 2026-07-27 and left uncommitted since. Recording the decision where it
belongs rather than leaving it as an untracked file on one machine.
* chore(ci): move hydra-gates-ref v1.3.0 -> v1.4.0

A pinned `hydra-gates-ref` is a silent expiry date on every upstream fix:
this repo cannot receive a gate-package change until this line moves.

v1.4.0 is the latest tag and the first one that carries
`hydra-gates/scripts/axe-run.cjs` (verified absent at v1.3.0), so it is
also the first that has ConductionNL/.github#168 axe DOM scoping and
ConductionNL/.github#165 gate-46 fix.

`enable-axe` is deliberately NOT enabled in this commit. Ordering matters:
the ref lands first, enabling axe is a separate decision.

* chore(ci): stop pinning hydra-gates — track the package at @main

Removes the `hydra-gates-ref` input from the `quality.yml` caller. The
shared workflow already defaults it to `main`, and this repo consumes
`quality.yml` itself at `@main`, so dropping the override makes both
sides move together: a gate-package fix lands here without a commit here.

A pin is a silent expiry date on every upstream fix, and we have paid for
that twice already:

  - .github#159 — 22 repos sat on v1.0.1, which predated the gate fixes.
    16 gates were dead fleet-wide and every single one reported PASS. A
    gate that never runs emits a tick identical to one that did, so
    nothing in any repo's history showed it.

  - .github#173 — the shared side flipped a default at @main while the
    package stayed pinned per caller. Old runners lacked the coverage
    accounting the new default assumed, so they went red on gates they
    had no subject matter for.

Removing the pin closes both shapes at once. Rolling back is a revert on
ConductionNL/.github main, which reaches the whole fleet in one commit;
holding this one repo still is still possible by setting the input
explicitly, with a reason.

`enable-hydra-gates: true` is unchanged. `enable-axe` remains unset.
The comment block that justified the pin is replaced with a short note
saying why there is no pin.
rjzondervan and others added 28 commits August 19, 2026 16:30
…his spec

Every scenario in secrets-write-ui is now accounted for: 15/15, ten by a Playwright
test and five by an exclude that names the tests actually driving it. None of the
excludes claims coverage that does not exist — that was the whole point of not
waiving these earlier.

The three closed here had NO coverage of any kind, and all three are older than the
work that surfaced them:

- **Name and value are required** and **Create a secret inside the current folder** —
  carried across PR #270 and #282 as a known gap. SecretCreateDialog.requiredFields
  walks every partial state (including whitespace-only), asserts no request is made,
  and covers the folder default plus the null-folder case at the vault root.
- **Edit metadata only** — SecretEditDialog.metadataOnly asserts only the name is
  sent when only the name changed, nothing at all is sent when nothing changed, and
  the counterpart: the value IS re-encrypted when it really changes. That matters
  beyond wasted work — re-encrypting on every rename would rewrite the whole
  additional-fields blob, which is what loses members another session added.

A harness trap is documented in both files, because it silently defeats tests of
this exact kind: `sessionStore.isLocked = false` is a NO-OP. It is a getter over
`cryptoKey`, so the assignment does nothing (Vue logs "target is readonly"), the
dialog stays locked, canSubmit stays false, and submit() returns having done nothing
— a test asserting on the resulting call would simply never see one. The existing
SecretCreateDialog.generator spec does the same thing.

Two disclosures.

First, commit 4fcc170 carries content its message does not describe: the
registry-dispatch spec and two scenario annotations were swept into it by a `git add
-A` before I had written their commit. The work is right, the message is incomplete,
and history stays as it is rather than being rewritten.

Second, eslint --fix and prettier disagree on these files and will undo each other.
Prettier has to run last; after that both are clean.

Assisted-by: ClaudeCode:claude-opus-5
…bs/php_codesniffer-4.0.4

build(deps-dev): bump squizlabs/php_codesniffer from 3.13.6 to 4.0.4
Bumps [twig/twig](https://github.com/twigphp/Twig) from 3.27.0 to 3.28.0.
- [Release notes](https://github.com/twigphp/Twig/releases)
- [Changelog](https://github.com/twigphp/Twig/blob/3.x/CHANGELOG)
- [Commits](twigphp/Twig@v3.27.0...v3.28.0)

---
updated-dependencies:
- dependency-name: twig/twig
  dependency-version: 3.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
…60819081801

chore(release): 0.1.6-unstable.20260819081801
…uuid-4.9.3

build(deps): bump ramsey/uuid from 4.9.2 to 4.9.3
…ud/ocp-34.0.3

build(deps-dev): bump nextcloud/ocp from 34.0.2 to 34.0.3
…ib/phpseclib-3.0.56

build(deps): bump phpseclib/phpseclib from 3.0.55 to 3.0.56
…ig-3.28.0

build(deps-dev): bump twig/twig from 3.27.0 to 3.28.0
…andards/phpcsextra-1.5.1

build(deps-dev): bump phpcsstandards/phpcsextra from 1.5.0 to 1.5.1
…ics/phpmetrics-2.11.0

build(deps-dev): bump phpmetrics/phpmetrics from 2.9.1 to 2.11.0
…oken

v1.8.0 shipped an ObjectServiceInterface WITHOUT patchObject() and with
updateObject() still summarised as "Apply a partial update" — the wording
that sent a consumer down the erasing path. The correction landed on main
two days after the tag; every app has been pinned to the broken copy since.

It is not confined to this repo: hydra-gates claims OCA\OpenRegister\Contract\
in its composer autoload, a LONGER psr-4 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 updating
openregister ALONE did not change the winner — which is why this lands
across the fleet rather than in one repo.
chore(deps): take hydra-gates v1.8.1 — the contract v1.8.0 shipped broken
All three reproduce locally and all three are this branch's own, which is why they
could not be fixed centrally: `npm run lint` exits 0 on development and 2 here.

**`npm run format`** — CI runs prettier over the WHOLE repo
(`**/*.{js,ts,vue,css,scss}`), not the `src/` and `tests/` subsets I had been
checking, which is why my local sweeps reported clean. One file was unformatted:
tests/views/SecretRequestFill.spec.js. `format:fix` touched only that file.

**`npm run lint`** — exited 2 while printing "0 errors", on "There are suppressions
left that do not occur anymore". Pruning removed exactly one entry:
`src/App.vue @nextcloud/l10n-enforce-ellipsis`. This branch's edits to App.vue
eliminated that violation, so the suppression went stale — which is also why
pruning on development would have been wrong: nothing is stale there, and the entry
is still needed.

**`npm run test:l10n`** — "Revoking…" (src/App.vue:194) was used in source but
missing from en.json. A pre-existing gap that predates this branch; it surfaced here
because this is where App.vue changed. Added with the same 36 translations used on
the later branches, so merging up is a no-op rather than a conflict. Mixed-script
scan clean.

Worth stating plainly: I reported "eslint 0 errors" and "prettier clean" as green in
earlier commit messages on this branch. Both commands actually failed — one by exit
code while printing a reassuring summary line, the other because I checked a
narrower path set than CI does. Exit codes from here on, not summary text.

NOT fixed here, because it is not a code problem: the four PHPUnit matrix jobs fail
on `HTTP/2 504` fetching php-fig/cache while composer-installing the sibling
openregister app, and the job correctly refuses to continue rather than report that
app's missing classes as ours. Same signature on all four legs. Needs a re-run.

Sweep: gates 49/49 applicable, PHPUnit passing, vitest 583, format/lint/test:l10n
and the l10n parity ratchet all exit 0.

Assisted-by: ClaudeCode:claude-opus-5
…y-lifecycle' into feature/268/secret-request-expiry-lifecycle
…ores

chore(openspec): archive two finished changes and clear the debt behind them
…expiry-lifecycle

feat(secret-requests): make an expiry something that is acted on, not only checked
Two user-visible defects are fixed by this bump.

TWO AI-COMPANION HEXES ON EVERY PAGE. The companion singleton landed in
2.7.0. Below that the host app's own companion never stands down, so any
page of this app rendered a second hex 8px from hermiq's — measured on a
running instance: openconnector (2.7.1) showed ONE, openbuild (2.6.3)
showed TWO, both visible at 52x60, from two separate mounts.

THE DETAIL PAGE RECLOSED ITS SIDEBAR WHILE HYDRATING. CnDetailPage set
sidebarSeeded and never read it, so 'open' was re-applied on every sync
and each reactive change during hydration reset it to the prop default.
Fixed in nextcloud-vue#711; that is the cause behind openbuild#268 and,
on the evidence, #188.

Lockfile only — the existing caret already allowed this. Three-line diff
(version, resolved, integrity).
…mption

docs(gate-7): declare the k-anonymity range proxy exempt, with its reason
chore(deps): take @conduction/nextcloud-vue 2.8.2 (was 2.3.0)
Brings beta up to date with development for tonight's fleet-wide beta
checkup, so tomorrow's review runs against current code.

Conflict resolved: package.json — took development's dependency set. Beta's `linkifyjs` is dropped: development neither declares nor imports it (verified), so beta would be shipping a dependency its own source never uses.

Beta-only files that did not conflict are preserved.
@rubenvdlinde
rubenvdlinde merged commit d309248 into beta Aug 20, 2026
44 of 45 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/doriath @ 6c217d7

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
composer ✅ 117/117
npm ✅ 533/533
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-20 20:42 UTC

Download the full PDF report from the workflow artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants