Skip to content

Release: merge development into beta - #79

Open
github-actions[bot] wants to merge 549 commits into
betafrom
development
Open

Release: merge development into beta#79
github-actions[bot] wants to merge 549 commits into
betafrom
development

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

Conduction Release Bot and others added 30 commits July 24, 2026 17:43
…rage gaps

- Fixed the "Logo defaults..." requirement anchor (apostrophe slugifies to
  "-s-", not "s") across all three files that reference it.
- Added missing @SPEC tags to every changed method/computed flagged by
  hydra gate-16 (spec-coverage): AppBrandedHeader.vue, AppCustomThemeSection.vue
  (including the headerStyleOption get/set pair), useAppCustomTheme.js,
  checkThemeContrast.js helper functions.

Result: 38/39 hydra gates green (--scope-to-diff); the sole remaining
failure (gate-46 spec-anchor-existence) is pre-existing dangling @SPEC
references to archived change directories, verified identical on
origin/development before this change.
feat(app-theming): logo + 3-color + header-style theming with WCAG contrast guardrail
- Move openspec/changes/app-theming/ to openspec/changes/archive/2026-07-24-app-theming/
- Sync openspec/specs/app-theming/spec.md from the archived delta (Status: done),
  corrected against the actual implementation (var()-fallback precedence
  mechanism, PageDesignerHost.save() as the persist-boundary hard block,
  AppCustomThemeSection.vue as the editor location) rather than design.md's
  implementation-time guesses
- CHANGELOG.md entry under the existing [0.7.7] - 2026-07-24 release
…run log

Adds the Agent Workspace feature on top of the existing ai-copilot plan/
execute engine (ADR-022 consume-not-rebuild, design.md Decision 1):

- Agent + AgentRun schemas (register.d fragment 70-agent-workspace.json).
  enabledTools is validated against the eight OpenBuildToolProvider tool
  ids via a schema-level enum (declarative, ADR-031).
- CopilotService::plan()/execute() gain an optional agentId that resolves
  the Agent server-side (never trusted from the client beyond the id),
  narrows the effective tool allow-list to the intersection with the
  agent's enabledTools, and prefixes its instructions onto the system
  prompt. maxActionsPerRun is enforced at plan-acceptance time (422 naming
  max_actions_per_run). A new discard() endpoint logs a discarded proposal.
