Skip to content

Release: merge development into beta - #79

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

Release: merge development into beta#79
github-actions[bot] wants to merge 523 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.

rubenvdlinde and others added 30 commits July 23, 2026 21:19
fix(security): harden SSRF, rules-engine DoS, CSRF, and XSS surfaces
Adds OpenBuild's first #[PublicPage] surface: a ShareToken model (OR-backed,
openbuild register) scoping anonymous access to exactly one Application +
one page, with submit/read/edit modes, honeypot spam guard, AnonRateLimit,
prefill-from-URL, and per-record edit links. Public rendering/submission
resolve solely through the token, never session/organisation auth; writes
go through PublicSubmissionService acting as the Application owner, never
the OR client-facing objects API or a visitor identity.

- ShareToken schema (register.d/50-public-forms-runtime.json, ADR-037 fragment)
- ShareTokenService (issue/revoke/resolve/list) + PublicSubmissionService (submit)
- PublicFormController (#[PublicPage] render/submit) + ShareTokenController (authenticated CRUD)
- Public bootstrap entry (src/public-form.js, new webpack/template/route) + ShareTokenDialog.vue
- FormPageEditor.vue "Public access" block; wired from PageDesigner toolbar + app Actions menu
- 27 new PHPUnit tests (ShareTokenService, PublicSubmissionService) + 13 new vitest tests

Archives change public-forms-runtime; syncs public-form-access (new) and
openbuild-runtime (added requirement) canonical specs.
feat(public-forms-runtime): anonymous share-token render/submit surface
…/download for report actions; v0.7.7 (#9)

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ect follow-ups, My Approvals widget

Adds a new `approval` automation action kind (event/lifecycle-transition
triggers only, group-only assignee) that compiles to an OpenRegister
ApprovalChain and initialises a step via ApprovalService::initializeChain()
at trigger-fire time (AutomationApprovalTriggerListener) — consume-not-rebuild
per ADR-022, no new approval engine in OpenBuild.

- AutomationCompilerService: matrix extended, approval-backend compile
  branch (upsert ApprovalChain, idempotent), provenance.approvalChainName,
  approvalState() for status/dry-run surfacing.
- ApprovalOutcomeListener: dispatches an automation's onApprove/onReject
  follow-up actions on ApprovalStepApprovedEvent/RejectedEvent via the
  shared RuleActionDispatcher.
- AutomationEditDialog: approval action type, NC-group assignee picker
  (degrades to free-text), on-approve/on-reject nested action-list editors
  (new AutomationActionListEditor.vue).
- MyApprovalsWidget.vue: runtime widget listing the viewer's pending
  approval steps (client-side group filter via IInitialState), approve/
  reject calling OpenRegister's /api/approval-steps directly — no
  OpenBuild pass-through controller.
- AutomationsController::status()/dryRun(): approvalState field.
- OpenRegister test stubs extended (ApprovalChain/Step/Mapper/Service,
  ApprovalStep{Approved,Rejected}Event, Object{Created,Updated}Event) so
  the unit suite is self-contained without a sibling checkout.

Archives openspec change automation-approval-steps; syncs delta specs into
automation-designer (REQ-AUTD-002/003/004/007) and the new
automation-approval-action capability spec (Status: done).
feat(automation-approval-steps): approval action kind, on-approve/reject follow-ups, My Approvals widget
…ocs (#4)

M1: resolve rule-sets / decision-tables / test-cases through OpenRegister
searchObjectsBySlug (caller's schema-RBAC + organisation scope) instead of raw
findAll, in RuleEngineService::findMany and RulesController::query. Correct the
false "multi-tenant isolation enforced / foreign slug → 404 / no IDOR" docblocks
in RuleEngineService::loadBundle, the RulesController class docblock, and
appinfo/routes.php to state the real boundary (org + schema-RBAC scope, NOT
per-owner isolation).

Adds RuleEngineServiceTest cases: resolution never uses findAll; an out-of-scope
rule-set resolves to 404. Includes the openspec change artifacts.

Covers harden-rules-authz-and-audit-parity group 1 (M1).

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
L8: ExportsController::isAuthorisedForJob read a `submittedBy` key that is never
persisted (the broker migration persists `requestedBy`), so the check always
fell through to the `@self.owner` fallback. Read `requestedBy ?? @self.owner`
instead, and require a non-empty match.

Test fixture updated to the persisted `requestedBy` field so the owner-download
path is authorized via the correct key; the unauthorized-caller test still 404s.

Covers harden-rules-authz-and-audit-parity group 2 (L8).

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
)

L2: MCP admin-bypass was logged to PSR only, invisible in the REQ-OBRBAC-007
permission-history panel that is the system of record for the HTTP path.

- AbstractToolHandler: inject (autowired, nullable) AuditTrailMapper and add a
  recordAdminBypass() helper that writes the bypass to the OR per-object audit
  trail, mirrors to PSR info, and logs critical on write failure (fail-soft,
  never aborts). Thread the mapper through OpenBuildToolProvider::makeHandler.
- CopilotService: same audit write on its own admin-bypass branch.

Tests: recordAdminBypass writes an audit entry when the mapper + app entity are
present, and falls back to a PSR log otherwise. CopilotService + provider tests
still green (nullable appended param).

Covers harden-rules-authz-and-audit-parity group 3 (L2).

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
L9: ApplicationInsightsService::callerInAnyRole matched only user:/bare-uid
principals, so a caller authorized solely via a group: principal was wrongly
denied (fail-closed). Delegate to the shared PermissionResolver::matchesCaller
(no admin bypass) so insights authorization is group-aware and consistent with
every other guard. PermissionResolver injected nullable/autowired (fallback
keeps the user-only match when absent).

L4: correct the IconController docblock — the endpoints enforce session-only
access (no per-app viewer check), so drop the "personalised to the caller's app
access" claim; `private` cache is a no-shared-cache default, not access scoping.

Tests: a group-only caller is granted; an unmatched caller denied.

Covers harden-rules-authz-and-audit-parity groups 4 (L9) + 5 (L4).

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
Wrap-up for harden-rules-authz-and-audit-parity (group 6):
- Register the change in the five capability specs (OpenSpec changes link;
  flip application-insights + app-icon-management to in-progress).
- Reconcile phpmd.baseline.xml for three threshold-nudging violations the fixes
  introduced: ExportsController::isAuthorisedForJob CC, RulesController::query
  CC, and AbstractToolHandler coupling (the added AuditTrailMapper dependency).

Full suite green: PHPCS 0 errors, Psalm, PHPStan, 598 PHP unit tests; no new
phpmd violations.

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
The group-6 commit staged specs/baseline/tasks but missed the phpcbf alignment
and @param docblock fixes (and CopilotService //end if) applied during the
quality pass. Doc/formatting only — no logic change — so the committed branch
is PHPCS-clean.

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
… register (#4)

Verify caught a regression risk in the M1 change: searchObjectsBySlug defaults
_multitenancy:true, which makes OR's registerMapper->find('openbuild') throw for
any caller outside the register's organisation → caught → empty → 404, breaking
rule-set resolution in multi-org setups (the raw findAll it replaced did not).
The `openbuild` register is system-wide (ListAppsHandler uses _multitenancy:false
for exactly this reason), so mirror that: pass _rbac:true, _multitenancy:false.

Also corrects the docblocks (RuleEngineService::loadBundle, RulesController class,
routes.php) to state the honest boundary: schema-RBAC only, NOT per-owner/per-org
read isolation on a system-wide register; writes stay admin-gated.

598 PHP unit tests pass; PHPCS clean.

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
Implements the automation-document-action OpenSpec change: a new
generateDocument action kind lets an automation trigger Docudesk document
generation on object create/update/delete/lifecycle-transition, without
ever importing a Docudesk PHP class or bypassing REQ-DDT-006's closed
integration contract.

Backend:
- AutomationCompilerService: generateDocument added to the v1 matrix on
  event/lifecycle-transition triggers; compile-time validation (templateId
  present, output non-empty, notify never alone) and a fail-closed
  UnsupportedAutomationCombinationException when Docudesk is absent.
- DocumentGenerationService: owner-impersonated (JobOwnerImpersonator)
  internal HTTP call to the pinned POST /apps/docudesk/api/correspondence/
  generate route, authenticated via a short-lived NC login token minted
  through OC\Authentication\Token\IProvider and invalidated immediately
  after use. attach writes to Nextcloud Files + a {ref} field on the
  object; download-link writes to OpenBuild's own app-private storage
  (never the user's Files tree) behind a random ~24h token served by the
  new GeneratedDocumentController; notify reuses RuleActionDispatcher's
  existing send-notification path.
- DocumentGenerationListener: trigger-fire dispatch, mirrors
  AutomationApprovalTriggerListener's shape for the sibling approval kind.

Frontend:
- useDocudeskTemplates.js: shared template-list fetch, now used by BOTH
  DocumentTemplateAttachmentDialog and the new automation editor picker
  (no second implementation).
- AutomationEditDialog.vue: generateDocument action editor (template
  picker + output-mode multi-select), missing-app degradation via
  useAppStatus('docudesk'), and matrix-aligned save validation.

Tests: 27 new PHPUnit tests (compiler, service, listener, controller,
source-tree Docudesk-import guard) + 21 new/updated vitest tests. Full
suites green (699 PHPUnit / 1236 vitest), phpcs/phpstan/psalm clean on
touched files.
- Revert the unrelated SetupController.php phpstan tidy-up: it pulled 3
  pre-existing @NoAdminRequired/admin-body semantic-auth findings into this
  diff's gate scope for a file this change has no other reason to touch.
- Add the @SPEC tag templateToOption() was missing (gate-16 spec-coverage).
… pin

- Playwright: compose a generateDocument automation on a lifecycle
  transition (template picker/free-text degrade, output-mode select) and
  the schedule-trigger fail-closed block, mirroring the existing
  approval-action e2e suite's structure. CI-run only, not executed against
  the shared dev instance in this session.
- Newman: item 6 pins the automation-triggered call to the SAME
  correspondence/generate route + request shape item 4 already asserts.
- tasks.md: check off 4.1-7.2 (all 18 tasks complete).
…pecs

- Move openspec/changes/automation-document-action/ to
  openspec/changes/archive/2026-07-24-automation-document-action/.
- Sync canonical specs: automation-document-action (new capability, ADDED
  requirements, Status done), automation-designer (REQ-AUTD-002/003/004
  MODIFIED with the generateDocument scenarios, Status done — last active
  change), docudesk-document-templates (REQ-DDT-006 MODIFIED to name the
  owner-impersonated automation caller shape; harden-xss-dos-csrf still
  active so Status stays in-progress).
- CHANGELOG.md: add the 0.7.5 entry.
- docs/automation-designer.md: extend the compilation matrix with the
  Require approval and Generate document columns (both were missing —
  approval predates this PR; documented together since they share the
  same "needs a concrete fired object" constraint).
Add @e2e Playwright markers for the two scenarios this PR's automations
e2e suite exercises, and reason-bearing @e2e exclude annotations (backend/
service contract, already covered by PHPUnit + Newman) for the remaining
scenarios this change added. Leaves the pre-existing automation-designer/
automation-approval-steps scenarios untouched — gate-19 flags them because
editing shared requirement bodies pulls the whole file into diff scope,
not because this PR modified their behaviour; backfilling e2e coverage for
already-shipped, unrelated functionality is out of this change's scope.
feat(automation): add generateDocument automation action
Adds ComponentBlock: capture a configured widget or a multi-widget page
section from the page designer into a named, reusable manifest fragment;
insert it onto other pages/apps with schema-dependency remap when the
target app's schemas don't exact-match. Insert always deep-copies and
mints fresh widget ids so two insertions never collide, and never writes
back to the source block.

- Schema: lib/Settings/register.d/60-component-blocks.json (ADR-037
  fragment, new disjoint `componentBlock` schema in the openbuild register)
- Capture: src/services/blockCapture.js reuses save-as-template's
  deNamespaceSlug/rewriteSchemaRefs; src/dialogs/SaveBlockDialog.vue
- Insert + remap: src/services/blockInsert.js (pure deep-copy/id-mint/
  remap); src/dialogs/BlockRemapDialog.vue opens only on a schema-slug
  mismatch, never auto-guesses
- Library panel: src/components/page-editor/BlockLibraryPanel.vue, an
  NcAppSidebar panel in PageDesigner.vue; insert merges the new widgets
  onto the target page via the existing mergeManifestDelta keyed-merge
  engine (widgets[] merges by id) rather than hand-splicing the manifest
- Widget/section selection affordance:
  src/components/page-editor/WidgetSelectionPanel.vue
- Export/import as JSON: src/services/blockExport.js (client-side
  download/parse, mirroring RuleSetsPage's existing export-as-JSON shape)
- Template catalogue: TemplateGallery.vue gains a Templates/Blocks toggle
  (REQ-OBTC-003) — Blocks lists ComponentBlocks read-only, no clone action
- Tests: 32 new vitest unit tests (blockCapture, blockInsert) covering
  de-namespace capture, id-mint/no-collision-on-double-insert, remap
  trigger conditions, and the no-live-reference-after-edit guarantee
… coverage

- tests/integration/openbuild-component-blocks.postman_collection.json —
  ComponentBlock CRUD via standard OR REST, export/import round-trip
  (mirrors the openbuild-templates-marketplace collection's pattern)
- tests/e2e/component-blocks.spec.ts — Playwright coverage for save/insert/
  remap/export-import/gallery-filter, QUARANTINED (test.describe.skip)
  under Conduction/openbuild#41 same as save-as-template.spec.ts and
  template-gallery.spec.ts; @e2e scenario tags for gate-19 traceability
- tasks.md: all 15 tasks checked off with implementation notes and
  documented deviations (client-side export vs ExportJobService pipeline;
  register.d fragment vs monolith edit)
feat(component-blocks): save/insert reusable page-designer blocks
- openspec/changes/archive/2026-07-24-component-blocks/ — moved from
  openspec/changes/component-blocks/
- openspec/specs/component-blocks/spec.md — new capability spec, synced
  from the change's delta (7 requirements)
- openspec/specs/openbuild-template-catalogue/spec.md — merged the
  "Blocks" filter delta into REQ-OBTC-003; updated its OpenSpec-changes
  link for component-blocks to the archived location (harden-xss-dos-csrf
  remains active, so Status stays in-progress)
- CHANGELOG.md — 0.7.7 entry
docs(openspec): archive component-blocks, sync capability specs
…ntrast guardrail

Adds the runtime.appTheme manifest block (logoRef, primaryColor,
secondaryColor, accentColor, headerStyle) as a lightweight, non-nldesign
theming layer for virtual apps, reusing nldesign-theme-selection's scoped
[data-openbuild-theme-scope] applier mechanism and app-icon-management's
icon/iconDark fields unmodified.

- appTheme manifest validation (services/manifestValidation/appTheme.js)
- checkThemeContrast.js — pure WCAG relative-luminance contrast guardrail
  (4.5:1 text, 3:1 UI element), no override/bypass
- AppCustomThemeSection.vue — theme editor, sibling of ThemeSection.vue in
  PageDesignerHost.vue; Save blocked at the actual persist boundary when
  contrast fails
- useAppCustomTheme.js — scoped CSS-variable applier; maps colors onto
  --color-primary/--color-primary-element via a
  var(--nldesign-color-primary, <fallback>) chain (verified against the
  ACTUAL fetched nldesign token CSS, which only ever sets --nldesign-*
  names) so an active nldesign theme genuinely takes precedence
- AppBrandedHeader.vue — OpenBuild-side branded-header binding (CnAppRoot
  has no logo/branding slot), wired into BuilderHost.vue

See the PR description for the full list of deviations from design.md's
implementation-time open questions and their rationale.
…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
rubenvdlinde and others added 30 commits August 3, 2026 21:48
…110)

Bumps @conduction/nextcloud-vue 2.1.0-vue3.16 -> 3.0.0-vue3.2, pinned
exactly.

The major bump is narrow. Diffing the packed tarballs, 3.0.0-vue3.2
removes exactly three symbols against every 2.1.0-vue3.x baseline
(.13/.15/.16/.17/.19): CnFlowCanvas, CnEditFlowsModal and
CnFlowCanvasModal — the old flow-authoring surface, which moved into
OpenRegister's one flow store. From .16 it also ADDS CnFlowDetail,
CnFlowEditModal and CnObjectAccessTab, and exports useFlowStore from the
barrel. Nothing else in src/ changed: the only other edited file is
CnOpenBuildEditButton, which internally swapped the two removed dialogs
for CnFlowEditModal.

No OpenBuild source imported any of the three removed symbols, so the
bump needs no code change here. Verified in the built bundle rather than
by assertion: after the rebuild neither removed symbol appears in js/,
while CnFlowEditModal does — the positive control for that grep.

Separately, four peers nc-vue has declared since at least 2.1.0-vue3.13
were never declared by this app and were resolving only by luck of
hoisting: @vueuse/core, axe-core, dexie and marked. Declared them at the
versions OpenRegister and OpenConnector already use.

Verified from the lockfile, not package.json: nextcloud-vue is exactly
3.0.0-vue3.2, and vue3-apexcharts resolves to 1.8.0 — below the 1.9.0
that turned proprietary and cannot be sublicensed under EUPL-1.2.
@nextcloud/l10n resolves to 3.4.1, so this app is not exposed to the v2
boot-killer where nc-vue's pre-bundled dialogs call the v3-only
getGettextBuilder().detectLanguage().

Gates, all after a full rm -rf of node_modules and package-lock.json:
npm install (npm 11) then npm ci (npm 10) both exit 0 on a 780 KB /
1717-package lockfile, eslint exit 0, 1364 unit tests in 140 files
passing, and webpack exit 0 with only the pre-existing asset-size
warnings. The build was run with USE_LOCAL_LIB=false so it measured the
published package: this repo sits next to a nextcloud-vue checkout, and
webpack.config.js aliases the sibling by default, which would have
measured the wrong code entirely.

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

* build(quality): run the Hydra gates as part of composer check:strict

openbuild is the pilot for conduction/hydra-gates. The gates have until now only
run inside hydra's own containers, which means an agent finishing a task here
could report done without any of the 61 mechanical gates having looked at its
diff. Wiring them into check:strict makes "gates pass" part of the definition of
done in the same command everything else already runs.

The gates are diff-scoped per ADR-020, so this does NOT import openbuild's
inherited debt. A full-repo run of this tree fails 16 gates today (img-alt,
button-name, table-headers, spec-anchor-existence and friends); scoped to a PR's
own diff, only what that PR touched is enforced. `composer gates:full` is
available for the audit view, and is deliberately not what check:strict runs.

check:strict keeps its 0/1 contract, but the gate exit code is preserved
separately and reported, because it carries the failure COUNT and flows route on
it. 99 is distinguished from a gate failure in the summary: it means the gates
could not run at all, which is a configuration error and not a clean tree.

The dependency is declared against a `path` repository at ../hydra, which is the
fleet's checkout layout. That is the part of this change that is provisional —
see the PR description; how hydra-gates should be DISTRIBUTED (private Packagist,
a Satis mirror, or a split public repo) is an open decision, and a path
repository is the shape that is verifiable today without one.

* build(quality): lock conduction/hydra-gates at dev stability

A path repository derives its version from the checked-out branch, so the
default minimum-stability of 'stable' rejects a '*' constraint outright:
'found conduction/hydra-gates[dev-main, ...] but it does not match your
minimum-stability'. '@dev' is the constraint that resolves, and it keeps
working once the package is eventually tagged.

* build(quality): resolve hydra-gates from the public package, not a path repo

The `path` repository at `../hydra` is what blocked this PR. It works on a
developer machine and fails in CI with `Source path "../hydra" is not found`,
because the fleet's sibling-checkout layout does not exist in a CI job and
hydra is private, so no runner can fetch it.

conduction/hydra-gates now lives in ConductionNL/.github, which is PUBLIC and
already owns the shared workflows (ConductionNL/.github#131). Pointing at it
needs no credentials in this repo or on any runner.

- repositories: `path ../hydra` -> `vcs https://github.com/ConductionNL/.github.git`
  with `"no-api": true`, so composer clones over git instead of the GitHub API
  and a rate-limited unauthenticated runner cannot become a failed install.
- require-dev: `@dev` -> `^1.0`, resolving to the v1.0.0 tag. That also drops
  the dev-stability requirement the previous commit needed, because a path
  repository derives its version from the checked-out branch and a tag does not.
- composer.lock pins commit fdad2546f2ac68aa64be5fecef898784c1847538.

Nothing else changes. `composer gates`, `composer gates:full` and the gate
handling inside `check:strict` are untouched, including the part that captures
the gate exit code separately: it carries the FAILURE COUNT, and 99 ("could not
run at all") is still reported distinctly from a gate failure so a
configuration error can never read as a clean tree.

Also merged origin/development in. The branch predated the fleet-wide
timeout-minutes work, so it was carrying a silent revert of the bounds on
exporter-e2e.yml and pull-request-lint-check.yaml.

* ci(quality): actually run the gates in CI, not just in a local composer script

Wiring the gates into `composer check:strict` was a local-only change. Nothing
in this repository's CI invokes check:strict — no workflow does, verified by
grepping .github/workflows — so this PR would have merged green with the 61
gates never having executed on any diff. A gate that runs only on a developer's
machine is not a gate, and a green that never ran it is the failure mode the
gates exist to catch.

The shared quality workflow now ships a `hydra-gates` job (ConductionNL/.github#131),
opt-in per repo and defaulting to off. openbuild is the pilot, so it opts in.

`hydra-gates-ref` is pinned to the v1.0.0 tag rather than tracking `main`, so a
change to the gates cannot move this repo's verdict without a commit here to
move the pin.

The job resolves the diff base from the PR's real target branch, checks out the
app with fetch-depth: 0 (a shallow clone has no base to diff against), and
reports exit 99 — "could not run at all" — distinctly from "N gates failed".

* build(quality): bump hydra-gates to v1.0.1 for the gate-4 audit fix

The first real CI run of the gates on this PR failed on gate-4:
`composer-audit: FAIL — CVEs or advisories`. There are no CVEs — auditing this
repo's lock properly reports "No security vulnerability advisories found."

`composer audit` with no vendor/ present does not audit the lock. On the
runner's composer it errors and exits non-zero, which gate-4 reported as a
security finding. On composer 2.7.x the same situation prints "No packages -
skipping audit" and exits 0 — a silent fail-open that passes having audited
nothing.

Fixed in the package (ConductionNL/.github#135, released as v1.0.1): gate-4
audits `--locked`, treats an exit 0 that audited no packages as a failure, and
reports "could not run" in those words rather than as a CVE finding.

Both the workflow pin and composer.lock move to v1.0.1 so the two paths into
the gates cannot disagree about which version this repo is on.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Picks up the ADR-079 admin-settings link-out: the gear foldout's 'Admin
settings' entry becomes an admin-gated LINK to /settings/admin/<app> instead of
a modal, and ADR-079 Step 2's organisation credential broker home on
CnAdminSettingsShell.

Verified the feature is genuinely in the published tarball rather than trusting
a green Release run: vue3.2 ships 0 files containing showAdminSettingsLink and 9
containing the removed cnOpenAdminSettings; vue3.4 ships the new API.

Live-verified end-to-end on openconnector (ConductionNL/openconnector#1136): the
foldout renders Settings > Personal settings + Admin settings ->
/settings/admin/openconnector, which returns 401 for an anonymous caller.

Lockfile updated with --package-lock-only; CI builds and runs this app's own
e2e against it.
…ent (#113)

The comment introduced in #104 read "the 61 Hydra mechanical gates". That
number was never corrected here to a newer one on purpose — correcting it
would only reset the clock on the same failure mode.

The count has been wrong in both directions: the prose said 61 while only 59
gates actually reported, because gate-33 (axe-core) and gate-24 skip silently.
A hard-coded digit sitting in a comment is exactly how that 61/59 gap survived
unnoticed — nothing in CI compares the prose to the runner, so the number can
only ever drift, and a stale number overstates the coverage this repo has.

So the comment now carries no number at all and points at the composer package
`conduction/hydra-gates` (ConductionNL/.github, hydra-gates/) as the single
source of truth for which gates exist and how many report.

Comment-only change: no workflow input, pin or behaviour is touched.

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

GitHub names a reusable-workflow status context `<caller-job-id> / <called-job-name>`, so this
caller reported as `check / check-branch` while the org ruleset requires
`branch-protection / check-branch`. development is the head branch for the
`development -> beta` release PRs, so its tip decides the context name on those PRs.
…stallable standalone apps

RESCUED FROM CODEBERG. ExportService::buildScaffoldMap() landed on
codeberg.org/Conduction/openbuild as 4a47d797 (2026-07-26) and never reached
GitHub. Codeberg is being retired, so it would have been destroyed with the
remote. Searched all 230 origin refs for 'buildScaffoldMap' in
lib/Service/ExportService.php — zero hits.

Three of the four Codeberg-only openbuild commits DID land on GitHub, via
merge 001ea41 'import Codeberg feature line onto GitHub'. Blob-identity
checked at both tips for PageDesignerHost.vue, BuilderHost.vue,
AgentsController.php, GeneratedDocumentController.php, the three listeners and
CHANGELOG.md — all IDENTICAL. This method is what that import missed.

The port is purely additive — 73 insertions, 0 deletions across the three
files — so GitHub's copies were strict subsets and taking the Codeberg blobs
reproduces them exactly. All six helpers the method calls (prepareScratchDir,
copyTemplate, resolvePlaceholders, bundleDataRegisterSchemas, listFilesSorted,
rrmdir) are verified present on GitHub's ExportService.

ONE DELIBERATE CHANGE from the Codeberg original. Its docblock read:

  @SPEC openspec/changes/federated-config-sharing/specs/federated-config-sharing/spec.md

That path does not exist on GitHub — and does not exist on the CODEBERG tip
either. It was a dangling anchor from the moment it was written, and would
fail gate-58 (spec-anchor-existence). Re-pointed at the github-app-sync spec,
which is the change this method actually serves (GitHubAppSyncService folds
the scaffold into the config-set repo publish) and which does exist here.
The bump itself was always fine; the lockfile was written by the wrong npm.

CI runs Node 20.20.2 / npm 10.8.2. My workstation runs Node 22 / npm 11.13.0,
and an npm-11-written lockfile is REJECTED by npm 10:

    npm error code EUSAGE
    `npm ci` can only install packages when your package.json and
    package-lock.json are in sync.
    Missing: pinia@4.0.2 from lock file
    Missing: vite@8.2.0 from lock file

npm 11 prunes entries npm 10 still requires — regenerating under Node 20 adds
~1350 lines straight back. Verified `npm ci --dry-run` rc=0 under node:20
before pushing.

Worth noting the misdiagnosis this corrects: `npm ci` passed locally under
npm 11 on the same lockfile, so "works on my machine" was true and useless. The
package versions were never wrong (pinia 2.3.1 / vite 5.4.21, identical to what
development already locked) — only the lockfile FORMAT was.
chore(deps): @conduction/nextcloud-vue 3.0.0-vue3.4
… calls

phpmd's TooManyPublicMethods fired on this class the moment
buildScaffoldMap() was rescued, and the finding was pointing at
something real: of 12 public methods, only two — generateAppZip() and
scratchTreeDir() — had a production caller (both RunExportJob). The
other nine were public purely so the two test files could reach them.

Nine methods are now private: copyTemplate, resolvePlaceholders,
packageZip, listFilesSorted, isBinary, prepareScratchDir,
getOrCreateAppDataDir, rrmdir, bundleDataRegisterSchemas.

Both test files now drive the real entry points instead. Every
behaviour the old tests asserted directly on a step is now asserted
through its effect on what generateAppZip() / buildScaffoldMap()
produce — placeholder resolution, ASCII entry ordering, the snapshot
helper-file skip, data-register bundling, scratch-tree cleanup — plus
three assertions the old tests did not make at all. Suite: 743 -> 746
tests, 2245 -> 2342 assertions.

No baseline entry was added: phpmd is now clean on this class, and the
report set-diffs empty against development.
…ldmap

feat(publish): rescue buildScaffoldMap() — openbuild-app repos are installable standalone apps
Codeberg is retired; ConductionNL is GitHub-only. GitHub Actions never
executes .forgejo/**, so these workflows contributed zero status checks.

Deleted 6 of 8, each with a GitHub counterpart (code-quality.yml,
documentation.yml, release-beta.yml, release-stable.yml, exporter-e2e.yml).

KEPT app-tests.yml + tests.yml -- tests.yml carries the only definition of
the l10n extraction gate and the coverage ratchets (no .github/workflows/
l10n.yml here, enable-coverage-guard unset), and
openspec/changes/openbuild-nl-locale-parity targets that file.

Also repointed the .forgejo reference in tests/e2e/visual/README.md.
… button (#121)

`@nextcloud/vue` 9 renamed NcButton's visual prop `type` -> `variant` and
repurposed `type` as the NATIVE button type, with `default: "button"`.
`nativeType` was removed outright — it was the v8 name. This repo declares
^9.5.0 but the lockfile resolves 9.9.0 (the caret does not move an
already-resolved version, so the lockfile is what ships).

The settings Save button passed BOTH v8 spellings
(`type="primary" native-type="submit"`). Precisely what that does:

  • `native-type` is undeclared on v9, so it is inert.
  • `type="primary"` is consumed as the NATIVE button type. "primary" is
    not a valid one, and HTML's invalid-value default for <button type> is
    the Submit Button state — so the form DOES still save.
  • but `variant` never receives "primary", so it keeps its "secondary"
    default and the button renders unstyled.

So this was mis-styled rather than dead. Fixed anyway: the markup is
invalid and the shape is one upstream change away from breaking outright.

Verified in place: the button sits inside `<form @submit.prevent="save">`.

Behaviour is proven in openconnector#1140, which mounts the real NcButton
9.9.0 and pins all three spellings with negative controls.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Remove the 'if [ -f vendor/bin/phpunit ]' guard that made test:unit/test:all always exit 0. Measured: 746 tests now run and the suite fails when an assertion is broken.
chore(deps): @conduction/nextcloud-vue 3.0.0-vue3.6
…pping (#123)

* ci(quality): enable the Code Quality gates this repo was silently skipping

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.

* ci(quality): remove duplicate with: keys; respect documented newman opt-outs

A second key in the same with: block is accepted last-one-wins by every
YAML parser involved, so the file reads as configured while the value is
decided somewhere else in it.

Where enable-newman: false already carried a specific, still-live reason
(OR React/Async await + SQLite REGEXP; ZGW API incomplete; collections
hard-coding non-CI URLs and unseeded registers), it is left off and the
reason is quoted rather than overridden. Where it was a bare line with no
comment, the stale line is removed instead of shadowed.
…an install (#125)

lib/Settings/openbuild_register.json declared no components.registers section.
OpenRegister's ImportHandler creates a Register row from that key and nowhere
else on the main/beta lines (ImportHandler.php:1514) -- so a clean install
created 15 schemas and ZERO registers, and then skipped all 7 seed objects,
whose @self.register is resolved through the registersMap that section
populates. Every GET /api/objects/openbuild/<schema> 404s with
"Register not found: 'openbuild'".

Verified on a disposable Nextcloud 34 + PostgreSQL + OpenRegister main
(0.2.19), importing the merged register document (base + register.d/*.json,
merged exactly as SettingsService::deepMergeConfig does) into a VIRGIN
OpenRegister with zero registers:

  configurations/import -> HTTP 200
  register 'openbuild'  -> present, id/version 0.9.0
  15/15 expected schema slugs present AND bound into the register
  15/15 GET /api/objects/openbuild/<schema> -> HTTP 200

The slug is derived, not invented: lib/ and src/ address
`objects/openbuild/<schema>` in 41 places and all 7 seed objects carry
`@self.register: "openbuild"`.

The schema list is by SLUG, not by components.schemas key -- and for openbuild
every one of the 15 differs (`Application` -> `application`,
`ApplicationTemplate` -> `application-template`, `HelloMessage` ->
`hello-message`, `TestCase` -> `rule-test-case`, ...). ImportHandler keys its
schemasMap by $schema->getSlug() (ImportHandler.php:1397/1477), so a register
listing the KEYS binds ZERO schemas while still looking correctly declared;
that is exactly what the first draft of this change did, and only the live
check caught it.

info.version 0.8.0 -> 0.9.0 so OpenRegister's version-gated importFromApp
actually re-imports.

appinfo/info.xml already lists the register-importing InitializeSettings step
under <install> as well as <post-migration>, so openbuild does NOT have the
second, independent gap where the import is post-migration-only and therefore
never runs on a fresh install (Installer::installAppLastSteps() guards both
migration blocks with `if ($previousVersion !== '')`).

NOTE for reviewers: openbuild's appinfo/routes.php references
OCA\OpenRegister\AppHost\Routes, which exists only on OpenRegister
development. With OpenRegister main installed, enabling openbuild makes EVERY
route on the instance return 500 ("Class OCA\OpenRegister\AppHost\Routes not
found") -- NC's router reflects every controller during route matching. That
is pre-existing, unrelated to this change, and is why openbuild's CI pins
OpenRegister development; it is reported separately.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* chore(deps): move to @conduction/nextcloud-vue 2.2.0-vue3.1

The 3.0.0-vue3.* line is being withdrawn from npm. The major was cut from a
real BREAKING CHANGE footer (the retired action-list flow editors), but it
applied to a prerelease channel only our own apps consume, so the line is
resumed at 2.x rather than carried forward. 2.2.0-vue3.1 is a superset of
3.0.0-vue3.6 — it additionally carries the recovered Vue 2 -> Vue 3 component
conversion and the CnGraphCanvas port/loop work. Despite the lower version
number this is not a downgrade in content.

The lockfile is regenerated with npm 10.8.2, matching CI's node 20 toolchain.
Local npm 11 prunes optional entries that do not apply to the current platform,
which makes CI's `npm ci` fail with "Missing: ... from lock file"; running
`npm ci` locally does not reproduce it because npm 11 accepts its own lockfile.

Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false
npm run build` exits 0 with no unresolved modules. The USE_LOCAL_LIB=false is
load-bearing — this repo's webpack aliases @conduction/nextcloud-vue to a
sibling ../nextcloud-vue/src checkout when one exists, so a plain build can
silently compile the sibling's source instead of the package under test.

* fix(l10n): add the 111 source strings missing from en.json

test:l10n was already failing on development before this branch: 111 keys are
used via t('openbuild', '...') in source but absent from l10n/en.json, so the
gate could not pass on any PR.

Generated with the repo's own remedy, `node tests/l10n/check-l10n.js --write`,
which extracts the used strings with key === English source. All 111 additions
are identity-mapped (verified), so this adds no translation content and changes
no rendered English text — it only makes the keys declarable for translators.

This check has no locale-parity stage, so en.json is the whole requirement here
and test:l10n now exits 0 (1288 -> 1399 keys).

---------

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

Picks up the two releases that landed after 2.2.0-vue3.1:

  2.2.0-vue3.2  four dashboard defects — date-range chip shows its dates and
                calendar-aligned presets, a dangling labelResolve no longer
                renders a raw UUID, and the table's "View all" pins to the
                bottom instead of scrolling away
  2.2.0-vue3.3  gridstack's stylesheet now ships with the library that
                requires it; CnFormDialog splits over-long schema descriptions
                behind an info popover; CnContextMenu closes again on outside
                press and stops hijacking every popper with a cursor transform

Lockfile regenerated with npm 10.8.2 to match CI's node 20 toolchain — local
npm 11 prunes optional entries that do not apply to the current platform, which
makes CI's `npm ci` fail with "Missing: ... from lock file". Running `npm ci`
locally does not reproduce it, because npm 11 accepts its own lockfile.

Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false
npm run build` exits 0 with no unresolved modules and no reference to a sibling
nextcloud-vue checkout. USE_LOCAL_LIB=false is load-bearing: webpack aliases
@conduction/nextcloud-vue to ../nextcloud-vue/src when that sibling exists, so
a plain build can silently compile the sibling instead of the package under test.

* fix(deps): restore the optional lockfile entries npm 11 pruned

The verification build ran `npm install` under local npm 11 AFTER the lockfile
had been regenerated with npm 10.8.2, which silently re-pruned the optional
entries that do not apply to this platform — per-arch esbuild/rolldown/
lightningcss binaries and @nextcloud/vue's optional pinia and vite.

CI runs npm 10.8.2, whose `npm ci` requires those entries, so it failed at
Install dependencies and took every dependent job with it.

Regenerated with `npx npm@10.8.2 install --package-lock-only` and this time
nothing runs npm 11 against it afterwards. Verified with
`npx npm@10.8.2 ci --dry-run` (exit 0).

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* ci(quality): move the hydra-gates pin v1.0.1 -> v1.2.0

v1.0.1's gate-5 (route-auth) could not tell "this routed method has no auth
attribute" from "I could not resolve the controller class", and reported both
as the former. It also read route names through `'[a-z_]+#...'`, so every
camelCase route slug was invisible to it in either direction, and its 20-line
attribute lookback could borrow the PREVIOUS method's attribute.

Fixed in ConductionNL/.github#162 (closing #153). v1.2.0 also carries the
runner's COVERAGE accounting, which v1.0.1 does not have at all: v1.0.1's
summary printed only "N gate(s) failed", so a gate that never ran was
indistinguishable from one that passed.

EXPECT NEW FINDINGS. gate-5 now sees code it never saw. Red is the honest
outcome of a measurement that previously was not taken; nothing here is
baselined or suppressed to avoid it.

* ci(quality): advance the hydra-gates pin v1.2.0 -> v1.3.0

v1.2.0 added the COVERAGE accounting but not the declarations that accounting
asks for. v1.0.1/v1.2.0 have no `_skip` vocabulary, so a gate whose
prerequisite is absent emits nothing and require-full-coverage (default ON)
counts it as DID NOT RUN. v1.3.0 ships 36 not-applicable declarations;
measured on doriath PR #160, Hydra Gates went failure -> success on the pin
alone. Lands this repo on the same pin as the rest of the fleet.
…1.2 (#134)

Three test files 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 every lib/**.php @license tag) says EUPL-1.2.

- tests/e2e/docs-screenshots.spec.ts
- tests/e2e/visual/_visual-helpers.ts
- tests/e2e/visual/openbuild.visual.spec.ts

NOT changed: lib/Resources/template/appinfo/info.xml still says
<licence>agpl</licence>. That file is inside the vendored nextcloud-app-template
snapshot, which docs/releasing.md says must be refreshed wholesale by rsync and
never hand-edited, and whose .snapshot-meta.json records the exact upstream
commit it came from. Upstream already says EUPL-1.2, so the correct fix is a
snapshot refresh, not an edit here. See the PR body.

Header-only change; PHPUnit (746 tests) and vitest (1364 tests) are identical
before and after. gate-28: PASS -> PASS.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
… inert exclude-pattern (#129)

* chore(phpmd): burn down 46 baselined findings and fix the inert exclude-pattern

Part of ConductionNL/.github#155 (fleet PHPMD suppression audit).

`composer phpmd` reports 0 today. With phpmd.baseline.xml deleted it reports
195. The gate is live: openbuild's Code Quality workflow calls the shared
ConductionNL/.github quality.yml, where `enable-phpmd` defaults to true, so the
phpmd leg genuinely runs on every push and PR — the baseline is hiding findings
from a check that really executes.

Two things worth recording about the baseline file itself:

  * Its 174 entries suppress 195 findings, because a PHPMD baseline entry is
    scoped to a (rule, file) pair — optionally a method — never to a line. A
    class-scoped entry is therefore an open licence: every FUTURE violation of
    that rule in that file is silently suppressed too. "174 entries" is not
    "174 known problems", and the shortfall is granularity, not staleness.
  * PHPMD auto-discovers phpmd.baseline.xml from the working directory.
    Dropping --baseline-file from the composer script changes nothing; only
    deleting the file disables it.

What this commit retires (measured, PHPMD 2.15.0 / PHP 8.4.22, baseline
deleted both times, --exclude '*/Resources/template/*' on every leg): 195 -> 149.

  * ElseExpression 27 -> 1.
  * MissingImport 3 -> 0 (fully-qualified `new \RuntimeException` in
    AppOverrideService).
  * ShortVariable 9 -> 0 ($m/$ca/$cb/$i -> $matches/$condA/$condB/$seq/$cursor).
  * LongVariable 5 -> 0; the two renamed constructor-promoted properties are
    also renamed at their named-argument call sites in the unit tests.
  * UnusedFormalParameter 3 -> 1. Two were genuinely dead and are REMOVED, with
    their call sites updated: AppOverrideService::userVersionSlug($appId) and
    AppRepoParser::decodeChannelEntry($path).
  * CyclomaticComplexity 41 -> 40, from the two consolidating extractions below.

The ElseExpression work is behaviour-preserving throughout: initialise-then-
override where both branches were pure, guard-and-return where they were not,
`continue` inside loops, and extractions where both arms had to write so exactly
one could run. No ternaries were introduced.

Two traps from the brief fired for real, and re-measuring after every rule is
what caught them:

  1. THE EXTRACTION TIPS THE CLASS. Three extractions from AppOverrideService
     pushed BooleanArgumentFlag 13->14, ExcessiveClassLength 7->8 and
     TooManyMethods 4->5 — the class sits exactly at those thresholds. All three
     were restructured to need no new method (a `continue` guard in
     deepMergeDelta, an early return in upsert), and the one that genuinely
     could not be — upsertUserDelta, where both arms write a different object —
     was LEFT AS AN ELSE rather than traded for three other findings. That is
     the single remaining ElseExpression.

  2. REMOVING AN `else` ADDS COMPLEXITY. Converting the two elevate-or-not
     if/elses in MigrateToVersionedModel::migrateOne() gave it a NEW
     NPathComplexity finding (336), and converting the two update-or-create
     if/elses in AutomationCompilerService::applyRuleSet() took its
     CyclomaticComplexity from 9 to 11. Both were fixed STRUCTURALLY rather than
     by relaxing anything: each pair of duplicated branches was folded into one
     guarded helper (`runElevated()`, `saveOrUpdate()`), which removes the elses
     AND lowers the host method's complexity below where it started. A third,
     the state chain in run(), keeps its `else if` and only drops the bare
     `else` — PHPMD flags the bare `else` only, so that costs no extra path,
     whereas two independent ifs would have doubled an already-flagged NPath.

Also applies the fleet-standard scoped-ruleset shape. The
`<exclude-pattern>*Migration*</exclude-pattern>` nested inside the
UnusedFormalParameter `<rule>` was INERT — PHPMD 2.15 honours exclude-pattern
only as a direct child of `<ruleset>`. The rule now lives alone in
phpmd-unusedparams.xml with a top-level exclusion, run as a second leg with
neither leg able to short-circuit the other, and `--exclude
'*/Resources/template/*'` preserved on BOTH legs so the analysed file set is
unchanged. Stated plainly: openbuild has no lib/Migration directory, so this
retires ZERO findings here. It is adopted for fleet uniformity.

phpmd.baseline.xml is RETAINED. 149 findings remain and deleting it would
redden a gate that really runs. What remains, and why:

  * CyclomaticComplexity 40 / NPathComplexity 25 / ExcessiveClassComplexity 23 /
    ExcessiveMethodLength 11 / ExcessiveClassLength 7 — genuine debt in the
    automation-compiler, copilot and app-override services.
  * CouplingBetweenObjects 15, TooManyMethods 4, ExcessiveParameterList 3,
    TooManyPublicMethods 1 — class-level; need collaborator extraction, which
    AppOverrideService's thresholds show cannot be done piecemeal.
  * BooleanArgumentFlag 13 — genuine debt; each is an API change.
  * StaticAccess 4 — `\OCP\Server::get()` service-locator calls in three broker
    paths plus OpenRegister's `Bootstrap::register` in AppInfo\Application; the
    Bootstrap one is a bootstrap-time static by design.
  * ElseExpression 1 — AppOverrideService::upsertUserDelta, see trap 1 above.
  * UnusedFormalParameter 1 — INTERFACE-MANDATED. SeedHelloWorldFixture::execute()
    implements Symfony\Component\Console\Command\Command::execute(InputInterface
    $input, OutputInterface $output). `$input` is unused but cannot be dropped.
  * LongClassName 1 — UnsupportedAutomationCombinationException. Deliberately
    NOT renamed: the name is a normative identifier in
    openspec/specs/automation-designer/spec.md and in three archived changes.
    Renaming it in code alone would desynchronise the spec, and rewriting
    archived changes is out of scope.

Verified: phpunit 746 tests / 2342 assertions before AND after, both green.
phpcs exits 0 (the renames left stale docblock alignment; fixed with the repo's
own phpcbf, 24 errors, no remaining errors). psalm and phpstan both exit 0.
Both phpmd legs exit 0 with the baseline in place, so CI stays green.

No suppression, baseline entry, threshold change or skip was added anywhere.

* chore(phpmd): keep this PR single-purpose — revert the else-fixes in 6 files

Touching these six files pulls three PRE-EXISTING hydra-gate failures into the
diff-scoped gate run, all of them in code this PR did not write:

  * gate-2 forbidden-patterns — the deliberate, documented error_log() in
    AppInfo\Application::register(), which carries its own phpcs:ignore and a
    comment explaining that no PSR logger exists that early. Removing an else
    necessarily moves those lines into the diff. The call must stay; the gate
    has no per-call waiver; so the file must stay untouched.
  * gate-46 spec-anchor-existence — 17 dead @SPEC targets across
    ExportsController, AutomationsController, AppInfo\Application,
    RuleActionDispatcher, AutomationCompilerService and TemplateSeedService.
    They point at openspec/changes/automation-designer/... which was ARCHIVED
    to openspec/changes/archive/2026-07-11-automation-designer/... and at
    #req-autd-NNN anchors that exist in NO version of the spec, canonical or
    archived — the canonical spec uses '### Requirement: <prose>' headings.
    Repairing them means deciding, per tag, which Requirement each method now
    implements. That is spec-traceability work for the automation-designer
    owner; guessing it here would encode WRONG links, which is worse than a
    dead one.
  * gate-49 controller-exception-translation —
    AutomationsController::recompileAndRespond() lacks a try/catch or @throws.

The gates are FILE-scoped (_filter_grep_by_scope keys on the path), so any edit
to one of these files puts all of its pre-existing findings in scope. Reverting
is the only way to keep a PHPMD suppression burn-down from silently becoming a
spec-metadata rewrite.

Cost, measured: openbuild goes 149 -> 161 with the baseline deleted (still
195 -> 161). Specifically this gives back 11 ElseExpression, 1 ShortVariable and
1 LongVariable, and drops the AutomationCompilerService::saveOrUpdate()
consolidation, so CyclomaticComplexity returns to 41.

Follow-up filed in the PR body: those three gate failures are real and now
precisely diagnosed, but they belong to a separate change.

* fix(phpmd): narrow the UFP exclude to the app's own lib/ directory

Positive-controlled (PHPMD 2.15, PHP 8.4, absolute paths): with */lib/X/* a
probe in lib/Service/X/ is reported; with */X/* it is silently swallowed. Both
correctly exclude the app's own directory, so the narrow form is strictly safer.
Matches the fleet-canonical shape in nextcloud-app-template #125, doriath #157,
larpingapp #263 and planix #315.
#135)

The embedded template snapshot's appinfo/info.xml hardcoded
`<licence>agpl</licence>` while the very same file's description read "Free
and open source under the EUPL-1.2 license". Every app OpenBuild has ever
generated inherited that contradiction, and nothing failed.

The `{{license}}` token was already wired end to end — ExportJobService ->
RunExportJob -> PlaceholderResolver, defaulting to EUPL-1.2 at all three
layers — and reached exactly one file, src/manifest.json. info.xml never
consumed it. `<licence>{{license}}</licence>` closes that, and as a side
effect a caller who picks a licence now gets the licence they picked.

EUPL-1.2 is the schema-correct spelling, not a preference: the Nextcloud
appstore's own info.xsd enumerates `EUPL-1.2` as an allowed <licence> value.
It does NOT enumerate `eupl` — which is what upstream nextcloud-app-template's
`main` branch currently ships, and is separately invalid.

A test first, and shown to fail. testGeneratedAppDeclaresTheRequestedLicence()
exports a real app and asserts info.xml declares EUPL-1.2 and does not declare
agpl. Against the pre-fix snapshot it failed with the generated app's info.xml
in the message; after the one-line change all 3 integration tests and all 746
unit tests pass. Nothing in the suite covered this before: the existing
unresolved-placeholder assertion matches /\{\{[a-zA-Z]+\}\}/, and a hardcoded
wrong value contains no placeholder to be left unresolved.

Why this is a one-line Edit and not the whole-tree refresh docs/releasing.md
prescribes. The refresh is unsafe as written, measured three ways:

  1. There is no upstream ref this tree fast-forwards from. The declared
     sourceCommit 7ee06aae is not an ancestor of upstream main OR development;
     `git branch -r --contains` places it only on two abandoned wsl-rescue/*
     branches.

  2. The snapshot carries OpenBuild-only fixes upstream never received — at
     least 10 commits have edited files inside lib/Resources/template/ since,
     among them c87d8c4 "fix(template): the generated app could not be built
     at all (#39)" and 12da26f "fix(export): make exported app a Tier-4
     manifest consumer (ADR-024)". `rsync --delete` reverts both.

  3. The dialects differ. OpenBuild resolves {{token}}; upstream's info.xml
     uses {APP_NAME}/{APP_SUMMARY}/{APP_DESCRIPTION}. PlaceholderResolver does
     not know that dialect and the unresolved-placeholder assertion does not
     match it, so a refreshed tree would ship literal {APP_NAME} into every
     generated app and no test would fail.

.snapshot-meta.json and docs/releasing.md now say so at the point of use, so
the next agent does not run the rsync on the strength of the doc alone.
docs/releasing.md also now records that the "CI drift check" it describes does
not exist — no workflow mentions drift or nextcloud-app-template — which is
why the snapshot sat 86 days stale shipping the wrong licence.

.path-manifest.txt no longer lists .snapshot-meta.json: the regeneration
command in docs/releasing.md excludes it, so the checked-in manifest
disagreed with its own generator.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* ci: run the E2E (Playwright) job — it has never executed

`enable-playwright` defaults to false and code-quality.yml never set it, so
the shared workflow's `E2E Tests (Playwright)` job has reported `skipped` on
every run this repo has produced, while 50 spec files sat in tests/e2e/.
Unlike `enable-newman: false`, nothing recorded that as a decision.

Adds `enable-playwright: true` + `playwright-test-path: tests/e2e`, and the
config that path selects. The workflow runs its chosen config with NO
`--project`, so the root config would have run `docs-capture` (owned by the
Journeydoc Capture job) and `visual` (PNG baselines its own header says a CI
Linux runner cannot byte-match) alongside the regression suite.
tests/e2e/playwright.config.ts declares only `chromium`, and writes its
report/traces to the app root — the paths the workflow's upload steps look
at. The root config is untouched.

`playwright-seed-command` supplies the two pieces of instance setup
global-setup.ts can only do through a docker container (it refuses to guess
one, by design): disabling the wizard's 10/hour user rate limit, and seeding
the hello-world fixture via the test-only occ command.

* ci: run on Postgres — OpenRegister cannot run on SQLite

Measured on run 31018480923: the E2E seed died before a single spec ran with

  Failed to seed hello-world fixture: … SQLSTATE[HY000]: General error:
  1 no such table: information_schema.columns

OR's MagicTableHandler reads `information_schema.columns` to decide whether
an object table needs creating; Postgres and MySQL ship that view, SQLite
does not have it. Same family as documented Newman blocker #2 (`no such
function: REGEXP`, also OR's MagicMapper, also SQLite-only).

`pgsql` is the shared workflow's own default — the repos whose E2E is green
(opencatalogi, doriath) set no `database:` at all. Stated explicitly rather
than deleted so the reason survives.

* fix(seed): the hybrid example could never be seeded

Measured on run 31029961494, the first time the E2E job has ever run:

  Failed to seed hello-world fixture: A hybrid app's description is read-only
  — it mirrors the installed Nextcloud app it customizes.

HybridMetadataLockListener locks slug/name/description/productionVersion on a
hybrid app and fires on ObjectUpdatingEvent only — creation with the locked
identity is explicitly allowed. seedHybridExample() created the Application,
created the version, then UPDATED the Application to attach productionVersion:
an update touching two locked fields at once, rejected every time. So the
hybrid fixture has been unseedable since that listener shipped, and nothing
said so — globalSetup swallows a seed failure as a warning, and the E2E job
had never run in CI.

Mint the Application UUID up front, create the version against it, then write
the Application in ONE create carrying its full identity and the production
pointer. The lock is satisfied by construction, not bypassed. The forward
reference is safe: OR validates relation targets only for `$ref` properties
carrying `validateReference`, and ApplicationVersion.application has neither.

* ci(e2e): make the Playwright job produce a verdict, and fix the deep-link root cause

The first run of this suite in repo history (run 31030663352, job 92390852268)
was CANCELLED at the shared workflow's 45-minute cap after 45m16s. A
cancellation is not a verdict: no tally, and — because Playwright writes its
HTML report only after the last test — `actions/upload-artifact` found nothing
("No files were found with the provided path: .../playwright-report/"), so
there were no traces or screenshots either.

What it did measure before it died, read out of the job log:

    COLLECTED  249 tests in 50 files
    REACHED     83 unique tests (98 attempts, 15 of them retries)
    pass         4
    fail        68
    skip        11
    slowest PASSING test  3.5s   (the four passes: 0.4s, 0.4s, 2.9s, 3.5s)
    time in failures      29.6 min of 42.5

BUDGET — tightened only in the strict direction

* retries 1 -> 0. A retry can only convert red to green; it bought nothing and
  doubled the cost of every failure, which is what blew the budget.
* per-test timeout 60s -> 30s. Measured: the slowest test that PASSED took
  3.5s, so 30s is ~8.5x it, and still above the largest wait a spec asks for
  itself (`waitForSelector(…, 20_000)`) plus navigation, so a genuine failure
  still reports its own message. Six component-blocks failures had been sitting
  on the full 60s.
* NEW globalTimeout of 36 min, below the job's 45. Playwright now stops itself,
  prints the tally, and flushes the report so the artifact uploads.
* workers stays 1. Four independent classes of cross-talk make parallelism
  unsafe (shared `hello-world` slug with three writers, exact-count assertions,
  the brute-force throttle, and the `_limit=5000` backend bottleneck). It is
  documented in the config so it is not raised later to save wall clock.

ROOT CAUSE OF THE FAILURES — `htaccess.IgnoreFrontController`

openbuild builds its router as `createWebHistory(generateUrl('/apps/openbuild'))`,
and `generateUrl()` prefixes `/index.php` unless the served page reports
`modRewriteWorking`. A freshly `maintenance:install`ed instance does not set it,
so the router base is `/index.php/apps/openbuild` while every spec navigates to
the pretty `/apps/openbuild/...`. They disagree, vue-router matches nothing, and
the SPA mounts and renders perfectly — on the DASHBOARD. No 404, no console
error, no failing request.

The pass/fail pattern is the proof. The only UI tests that passed were
builder-host's first two, whose inner router is based at
`/apps/openbuild/builder/<slug>` — the exact URL the spec opens — so its DEFAULT
route is the page under assertion. builder-host's third test, the only one that
needs the `/messages/new` SUB-path to survive, failed with the rest. Everything
else that passed was a pure `request.get()` API test.

The CI router already serves pretty URLs (the workflow's own front-controller
check proves it), so setting the flag states something TRUE about the instance
rather than working around anything.

SEED — adopt tests/e2e/ci-seed.sh (ported from #111)

`playwright-seed-command` becomes `bash apps/openbuild/tests/e2e/ci-seed.sh`,
replacing the two-command inline string. Beyond the pretty-URL fix it: forces
the register import over the admin HTTP API (the `InitializeSettings` repair
step runs session-less, so OR's RBAC denies it and the step downgrades the throw
to a warning) and VERIFIES all 15 schema slugs; pre-marks the admin as a
returning user so nc-vue's first-visit walkthrough and support dialog stop
swallowing clicks in every fresh Playwright context — via the product's own
preference mechanism, and deliberately NOT for the rbac-* fixture users, whose
specs assert exactly that; proves the hello-world fixture over HTTP rather than
trusting the occ exit code; and GATES on the served bundle actually being
JavaScript, because a missing bundle returns HTTP 200 text/html, not 404.

HARNESS HONESTY — ported from #111

* tests/e2e/support/baseUrl.ts accepts `BASE_URL` (which is what the shared
  workflow exports) and no longer defaults to localhost:8080 off CI — that is
  the SHARED dev container, and this suite WRITES.
* tests/e2e/global-setup.ts fails LOUDLY on CI when admin or an rbac-* session
  cannot be minted. An absent storage state does not error; the spec just runs
  UNAUTHENTICATED, and rbac-403 / versionRouting then pass for exactly the wrong
  reason.
* tests/e2e/.gitignore — the root ignores are root-anchored and do not match
  these paths.

APP DEFECTS FIXED

* HybridMetadataLockListener no longer locks `productionVersion` (the canonical
  spec REQUIRES a hybrid app to point at its delta version, so the lock made
  every create/republish path fail at its last step) or `description` (never in
  the spec's lock list, and OR's PUT-semantic saveObject delivers an unmentioned
  field as null, which the lock read as a deliberate edit). Unit test updated.
* register.d/10-business-rules.json: dropped an empty `"condities": {}` — OR
  rejects both `{}` and `null` for object properties, so the key has to be
  omitted.

* fix(seed): the production-pointer write silently wiped hello-world's permissions

MEASURED VERDICT that this fixes (run 31040914410, job 92425229324) — the first
run of this suite that finished on its own:

    COLLECTED 249   REACHED 249   pass 154   fail 19   skip 76   23.6 min

(the previous run was CANCELLED at the 45-minute cap having reached 83 of 249
with 4 passes, and uploaded no report at all).

Of the 19 failures, 14 share ONE cause, and it is not a permissions bug in the
product — it is a partial write in the seeder.

OR's `saveObject()` update path is PUT-semantic:
`SaveObject::fillMissingSchemaPropertiesWithNull()` sets every schema property
absent from the payload to null. Step 3 of the hello-world seed exists only to
attach `productionVersion`, and it listed four fields. `permissions` was not one
of them — so the owner block step 1 had just written was nulled a moment later,
and the fixture came out owned by NOBODY (`allowAdminBypass` is false, so an
empty block denies everyone).

From each failing spec's side that is indistinguishable from the product
refusing a legitimate owner:

  * spec-coverage/openbuild-runtime REQ-OBR-005 x4 — `locator.fill` on
    `<textarea readonly … data-testid="openbuild-editor-textarea">`;
    `:readonly` is bound to `obAppRole === 'viewer' || 'none'`.
  * REQ-OBR-006b, REQ-OBR-008b — the owner-only Settings menu entry absent,
    and "an owner must get an editable manifest" failing on the same readonly.
  * copilot-panel — `403 {"error":"forbidden","message":"You do not have owner
    or editor access to application 'hello-world'."}`
  * automations x7 — the edit modal never closes because the save 403s; the
    failure ARIA snapshot shows `alert: "Could not save the automation."`, and
    the four later scenarios then cascade on the row that was never created.
    (Automation ops require WRITE_ROLES ['owners','editors'].)

The fix carries `permissions` forward in that write, with a comment naming the
PUT semantics so it is not trimmed again as "redundant".

PROOF IT CAN FAIL — new tests/Unit/Command/SeedHelloWorldFixtureTest.php

The field-preservation assertion the failure mode demands: it asserts the LAST
Application write still carries `permissions.owners`. A test that only checked
the field the write MEANT to change passes on the broken code. Mutation-proved
locally against PHP 8.4:

  * remove `permissions` from the pointer write   -> 1 failure
    ("Failed asserting that null is identical to Array [0 => 'user:admin']")
  * replace the minted UUID with a constant       -> 2 failures
    (v4 shape, and each run minting a distinct id)
  * restore HybridMetadataLockListener's old
    LOCKED_FIELDS                                 -> 3 failures in that
    listener's own suite

All restored; 751 tests green locally (8 errors are `ZipArchive` missing from
the throwaway PHP container, absent in CI).

ALSO

* tests/e2e/spec-coverage/dashboard-overview.spec.ts — `a[href$="/apps/openbuild/"]`
  matches TWO links, and `.first()` is Nextcloud's GLOBAL app-menu entry, which
  is labelled "OpenBuild", not the in-app "Dashboard" nav entry. Read straight
  off the failure's ARIA snapshot. Scoped to `#app-navigation-vue` — the id
  NcAppNavigation renders and the target of the page's own "Skip to app
  navigation" link, containing all four in-app entries. A narrower locator; the
  label assertion is unchanged. The other three entries had been passing only
  because no global menu link ends in `/applications`, `/templates` or
  `/features-roadmap`.
* SeedHelloWorldFixture: two comment lines re-indented — PHPCS reads `//   x` as
  an inline comment with 3 leading spaces and errors ("use block comment if you
  need indentation"). That was the whole of the red `PHP Quality (phpcs)` job.

* fix: hoist the seed permissions to a constant, and floor the bundle gate on size

MEASURED (run 31046063272, job 92443507627) — the previous commit's effect:

    COLLECTED 249   REACHED 249   pass 169   fail 4   skip 76   18.8 min

against 154/19/76 in 23.6 min before it. Carrying `permissions` through the
production-pointer write cleared 15 failures, and the run also got 4.8 minutes
faster because fourteen of them were no longer sitting on a timeout.

That commit's explanation lived inside `execute()`, which two gates objected to
and both were right:

  * PHPCS — `//   403 {...}` is an inline comment with 3 leading spaces
    ("use block comment if you need indentation").
  * PHPMD — ExcessiveMethodLength: `execute()` reached 125 lines against a
    threshold of 100.

Fixed by giving the block a better home rather than trimming the reasoning: a
`SEED_PERMISSIONS` class constant carrying the full docblock, referenced from
both Application writes. That also removes the duplicated literal, so the two
writes can no longer drift apart — which is the failure mode the comment is
about.

BUNDLE GATE — content type alone cannot catch a truncated bundle

`ci-seed.sh` asserted the served bundle's Content-Type was JavaScript. A
zero-byte or truncated bundle still serves `200 application/javascript`, so
that gate passes over precisely the failure it exists to catch — reported live
on a sibling repo where it did exactly that. Added a floor on the byte count
curl already reports (measured on run 31040914410: 13,552,436 bytes; floor set
at 100,000 so it fires on truncation without becoming a second size budget that
silently drifts).

REMAINING FAILURES — 4, all root-caused, none of them a budget artifact:

  * docudesk-document-templates ×3 — `/apps/docudesk/api/templates` 404s
    because docudesk is not installed on the runner. The third failure is
    self-documenting: the page renders "Docudesk is not installed or enabled on
    this instance" alongside the warning the spec was looking for. The fix is
    adding docudesk to `additional-apps` so these RUN; that input feeds every
    job in the workflow including six PHPUnit matrix cells, so it belongs in its
    own change, not here, and NOT behind a ninth skip.
  * page-editor-coverage REQ-PEC-006 — `selectOption('openbuild-hello-world-production')`
    on a `<select aria-invalid="true">` with "did not find some options". The
    per-version data register that name refers to does not exist: the seeded
    ApplicationVersion records `register: 'openbuild-hello-world'` as metadata
    only. A real fixture gap in what the seeder provisions, and which service
    ought to create that register is a product question, not a guess.

---------

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

The Hydra Gates job fails with a message that says outright it is not about this
repository:

  hydra-gates-ref <old> does not contain: scripts/lib/check_spec_anchors.py
  scripts/lib/check_form_labels.py scripts/lib/check_license_triangle.py

The reusable workflow floats on @main and calls those scripts BY PATH inside the
PINNED package, so a pin older than the scripts cannot run the gates that
implement them. A pinned ref is a silent expiry date on every upstream change,
and the failure reports on the pin while saying nothing about the code.

v1.5.0 is the first tag containing all of them, verified by reading each path at
that tag rather than assuming the newest tag has everything.

Swept across the fleet: 11 of 13 repos were pinned below v1.5.0 and every one of
them was failing this way.
…#137)

.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.
* 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 `hydra-gates-ref` entirely instead of moving it v1.3.0 -> v1.4.0.
The shared workflow already defaults the input to @main, and this repo
consumes `ConductionNL/.github/.github/workflows/quality.yml@main`, so both
sides now move together: a gate-package fix reaches this repo with no commit
here.

A pin is a silent expiry date. 22 repos sat on v1.0.1 while 16 gates were
dead fleet-wide and every one of them reported PASS (.github#159), and a
default flipped at @main later reached those same old runners and failed them
on gates they had no subject matter for (.github#173). Bumping the pin is a
treadmill that reintroduces the same failure mode on the next release.

The pin-justifying comment block is replaced with a short note saying why
there is no ref here and how to reintroduce one deliberately. The rationale
for `enable-hydra-gates: true` is kept unchanged, as is `enable-axe` being
left off.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…ommand injection) (#143)

quality / Security (composer) is red on every PR here as of today:

    Advisory ID: PKSA-rdkp-vv9z-mjkg
    CVE: CVE-2026-67434  —  OS Command injection
    Affected versions: <3.13.6|>=4.0.0,<4.0.2
    Reported at: 2026-08-05T23:53:11+00:00

The advisory was published YESTERDAY and roave/security-advisories installs
as dev-latest each run, so the same lockfile was clean on 2026-08-05 and is
vulnerable on 2026-08-06 with no commit in between. The last green run is
evidence of when it ran, not that the lockfile is safe.

composer.json's existing constraint already permits the fixed version, so
this is a lockfile move only: 1 update, 0 installs, 0 removals. Verified the
diff touches exactly two lines, both the version string, and no other file.

Part of a fleet sweep — 13 of 16 repos checked were on the affected 3.13.5.

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