- AgentRunLogger persists one AgentRun per plan+execute/discard turn
  (prompt, plan, every tool call's arguments+result, outcome), as a thin
  wrapper CopilotService calls only on the agent-scoped branches — the
  bare copilot path is unchanged and unaffected.
- AgentsController::runs() serves the owners/editors-only run-history read
  with an explicit per-object RBAC guard (closes the IDOR shape a bare
  #[NoAdminRequired] + generic OR REST read would have).
- Frontend: AgentsPage.vue (CRUD + chat + history), AgentEditDialog.vue,
  AgentRunHistory.vue; CopilotPanel.vue gains optional agentId/name/
  instructions/enabledTools props, fully backwards-compatible.

PHPUnit: 729 passing (baseline 699 + 30 new). Vitest: 1366 passing
(baseline 1338 + 28 new). Playwright e2e written, CI-run only per policy.
…ge, no-admin-idor exempt, e2e visual reference
…gelog

- Move openspec/changes/agent-workspace/ to openspec/changes/archive/2026-07-24-agent-workspace/
- Sync openspec/specs/agent-workspace/spec.md from the archived delta (Status: done)
- Apply the MODIFIED REQ-OBAIC-002/004 requirements + new agent-scoped
  scenarios into openspec/specs/ai-copilot/spec.md; mark agent-workspace
  archived in its OpenSpec changes list, Status: done
- CHANGELOG.md entry under the existing [0.7.7] - 2026-07-24 release
feat(agent-workspace): named, tool-scoped AI agents with transparent run log
feat(runtime-group-scoped-access): server-side group-scoped menu/page filtering
Deploy marker for the 2026-07-23/24 market-deepdive wave (7 merged features:
public-forms-runtime, automation-approval-steps, automation-document-action,
component-blocks, app-theming, agent-workspace, runtime-group-scoped-access).
Version bump triggers InitializeSettings repair (register.d fragment import)
and busts the frontend ?v= bundle cache.
chore(release): bump 0.7.7 → 0.8.0 (deploy marker for deepdive wave)
…liq/OR and theming to nldesign

public-forms-runtime (PR #8) built a public/citizen form surface + ShareToken
model + anonymous-write service inside OpenBuild. Citizen-facing rendering is
Portaliq's domain, and external-user object creation already exists in
OpenRegister (CaseTokenController + FormLinkService). Removed entirely; will
be rebuilt correctly later as a thin leaf.

app-theming (PR #15 + archive PR #16) built per-app theming + a WCAG contrast
checker + scoped-CSS applier inside OpenBuild. App-specific theming belongs to
nldesign (NlDesignTheme). Removed entirely.

Deleted all new files from both features (controllers, services, Vue
components/composables, dialogs, templates, tests, openspec change/spec
dirs) and surgically reverted their hunks in files also touched by later,
preserved PRs (routes.php, DashboardController, ManifestResolverService,
PageDesigner.vue, ApplicationDetailActions.vue, FormPageEditor.vue,
useManifestValidator.js, BuilderHost.vue, PageDesignerHost.vue,
webpack.config.js, CHANGELOG.md, openbuild-runtime spec.md) — none of
automation-approval-steps (#10), automation-document-action (#12),
component-blocks (#13/#14), agent-workspace (#17),
runtime-group-scoped-access (#18), or the 0.8.0 version bump (#19) were
altered.

PHPUnit: 742 -> 715 tests (delta = 27, exactly ShareTokenServiceTest +
PublicSubmissionServiceTest), 0 failures before and after.
Vitest: 132 files / 1290 tests, all passing after the revert.
ESLint (src): 0 errors.
…app-theming

revert(public-forms-runtime,app-theming): move citizen forms to Portaliq/OR and theming to nldesign
…ous intake + Portaliq portalPage

Rebuilds the reverted public-forms-runtime (PR#8/#20) as a configuration
leaf: OpenBuild PATCHes an OpenRegister schema's authorization (read-merge-
write, never a partial fragment) and creates/updates a Portaliq portalPage
object, then surfaces the resulting public URLs. No PublicPage controller,
no anonymous route, no ShareToken model, no rendering — every anonymous
surface stays owned by OpenRegister/Portaliq (ADR-022).

- externalFormProvisioningService.js: enablePublicCreate/revokePublicCreate
  (schema authorization) + provisionPortalPage/draftPortalPage (Portaliq),
  all GET-merge-PUT/PATCH.
- ExternalFormAccessDialog.vue: builder toggle wired from FormPageEditor's
  new "External access" section (gated on an OR-shaped submitEndpoint).
- useTrackLinkAction.js + TrackLinkAction.vue: owner-context "mint a
  track-link" action, registered in runtimeRegistry.js for BUILT apps'
  Detail pages, gated on runtime.externalForms[].trackLinkAction.enabled.
- manifestValidation/externalForms.js: strict shape validation for the new
  runtime.externalForms[] manifest block, wired into useManifestValidator.
…ed annotations

Satisfies hydra gate-16 (spec-coverage) and gate-19 (e2e-coverage): tags
the remaining changed computed/methods in TrackLinkAction.vue and
ExternalFormAccessDialog.vue, and marks the 5 spec scenarios that need a
live running instance as @e2e exclude (deferred — no shared-dev deploy per
workflow), pointing each at its Vitest coverage.
…tus=done

All 7 requirements implemented and verified (1.1-4.2, 5.3); live Playwright
e2e (5.1) and Newman (5.2) deferred — no shared-dev deploy available this
session, marked DEFERRED in tasks.md with reasons, unit/vitest coverage
substitutes at the request-shape level per the fleet's established DEFERRED
convention (see 2026-06-14-procest-workflow-attachments precedent).
feat(external-form-provisioning): thin-leaf provisioning of OR anonymous intake + Portaliq portalPage
Bumps @conduction/nextcloud-vue to ^1.0.0-beta.221 (ships useScopedTheme,
CnAppRoot self-application of runtime.theme, manifest schema 2.21.0) and
deletes OpenBuild's local scoped-CSS applier (useAppTheme.js) and local
theme manifest validation (manifestValidation/theme.js), both now owned by
the library. ThemePickerDialog collapses its three-tier admin/probe/free-text
fallback to a single useScopedTheme().listTokenSets() call against nldesign's
real non-admin GET /api/token-sets endpoint, adds warn-only contrast display
via evaluateContrast(), and retargets live preview at the page-designer
sandboxed CnAppRoot instance instead of a separate applier. BuilderHost and
PageDesignerHost drop their data-openbuild-theme-scope wiring entirely.

Closes the loop nldesign-theme-selection's own spec flagged as a stopgap.
Hydra gate-16 flagged the changed setup() method (adds the useLivePreview
composable) as missing traceability.
…al spec

Syncs REQ-NTS-002/003/006 (modified) and REQ-NTS-007/008 (added) into the
canonical nldesign-theme-selection spec, and moves the change to
openspec/changes/archive/2026-07-25-theme-picker-consumes-nldesign/.
…design

Theme picker consumes nldesign's published useScopedTheme
…ave)

Deploy marker for the leaf-architecture correction: reverted misplaced
public-forms + app-theming (#20), added thin external-form-provisioning leaf
(#21) and theme-picker-consumes-nldesign leaf (#22, consumes @conduction/
nextcloud-vue beta.221 useScopedTheme + nldesign token/contrast endpoints).
Version bump busts the frontend ?v= bundle cache and re-runs the settings
repair.
rubenvdlinde and others added 5 commits August 6, 2026 14:58
…ppHost (gate-64 / ADR-040) (#138)

* fix(apphost): register OpenRegister's autoloader before referencing AppHost

`openbuild` sorts before `openregister`, and Nextcloud registers apps in
sorted order: OC_App::getEnabledApps() does sort($apps) and
Coordinator::registerApps() walks that list calling
OC_App::registerAutoloading($appId, $path) and then $app->register() for one
app at a time. So OCA\OpenRegister\ was NOT autoloadable inside OpenBuild's
own register() — on a perfectly healthy instance, with OpenRegister enabled.

Measured, not theorised: `OpenBuild: OpenRegister AppHost\Bootstrap is not
autoloadable` was logged 3x in the E2E job (every occ call in ci-seed.sh)
while lib/AppHost/Bootstrap.php existed on OpenRegister the whole time.

Because the reference sits behind a class_exists() guard, this degraded
SILENTLY: Bootstrap::register() apparently never ran, so the generic
dashboard/settings/preferences controllers, the observability controllers, the
install repair steps and the manifest-driven deep-link listener were absent,
with nothing in the UI to say so.

Fix: put OpenRegister's PSR-4 prefix on the autoloader ourselves before the
guard. registerAutoloading() touches only the autoloader and is idempotent.
IAppManager::loadApp() is deliberately NOT used: it marks OpenRegister loaded
and calls Coordinator::bootApp(), booting it before its own register() has run.

The prelude lives in its own class so its 'never throws' contract is reachable
from a unit test — Application cannot be constructed without a NC DI container.

Caught by hydra gate-64 (apphost-autoload-prelude), ADR-040.

* fix(apphost): make the prelude branch-free and declare OC_App to psalm

Two CI findings on the prelude, both real:

1. psalm UndefinedClass on \OC_App. It is Nextcloud's server-private legacy
   bootstrap class, absent from nextcloud/ocp, and there is no OCP interface
   for registering another app's autoloader. Declared as a suppressed
   referencedClass in psalm.xml, the same way doriath declares it.

2. The coverage ratchet. `return true` after the call plus `return false` in
   the catch gave the method a branch that NO environment can exercise both
   sides of — whichever runs, the other is dead in that run — so the class
   could never reach full line coverage. No caller ever consumed the return
   value either: what callers depend on is the class_exists() guard that
   follows the call. The method is now void with a single statement in the
   try and a comment-only catch, so every executable line runs in every
   environment.

The tests now assert the two things that are actually observable: that control
returns to the caller at all (a Throwable escaping would fail the test, and in
production would abort the whole register()), and that a second call does not
stack another autoloader.

phpmd StaticAccess on the new composition-root call is documented on the
calling method rather than baselined.

* test(e2e): assert the AppHost-bound routes actually dispatch, and drop the exclusions

The spec scenarios for the ADR-040 prelude carried '@e2e exclude
composition-root load order'. That was wrong twice over: an exclusion is not
evidence, and the load order HAS a user-visible symptom that Playwright can
reach.

OpenBuild ships no concrete HealthController or MetricsController. Their route
targets health#index and metrics#index — declared by
\OCA\OpenRegister\AppHost\Routes::standard() in appinfo/routes.php — exist ONLY
as DI aliases created by Bootstrap::register(). Without the prelude,
class_exists(Bootstrap::class) answered false, Bootstrap::register() never ran,
those aliases were never created, and the routes resolved to a class with no
binding: HTTP 500, not 404.

tests/e2e/apphost-observability.spec.ts asserts /api/health returns 200 with the
engine's {status, app, version, checks} shape and app = 'openbuild', and that
/api/metrics does not 5xx. It FAILS on the code before the prelude and passes
after it — which is exactly what an exclusion cannot do.

Verified locally: gate-19 PASSES with the @e2e annotation on the scenario, and
FAILS with the annotation removed, so the traceability is real and not a tag
that would be counted whatever happened.

The absent-OpenRegister scenario is removed rather than excluded: it is not
reachable from an HTTP client at all, so it is stated in the requirement prose
and asserted in tests/Unit/AppInfo/OpenRegisterAutoloaderTest.php.

* test: cover the prelude's degraded path, which no instance could reach

The coverage ratchet was right and the code was wrong. Clover for scholiq shows
it exactly: line 100 (the registerAutoloading call) count=2, line 101 (the
catch) count=0. The catch was never entered — because every instance this suite
runs on HAS OpenRegister installed, so getAppPath() never throws. The
never-rethrow branch, which is the entire reason this class exists, had never
once been executed by a test.

register() now takes an optional app id. Production callers pass nothing and get
'openregister'; the new test passes an id that cannot resolve, so
getAppPath() throws and the catch runs. The literal stays AT the
registerAutoloading call site rather than becoming a signature default, so it
remains visible to a reader and to hydra gate-64, which reads that call's
arguments.

The new test asserts something real rather than merely not throwing: a prelude
whose app cannot be resolved must leave spl_autoload_functions() untouched.

* fix(apphost): drop the error_log else-branch, point the @SPEC anchors at specs, and correct the blast-radius claim

Three things, all forced by measurement rather than taste.

1. gate-2 forbidden-patterns FAILS on this branch and PASSED on the baseline
   run (31081906401). Nothing new was added: `error_log()` has been in
   Application.php all along, and gate-2 is diff-scoped, so touching the
   surrounding lines is what put it in scope. The else-branch is removed rather
   than suppressed. With the prelude above it, a false `class_exists()` no
   longer means "not autoloadable YET" — it means OpenRegister genuinely is
   absent, which is a whole-instance condition an app cannot usefully narrate
   from its composition root with no PSR logger resolvable. /api/health, bound
   by `observability => true`, is where that state is readable. Same choice
   doriath made.

2. gate-46 spec-anchor-existence FAILS on two `@spec` tags pointing at
   `openspec/changes/archive/2026-05-12-openbuild-rbac/tasks.md`. An archived
   change dir is never a valid @SPEC target; the canonical home is
   `openspec/specs/openbuild-rbac/spec.md`, which exists.

3. The prose overclaimed, and the overclaim is the interesting part. Comparing
   the pre-prelude run 31081906401 with this branch's run 31085597692:

   - `OpenBuild: OpenRegister AppHost\Bootstrap is not autoloadable` appeared
     3x before and 0x after — once per `occ` call in ci-seed.sh. So under the
     CLI SAPI the guard really was answering false on a healthy instance, and
     every occ command, background job and repair step lost the whole generic
     AppHost plumbing.
   - But in that SAME pre-prelude run, `/api/health` returned 200 with
     `status: ok` and `/api/metrics` rendered the manifest gauges. OpenBuild
     ships no concrete HealthController/MetricsController and `health#index`
     exists ONLY as a `Bootstrap::register()` DI alias — so under the web SAPI
     the guard was answering TRUE and Bootstrap::register() did run.

   The CLI/web divergence is not explained. So the claims "on every single
   request" and "had apparently never run" are withdrawn from
   Application.php, OpenRegisterAutoloader.php, REQ-OBS-006 and the e2e
   docblock, and replaced with what was actually measured. In particular
   tests/e2e/apphost-observability.spec.ts is relabelled: it did NOT fail
   before the prelude (both routes already answered 200), so it is a
   regression guard on the aliases, not the before/after evidence. The
   before/after evidence for the prelude's own contract is the unit test.

Verified locally against this tree:
  - tests/Unit/AppInfo/OpenRegisterAutoloaderTest.php: OK (3 tests).
  - Negative control: deleting the try/catch from the prelude turns all 3 into
    errors ("Class \"OC\" not found"), so the never-throws contract is a check
    that can fail, not a check that always passes.

* style(phpcs): no blank line between the removed-else note and the comment that follows

Removing the else branch left an inline comment block followed by a blank
line, which Squiz.Commenting.InlineComment rejects: phpcs went 0 ERRORS ->
1 ERROR at Application.php:191 while every other job held. Verified locally
against this tree — full-tree phpcs now exits 0 with 0 errors.

Also drops 'on every single request' from that same note. The error_log line
was measured on occ calls (3 per E2E run); whether web requests reached it
was never established, and the comment should not claim more than the
measurement does.

* fix(deps): squizlabs/php_codesniffer 3.13.5 -> 3.13.6 (CVE-2026-67434)

quality / Security (composer) went success -> failure between run 31095490811
(11:11Z) and run 31097941416 (11:42Z) on this branch. Nothing in the branch
changed in between: the advisory was published 2026-08-05T23:53Z and reached
the Packagist advisory database in that window.

  Package: squizlabs/php_codesniffer
  CVE-2026-67434 — OS Command injection
  Affected: <3.13.6 | >=4.0.0,<4.0.2

The composer.json constraint (^3.9) already permitted the patched release, so
this is a lock-only change: one package, 5 lines. Verified afterwards with
'composer audit' -> 'No security vulnerability advisories found', full-tree
phpcs exit 0 / 0 errors, and the prelude unit suite still OK (3 tests).

This is a dev dependency and unrelated to the ADR-040 fix, but it blocks the
merge and the fix is a version bump rather than a suppression, so it lands
here instead of behind an ignore entry.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
)

min-version is enforced at install time, so a 32 floor makes occ app:enable
refuse on stable31, which this repo's own CI runs. The e2e seed then fails
with "is not installed or enabled".

The reason the floor was raised no longer holds: openregister#2372 removed
every eager reference to its ContextChat provider, so the class is only
loaded behind interface_exists() guards and never read on an older server.
openregister#2380 restored its own 28 floor on that evidence.
… not added one (#127)

* fix(security): the setup wizard's #[NoAdminRequired] removed a layer, not added one

Hydra gate-9 (semantic-auth), run against the FULL tree rather than the
enablement PR's own 3-file diff, flags all three SetupController methods:
`#[NoAdminRequired]` on a body that calls `requireAdmin()`.

The class docblock defended the shape as defence in depth — "the body
enforces an explicit IGroupManager::isAdmin gate (ADR-005 — do not rely on
the SecurityMiddleware default alone)". That is inverted. `#[NoAdminRequired]`
does not ADD a check, it REMOVES one: it tells NC's SecurityMiddleware to
stop requiring admin. The body gate was therefore not a second layer, it was
the ONLY layer, in front of endpoints that write app config
(`registry_url`/`registry_token`) and seed OpenRegister objects.

Swapped for `#[AuthorizedAdminSetting(AdminSettings::class)]`, so the
middleware enforces admin before dispatch AND `requireAdmin()` stays in each
body — the two layers the original comment was reaching for. Effective policy
is unchanged for admins and strictly stricter for everyone else; the body gate
still decides the response shape, so the 403 payload non-admins already saw is
unchanged for anyone who reaches it.

gate-5 (route-auth) stays green: `#[AuthorizedAdminSetting]` is an accepted
auth posture, so the methods remain explicitly annotated.

Hydra gates, full tree: 16 -> 15 failing; gate-9 3 -> PASS.

* fix(quality): correct the AppHost settings stub, give the spec anchors a canonical home, cover the posture

Three CI failures from the previous commit, all fixed at the cause.

1. phpstan: "#[AuthorizedAdminSetting] expects class-string<IDelegatedSettings>,
   string given" x3.

   Not a problem with the attribute — a WRONG STUB. The real
   OCA\OpenRegister\AppHost\Settings\GenericAdminSettings implements
   IDelegatedSettings; tests/stubs/openregister-apphost.stub.php declared it
   `implements ISettings`. AdminSettings extends it, so static analysis could
   only see a plain string for code that is correct at runtime. Corrected the
   stub to IDelegatedSettings and added the two members that interface adds
   (getName, getAuthorizedAppConfig), with a note to keep it in step with the
   real class. A stub that understates the real contract is a trap for the next
   person to reach for this attribute.

2. gate-46 spec-anchor-existence: 6 unresolved @SPEC targets.

   All six anchors in this file pointed at
   openspec/changes/openbuild-first-time-setup/tasks.md#task-21|31|41 — headings
   that do not exist (tasks.md has "## 2. Setup action endpoint" and friends).
   Anchors into openspec/changes/** dangle the moment the change is archived, so
   re-pointing at another change dir would only reset the timer. Promoted the
   change's spec delta to openspec/specs/first-time-setup/spec.md and pointed
   all six at real requirement headings there. Diff-scoped gate, so touching the
   file is what surfaced the debt; leaving it red was not an option.

3. gate-47 security-change-has-tests: a security-touching change with no test.

   Fair. Added SetupControllerAuthPostureTest: the attribute invariant (no
   #[NoAdminRequired], exactly one #[AuthorizedAdminSetting] on each of the
   three routed methods) plus the behavioural gate — a non-admin gets 403 and a
   session-less caller 401, with `seed()` and `updateSettings()` asserted NEVER
   to run on either path. It fails if either layer is removed.

Suite: 749 tests green. phpstan OK. phpcs lib: 0 errors. phpmd: only
pre-existing findings under lib/Resources/template (the verbatim snapshot).

* revert(openspec): back the first-time-setup spec promotion out of this PR

Promoting the change delta to openspec/specs/first-time-setup/ is the right
fix for the six dangling @SPEC anchors in SetupController — but it brings
SEVEN new Scenarios under gate-19 (e2e-coverage), and openbuild has no
setup-wizard Playwright spec. The only ways to close that are to write and
VERIFY seven e2e scenarios, or to add `@e2e exclude`. Shipping seven
unverified e2e specs would be the green-but-dead pattern this whole exercise
exists to find, and an exclude is a suppression.

So it comes out of this PR and is filed as its own piece of work. gate-46
therefore still reports the six pre-existing anchors on the diff-scoped CI
run — stated, not silenced.

---------

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

* fix(e2e): install Docudesk in CI, repair 2 lying skips, turn traces back on

The E2E job measured 168 passed / 5 failed / 76 skipped (run 31083894467).
This addresses the instrument first, then the failures whose cause was a
missing dependency or a guard that lied.

TRACES WERE NEVER WRITTEN. tests/e2e/playwright.config.ts pairs
`trace: 'on-first-retry'` with `retries: 0`. Playwright only records under
that mode ON a retry, and a retry can never happen, so no trace file has
ever been produced by this job — while the workflow's upload step ran,
found nothing, and said so quietly under `if-no-files-found: ignore`. Every
red run had a screenshot and a video but not the one artifact carrying the
network log. `retain-on-failure` has no dependence on retries. The output
directory is already globbed by the shared workflow (it uploads both
`server/apps/<app>/test-results/` and `.../tests/e2e/test-results/`), so
the traces now actually leave the runner. The root config gets the same
change plus the `globalTimeout` the CI config already carries — it is the
documented fallback if the CI config is ever absent, and a job cancelled at
`timeout-minutes: 45` produces no tally and uploads nothing.

DOCUDESK WAS NOT INSTALLED. Three REQ-DDT-002 specs drive Docudesk's REST
surface (`api/templates`, `api/templates/{id}`, `.../preview`). With the app
absent every call is a router 404 and the run said so: `[globalSetup]
docudesk not installed`, then `Expected: 200 / Received: 404`. No code
change here could have fixed that. global-setup.ts already configures
Docudesk's template register and seeds the fixtures once it IS present, so
adding it to `additional-apps` is the whole fix. `ref: main` — Docudesk
publishes `main` and `beta` only, no `development`.

TWO WARNINGS RENDERED AT ONCE. `.ob-document-attach__warn` resolved to two
elements — "Docudesk is not installed" and "the attached template no longer
exists in Docudesk" — which are mutually exclusive claims; the second is not
knowable when the first is true, because a 404 from an absent app is the
router reporting a missing ROUTE, not Docudesk reporting a missing TEMPLATE.
They were two independent `v-if`s and both fired whenever the capability
flag arrived late, which is the normal case (PageDesignerHost initialises it
`true` and resolves it asynchronously). Now `v-else-if`, with a unit test
that fails on the old markup: `expected [...] to have a length of 1 but
got 2`.

A SKIP THAT COULD HAVE SEEDED ITSELF. builder-undo-redo REQ-BUR-004 probed
for an ApplicationVersion named "staging", found none, and skipped with
"seed one to exercise this scenario". Nothing was ever going to. The suite's
own `ensureVersionChain()` provisions development -> staging -> production
on demand and is proven in this same job by versionRouting.spec.ts. Given a
DEDICATED slug, not the shared one: the neighbouring tests open `/pages`
with no `?_version=`, so growing a chain on `pw-undo-redo` would change what
they test on every run after the first.

A FIXTURE THAT ONLY EXISTED ON ONE MACHINE. page-editor REQ-PEC-006 bound
`openbuild-hello-world-production` / `hello-world-production-hello-message`.
Those are wizard-minted names; the CI fixture is built by `occ
openbuild:seed-hello-world-fixture`, which deliberately does not mint a
per-version register and puts everything in the shared `openbuild` register
— ci-seed.sh prints the register list and it is not there. Bound to the pair
the fixture actually provides, so CI and a dev box now agree.

FOUR STALE QUARANTINES LIFTED. bootstrap-openbuild's index-page test,
page-designer-ui REQ-OBPDUI-001/002 and application-detail-overview
REQ-OBADO-006 all carry the openbuild#41 reason that builder-host.spec.ts's
own note says "no longer holds" — and the routes they claim are broken are
driven successfully by passing specs in the same run. Two were strengthened
while being lifted: REQ-OBPDUI-002 asserted "the page body does not contain
the word 404", which would fail on the legitimate version-not-found copy the
next test requires and pass on a blank designer; REQ-OBADO-006 used a
page-wide `locator('a, button').filter(...).first()` that would keep passing
with the Register widget deleted, and read an `href` the widget does not
have (it navigates via `window.location.href`), guarded by an `if (href)`
that asserted nothing.

* fix(e2e): the automations-RBAC guard was reporting a failed lookup as a defect

Both tests in automations-rbac.spec.ts skipped with "the openbuild
`automation` schema slug collides with a pre-existing schema of the same slug
on this shared instance — automation CREATE/SAVE 400s regardless of
app/version".

Run 31083894467 disproves that within a single run. Seven tests in
automations.spec.ts sit behind a copy of the SAME helper, against the SAME
instance, and passed — composing and saving real automations end to end
(REQ-AUTD-002 x3, -003, -005, -006, -007). Only this file's two skipped.

The discriminator is not the instance, it is the auth context. This describe
declares `test.use({ storageState: { cookies: [], origins: [] } })` so each
test can log in as a non-admin. That also makes the `request` fixture
anonymous, so the probe's read of `api/schemas/automation` was refused —
and `if (resp.ok() === false) return false` turned "I could not look" into
"the feature is broken", with a confident and wrong explanation attached.

The probe now authenticates with the admin credentials the config already
uses for `httpCredentials`, independently of the test's storageState. And a
non-OK response THROWS instead of returning false: a probe that cannot probe
now fails the run loudly rather than silently becoming a verdict. The skip
reasons are rewritten to describe what the check actually establishes.

These two tests may now fail for real reasons. That is the point — a real
failure is information and a false skip is not.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
@rubenvdlinde

Copy link
Copy Markdown
Contributor

Triage 2026-08-08 (openbuild dedupe sweep) — left open with a precise blocker.

State: mergeable: CONFLICTING, mergeStateStatus: DIRTY. developmentbeta is the human gate, so this sweep does not touch it.

Failing checks, by name (count is not the measure — the names are):

  • quality / E2E Tests (Playwright) — FAILURE
  • quality / Hydra Gates — FAILURE
  • quality / Quality Report — FAILURE. Pure aggregator. It has no findings of its own; it is red because the two above are. Do not count it as independent debt.

Everything else on this PR is SUCCESS, including all four PHPUnit (PHP 8.3/8.4, NC stable32/stable33) legs and SBOM.

⚠️ On the Hydra Gates number specifically: this PR's gate job is a push-shaped run, so its scope is not the full tree. For reference, a direct full-tree run of hydra-gates @main over origin/development at c70f7b8d9 — no diff scoping, ajv provisioned into the gates package exactly as quality.yml does — reports 15 failing gates / 62 of 64 gates reporting. Two of the fifteen (gate-14 route-reachability, 7 findings) are a known false-positive class: routes supplied by \OCA\OpenRegister\AppHost\Routes::standard() do not appear as literals in appinfo/routes.php, so the engine-owned canonical set reads as "unrouted".

Blockers before this can move:

  1. resolve the merge conflict (787 files, development has moved a long way);
  2. ci: rename branch-protection caller job id to branch-protection #115 first — beta's branch-protection.yml still calls Conduction/.github, the wrong org, so the reusable workflow behind the required check cannot resolve there;
  3. a human decision, which is what the beta gate is for.

rubenvdlinde and others added 22 commits August 8, 2026 13:16
… layer, not added one (#147)

`ApplicationCreationController::wizard()` is admin-only — it provisions an
OpenRegister Register, mirroring OR's admin-only RegistersController gate
(issue #157, OR #1949) — and enforces that with an `IGroupManager::isAdmin()`
check in the body.

It also carried `#[NoAdminRequired]`, which does not ADD a layer: it tells
NC's SecurityMiddleware that any logged-in user may reach the method, leaving
the body check as the only thing between a regular user and register
provisioning. Hydra gate-9 (semantic-auth) reports exactly that contradiction.
It is the same defect SetupController carried until #127, and it gets the same
correction: `#[AuthorizedAdminSetting(AdminSettings::class)]`, so the
middleware refuses before dispatch and the in-body gate becomes defence in
depth rather than the whole defence.

Measured, full-tree (hydra-gates @main over the entire tree, not diff-scoped):
`development` fails 15 gates; with this change, 14. gate-5 (route-auth) stays
PASS — `#[AuthorizedAdminSetting]` is one of the four attributes it accepts, so
this does not trade a gate-9 finding for a gate-5 one.

The unit test that asserted the OLD posture (`wizard() must carry
#[NoAdminRequired]`) asserted the defect, so it is replaced rather than kept.
Can-fail proof: re-adding `#[NoAdminRequired]` to the method turns
`wizardDeclaresTheAdminPostureAtTheMiddlewareLayer` RED with
"wizard() must not carry #[NoAdminRequired]... Failed asserting that actual
size 1 matches expected size 0" — the assertion fails for the reason it exists.

`openspec/changes/openbuild-first-time-setup/tasks.md` claims
`AuthorizedAdminSetting` "fails PHPStan L5 in this repo's stub set". Measured
on this change: phpcs 0 errors, phpstan `[OK] No errors`. That note is stale.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…sured two different numbers because of it (#149)

`.gitignore` carried a scaffold block headed "Files with unusual extensions or
no extensions that could be mistakes" — patterns aimed at stray notes-to-self
like `PR notes` or `endpoints list`. They are substring globs, so
`**/*references*` also matches:

    lib/Controller/PreferencesController.php
    tests/Unit/Controller/PreferencesControllerTest.php

Both are tracked, so git keeps versioning them and nothing complains day to day.
`git check-ignore` does not even report them without `--no-index`, because it
skips tracked paths by default — which is why this survived since the initial
scaffold commit (9bfa57e).

It is not harmless. Anything that re-adds from scratch drops them, and every
tool that enumerates the repository through `git ls-files` cannot see them.
The hydra-gates runner enumerates controllers exactly that way (`_enum_tracked`
→ `git ls-files`). Measured 2026-08-08 on this commit:

    working tree                     gate-14: FAIL — 7 unrouted method(s)
    `git archive` + `git add -A`     gate-14: FAIL — 5 unrouted method(s)

Same commit, same file bytes (`diff` reports IDENTICAL), two verdicts — the
second copy simply had no PreferencesController.php in it (20 controllers
tracked instead of 21). Two numbers for one measurement means one came through
a lossy channel.

The fix is a negation for the source directories, not an edit to each pattern:
the block is inherited scaffold and the next `PreferencesService.php` would be
swallowed again. Verified the negation adds nothing unwanted — the count of
ignored-but-untracked files under lib/src/appinfo/tests was 0 before and is 0
after, and `git status --porcelain` reports no newly visible untracked file, so
nothing junk starts being tracked.

Scope is source only. The other 304 tracked-and-ignored paths are all under
`docs/build/` and `docs/.docusaurus/` — committed build output, a separate
decision, deliberately left alone.

`scripts/check-gitignore.js` makes this a standing check rather than a one-time
repair, wired into the CI `frontend-checks` matrix as `check:gitignore` so it
runs as its own named job.

Can-fail proofs, both run:
  - restore `origin/development`'s .gitignore → exit 1, naming both files.
  - point the script at a non-existent directory (positive control on the
    INPUT) → exit 2, "Enumerated ZERO tracked files ... That is a broken
    enumeration, not an empty repository." Without that control, a scanner that
    reads nothing is indistinguishable from one that finds nothing.
  - restored → exit 0, "743 tracked file(s) ... none matched by .gitignore."

Archive round-trip after the fix: 21 controllers, PreferencesController.php
present.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ects

Two tools in the same pipeline gave OPPOSITE instructions about where an
`@spec` tag should point, and following the one that runs FIRST manufactured
findings for the one that runs SECOND.

`SpecTagSniff` runs as a blocking `PHP Quality (phpcs)` job and told every
developer, in its file docblock and in its own warning text:

    @SPEC openspec/changes/{change-name}/tasks.md#task-N

A change directory is temporary by definition — completing a change moves it
to `openspec/changes/archive/<date>-<name>/`, and renaming or dropping one
removes the target outright. Every tag written to that instruction dangles
from that moment on, and gate-46 (spec-anchor-existence) reports it. The
developer who wrote the tag had followed this sniff's own advice.

Measured on portaliq: 100 unresolved gate-46 targets, and 260 of its 385 live
tags pointing into a change directory. The sniff ships identically in 20
ConductionNL repos, so grinding the tags without fixing the sniff regenerates
them at the rate changes are archived.

This changes the docblock example and BOTH warning messages to the canonical
form gate-46 and the project rule agree on:

    @SPEC openspec/specs/{capability}/spec.md#requirement-{slug}

The method-level message previously carried no guidance at all, so a developer
reading it had only the class message to copy from; it now names the same
canonical shape.

Behaviour is unchanged: severity stays WARNING (verified via phpcs — an
untagged class and public method still report 0 errors / 2 warnings, and a
tagged file still reports nothing), and an `openspec/changes/...` target is
still accepted, since this sniff only checks that a tag is PRESENT.

No `@spec` tags are repointed here — this repo's existing tags are untouched.

Refs ConductionNL/.github#228
…l-target

fix(phpcs): stop the SpecTagSniff instructing the pattern gate-46 rejects
…UT /api/settings (#155)

`\OCA\OpenRegister\AppHost\Routes::standard()` ships the canonical route
`settings#update` (PUT /api/settings), and `appinfo/routes.php` calls it, so
the route is live. `AppHost\Bootstrap::aliasControllerUnlessLeafDefinesIt()`
only substitutes OpenRegister's `GenericSettingsController` when the leaf does
NOT ship a class of that name — OpenBuild DOES, so the alias is skipped and
OpenBuild owes every method the canonical table routes to `settings#`. It had
`index/create/load` but no `update()`.

Measured live on the dev instance 2026-08-08:

    GET  /apps/openbuild/api/settings -> 200   (positive control)
    PUT  /apps/openbuild/api/settings -> 500

    ReflectionException: Method
    OCA\OpenBuild\Controller\SettingsController::update() does not exist
    at lib/private/AppFramework/Utility/ControllerMethodReflector.php:40

The router matches the URL and the dispatcher reflects the method, so a missing
canonical method is a 500, not a 404.

`create()`'s body — the H6 admin guard included — moves into `update()`, and
`create()` becomes `return $this->update();` while keeping its own attributes.
Both verbs now share one enforcement path and cannot drift apart.

Auth posture is unchanged and deliberately NOT converted to
`#[AuthorizedAdminSetting]`: `update()` keeps `#[NoAdminRequired]` plus the
in-body 401/403 guard, matching `index()`/`load()`. Nextcloud's
SecurityMiddleware only evaluates attributes on the DISPATCHED method, so
`#[NoAdminRequired]` alone would leave this instance-wide write open to any
authenticated user — the in-body check is the guard.

Tests (tests/Unit/AppInfo/CanonicalRouteMethodContractTest.php,
tests/Unit/Controller/SettingsControllerWriteTest.php):
  - each canonical method is asserted individually as existing, public and
    non-static (the ITEM, not the container), with an `$inspected > 0`
    positive control so a green cannot be produced vacuously;
  - a second control asserts routes.php still delegates to Routes::standard()
    and cross-checks the transcribed canonical table against OpenRegister's
    own source when resolvable;
  - update() writes through SettingsService and returns the stored config;
    create() delegates and still writes;
  - the guard is asserted on update() itself: non-admin -> 403, unauthenticated
    -> 401, and updateSettings() is never called in either case.

Also closes the one pre-existing uncovered statement in this controller (the
unauthenticated branch of `load()`), taking SettingsController to 100% line and
method coverage.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…s 32, 43 and 45 to green (#150)

* fix(a11y): keyboard parity, table semantics and reduced-motion — gates 32, 43 and 45 to green

Three mechanical accessibility gates, 13 findings, all read and confirmed real
before touching anything.

gate-32 semantic-controls (2) — WCAG 2.2 AA 2.1.1 Keyboard
  PageListEditor's page row was selectable by CLICK ONLY. Every field inside it
  carries `@click.stop`, so a keyboard user tabbing into a row's inputs was
  editing a page that had never been selected. `@focusin` is the substantive
  repair: focus reaching any descendant now selects the row, the same outcome
  the mouse always had.

  The row gets `role="group"` + `aria-label`, NOT `role="button"`. A button's
  children are presentational, so `role="button"` on a container full of inputs
  would hide those inputs from assistive technology — it satisfies the gate and
  makes the page less accessible. `tabindex="-1"` makes the row programmatically
  focusable without inserting a second tab stop ahead of its own fields.

  WalkthroughDesigner's tour <li> has no nested interactive content, so there
  `role="button"` + Enter/Space handlers IS right, plus `aria-current` so the
  open tour is announced rather than only coloured.

gate-43 table-headers (8) — WCAG 2.2 AA 1.3.1 Info and Relationships
  Seven tables carried `<th>` with no `scope`. The eighth, ImportDataWizard's
  CSV sample, had no `<th>` at all: `sampleRows` is populated only by
  readCsvSample(), and a CSV's first line IS its header — it was rendering as
  ordinary `<td>` data. Promoting row 0 to a `<thead>` of `<th scope="col">` is
  both the a11y fix and a truer rendering of the file.

  Three action columns had a bare `<th />`. They are still column headers, so
  they keep `scope="col"` and gain an sr-only name rather than being downgraded
  to `<td>` — the downgrade would have satisfied the gate by removing the
  header.

gate-45 prefers-reduced-motion (3) — WCAG 2.2 AA 2.3.3
  Three `transition:` declarations with no reduced-motion fallback. Each fix is
  scoped to the component's own selector, never a blanket
  `* { transition: none }`, so it cannot reach into NcButton/NcSelect internals.

Measured, full-tree (hydra-gates @main over the entire tree, not diff-scoped,
with ajv provisioned into the gates package exactly as quality.yml does):
`development` fails 15 gates; with this change, 12. Gates 32, 43 and 45 are
green; no other gate's verdict changed.

Frontend suite: 140 files / 1366 tests before, 140 / 1369 after — all passing.
eslint: 0 errors, 0 warnings (the added `scope="col"` made several `<th>`
multi-attribute, which the `vue/singleline-html-element-content-newline` rule
reformats; run through `lint-fix` so the branch introduces no new warnings —
`development` has zero).

Can-fail proofs, all four run:
  - remove `@focusin` from the row -> "focusing a field inside a row emits
    select(index)" FAILS (1 failed | 18 passed).
  - `role="group"` -> `role="button"` -> "expected 'button' to be 'group'"
    (1 failed | 18 passed). NB the first attempt at this mutation edited the
    word inside the explanatory COMMENT and the suite stayed green — a reminder
    that a string match is not a code match, in both directions.
  - drop `scope="col"` from the sample header -> "expected undefined to be
    'col'" (1 failed | 6 passed).
  - `sampleBody` slice(1) -> slice(0), i.e. the header duplicated as data ->
    "expected ... to have a length of 2 but got 3" (1 failed | 6 passed).

The new tests assert on the CELLS and the EMITTED EVENT, never on the `<thead>`
wrapper or the presence of an attribute: an empty `<thead>` would satisfy a
container assertion while announcing nothing, and a `tabindex` assertion would
pass over a handler that does nothing.

NOT fixed here, deliberately: gate-31 img-alt's 3 findings are false positives.
All three are the literal string `<img>` inside JSDoc `@param` comments in
`<script>` blocks; every real `<img>` in those files already carries `:alt`,
which the gate's own regex accepts. Reproduced with the gate's own matcher.
Reported upstream rather than "fixed" by rewording documentation.

* fix(l10n): register the new "Page {position}" source string

`Frontend Check (test:l10n)` went RED on this branch and nowhere else:

    l10n-check: FAIL — 1 translation key(s) used in source but MISSING
    from l10n/en.json:
      • "Page {position}"   src/components/page-editor/PageListEditor.vue:66

The row's `aria-label` introduced a new translatable literal. `test:l10n` is
exactly the check that is supposed to notice, and it did — this is the gate
working, not noise. Registered in en.json (source === English) and translated in
nl.json rather than left English-only, since the aria-label is what a Dutch
screen-reader user hears.

---------

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

gate-46 spec-anchor-existence measured 102 unresolved findings across 41
targets. They are not 102 bad tags; they are three defects.

1. TAGS POINTING AT AN ARCHIVED CHANGE DIR (52 findings).
   Seven targets named `openspec/changes/<change>/specs/<cap>/spec.md` for
   changes that have since been archived. The gate's resolver finds the
   ARCHIVED copy under `openspec/changes/archive/<date>-<change>/` and judges
   the anchor against that frozen snapshot — never against the canonical
   spec. Retargeted to `openspec/specs/<capability>/spec.md`, which is where
   the project rule (and openbuild#152's SpecTagSniff) says a @SPEC tag
   points.

2. REQUIREMENT IDS LIVED IN THE BODY, NOT THE HEADING (50 findings).
   The specs carried `**ID:** REQ-XXX-NNN` as a body line. ADR-037 rule 2 is
   explicit that the ID belongs in the heading as a parenthesised suffix, and
   explicit about why the body line is wrong. An anchor resolves against
   HEADINGS, so `#req-autd-001` named nothing. Moved the IDs into the
   headings for every requirement in the six specs involved — not only the
   ones a tag happens to reference, so the files are uniformly ADR-037 Form A.

3. `2.1` WRITTEN AS `21` (15 findings).
   `#task-21` is read by the gate as "the 21st checkbox". The tasks files
   have 17. The tags meant task **2.1** — and each one lines up exactly with
   its task (SetupController against 2.1 "Create SetupController.php"). The
   anchor spelling that names it is `#task-2.1`.

Measured with the helper directly, both directions: `#task-21` FAILS and
`#task-2.1` RESOLVES against the same file, so the fix is not the check
being loosened. Full suite at gate package 48c88ba: gate-46 102 -> 0.

Note: gate-61 (listener-work-placement) reports 6 findings on this branch
that development does not show. They are PRE-EXISTING debt in
AutomationApprovalTriggerListener and DocumentGenerationListener — gate-61
is diff-scoped against origin/development inside its own helper, and these
@SPEC retags are what pulled the two files into its scope. No behaviour in
either listener changed here.

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

gate-51 schema-property-titles: 11 -> 0.
gate-54 relation-dialect: 2 -> 1 (the remaining one is not honestly fixable
here — see below).

gate-51. Eleven properties in the Automation schema shipped with no title
and, in nine cases, no description at all: the notification recipient
`kind`/`permission` pair, the `nl`/`en` subject templates, and every field of
the `provenance` bookkeeping arrays. These are the labels OpenRegister renders
in its own object editor, so an unnamed property shows as a raw JSON key to
whoever has to fill it in. The two `type` enums (trigger, condition) had a
title but no description, which is the same problem one step smaller: the
enum values are the whole contract and nothing said what they mean.

gate-54. `Automation.versionUuid` is `format: uuid` with a description that
says it points at an ApplicationVersion, but carried no `$ref` — so
OpenRegister had no way to resolve or render the relation. Added
`"$ref": "ApplicationVersion"`, the canonical ADR-062 rule-7 dialect, which
resolves against the schema key in `lib/Settings/openbuild_register.json`.

NOT FIXED, deliberately: `Automation.actions.items.templateId`.
It is `format: uuid` and its description does say "UUID of the Docudesk
template", so gate-54's relation-shape heuristic matches. But the object it
names is a DOCUDESK template, fetched over Docudesk's own REST API — it is
not an object in OpenBuild's register, and ADR-062 rule 7 requires a `$ref`
that resolves to a schema key IN THE SAME REGISTER FILE SET (check f). Any
`$ref` I could write here would be a dangling one, trading a real finding for
a worse one. The alternative — rewording the description until the regex
stops matching — would leave the schema unchanged and only move the words,
which is exactly the comment-satisfaction this programme forbids. Rule 7 has
no vocabulary for a cross-app foreign key; that is the gap, and it belongs
upstream rather than in this file.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…y installs on (#148)

* chore: raise the Nextcloud floor to 32, and pin it to what CI actually installs on

Product decision: the fleet standardises on Nextcloud 32 so it can require
PHP 8.3, which `<php min-version="8.3"/>` already declares. opencatalogi,
openconnector and docudesk are already at 32; this brings OpenBuild in line.
max-version stays 34 — the fleet value everywhere except openconnector, which
declares 35 (measured 2026-08-08 across 8 repos' `development`).

This is NOT the openconnector#1172 dependency-floor rule firing, and the
comment that stood here said it was. That rule — min-version >= the max of
every `<app>` dependency's floor — is satisfied either way: measured on
2026-08-08, `ConductionNL/openregister` declares `min-version="28"` on BOTH
`development` and `main`, having reverted its own 32 bump in #2380 after #2372
removed every eager reference to its ContextChat provider. 32 >= 28 today, and
still >= 32 if openregister moves back up.

The previous comment also contradicted the file it was in: it asserted
"min-version is 32, NOT 28" directly above `min-version="28"`. #146 lowered the
value and left the prose. Prose is what a reader trusts, so this replaces it
rather than editing around it.

Safety of raising the floor: min-version is enforced at INSTALL time, so a
floor above what CI installs on makes `occ app:enable openbuild` refuse, and
the failure then surfaces ~70s later as missing tables — which reads like a
migration fault and sends you to the wrong file. `code-quality.yml` already
runs `nextcloud-test-refs: ["stable32", "stable33"]` (stable31 was removed),
and it is the only place in this repo that names a Nextcloud version, so no
leg is left behind.

`NextcloudFloorTest` makes that a standing invariant rather than a one-time
check: every `nextcloud-test-refs` entry must be >= the declared floor, and the
floor itself is pinned to 32 so a silent lowering has to argue with a test.

Can-fail proofs, all three run:
  - floor 32 -> 34: "CI installs OpenBuild on stable32 (Nextcloud 32) but
    appinfo/info.xml declares min-version=34" (2 failures).
  - CI refs + stable31: "CI installs OpenBuild on stable31 (Nextcloud 31) but
    appinfo/info.xml declares min-version=32" (1 failure).
  - CI refs -> '[]' (parser positive control): "Parsed ZERO nextcloud-test-refs
    ... That is a broken parser, not a clean result" (1 failure). Without this
    the whole assertion is vacuously true when the parser breaks.

* fix(ci): move the floor invariant out of PHPUnit — it runs against a DEPLOYED app, not the repo

The previous commit added `tests/Unit/AppInfo/NextcloudFloorTest.php`. It went
RED on all four PHPUnit legs:

    RuntimeException: Could not parse
      /home/runner/work/openbuild/openbuild/server/apps/openbuild/appinfo/info.xml

That path is the tell. This fleet's PHPUnit job copies the app to
`server/apps/openbuild` and runs the suite from THERE — against a deployed copy,
not the repository. `.github/workflows/code-quality.yml` is not part of a
deployed app, so half the invariant had no input, and the deployed `appinfo/`
did not parse the way it does in a checkout either. A test asserting on
REPOSITORY LAYOUT is in the wrong harness, and its red said nothing about the
floor.

`scripts/check-nc-floor.js` is the same invariant in the harness that can answer
it: the `frontend-checks` matrix runs in the checkout. It asserts the same two
things and one more:

  1. every `nextcloud-test-refs` entry is >= `<nextcloud min-version>`;
  2. the floor is pinned at 32 and PHP at 8.3, so a silent LOWERING also fails
     (the comparison alone would happily accept 28 — which is how #146's
     regression would have slipped through again);
  3. a positive control on the input — a parser that reads zero refs exits 2
     rather than passing vacuously.

It also reads the assignment LINE, never the surrounding comment: the comment
above that input names `stable31` while explaining it was REMOVED, so a
comment-blind grep reads a leg that does not exist.

Can-fail proofs, all four run:
  - floor 32 -> 34: two "CI installs OpenBuild on stableN ... declares
    min-version=34" lines plus the pin, exit 1.
  - floor 32 -> 28: "Nextcloud floor is 28, expected 32", exit 1.
  - CI refs + stable31: "CI installs OpenBuild on stable31 (Nextcloud 31) ...",
    exit 1.
  - CI refs -> '[]': "Parsed ZERO nextcloud-test-refs ... That is a broken
    parser, not a clean result", exit 2.
  - restored: exit 0, "floor NC 32 / PHP 8.3; CI legs stable32, stable33 all
    satisfy it."

NOTE for whoever merges second: this touches the same `frontend-checks` line as
the `check:gitignore` branch (#149). The two lists have to be unioned by hand;
they are otherwise independent.

* docs(info.xml): re-measure the openregister floor — it is 32 again (#2384)

The comment shipped in this PR asserted openregister declares min-version=28,
citing openregister#2380. That was true when written and expired 25 minutes
before this PR's head commit: openregister#2384 merged 2026-08-08T10:45Z and
put the floor back to 32 fleet-wide.

The consequence is not cosmetic. Under #2380's value the 32 here was a free
product choice (32 >= 28). Under #2384 it is FORCED: openregister is a hard
<app> dependency, so by the openconnector#1172/#1173 rule this app's floor may
not sit below it.

Also avoids writing any XML element syntax inside the comment: check:nc-floor
asserts the COUNT of nextcloud elements over raw file text, so a quoted example
in a comment reads as a second, contradictory declaration.

---------

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

* fix(controllers): seven 404 branches were unreachable — the lookup threw first

gate-49 controller-exception-translation: 7 -> 0. Measured full-repo at gate
package 48c88ba.

This is not a docblock change. Every one of the seven methods declares
"null when missing" and every caller has an `if (... === null)` branch that
returns a 404 — and none of those branches could ever be taken, because the
lookup throws instead of returning null:

  * `ObjectService::find()` documents `@throws Exception If the object is not
    found`. ApplicationPublishController::loadApplication,
    VersionPromotionController::loadApplication and ::loadVersion all treated
    it as null-returning. A missing Application or ApplicationVersion escaped
    as an exception and came back as a 500 `internal_error` from the outer
    `catch (Throwable)` — the 404 was dead code.

  * `RegisterMapper::find()` / `SchemaMapper::find()` throw
    DoesNotExistException when the register or schema is absent.
    ApplicationVersionsController::findVersionRowBySlug, ::loadApplication and
    ::findVersionForApplication, and ApplicationsController::
    resolveApplicationBySlug, all called them unguarded. Three of those call
    sites (ApplicationVersionsController::show, ::release,
    ApplicationsController's manifest path) sit OUTSIDE any try/catch, so an
    unprovisioned OpenRegister answered with a framework 500 and a stack
    trace — on #[NoAdminRequired] endpoints.

Each lookup is now wrapped and translated. The object-level misses return
null, which is what the signature always claimed and what makes the callers'
404 branches reachable. The register/schema misses are logged at warning
(they mean OpenRegister is not provisioned, which is not a 404 about one
object) and resolveApplicationBySlug returns a translated 500 envelope
instead of an unhandled throw.

Causes are logged in every arm, so nothing is flattened into a silent null.

* refactor(versions): one register/schema resolver instead of four copies

Follow-up to the gate-49 fix in this branch, addressing two findings that
change CAUSED and that development does not have:

  * phpmd ExcessiveClassLength — ApplicationVersionsController reached 1006
    lines against a 1000 threshold. The cause was my own fix: the same
    try/catch around RegisterMapper::find() + SchemaMapper::find() written
    out three times.
  * the coverage ratchet — those three copies were three untested arms.

Both are answered by the same change rather than by shaving the metric:
`resolveRegisterAndSchema()` is now the single place the mapper's
DoesNotExistException is translated, and the four call sites (index(),
findVersionRowBySlug(), loadApplication(), findVersionForApplication()) go
through it. index() was already inside a try/catch so gate-49 never flagged
it; it is included because leaving one copy behind is how these drift.

Measured: class length 1006 -> 994 (threshold 1000, development 970), longest
line 144 (limit 150), gate-49 still PASS, php -l clean.

* fix(versions): restore named arguments, and cover the two translated arms

Two follow-ups to the previous commit, both caused by it.

1. phpcs: "All arguments in calls to internal code must use named arguments"
   — 4 errors. I had collapsed the resolveRegisterAndSchema() call sites onto
   one line to win back class-length budget and dropped `schemaSlug:` doing it.
   That was shaving a metric at the cost of the repo's own convention. The
   named form fits on one line anyway (longest line 144, limit 150), so both
   constraints hold: class length 995 (threshold 1000, development 970) and
   named arguments at all four sites.

2. The coverage ratchet: the new catch arm was untested, so the ratchet
   correctly reported a drop. Two regression tests added, one per lookup
   direction:

     * release() with a RegisterMapper that throws DoesNotExistException
     * show()   with a SchemaMapper   that throws DoesNotExistException

   Both call their lookup helper OUTSIDE any try/catch, so against the
   pre-fix code the exception escapes the controller and the test RAISES.
   They fail before and pass after — a regression test, not a restatement of
   current behaviour.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
… not a label (#160)

gate-40 form-label-association: 54 -> 0, measured full-repo at gate package
48c88ba.

ADJUDICATED PER ITEM FIRST. gate-40 carries a known fleet-wide false-positive
rate (~58%, .github#273), so every finding was checked against the file rather
than assumed: does a real `<label for>` already point at this control, or does
it already carry an aria-label? For openbuild the answer was NO in all 54 —
zero false positives here. The FP class elsewhere is inputs that ARE labelled
and get flagged anyway; openbuild's are bare `<input>`s whose only labelling is
a `placeholder`, which is not an accessible name (WCAG 2.2 AA SC 1.3.1 / 3.3.2
— a placeholder disappears on first keystroke and is not reliably announced).

Two shapes of fix:

  * Where a visible label already exists for sibling fields, add a real one.
    Step1Basics had `<label for>` for name and description but the slug input
    under "Advanced" had none — it now has the same visible label its
    neighbours have, not an ARIA patch.

  * Where the control is a narrow cell in a repeated builder row and a visible
    label would break the layout, `aria-label` carrying THE SAME string the
    placeholder shows. Screen-reader users get exactly the name sighted users
    already get. Reusing the existing `t('openbuild', …)` string means this
    introduces no translation churn: 52 of the 54 needed no new source string
    at all.

    The two exceptions: DecisionTableEditor's condition cells take the column
    name (`col.naam`) so each cell announces its own column; its decision cell
    reuses the `<th>`'s own "Decision" string.

One genuinely new source string, "Raw page configuration (JSON)", for
StubPageEditor's textarea — it had no placeholder, no legend and no heading of
its own to borrow. Registered in l10n/en.json AND l10n/nl.json.

Verified: `node tests/l10n/check-l10n.js` OK; eslint clean on all 24 changed
files; full gate suite shows gate-40 gone and NO other gate gained a finding.

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

gate-29 gitignore-then-commit: 7 -> 2 (the 2 left are an upstream gate bug,
see below). Measured at gate package 651e5c5, --scope-to-diff --base <root>.

`.gitignore:19` ignores `docs/build/`, and 263 files under it were tracked
anyway. That combination is the defect the gate is named for: the directory is
`docusaurus build` output, so the committed copy is a snapshot that no longer
matches the sources beside it and that nothing updates.

Nothing consumes it. `.github/workflows/documentation.yml` only fires on the
`documentation` branch and delegates to the shared reusable workflow, whose
build step begins `rm -rf node_modules/.cache .docusaurus build` and then
publishes `<source-folder>/build` — it DELETES the committed output and
regenerates it. So these 263 files have never been the published site; they
are a stale copy that the ignore rule was already trying to stop.

`git rm -r --cached` only: the files stay on disk and stay ignored.

NOT FIXED — 2 remaining findings are a gate false positive:

  ignore_pattern=!tests/vitest/setup.js    tracked_file=tests/vitest/setup.js
  ignore_pattern=!tests/e2e/global-setup.ts tracked_file=tests/e2e/global-setup.ts

Both patterns begin with `!`. A `!` line UN-ignores; these two exist precisely
to rescue real source from the broader `**/setup*` rule three lines above, and
they are the reason those files are still tracked. The gate reads them as
"newly-ignored path(s)", which inverts their meaning.

Verified by reading OUTPUT, not an exit code (`git check-ignore -q` returns 0
when a NEGATION matches, which is the trap that makes this look real):

  $ git check-ignore -v tests/vitest/setup.js      -> no output (NOT ignored)
  $ git check-ignore -v tests/e2e/global-setup.ts  -> no output (NOT ignored)
  $ git status --ignored --porcelain <both>        -> neither is in the ignored set

Deleting the negations to satisfy the gate would ignore two real test files —
the exact outcome the gate exists to prevent. Left red and filed upstream.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…165)

gate-54 relation-dialect: 1 -> 0. Measured at gate package 365fa31.

`Automation.actions.items.templateId` is `format: uuid` with a description
that says it points at a Docudesk template, so gate-54's relation-shape rule
matched it. It was the last gate-54 finding in this repo and, until now, it
had no honest fix: the object it names lives in DOCUDESK's register, and
check (f) requires a `$ref` that resolves to a schema key in the SAME register
file set. Both arms failed —

  WITH    "$ref": <anything>  -> "does not resolve to a schema key in the
                                  register set (case-exact)"
  WITHOUT $ref                -> "lacks canonical $ref (ADR-062 rule 7)"

— so the only route to green was rewording the description until the regex
stopped matching, which degrades documentation to dodge a check. I refused
that and reported it upstream instead (ConductionNL/.github#307).

.github#286 added the missing vocabulary: `x-external-register: <app>`, for a
relation whose target lives in another app's register. That is exactly this
property's shape, so it is now declared rather than hidden — and the
description says so in words as well.

Verified against package 365fa31, all four arms:

  A  with x-external-register              -> 0 findings
  B  with it REMOVED (negative control)    -> 1  "lacks canonical $ref"
  C  with it AND a $ref (abuse control)    -> 1  "carries x-external-register
                                                 'docudesk' AND $ref … drop
                                                 the $ref"
  D  restored                              -> 0 findings

Arm B proves the gate still fires here, so this is not a blind pass. Arm C
proves the exemption is narrow: it suppresses only the two $ref rules and
cannot be used as a blanket waiver.

Full root-scoped suite unchanged otherwise: gate-54 gone, no other gate moved.

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

* fix(a11y): replace 7 native window.confirm/prompt calls with real dialogs

gate-34 window-confirm: 7 -> 0. Measured full-repo at gate package 651e5c5.

Native window.confirm/prompt blocks the JS thread, cannot be translated or
themed, and inside Nextcloud's modal stack renders outside the dialog the user
is already in. window.prompt is worse still: its input has no programmatic
label at all.

All seven call sites had the same shape — a synchronous boolean guard in front
of a destructive continuation — so they share two new components rather than
each growing an inline NcDialog, which ADR-004 / gate-13 forbids:

  src/dialogs/ConfirmActionDialog.vue   (6 sites)
  src/dialogs/PromptTextDialog.vue      (1 site, the window.prompt)

Sites: AutomationsPage (delete automation), ThemeSection (remove theme),
SchedulesSection (remove task), WorkflowAttachmentsSection (detach case type),
DocumentAttachmentsSection (detach template), DataSourceOriginToggle (discard
connector mapping), ConnectorFieldMapper (name a display field).

THE FAIL-SAFE DIRECTION IS PRESERVED, AND NOW TESTED. The dialogs never run
the action; they only emit. The parent holds the target in `pending*` and acts
only in an `onConfirm*` handler, so a closed, cancelled or dismissed dialog
leaves state untouched. Every one of the six sites gained a test asserting the
destructive step has NOT run after the ask — the property window.confirm gave
us for free and the one worth pinning.

Two behaviours improved rather than merely ported:
  * AutomationsPage keeps the dialog open with disabled buttons while the
    DELETE is in flight (`busy`) — a synchronous confirm could not express it.
  * PromptTextDialog disables submit while the value is blank, so the empty
    -name guard is enforced at the source instead of by `if (!name) return`.

Verification, all re-run on this branch:
  * gate-34 PASS. Proven not blind: planting one window.confirm back into
    ThemeSection flips it PASS -> FAIL(1) -> PASS on removal.
  * gate-12 nc-input-labels PASS, gate-13 modal-isolation PASS, gate-16
    spec-coverage PASS — the new dialogs did not push a finding elsewhere.
  * vitest 140 files / 1375 tests pass (was 1366; +9 net).
  * eslint clean on all changed and new files.
  * check-l10n OK; 6 new source strings registered in en.json AND nl.json.

* fix(spec): tag PromptTextDialog's two methods, which gate-16 caught in CI

gate-16 spec-coverage: 2 -> 0 on this branch's diff.

CI caught what my local run could not: gate-16 is DIFF-SCOPED, so a full-repo
run reports nothing for it and my "no other gate moved" check was blind to it
by construction. The two findings are both in the new file this PR adds:

  src/dialogs/PromptTextDialog.vue::onSubmit — missing @SPEC
  src/dialogs/PromptTextDialog.vue::open     — missing @SPEC

(`open` is the WATCHER on the `open` prop, which is a method in the options
object — worth knowing, because it reads like a prop in the finding.)

Both now carry `@spec openspec/specs/openconnector-api-sources/spec.md
#req-ocas-003` — "Schema mapping of the external payload to display fields",
which is exactly what this dialog serves: it collects the display-field name
for a mapped leaf.

Deliberately the CANONICAL spec, not the tasks file the calling component
uses. `openspec/changes/openconnector-api-sources/tasks.md#task-3.1` does
resolve, but only through the ARCHIVE index — it is an archived change dir,
which is precisely the shape #157 cleaned 52 tags out of. Adding a new tag of
that shape would re-introduce it. Verified the canonical anchor resolves
directly: has_anchor('openspec/specs/openconnector-api-sources/spec.md',
'req-ocas-003') -> True.

Re-measured --scope-to-diff --base origin/development at gate package
365fa31: NO gate fails on this diff (gate-16 PASS, gate-34 PASS, gate-46
PASS). eslint clean; vitest 140 files / 1375 tests pass.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…167)

gate-25 contract-coverage: 10 -> 0 (PASS). Measured at gate package 365fa31.

Ten `#[NoAdminRequired]` / `#[PublicPage]` endpoints were registered in
appinfo/routes.php with no Newman collection and no PHPUnit test touching
them. gate-25 matches on an actual `->method(` call in a *Test.php, so these
could not be satisfied by annotation — only by really calling them.

  AppOverrideController   saveUser, getUser, clearUser   (8 tests)
  AutomationsController   disable                        (2 tests)
  RulesController         testAll                        (4 tests)
  ShopController          githubSearch, githubInstall    (5 tests, new file)
  GitHubSyncController    link, pull                     (7 tests, new file)
  ApplicationsController  saveManifest                   (2 tests, new file)

They pin authorisation and input validation rather than restating the happy
path, because that is where a network-facing endpoint actually fails:

  * anonymous -> 401 AND the service is never called (expects(never())), so a
    guard that returns the right status while still doing the work would fail;
  * non-owner -> 403 with a REAL PermissionResolver, not a stubbed verdict, so
    the role logic is genuinely exercised — including a VIEWER being refused a
    write, the case a coarse "has any role" check would wrongly allow;
  * malformed owner/repo/ref -> 400 BEFORE any outbound GitHub call, which is
    the guard that keeps an attacker-supplied string out of the request URL.

Proven not blind: replacing the `->githubSearch()` calls in ShopControllerTest
with a dummy name flips gate-25 PASS -> FAIL(1), and restoring them returns it
to PASS.

Verified by running PHPUnit locally in a DISPOSABLE container against this
worktree (nextcloud:34.0.0-apache, PHP 8.4) rather than the shared instance,
whose bind mount points at the main checkout and would have tested that lib/
instead of this branch's: 797 tests, 2480 assertions, OK (was 769).

One incidental finding while writing these: ShopController::githubSearch reads
`$result['brokerUsed']` unguarded. Every documented return path of
GitHubCatalogService::search() supplies it, so the fixture was at fault and was
corrected — but note search() has a `return $cached` path, so a cache entry
written before that key existed would surface as an undefined-key warning.
Not changed here; recorded for whoever touches that cache.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* chore(deps): pin @conduction/nextcloud-vue to 2.2.0-vue3.9

Hard pin to the vue3 dist-tag head. Not a range: ^2.2.0 does not match
2.2.0-vue3.9, and latest/beta are the retired Vue 2 lineage.

Lockfile control ran first: regenerating with the pin UNCHANGED already
produces +115/-1351 (npm 11 dedupe). Bump-attributable portion is +5/-5.

installed off disk after a real npm ci: 2.2.0-vue3.9, one copy,
peer vue ^3.5.0
unit 1375 passed / 140 files -> 1375 passed / 140 files
test:l10n exit 0; check:manifest exit 0
build 3 warnings -> 3 warnings
bundle 31,715,872 -> 31,733,865 bytes

* fix(deps): regenerate the lockfile with npm 10, the version CI actually runs

The earlier lockfile was generated with npm 11.13.0, which prunes
optional+peer entries npm 10 still requires. CI runs node 20.20.2 /
npm 10.8.2, matching this repo's declared engines (node ^20, npm ^10).

Regenerated with npm 10.8.2 under node 20. The +115/-1351 previously
attributed to pre-existing npm dedupe was an npm 11 artifact, not real
churn. Total diff vs development is now exactly +5/-5.

npm ci under npm 10.8.2 exits 0; installed off disk 2.2.0-vue3.9, one
copy, peer vue ^3.5.0. unit 1375/140 pass; test:l10n exit 0;
check:manifest exit 0; build 3 warnings, 0 errors.
…ng (#172)

E2E Tests (Playwright) has failed on the `development` push run since #163.
Two of its four failures share one cause.

#163 replaced seven native window.confirm/prompt guards with real in-page
dialogs (gate-34 window-confirm: 7 -> 0). It did not update the two e2e specs
that drove two of those call sites, and both still carried

    page.once('dialog', (dialog) => dialog.accept())

immediately before the destructive click.

That handler does not fail when the page opens no native dialog — it simply
never fires. So the click opened ConfirmActionDialog, the spec never answered
it, the DELETE/manifest write was never issued, and the follow-up assertion
failed as though the endpoint were broken:

  automations.spec.ts:293                 Expected: 0  Received: 1
  docudesk-document-templates.spec.ts:322 Expected: 0  Received: 1

Neither list was ever asked to change. The product is correct; the tests were
addressing a browser primitive it no longer uses.

Both now drive the dialog the product actually renders, through one shared
helper. The helper ASSERTS the dialog appeared before answering it: a helper
that merely clicked "confirm if present" would keep passing if a future change
dropped the confirmation step altogether, which is the one property worth
pinning here.

The dialog is located by its ACCESSIBLE NAME (NcDialog `name` -> aria-labelledby)
rather than a CSS class, so an unnamed confirmation dialog fails to match here
instead of passing unnoticed.

Refs #163

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

* test(e2e): replace 14 networkidle waits with deterministic signals (gate-58)

* test(e2e): accept the Dashboard create-app entry point as the chat-companion readiness signal

* test(e2e): poll the Version history tab to a selected end state instead of racing the sidebar transition

* test(e2e): require the Version history panel body, not just tab selection
…e anchors removed (#179)

* test(e2e): 25 gate-19 anchors onto tests that already prove them, and 2 that did not

gate-19 (@e2e traceability), FULL scope, measured with hydra-gates @ main
(b8c7ead) against openbuild@development:

  before  {scenarios: 767, covered: 72, excluded: 509, uncovered: 186}
  after   {scenarios: 767, covered: 97, excluded: 509, uncovered: 161}

186 -> 161. No test was skipped, no timeout widened, no exclusion added.

WHAT THE 25 ARE. Not new tests: `builder-undo-redo.spec.ts` and
`form-editor-logic.spec.ts` already drive these scenarios and already run green;
they carried no `@e2e` reference, so the gate could not see them. Every mapping
was re-derived by reading the test BODY against the scenario text, not by
matching titles.

  builder-undo-redo   11 anchors across 6 tests
  form-editor-logic   14 anchors across 5 tests

One of the 14 needed the test EXTENDED before it could be anchored honestly:
`deleting-a-step-returns-its-fields-to-the-unassigned-pool` asserted the removal
and the pool re-appearance but not the spec's final AND (save auto-assigns an
unassigned field to the last step). The surface exists
(`assignUnassignedFieldsToFinalStep()`, called from PageDesignerHost's save()),
so the test now re-adds a step, asserts the pool note, saves, and polls the
PERSISTED manifest for the assignment.

ONE SCENARIO WAS REFUSED, deliberately, with no exclusion written:
`form-editor-logic::raw-json-authored-logic-survives-unrelated-editor-edits`
asserts a Design <-> Raw JSON tab round-trip. That tab does not exist on
/builder/:slug/pages — the raw editor is a sidebar tab on a different page,
writing a different object. Anchoring it would have been false and excluding it
would have been a false statement about observability, so it stays uncovered and
an issue is the right next step.

AND TWO ANCHORS WERE REMOVED, which RAISES the covered count's honesty at the
cost of the number. `save-as-template.spec.ts` credited
`viewer-cannot-save-a-template` and `seeded-cards-remain-read-only` to a test
whose own comment, three lines below the tags, states that it proves neither:
the assertion is toHaveCount(0) on a button, under an ADMIN session, on a page
where the button is not scoped to appear. Filed as #178.

Removing them did NOT raise `uncovered` (161 either way) — because both
scenarios are ALSO swallowed by a requirement-level `@e2e exclude` whose stated
reason is about something else entirely. Measuring that led to the wider
finding: 471 of openbuild's 509 exclusions (92.5%) share a reason with a
sibling, 42 reasons covering them, one covering 32 scenarios. Filed upstream as
ConductionNL/.github#356.

NEGATIVE CONTROL (scope-independent):
  161  with tests/e2e/builder-undo-redo.spec.ts present
  172  with it removed          (+11 = exactly its 11 anchors)
  161  restored

Positive control that the gate validates slugs rather than the tag's presence:
corrupting one slug to #adding-a-step-groups-fields-by-BOGUS dropped covered
99 -> 98; restoring returned it to 99.

Refs #178

* test(e2e): a dangling @e2e anchor hid a scenario a running test already proves

161 -> 160.

`component-blocks.spec.ts` anchored
`component-blocks::blocks-filter-shows-blocks-without-the-clone-action`. That
scenario lives in `openbuild-template-catalogue`, not `component-blocks`, so the
ref resolved to NOTHING: gate-19 credited no scenario and reported no error,
while the real scenario sat in the uncovered list and a green test that proves
it was sitting right there.

A dangling anchor is silent in both directions — it looks like coverage in the
file and like an absence in the gate. Found by walking every anchor in the suite
against the gate's own parser rather than by eye.

openbuild has 15 of them. This is the only one that was a simple mis-spelled
target. The other 14 are in `spec-coverage/page-designer-ui.spec.ts`, name slugs
that exist in no spec, sit on seven tests CI never runs (`test.skip(!LIVE)`,
`OPENBUILD_E2E_LIVE` is never set), and belong to ten scenarios that are ALL
`@e2e exclude`d on the grounds that another spec's Playwright tests cover them.
Repointing those while the tests stay skipped would move them from silently
uncounted to counted against tests that never execute, so they are filed rather
than patched — #181.

What this anchor does NOT claim is recorded next to it: the scenario's THEN lists
"name, description, category and a preview"; the test asserts the name and the
AND (no clone action). The distinguishing behaviour — browse-only, no clone
affordance — is covered.

Refs #181

* test(e2e): an expect timeout equal to the test budget can never elapse

The new REQ-OBFEL-001 poll carried `timeout: 30_000`, which is exactly the
per-test budget in tests/e2e/playwright.config.ts. The test would always die
first, so the poll's own message — "saving must append the still-unassigned
field keys to the final step" — could never be printed, and the failure would
surface as a bare test timeout instead.

That is the precise failure mode the config's shorter `expect` timeout exists to
prevent; its own comment says so. Use the repo default, 15_000.

Not a widening: this LOWERS a number, and it makes the assertion able to report
itself.
…actured coverage the moment anyone un-skipped them (#182)

Seven `@e2e` anchors sat on `test.skip`ed tests whose bodies do not assert the
scenario they name. gate-19 does not credit a skipped test, so none of them
moves a number today. That is precisely why they are dangerous: un-skipping is
the obvious way to burn gate-19 down, and doing it would have scored seven
scenarios as covered without one new assertion being written.

MEASURED, expectation computed BEFORE the run (hydra-gates @ 81c8c97,
1735-line checker), openbuild@development ff25c1b:

  as shipped                       {scenarios: 767, covered:  98, excluded: 509, uncovered: 160}
  the 7 tests un-skipped, nothing
  else changed                     {scenarios: 767, covered: 105, excluded: 509, uncovered: 153}
  restored                         {scenarios: 767, covered:  98, excluded: 509, uncovered: 160}

+7 exactly, which is the count of anchors, naming those seven scenarios.

WHAT IS WRONG WITH EACH, read from the body, not the title:

  openbuild-rbac              two anchors about the VIEWER and the EDITOR sit
                              on tests titled for the OWNER and the ADMIN, both
                              driving the shared admin session. The second one's
                              only assertion is
                              `expect(count).toBeGreaterThanOrEqual(0)` on a
                              locator count — a count is never negative, so the
                              assertion CANNOT FAIL.
  application-detail-overview a requirement naming six rows IN DOM ORDER plus
                              four sidebar tabs, anchored to a test that asserts
                              `main` is visible and the title matches
                              /openbuild/i. Its sibling computes `iconCount` and
                              never asserts on it.
  app-icon-management         "uploads a light icon" uploads nothing; "removes
                              the dark icon" removes nothing and both of its
                              branches pass without the feature; "non-SVG is
                              rejected" puts its rejection assertion under
                              `if (fileInputCount > 0)` and then
                              `if (errorCount > 0)`, so the product failing to
                              reject is exactly the case where nothing is
                              asserted. That body already says so: "the test
                              passes vacuously because the UI is not built".

THE NUMBER DOES NOT MOVE, and that is the point:

  after removal                    {scenarios: 767, covered:  98, excluded: 509, uncovered: 160}

No test was skipped, un-skipped, deleted or loosened; no exclusion was written;
no timeout was widened. What changed is the count of uncovered scenarios
carrying a dead anchor: 35 -> 28 (the gate's own `collect_ref_status`).

The anchors are DELETED rather than commented out. Per ConductionNL/.github#358
both gate-19 and gate-26 parse prose, so an anchor quoted in an explanatory
comment is re-read as a live anchor — the replacement notes name no slug.

SCOPE, stated because it is weak: this change touches three TEST files and no
spec file, so `changed_spec_files()` returns the empty set and gate-19's own
cell on this PR is EMPTY SCOPE (exit 3, NOT APPLICABLE) — it corroborates
nothing. The full-scope figures above and the +7/-7 control are the only
evidence, and the control is the load-bearing half.

Refs #178, ConductionNL/.github#343, ConductionNL/.github#358
Lock-only bump; `composer audit` clean after it and CI's phpcs job exercises it.

Merged over a red E2E cell that is pre-existing and STRICTLY NOT WORSENED, verified against the same-tree baseline rather than an eyeball:

| | development 31459739568 | this PR |
|---|---|---|
| failed | 2 | **2 — identical set** |
| passed | 181 | **181** |
| skipped | 68 | **68** |

Both remaining failures are `automations-rbac.spec.ts:124` and `:152`, and both are test-harness gaps (a first-open CnSupportDialog modal mask, and a fixture app CI never creates) rather than the RBAC 500 — remedied by the harness half of the review-only PR #177.

`version-rollback.spec.ts:263`, which was red on the current development tip, PASSED here — consistent with the same-SHA-opposite-verdict pair already recorded (`2b1a8900` failed it in run 31438032584 and passed it in run 31459739568), so it is not attributable to any diff. Mechanism candidate filed as nextcloud-vue#632, deliberately not masked.
…sors — the slug resolver was dead (#185)

ObjectSchemaSlugResolver guarded all three of its accessor calls with
method_exists(). OpenRegister's ObjectEntity, Schema and Register extend
Nextcloud's Entity, which serves every column accessor through __call() and
declares it as an @method docblock only. method_exists() is therefore false for
getSchema(), getRegister() and getSlug() on the real classes, so every guard took
its false branch, schemaSlug() returned '' for every real entity, and
isOpenBuildSchema() was false even for openbuild's own objects.

Measured on this instance: method_exists false / is_callable true for those three,
against the concrete control getObject() which is true for both.

That disabled the two listeners the resolver exists to serve —
ProductionVersionGuardListener and AutomationCleanupListener. Both sit behind
ListenerSlugContract, which defaults off, so default behaviour does not change
here: what changes is that enabling the flag now actually wakes them instead of
silently keeping them off. Waking them remains a rollout decision, unchanged.

The file's own header already documented the neighbouring trap (getSchemaSlug()
does not exist and the ids are not slugs) while all three of its guards carried
this one, so the fix is applied at the single private seam they funnel into
rather than at the reported line.

is_callable() is not a membership test on a __call class — it is true for any
name — so readAccessor() is exception-safe; Entity throws BadFunctionCallException
for a column it does not have. ProductionVersionGuardListener::extractUuid()
carried the same defect on getUuid() and is fixed the same way.

Why the suite never caught it: tests/stubs/openregister-stubs.php declares these
accessors concretely, for a real reason (PHPUnit 10 removed addMethods(), so
createMock()->method('getUuid') cannot configure a magic method). That stub
inverts the exact predicate under test — method_exists is true in the suite and
false in production. The new test therefore extends the real
OCP\AppFramework\Db\Entity so its accessors are reached the way production
reaches them, and carries a concrete-method control so a false cannot come from a
broken fixture.

Negative control, predicted before running: reverting the guard at readAccessor()
turns exactly 3 of the 7 new tests red. Observed exactly those 3
(testSchemaSlugResolvesThroughMagicAccessor,
testRegisterSlugResolvesThroughMagicAccessor,
testIsOpenBuildSchemaMatchesOpenBuildsOwnObject); the other 4 stayed green.

Suite 797 -> 804 tests, all green. lint/phpcs/phpmd/psalm/phpstan clean.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
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.

3 participants