Skip to content

Release: merge development into beta - #476

Merged
rubenvdlinde merged 74 commits into
betafrom
hotfix/beta-sync-20260827
Aug 27, 2026
Merged

Release: merge development into beta#476
rubenvdlinde merged 74 commits into
betafrom
hotfix/beta-sync-20260827

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Same development->beta release merge as the bot PR, with the version-stamped conflicts resolved.

Branch is named hotfix/* because the beta branch-protection check only accepts development, main or hotfix/* as a source, and the sanctioned development -> beta path cannot be used here: resolving the conflict on that PR would merge beta INTO development, dragging beta-only release plumbing (re-trigger commits, semrel caller fixes) back into development.

The merge keeps BETA's version string rather than development's, which is one patch lower in every repo -- taking development's would have published a downgrade. Everything else takes development's content, including the removal of the codeberg.org URLs still present in beta's info.xml.

Verified per repo: XML and JSON re-parse, no conflict markers remain, and the version substitution matched exactly once.

rubenvdlinde and others added 30 commits August 20, 2026 22:50
Bumps phpstan/phpstan to ^2.0 and conduction/hydra-gates to ^1.8.2 (which
carries the shared phpstan-base.neon fixes for `treatPhpDocTypesAsCertain`
and PHPMD's `@SuppressWarnings` phpDoc.parseError), then removes the code
PHPStan 2 correctly identifies as unreachable. `phpstan analyse` goes from
11 errors to 0; 880 PHPUnit tests stay green and phpcs reports 0 errors.

Dead `method_exists()` back-compat probes (5)
---------------------------------------------
`runAsSystem()`, `lockObject()` and `unlockObject()` are all declared on
`OCA\OpenRegister\Contract\ObjectServiceInterface` — verified against both
the canonical `openregister@development` contract and the copy vendored by
hydra-gates v1.8.2, which is what static analysis resolves here.

The probes were written for back-compat with an OpenRegister release that
predates the elevation/locking API. That release cannot run this code at
all: `openregister@main` ships no `Contract` namespace whatsoever, so the
constructors that type-hint `ObjectServiceInterface` cannot have their
dependencies resolved there. Wherever this code executes, the contract is
the one that declares all three methods, and every fallback branch behind
the probes was unreachable.

Removing them cascades in MigrateToVersionedModel: `$hasSystemContext` was
always true, so `ROW_BLOCKED` was never returned and `STATE_BLOCKED` was
never written. Both constants and the retry prose go with them. This is
backwards compatible — a `blocked` value persisted by an older install is
still not `STATE_DONE`, so the step retries exactly as before.

Other always-true conditions (6)
--------------------------------
- AbstractToolHandler: the lock is acquired in a *separate* try/catch that
  throws on failure, so the later `finally` can only run with the lock
  held; the `$locked` flag could never be false and is now gone.
- AppChannelApplier / ApplicationVersionService: `is_array()` on findAll()'s
  return and `is_object()` on saveObject()'s ObjectEntityInterface.
- AgentsController / ManifestResolverService: `array_values()` on arrays
  only ever appended to with `[]=` (and usort()ed in place) — no-ops.
- SettingsService: `registry_url` is one of CONFIG_KEYS and is always set
  by the loop above, so the `?? ''` was unreachable.

Every removal is annotated in place so the next reader does not
reintroduce the guard.
Bumps [diff](https://github.com/kpdecker/jsdiff) from 5.2.2 to 9.0.0.
- [Changelog](https://github.com/kpdecker/jsdiff/blob/master/release-notes.md)
- [Commits](kpdecker/jsdiff@v5.2.2...v9.0.0)

---
updated-dependencies:
- dependency-name: diff
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [jsdom](https://github.com/jsdom/jsdom) from 24.1.3 to 30.0.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](jsdom/jsdom@v24.1.3...v30.0.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 30.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
chore(quality): migrate to PHPStan 2 and clear the 11 residual errors
…elopment/jsdom-30.0.1

build(deps-dev): bump jsdom from 24.1.3 to 30.0.1
…elopment/diff-9.0.0

build(deps): bump diff from 5.2.2 to 9.0.0
…al copy

Verbatim copy of ConductionNL/.github@main quality-config/coverage-guard.php.

The shipped 433-line copy predates --deletion-neutral, so deleting
well-tested dead code reads as a coverage drop: on decidesk the same class
of change gave 1782/2324 -> 1763/2305 statements, i.e. 19 deleted and all 19
covered, and the guard failed it as -0.19%. Nothing was less tested.

All nine PHP apps ship this identical stale copy with no local edits, so the
refresh is wholesale rather than patched. The ratchet is NOT disabled or
baselined — a real coverage loss still fails.
…on-neutral

chore(ci): refresh coverage-guard.php to the canonical deletion-neutral copy
…t mocks

The library upgrade is trivial -- openbuild uses exactly two of its exports,
`generateUrl` and `imagePath`, and v3 still exports both. Unlike @nextcloud/vue
v9 or @nextcloud/dialogs v7, v3 keeps `main` plus BOTH `import` and `require`
conditions, so there is no resolution work either.

What blocked this PR was 33 failing test files, and they were not the library's
fault.

    Error: [vitest] No "imagePath" export is defined on the
    "@nextcloud/router" mock. Did you forget to return it from "vi.mock"?

57 spec files mock @nextcloud/router, and most declared only what they
personally cared about:

    vi.mock('@nextcloud/router', () => ({ generateUrl: (p) => p }))

But a `vi.mock` factory replaces the module for the WHOLE module graph of that
test, not just for the spec's own imports. Any component reachable from the test
that imports `imagePath` gets a module where it does not exist. src has been
importing `imagePath` all along -- so these mocks have been incomplete since they
were written.

Why it only surfaces now: v2 was resolved as CJS, and vitest models a mocked CJS
module as a proxy that answers `undefined` for anything the factory omitted.
v3 declares `"type": "module"` and is resolved as ESM, where a missing named
export is an error rather than an undefined. The upgrade did not break the
mocks; it stopped hiding that they were broken.

Fixed with `importOriginal` rather than by adding `imagePath`
-------------------------------------------------------------
Every one of the 33 now spreads the real module and overrides only what it
means to stub:

    vi.mock('@nextcloud/router', async (importOriginal) => ({
        ...(await importOriginal()),
        generateUrl: (p) => p,
    }))

Adding `imagePath: () => ...` to each would have fixed today's failure and left
the same trap for the next export any component starts using. Spreading the
original cannot rot: the stub stays deliberate, everything else stays real.
Files that stub more than one function (ThemePickerDialog, the two
`{slug}`-expanding GitHub modals) keep their own overrides on top of the spread.

Measured, not assumed
---------------------
The test COUNT is the part worth reading, not the pass/fail:

    before (router 3, old mocks):  33 files failed,  1125 tests ran
    after:                        141 files passed,  1378 tests ran

Those 33 files errored during collection, so their 253 tests never executed at
all. A file that fails to load reports as one red file, not 253 missing tests --
the count is the only place the difference is visible.

Verified with the commands CI runs
----------------------------------
  npm ci                   rc=0
  npm run build            rc=0, 0 errors (3 pre-existing size warnings)
  npx vitest run           141/141 files, 1378/1378 tests
  npm run lint             rc=0
  npm run stylelint        rc=0
  npx prettier --check     rc=0
  check:manifest, check:gitignore, check:nc-floor, test:l10n   all rc=0

One caveat, stated rather than smoothed over: immediately after the cold
`npm ci` reinstall, one run reported `1 failed | 1377 passed`. Four subsequent
full runs were clean (1378/1378), and I did not capture which test it was before
the log was overwritten, so I cannot attribute it. It is not a mock failure --
those fail at collection and take a whole file with them, which this did not. I
am flagging it as an unidentified one-off rather than calling the suite
deterministic on four green runs.

Closes #246
chore(deps): @nextcloud/router 2 -> 3, and complete 33 incomplete test mocks
#297)

Dependabot opened #244 to bump @nextcloud/files 3.12.2 -> 4.0.0. The right
answer is not a bump: openbuild does not use this package.

    grep -rn "@nextcloud/files" --include=*.js --include=*.vue \
      --include=*.ts --include=*.json .    # minus node_modules and the lockfile
    package.json:48:  "@nextcloud/files": "^3.12.2"

One hit, and it is the declaration itself. No import in src/, no import in
tests/, no webpack external, no vitest alias.

It is still in the tree, just not as ours
-----------------------------------------
Two dependencies genuinely need it, and they disagree about the major:

    @conduction/nextcloud-vue@2.8.2  -> @nextcloud/files@3.12.2
    @nextcloud/dialogs@7.4.1         -> @nextcloud/files@4.0.0

npm resolves both, nested, and it keeps doing so after this change -- verified
by `npm ls @nextcloud/files` before and after. Removing OUR declaration removes
a claim we were not making good on; it does not remove the package.

That disagreement is also why bumping is the wrong move rather than merely an
unnecessary one. Declaring ^4.0.0 would hoist v4 to the top level while
@conduction/nextcloud-vue still expects v3, which is how an app ends up shipping
a library a consumer was not built against -- the same class of failure the
webpack config already documents at length for @vueuse/core. Declaring nothing
lets each consumer keep the major it was compiled for.

Verified with the commands CI runs
----------------------------------
  npm ci                   rc=0
  npm run build            rc=0, 0 errors
  npx vitest run           141/141 files, 1378/1378 tests
  npm run lint             rc=0
  npm run stylelint        rc=0
  npm run check:manifest   rc=0

1378 is the same total this repo reports on development, so nothing stopped
being collected -- worth checking explicitly, because a module that fails to
resolve takes its whole spec file out of the run and shows up as one red file
rather than as missing tests.

Closes #244

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
… 6, coverage-v8 4) (#298)

Combines dependabot #284 (vitest) and #288 (@vitest/coverage-v8). They cannot
land separately: vitest 4.1.11 peers `@vitest/coverage-v8: 4.1.11` EXACTLY, so
either PR alone produces a tree `npm ci` refuses. The peer chain drags in two
more packages, so this is a four-package move:

    vitest                 ^1.6.1 -> ^4.1.11
    @vitest/coverage-v8    ^1.6.1 -> ^4.1.11   (exact-pinned to vitest)
    vite                   ^5.4.0 -> ^7.3.6    (vitest 4 peers ^6 || ^7 || ^8)
    @vitejs/plugin-vue     ^5.2.1 -> ^6.0.8    (needed for vite 7)

vite 7, not vite 8, deliberately. vite 8.2.2 is the current latest, but this
repo's `.npmrc` sets `min-release-age=2` — a supply-chain cooldown that makes
the lockfile lag newly published versions ON PURPOSE. Pulling a package inside
its cooldown window is how you get a lock that resolves differently in CI than
locally. vite 7.3.6 is well outside it and satisfies vitest 4's peer range.

Five tests failed, all the same latent race
-------------------------------------------
Three in ManifestDiff.spec.js, one in PageDesignerHost.spec.js, one in
ApplicationDetailHeader.spec.js. None is a vitest bug and none needed a
production change — every one is a test seeding component state while that
component's own mounted-hook fetch is still in flight.

ManifestDiff is the clearest. The test mounts, then immediately does:

    await wrapper.setData({ fromBlob: sampleFrom, toBlob: sampleTo })

with a comment claiming this "skips the async fetch". It does not skip it, it
RACES it. The mocked axios resolves `{from: null, to: null}`, and whichever
settles second wins. Under vitest 1 setData won; under vitest 4 the fetch does,
nulls `fromBlob` straight back out, and `diffParts` computes over two empty
strings and returns []. Verified directly rather than guessed — a probe printed
`fromBlob: null` immediately after an awaited setData, while `diffLines` itself
was confirmed to be a working function returning correct hunks.

PageDesignerHost is the same shape wearing different clothes: it arms
`mockRejectedValueOnce` before the mount-time `load()` has settled, so the
mount's own request eats the rejection and the explicit `load()` succeeds.
ApplicationDetailHeader assigns `wrapper.vm.versions` while the mounted hook is
still fetching and assigning that same field.

Fixed by letting the mount settle first (`await flushPromises()` /
`await flush(wrapper)`) before seeding, with a comment at each site saying what
the ordering depends on. These tests were correct-by-accident for three major
versions.

    before: 3 files failed, 5 tests failed, 1373 passed
    after:  141 files passed, 1378 tests passed

The coverage baseline is recalibrated, and that number needs reading carefully
-------------------------------------------------------------------------------
tests/.coverage-baseline.json vitest: 85.18 -> 64.72.

This is NOT 20 points of lost coverage. The suite is identical — 1378 tests
before and after. The instrument changed:

    coverage-v8 v1:  src/App.vue = 235 lines   (exactly its `wc -l`)
    coverage-v8 v4:  src/App.vue = 24 lines
    across src/**:   59,333 -> 10,097 lines, over the same ~200 files

v1 treated every physical line as coverable — blank lines, comments and Vue
template markup included, nearly all of which score covered for free. v4 remaps
through `ast-v8-to-istanbul` to executable lines only. 64.72% of real statements
is a stricter bar than 89.63% of a file's line count, so the ratchet is being
recalibrated, not relaxed. The baseline file carries a `_note` explaining this
so nobody "restores" 85.18 — under v4 it is unreachable and meaningless.

Flagging explicitly for review: this repo does NOT currently run the vitest
ratchet in CI. `frontend-checks` is `["check:manifest", "test:l10n",
"check:gitignore", "check:nc-floor", "format"]`, and `enable-coverage-guard:
true` refers to the separate PHP clover guard (`.coverage-baseline` = 57.39).
So no CI gate changes either way here — but leaving 85.18 in place would make
the ratchet fail instantly the day someone wires it up, which is why it is
corrected rather than left alone.

Verified with the commands CI runs
----------------------------------
  npm ci                        rc=0
  npx vitest run                141/141 files, 1378/1378 tests
  npm run test:coverage         rc=0
  npm run test:coverage-ratchet rc=0 (holds at floor)
  npm run build                 rc=0
  npm run lint                  rc=0
  npm run stylelint             rc=0
  npx prettier --check          rc=0

Closes #284
Closes #288

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
hydra-gates v1.8.2 -> v1.8.2
nc-vue      2.8.2 -> 2.9.2

Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.

Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
hydra-gates v1.8.2 -> v1.8.2
nc-vue      2.9.2 -> 2.10.1

Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.

Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…a claimed prefix (#306)

conduction/hydra-gates claims `OCA\OpenRegister\Contract\` as a RUNTIME psr-4
prefix, so every consumer gets these interfaces implicitly. That prefix is
LONGER than openregister's own `OCA\OpenRegister\` -> `lib/`, and PSR-4 is
longest-prefix-wins, so whichever app's autoloader registers first defines
OpenRegister's contract for the whole process (ConductionNL/.github#531).

This adds the order-independent opt-in so the prefix can be dropped from
hydra-gates. It asks whether each interface is RESOLVABLE rather than who
registered first — the distinction matters, because appending a fallback
autoloader does NOT work: spl_autoload_register appends relative to
registration order, and registration order across independently loaded apps is
exactly what nobody controls.

Both interfaces, and BEFORE tests/stubs/openregister-stubs.php, because that
file declares

    class ObjectEntity ... implements \OCA\OpenRegister\Contract\ObjectEntityInterface

so the interface must exist by then or PHP fatals inside the bootstrap rather
than failing a test.

MEASURED both directions on this app, with the prefix removed from the vendored
package's entry in vendor/composer/installed.json (editing the vendored
composer.json does nothing — Composer reads installed.json):

  prefix PRESENT (today)          OK (839 tests, 2704 assertions) — guard no-ops
  prefix REMOVED (after #531)     OK (839 tests, 2704 assertions) — guard supplies
  prefix REMOVED, no guard        Error in bootstrap script:
                                  Interface "OCA\OpenRegister\Contract\ObjectEntityInterface" not found

Safe to land now: while hydra-gates still declares the prefix this is a no-op.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
hydra-gates v1.8.2 -> v1.9.0
nc-vue      2.10.1 -> 2.11.1

Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.

Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…#329)

The drift sweep found buildiq red on two docudesk-template specs, and the seed
log had already said why — phrased as success:

    [globalSetup] docudesk template "Bevestigingsbrief" create returned 500
    [globalSetup] docudesk template "Besluit" create returned 500
    [globalSetup] docudesk templates ready: Bevestigingsbrief, Besluit (already present)

Nothing was created and nothing was present. The summary read
`created.length ? '(created …)' : '(already present)'`, and `created` is empty
in BOTH the nothing-to-do case and the everything-failed case, so the message
picked the innocent reading of the ambiguity.

The cost is not the wrong word. It is that the failure then resurfaces 30
minutes later as

    Test timeout of 30000ms exceeded.
    waiting for getByRole('option').filter({ hasText: /Bevestigingsbrief/i })

— a Playwright timeout on an empty picker, which reads as a flaky UI test and
sent me looking at nc-vue, at openregister's register-scope change, and at the
sibling app's branch before I re-read the seeding log I already had.

Now a failed create is tracked and the summary says NOT ready, names the
failures with their status codes, and says where to look. A run that seeded
nothing can no longer announce that it seeded everything.

The 500 itself is a separate, live defect in docudesk's template-create path
against the current openregister development — reported on
ConductionNL/openregister#2774. This change is only about the instrument.

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

Two of the four app templates declared page types that exist in neither the
manifest v2 page-type enum nor CnPageRenderer's pageTypes registry:

- employee-onboarding: OnboardingTaskChecklist  type 'checklist'
- permit-tracker:      ApplicationKanban        type 'kanban'

CnPageRenderer looks the type up in the registry and, on a miss, logs a
console warning and renders NOTHING. An app scaffolded from either template
therefore shipped a blank page — and the manifest also fails validation
against the canonical v2 schema, so gate-22 would reject it.

Both pages list objects grouped by status, so both become 'index' over their
schema. A genuine board/checklist page type would have to be implemented in
nc-vue and added to the registry and the schema enum first; config.groupBy is
left in place for whenever that lands.

Adds a guard over every shipped template so a page type cannot drift from the
renderer again.

Refs #327

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

* fix(l10n): ship the browser catalogue, so the translations actually render

This app has a complete Dutch catalogue that no user has ever seen.

Nextcloud reads `l10n/<locale>.json` server-side for PHP `$l->t()`, but the
browser only ever gets `l10n/<locale>.js` — the `OC.L10N.register()` file.
Raw JSON is not served out of an app directory at all:

    GET /custom_apps/<app>/l10n/nl.json  ->  404   (measured)

With no `.js` half, `t('<app>', …)` has nothing registered, so it returns the
key unchanged. Every string in the interface renders in English no matter what
language the user picked, while every server-rendered string is translated.
Nothing errors, nothing logs, and a catalogue check that only reads the JSON
reports full coverage.

Three parts:

  - `scripts/build-l10n-js.js` GENERATES the .js from the .json, so the pair
    cannot drift. It reads the app id from appinfo/info.xml rather than
    hardcoding it — a catalogue registered under a stale id after a rename is
    silently ignored, which is the same failure one level down.
  - `pluralForm` added to both catalogues. Core's shape is
    {translations, pluralForm}; without it plural strings fall back.
  - `check:l10n-js` in CI fails when the committed .js is stale and names the
    command that regenerates it. Verified must-fail: mutate one value in the
    JSON and it exits 1 naming the file.

Generated, never hand-edited: run `npm run l10n:build` after touching a
catalogue.

* fix(l10n): generate EVERY locale, not just en/nl

The first commit generated `en.js` and `nl.js`, which is what humaniq needed.
This app ships far more than two catalogues, and all of the others were in the
same position: present as JSON, absent as JS, therefore unreachable.

  larpinq   37 locale catalogues, .js for 0 of them
  keepiq    .js present for all 37 — and STALE across the board:
            595 keys and 17 corrected translations never reached a browser

The generator now discovers locales from `l10n/*.json` instead of a hardcoded
pair, so adding a language is a JSON file and nothing else.

`pluralForm` is taken from the catalogue when it declares one. When it does
not, the fallback is the two-form rule `nplurals=2; plural=(n != 1);` — which
is what every generated catalogue in this fleet already carries, including for
languages that genuinely have more forms (cs, pl, ru). That is a known
simplification rather than a verified per-language rule, and it is documented
as such in the script: a catalogue that starts using plural strings in one of
those languages needs its real rule in the JSON, which the generator honours.

Verified non-destructive: across keepiq's 37 regenerated catalogues, 0 keys
lost, 595 added, 17 values corrected.

Also formatted the script with this repo's prettier config.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* feat: rename the app id from openbuild to buildiq

Moves the app id, PHP namespace (OCA\OpenBuild -> OCA\Buildiq), display name,
composer/package names, l10n domain, routes, bundle names and occ prefix.
29 files renamed with git mv; both lockfiles refreshed so npm ci and composer
validate.

FROZEN, each because renaming it fails SILENTLY rather than loudly:

  - OpenRegister register slug 'openbuild', and every slug DERIVED from it:
    `openbuild-${slug}`, 'openbuild-' . $slug, openbuild-app-staging,
    openbuild-preview-*. These address existing registers; a renamed slug
    creates a fresh EMPTY one and orphans every stored object.
  - GitHub repository topic 'openbuild-app'. Repositories out on GitHub carry
    that tag. Renaming the search term makes the template gallery return
    NOTHING, with no error.
  - openbuildEditable — a property name the ADR-024 manifest schema declares.
    Renamed, it becomes an "additional property" and the manifest stops
    validating.
  - openbuild-app.json, the descriptor filename inside template repos.
  - The DISCOVERY_TOPIC / entry-id / object-type / store-id / catch-all-route
    constants ('openbuild-app', 'openbuild-app-', 'openbuild-application',
    'openbuild-hidden', 'openbuild-schemas', 'openbuild-builder-catch-all').
  - openbuild.conduction.nl (HTTP 200; buildiq.conduction.nl is 000).
  - openspec/specs/openbuild-* and openspec/changes/openbuild-* and every
    @SPEC path: capability ids are dereferenced by gate-46 and cited from the
    frozen archive, so they cannot move.
  - lib/Settings/openbuild_register.json, loaded by an explicit path.

The virtual app ids this builder MINTS (openbuild-{slug}) are the sharpest
case: credentials are registered against them, so they name existing records
even though they look like this app's own id.

Local: phpcs 0, phpstan OK, psalm clean, phpmd 0, eslint 0 errors,
1378/1378 vitest, l10n OK, l10n-js up to date, prettier 0.

* fix(spec): tag the 88 methods surfaced by gate-16, and repoint one anchor

gate-16 is diff-scoped, and a rename touches nearly every file, so the app's
whole legacy @SPEC debt landed in this PR: 88 changed methods with no anchor.
Each now cites the capability it implements. Where no requirement covers the
method, the citation is the capability spec WITHOUT an anchor -- gate-16
accepts that, and a citation to a requirement that says nothing about the
method would be worse than none.

gate-46: one anchor was left behind by the freeze rule. I deliberately froze
@SPEC PATHS (openspec/changes/openbuild-* directories cannot move), but the
sweep did rename the product name inside the spec PROSE -- so the heading
became 'Owner signal is derived from existing Buildiq primitives' while the
citation still pointed at ...-openbuild-primitives. The directory stays frozen;
only the anchor follows the heading.

Two self-inflicted problems fixed before pushing:
  - The tagger inserted a docblock BETWEEN a method's PHP attributes and its
    signature, so phpcs read that empty block as the method's docblock and
    reported the real one's @param/@return as missing. Merged into the original.
  - It also injected a docblock inside a Vue TEMPLATE expression rather than at
    the method definition (fixed in the decidiq equivalent of this commit).

Local: phpcs 0, phpstan OK, psalm clean, eslint 0, 1378/1378 vitest, l10n OK,
gate-16 0, gate-46 0.

* fix: the register slug is openbuild everywhere; only the APP id is buildiq

CI's PHPUnit found the real cost of the bulk rename: the two identifiers look
alike and my sweep could not tell them apart.

  register slug  = openbuild  (FROZEN -- it addresses existing registers)
  app id         = buildiq    (moved)

The sweep had renamed the register in ~36 call sites -- register: 'buildiq',
searchObjectsBySlug('buildiq', ...), registerMapper->find('buildiq'),
REGISTER_SLUG constants, and positional saveObject() arguments -- while the
constants that spell it out (AgentChannelProvisioner::AGENT_REGISTER,
FlowAndAgentExportBundler::BUILDIQ_REGISTER) had stayed frozen. Production
therefore addressed a register that does not exist, and the mocks disagreed
with the calls. All reverted to openbuild.

It had also renamed the descriptor filename on ONE side: AppRepoParser looked
for buildiq-app.json while TemplateRepoSerializer still wrote openbuild-app.json.
That filename lives in template repositories out on GitHub, so both sides stay
frozen -- the parser now matches the serializer again.

Test fixtures that had been renamed the same way are reverted with the code, so
the expectations match what production actually passes:
  - saveObject register arguments and filters['register']
  - openbuild/built-app-route schema refs
  - allowedApps: ['openbuild'] -- existing broker credentials carry that value;
    a fixture claiming buildiq would misrepresent what is stored (production
    only ever tests for hermiq here, so it did not drive the failure).

Deliberately still buildiq, because they ARE the app id: Capabilities' key,
NOTIFICATION_APP, getAppPath(), appDataFactory->get(), and the MCP provider id.

Local: phpcs 0, phpstan OK, psalm clean, 881/881 phpunit, eslint 0,
1378/1378 vitest, l10n OK, gate-16 0.

* fix(e2e): the seed's register check must name the frozen slug

CI failed before any test ran:

  Buildiq registers missing after import: ['buildiq']
  The e2e suite cannot create an application, schema, page or automation
  without them; every UI spec would fail on an empty list.

ci-seed.sh verifies the imported registers by slug, and the sweep had rewritten
that expectation to 'buildiq'. The register is 'openbuild' -- src/manifest.json
still names it 13 times -- so the check manufactured an absence and aborted the
seed. Same for a page-editor spec's config.register comment.

The register SLUG stays openbuild; only the APP id moved. Noted inline so the
next reader does not 'fix' it back.

* fix: the URL segment after objects/ is the REGISTER slug, not the app id

Newman aborted in the seed:

  The buildiq application collection is not readable (HTTP 404).

ci-seed.sh probes
/index.php/apps/openregister/api/objects/<REGISTER>/application, and the sweep
had rewritten that segment to buildiq. OpenRegister addresses objects by
register SLUG, which stays openbuild -- so the probe asked for a register that
does not exist and read the 404 as 'the collection is missing'.

133 occurrences across 66 files: postman raw URLs AND their path arrays (Postman
builds from the array), vitest expectations, docs and active openspec. The
archive is untouched, and CHANGELOG entries describing past behaviour keep the
URL they actually described.

Local: phpcs 0, 881/881 phpunit, 1378/1378 vitest.

* docs: same register segment in a routes.php comment

* fix(newman): the per-app register pattern is openbuild-<slug>, not buildiq-<slug>

The version-create request failed 422 'already exists'. The collection asked
for register buildiq-hello-world-newman-test-v1, but SeedHelloWorldFixture
writes ApplicationVersionService::REGISTER_SLUG . '-' . 'hello-world' --
openbuild-hello-world -- because the register slug did not move with the app id.

Four references corrected across the collections. Also fixed the fixture's own
comment, which the sweep had rewritten to claim the pattern is 'buildiq-<slug>'
while the code one line below produces openbuild-<slug>: a comment that
contradicts its code is how the next person re-breaks this.

Local: phpcs 0, 881/881 phpunit.

* fix(newman): register values inside request BODIES, not just URLs

Traced the last two failures to their source instead of guessing.

The version-create returned 422 because the request body asked for
  "register": "buildiq-newman-roundtrip-production"
while ApplicationCreationService:269 mints per-app registers as
  'openbuild-' . $appSlug . '-' . $versionSlug
so the body named a register that does not exist. The 'has no resolvable
manifest' warning was a CASCADE: it comes from the manifest GET later in the
same folder (and returns 404, not 422), which could only fail once the version
create had failed. Reading it as the cause would have sent me into
ManifestResolverService, which was already correct.

Also fixed the manifest page config.register values inside the same bodies --
"config": { "register": "buildiq", "schema": "hello-message" } -- which name
the SHARED data register, likewise still openbuild.

These sat in escaped JSON inside Postman "raw" bodies, which is why every
earlier sweep missed them: the URL fixes matched /objects/<slug>/ and the path
arrays, but a register named in a request BODY looks like ordinary JSON text.

Corrected the ManifestResolverService docblock, which still claimed
'register=buildiq' one line above code reading openbuild.

Local: phpcs 0, 881/881 phpunit.

* fix: stored lifecycle-guard FQCNs in the register descriptor

Newman's version-transition step logged:

  Lifecycle guard tag "OCA\OpenBuild\Lifecycle\ApplicationVersionOwnerGuard"
  could not be resolved: Class ... does not exist

lib/Settings/openbuild_register.json carries that guard's fully-qualified class
name in three places. It is a STORED reference OpenRegister resolves at
transition time, so the namespace rename orphaned it: the guard silently stopped
running and the transition failed.

This is the same class of defect the integriq work found with jobClass, and the
reason it matters is the failure mode: a guard that cannot be resolved does not
throw at boot or at deploy. It fails at the moment it is supposed to make an
authorisation decision — on a user-scoped delta, ApplicationVersionOwnerGuard is
what stops one user reading or rewriting another user's override.

Worth stating plainly: the register descriptor is DATA, so a grep over lib/*.php
never sees it. I found it only because Newman surfaced the resolution error in
the Nextcloud log, not because any sweep matched it.

Local: phpcs 0, 881/881 phpunit, register JSON valid.

* fix(e2e): the register segment inside an escaped REGEX

Three page-editor specs (map, roadmap, search) failed with

  TimeoutError: page.waitForResponse: Timeout 20000ms exceeded

saveAndAwaitPersist() waits for the manifest write by matching

  /\/api\/objects\/buildiq\/(applicationVersion|application)\/[^/]+$/

The register segment is the frozen 'openbuild', so the matcher waited 20s for a
URL that never occurs — the save itself was fine.

This one survived every earlier objects/<slug> sweep because the slashes are
BACKSLASH-ESCAPED for the regex literal: 'objects/buildiq' does not appear in
the file, only 'objects\/buildiq'. A plain-text grep for the URL shape cannot
see it.

That is the fourth distinct syntax this same value has hidden behind in this
app: a plain URL, a Postman path ARRAY, an escaped JSON request BODY, and now
an escaped regex.

* fix(e2e): the fixture's register slug constant, and the page-editor selects

Four workflow/CRUD specs failed with the fixture's own message:

  at least one register must own slug "buildiq" (found 0)

tests/e2e/workflows/fixtures.ts declared

  export const BUILDIQ_REGISTER_SLUG = 'buildiq'

but the register slug is frozen at 'openbuild', so every fixture that resolves a
register by that constant found nothing and the suites failed at setup — which
is why three of them failed in well under a second.

Also repointed page-editor-coverage's register <select> assertions, and the
comment block that reasons about which registers exist. That comment is worth
reading: it already says the hello-world manifest pages carry
config.register = 'openbuild', two lines after asserting the list contains
'buildiq'. The prose and the assertion had drifted apart in the sweep.

Same value, a fifth syntax: after a plain URL, a Postman path array, an escaped
JSON body and an escaped regex, here it is as an exported TypeScript constant.

* fix(e2e): register slug inside a CSS attribute selector

REQ-PEC-006 failed with the spec's own message:

  the seeded app's register must be offered by the register picker
  Locator: ... .locator('option[value="buildiq"]')

The register picker lists real registers, and the seeded one is 'openbuild'.

Sixth syntax for this same frozen value, after a plain URL, a Postman path
array, an escaped JSON body, an escaped regex and an exported TS constant: a CSS
ATTRIBUTE SELECTOR.

Swept every remaining 'buildiq' spelling in tests/ and confirmed the rest are
legitimately the APP id: loadState('buildiq', ...), the ['apps','buildiq',...]
URL path segments, OC_App::loadApp('buildiq'), and ExporterEndToEndTest's
assertion that an exported app must NOT depend on buildiq.

* fix(e2e): the docs host is frozen — the assertion had been renamed

'surfaces the documentation link to openbuild.conduction.nl' asserted

  getByRole('link', { name: /buildiq\.conduction\.nl/i })

The docs host stays openbuild.conduction.nl (it is live; buildiq.conduction.nl
is not), and src/manifest.json correctly renders the frozen URL. The sweep had
rewritten the EXPECTATION while leaving the value frozen, so the test's own
title and its assertion contradicted each other — the title says openbuild, the
locator said buildiq.

I had written this failure off as 'depends on the live GitHub API'. That was
wrong, and the log said so plainly: 'element(s) not found' naming the locator.
Reading the assertion beats reasoning about the subject matter.

Seventh syntax for a frozen value in this app: inside a REGEX LITERAL in a
getByRole name matcher, where the dots are backslash-escaped.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Sixth in the series. The app id moved to `buildiq` in #334; the register it
addresses was still `openbuild`, together with `x-openregister.app` — which for
a `type: application` configuration IS a register slug, not an attribution
label (ImportHandler::autoCreateRegisterIfApplication reads
`$xOpenregister['app'] ?? $appId`).

MigrateRegisterSlug renames the register ROW ahead of InitializeSettings in both
hooks. OpenRegister resolves a register by slug and its not-found branch CREATES
a second, empty register rather than failing, so shipping a renamed slug without
it would fork the register and strand every stored object, silently. The rename
moves no data: an object is bound to its register by NUMERIC id.

SCOPED TO THIS APP'S OWN REGISTER, and that boundary is the interesting part
here. The per-generated-app registers `openbuild-{slug}[-{version}]` are
deliberately NOT in the map. Those names are minted per generated app, stored on
the app, resolved with `registerMapper->find('openbuild-' . $slug)`, and the
credential broker holds `allowedApps` entries against the matching
`openbuild-{slug}` virtual app ids — a strict in_array() that fails CLOSED. They
move only in a pass that moves the minted credentials with them. Every sweep rule
requires a closing quote immediately after the old name, so `'openbuild-' . $slug`
was never a candidate; the boundary is enforced by construction, not by care.

Two sweep rules earned their place here and are now applied fleet-wide:

  - Postman `path` ARRAYS (`"objects","openbuild","application"`, 25 of them).
    Postman builds the request from the array, not from `raw`, so rewriting only
    the raw URL leaves the request hitting the old one while the diff looks done.
  - Register constants under any name, not just REGISTER/REGISTER_SLUG.
    `AGENT_REGISTER` and `BUILDIQ_REGISTER` both held 'openbuild' and both were
    missed by a rule anchored on the exact constant name. Re-scanning the whole
    series on the broadened pattern found three more in integriq
    (SOURCE_REGISTER, SELF_REGISTER, ENDPOINT_REGISTER_SLUG).

Still frozen: `allowedApps: ['openbuild']` (minted credentials carry it),
`topic:openbuild-app` (a GitHub repository topic), `openbuildEditable` (declared
by the ADR-024 manifest schema), the `openbuild-{slug}` virtual app ids, the
docs host, and `lib/Settings/openbuild_register.json`'s FILENAME.

PHPUnit 896 passed · vitest 1378 passed · PHPCS 0 errors · PHPMD clean ·
Psalm 0 errors · PHPStan 0 errors · gate-16 count=0 · gate-46 clean.
`OC_REGISTER` / `BUILDIQ_REGISTER_SLUG` held the old slug and drove every
OpenRegister call in those specs. Same class as the PHP constants fixed earlier,
found by the same broadened rule once it was applied to .ts as well as .php.
`buildiq` is three characters shorter than `openbuild`, so a generateUrl() call
and a const initialiser now fit on one line and prettier wanted them there.
Applied its output rather than hand-wrapping.

This is the whole of `quality / Frontend Check (format)`. The six red PHPUnit
cells in the same run are a different matter and are NOT caused by this branch:
every one of them dies in the bootstrap with "Undefined constant
Doctrine\DBAL\ArrayParameterType::BINARY", raised from server/lib/private/
AppConfig.php before a single line of app code runs, and on all three Nextcloud
refs rather than one. This PR touches no composer file and no vendor directory.
CORRECTION. My previous commit said the six red PHPUnit cells were "NOT caused
by this branch". That was wrong, and I asserted it from the wrong evidence — I
compared against a development run from 14:46 and reasoned that the branch
changes no composer file. It does not, but it does change tests/bootstrap.php and
adds tests/stubs/DoctrineStubs.php, which I had not looked at. Dispatching
Code Quality on development just now settles it: all six cells pass there and
fail here, so this branch owns the failure.

WHAT IT IS. The stub declares Doctrine\DBAL\ArrayParameterType so
createMock(IDBConnection::class) can work where doctrine/dbal is not installed —
IQueryBuilder evaluates constants referencing it at parse time. The stub's own
docblock already records why class_exists() cannot protect this: at the moment
it runs, the genuine class is not yet reachable, the guard passes, and the STUB
WINS THE NAME for the rest of the process.

In a full-server leg that shadow is not harmless. Nextcloud's AppConfig reads
ArrayParameterType::BINARY while loading app versions, the stub does not declare
it, and every cell died with "Undefined constant
Doctrine\DBAL\ArrayParameterType::BINARY" — in the bootstrap, before a single
test ran and before any code this app owns. The stub was written to make mocking
possible and ended up breaking the leg that needs no mocking at all.

THE FIX is not to complete the constant list. That closes this symbol and leaves
the next one waiting for whoever adds a caller. The bootstrap now loads the stubs
only when lib/base.php is absent — where a real server exists it ships
doctrine/dbal in 3rdparty and the stub has nothing to add. BINARY is added as
well so the stub stays a faithful stand-in on the path where it IS used, rather
than a partial one that happens not to be asked yet.

The stub file's guidance ("stub a class nothing extends") was right as far as it
went; the missing half is that a class nothing extends can still be READ FROM,
and a stand-in that is complete enough for your callers is not complete enough
for someone else's.
…itor

The Doctrine fix cleared all six PHPUnit cells; what was left is the seed.

ci-seed.sh refused with "Buildiq registers missing after import: ['openbuild']"
while printing a register list that contains `buildiq`. Its required-registers
guard still named the old slug — and the note above it stated the OLD POLICY in
so many words: "The register SLUG stays 'openbuild' across the app-id rename …
Only the APP id moved." That was true while the rename was the app id alone.
This change renames the register too, and MigrateRegisterSlug renames the
existing row ahead of the import, so it is the same row answering to a new name.
The comment is replaced rather than deleted, because a stale rationale left in
place is worse than none — the next reader would have trusted it.

page-editor-coverage.spec.ts picked `openbuild` out of the register dropdown and
asserted the value round-trips. Its own long comment already explained that this
test had once been pinned to a fixture that existed only on the machine it was
written on; leaving the slug stale would have re-pinned it to a fixture that
exists nowhere.

DELIBERATELY NOT RENAMED. `openbuild_register.json` stays — the seed references
it by PATH, and a filename is not an identifier anything resolves.
`openbuild-preview-{slug}`, `openbuild-{slug}-{version}` and
`openbuild-application` are a wizard-generated appId convention and a type
constant. `openbuild-template-catalogue` and `openbuild-version-snapshots` are
spec file names. None of them is a register slug, and MigrateRegisterSlugTest's
`openbuild` occurrences are correct: they are the FROM side of the migration.

Seed script parses (bash -n) and its embedded python block still parses;
prettier clean.
…ster

`occ buildiq:seed-hello-world-fixture` failed its own schema:

    Property 'register' should match pattern
    '^openbuild-[a-z0-9][a-z0-9-]*[a-z0-9]$' but 'buildiq-hello-world' does not.

Two different identifiers had been sharing a spelling, and the rename separated
them. ApplicationVersionService::REGISTER_SLUG is the app's MAIN register and
correctly moved to `buildiq`. The applicationVersion `register` field names a
PER-VERSION register the creation wizard provisions, convention
`openbuild-{appSlug}-{versionSlug}` — and that one did NOT move: all five
producers still emit the `openbuild-` prefix (ApplicationsController,
ApplicationCreationService, AppRepoSerializer, GitHubAppSyncService,
UpsertSchemaHandler) and the schema pins it with a regex.

The fixture built the second from the first, so renaming the main register
dragged the per-version name with it and the value stopped matching the pattern
that guards it. This is the same shape as a validator and an executor each
holding their own copy of a grammar: here the copies agreed until one of them
was renamed.

Written out as a named constant rather than another literal. Its docblock is the
explanation, which also keeps it out of execute() — my first version put the
reasoning at the call site and pushed the method to 115 lines against phpmd's
100-line threshold, so the comment that explained the fix would have failed the
build on its own. It is now the seventh place this prefix appears; a shared
constant belongs in the change that touches those five producers, not in a
rename.

Verified the emitted value against the actual regex from the schema:
`openbuild-hello-world` matches, `buildiq-hello-world` does not — which is
exactly what CI reported. phpcs clean, phpmd exit 0.
The l10n rollout fixed a defect no existing check could see: `l10n/<locale>.js`
was missing, so `t('<app>', key)` had nothing registered and handed the key
back — the whole interface rendered English regardless of the user's language,
while every server-rendered string was translated. Nothing errored.

Six apps in the fleet ran an l10n check that passed the entire time, because
it reads the JSON — the half that was never broken. A check that validates the
SOURCE cannot see that the ARTEFACT the runtime loads does not exist, so this
test asserts from the browser instead:

  1. GET l10n/<locale>.js returns 200, is an OC.L10N.register call, and names
     the CURRENT app id. (Raw JSON out of an app directory is a 404, which is
     what made every translation unreachable.)
  2. The running app has that catalogue registered, and t() resolves a real
     key through it rather than falling back to returning the key.

Must-fail verified: delete l10n/nl.js and both scenarios fail — the first on
404, the second on the missing registration.

Written to be identical in every app: the app id is read from
appinfo/info.xml at run time rather than hardcoded, so it survives a rename —
and a catalogue registered under a pre-rename id, which `t()` silently
ignores, fails scenario 1. No fixture strings either: the assertion picks a
translated key out of the app's own registered catalogue at run time, so it
does not need editing when copy changes.
The file is meant to be byte-identical in every app, so it has to satisfy the
strictest formatter in the fleet. decidiq's format check objected; this is its
prettier output, verified to also satisfy every other app that runs one.
… slug

The seed failed a second time — 'buildiq-opencatalogi' against the same
'^openbuild-…' pattern — and chasing that one call site turned up the real
problem, which is not a test fixture.

REGISTER_SLUG was doing TWO JOBS. On development it read 'openbuild' and served
both as the app's main register slug AND as the prefix callers used to build
per-version register names (`REGISTER_SLUG . '-' . $slug`). Renaming it to
'buildiq' for the first job silently changed the second, and the applicationVersion
schema pins that one with "pattern": "^openbuild-[a-z0-9][a-z0-9-]*[a-z0-9]$".

FOUR PRODUCTION PATHS were affected, not just the seeder:

  * AppOverrideService, twice — writes an ApplicationVersion on override
  * GitHubAppSyncService — the draft register for a synced app
  * MigrateToVersionedModel — a REPAIR STEP, so it would have failed on upgrade

Each would have thrown "Property 'register' should match pattern … but
'buildiq-…' does not" the moment it ran. None of them is exercised by the unit
suite; the seed is the only path CI walks, which is why one fixture failure was
the whole visible surface of a four-site regression. Had the seed not existed,
this would have shipped and surfaced on a user's upgrade.

Added ApplicationVersionService::VERSION_REGISTER_PREFIX and pointed every
derived site at it, including the seeder — which now ALIASES the canonical
constant rather than declaring its own copy, since a second copy of this string
is exactly the shape of the bug it exists to prevent.

Deliberately NOT renaming the per-version registers themselves. They are real,
populated registers; moving them needs a data migration and a change to that
schema pattern, and neither belongs in an app-slug rename.

phpcs 0 errors, phpmd exit 0, all five files parse. PHPUnit cannot run in this
worktree (it needs OpenRegister on the include path); CI covers it.
rubenvdlinde and others added 21 commits August 24, 2026 11:53
…441)

OpenRegister resolves a REGISTER by slug alone (RegisterMapper::find) but
a SCHEMA by the PAIR (SchemaMapper::findByApplicationAndSlug). This app
now passes appId: Application::APP_ID = buildiq while every schema it
already owns still carries application = openbuild, so the pair matches
nothing — and ImportHandler's not-found branch is not an error path, it
is the "create a new one" path. The next import therefore builds a
SECOND, EMPTY schema set under the new application id while every stored
object stays bound to the old rows. Nothing errors; the app renders
empty collections.

Sibling of MigrateRegisterSlug, and neither covers the other. Registered
in both <post-migration> and <install>, immediately after
MigrateRegisterSlug and ahead of InitializeSettings, which triggers the import.

Three properties the step keeps:
  - it REFUSES rather than merges when a slug already has a twin under
    the new application id. Two rows sharing (application, slug) means
    the capped lookup silently picks one and the other's objects become
    unreachable — a decision about data, not a migration.
  - a FAILED READ is not an empty result. An empty list says every move
    is safe; a failed read says nothing at all. There is a test for it.
  - it never deletes a schema and never throws — it runs under
    <install>, where an escaping exception aborts the install and the
    app never enables.

Measured on a live install: 17 schemas still under openbuild, zero
colliding slugs under buildiq — all 17 move.

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

* fix(docs): publish the documentation from development, not a stale branch

This workflow triggered on `push: branches: [documentation]`. That branch
exists, which is why nothing ever looked broken — but nobody updates it. Its
last commit predates most of this year's work: larpinq 19 May (development is
594 commits ahead), buildiq 9 July (980 ahead).

So the docs pipeline has been faithfully republishing a months-old snapshot, and
reporting success every time. It is the reason larpinq.conduction.nl still says
"LarpingApp" and decidiq.conduction.nl still says "Decidesk" while both repos'
docs sources have said the new name since the app-id rename merged. The rename
was never missed in the docs — the docs were publishing from somewhere else.

Four of the twelve fleet apps (dossiq, integriq, stackiq, thematiq) already point
at `development`. This brings the rest in line.

Pairs with ConductionNL/.github#555, which made the reusable workflow publish to
the Cloudflare Worker that actually serves the host — previously it wrote
gh-pages and nothing read it. Both halves are needed: one fixes WHERE the docs go,
this one fixes WHICH COMMIT they come from.

* fix(docs): list both docs hosts, and name the worker that serves them

The trigger fix in this PR makes the docs workflow run. This commit makes
the run reach the edge without taking the new host down on the way.

`docs-hosts` was MISSING. It defaults to `cname` alone, and wrangler
reconciles the worker's triggers against whatever it is given rather than
appending — so the first successful deploy would have REMOVED the new
.conduction.nl hostname from the worker and taken it offline. It was
attached as a second custom domain on 2026-08-23 and answers 200 today.
A deploy is not the moment to discover that.

`worker-name` was never passed either, so the callee derived it from
`cname`. Today that derivation happens to land on the right worker, which
is precisely why it is now pinned: the moment `cname` moves to the new
subdomain the derived name silently becomes a worker that does not exist,
and the deploy forks off a second one while both custom domains keep
routing to the original — green, and reaching nobody.

Measured today: the live host still serves the pre-rename title, which is
the staleness this PR chain exists to end.

* fix(docs): ship the og:image file the config already names

The docs build FAILS, and has been failing — it was simply never run,
because the workflow triggered on a branch nobody updates. Making the
trigger correct surfaced it on the first run.

The app-id rename updated `docusaurus.config.js` to point og:image at
the new filename and left the actual PNG under its old name, so the
AI-baseline validator's last check fails:

  ✗ og:image URL resolves to a file in the build

and `npm run build` exits 1 via postbuild. Nothing could have published
even with a correct trigger and a correct worker.

Renames the asset to the name the config has been asking for. No
references to the old filename remain.

Verified locally: npm ci --legacy-peer-deps && npm run build now exits 0
with all 10 AI-baseline checks passing.
GitHub is the only host for this org — Codeberg was a mirror and is not
used for anything, including issues. Every codeberg.org URL in this repo
was therefore dead or pointing at an unused mirror.

Many of the links also carried PRE-RENAME repo names. The slug had drifted
on both hosts, so this repo contained both `Conduction/openbuild` and
`Conduction/buildiq` links; both map to `ConductionNL/buildiq`. Cross-app
links were remapped the same way: openconnector->integriq, procest->dossiq,
docudesk->filinq, nldesign->thematiq. openregister, launchpad, nextcloud-vue,
nextcloud-app-template and hydra keep their slugs.

Issue/PR numbers were NOT mapped across hosts. Codeberg issue numbers do
not correspond to GitHub issue numbers, so rewriting only the host would
have silently pointed at a real but unrelated GitHub issue — worse than a
dead link. Those three references (#69, #68 in the tutorials and #126 in
the phpstan baseline) were de-linked and kept as plain text noting they
are pre-migration and were not migrated to GitHub. Issue links with no
number (the tutorial's "issue tracker") were remapped normally.

Also converted: deep /src/branch/<b>/ links to /blob/<b>/, the raw
screenshot link to /raw/main/, and the two shields.io/ci.codeberg badges
to their GitHub equivalents (the code-quality badge points at the
code-quality.yml workflow, which exists in both this repo and the app
template it ships).

The docusaurus editUrl is the one place the branch was NOT preserved
verbatim: buildiq's `main` branch has no docs/ directory, so
/blob/main/docs/ 404s. It points at `development`, where the docs live.

Deliberately left untouched: the Conduction/concurrentie-analyse links
(that repo does not exist on GitHub), the `codeberg` forge-type enum and
its https://codeberg.org example placeholder in RoadmapPageEditor (a
user-selectable forge, not a link to us), historical "Codeberg issue on
X" prose in archived openspec changes, and .forgejo/ plus the generated
docs/.docusaurus/ build cache.

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

* feat(app-page): show pages, menu, schemas and flows on the app detail page

An app is navigation, pages, data and business logic. The app detail page
showed none of those as lists, so building an app meant opening the app and
using the in-page action menu. This puts the four structure tables on the
page itself.

Three of the four already existed and were mounted NOWHERE. PagesWidget,
MenuWidget and SchemasWidget are implemented, carry REQ-OBADO-009 spec tags,
and have passing unit tests, but `grep -rl` finds no importer. The
dashboard's own docblock claims a "Structural grid — Register / Schemas /
Groups / Pages / Menu", while its template mounted only three widgets. They
were built, spec'd, tested, and never rendered for a single user.

So this mounts them, fed by computed props the dashboard already had
(activePages / activeMenu / activeSchemas), and adds the missing fourth:

- FlowsWidget lists the OpenRegister flows bound to the app. Flows are a
  field on the Application record (ApplicationDetailActions.setFlows patches
  `{ flows }`), not part of the version manifest, so activeFlows reads the
  record. Rows deep-link into OpenRegister, which owns flows (ADR-022); the
  widget lists and hands off rather than wrapping OR's editor.
- flowState() reports an absent `enabled` as "Unknown" rather than
  "Disabled". An unset field is not a value, and defaulting it to the
  innocent-looking answer is how a missing state becomes a wrong one.

SchemasWidget's add button emitted an event nobody listened to, logging
"schema-create dialog not yet registered — deferred to schema-designer
spec". That designer shipped: SchemaDesignerList lives at
/builder/:slug/schemas. The emit now routes there.

Renamed that emit `add-schema` -> `addSchema` to satisfy
vue/v-on-event-hyphenation, which is safe precisely because nothing consumed
it, and declared two emits that were fired but never declared.

Also corrects a docblock that contradicted its own code: registerSlugForApp
returns `openbuild-{appSlug}` while its comment claimed `buildiq-{appSlug}`.
The code is right. The app id moved to buildiq and the CONTROL register
moved with it, but per-app register slugs are frozen once objects live under
them; every per-app register on a live instance is named `openbuild-*`.

Verified in a browser against a real instance, not just unit tests: created
an app and confirmed all four tables render with real rows (Pages 2, Menu 2,
Schemas 1, Flows empty-state). eslint clean, 1378/1378 vitest, phpcs clean,
check-manifest PASS.

* chore(lint): prune the suppression the addSchema rename made stale

Renaming SchemasWidget's emit from `add-schema` to `addSchema` fixed the
underlying vue/custom-event-name-casing warning, which left its entry in
eslint-suppressions.json describing a problem that no longer occurs. ESLint 9
treats an unmatched suppression as a failure, so Lint Check exited 2 with
"0 errors, 142 warnings" — the count was unchanged from development, and the
exit code came entirely from the stale entry.

`npx eslint src --prune-suppressions` removes exactly that one entry. eslint
now exits 0.

* feat(app-page): add Settings, Setup wizard and Walkthrough buttons

These edit the app's chrome — its settings, its first-run wizard, its guided
tour — rather than any one page, so they belong on the app page rather than
in the in-page orange edit menu, which is being narrowed to page-local
actions.

Setup wizard needed no new editor. The Walkthrough Designer already hosts
BOTH editors as tabs, so the work was making the tab reachable: `mode` now
initialises from `?mode=setup`, and the button deep-links to it. Without that
the button would land the user on the walkthrough tab and leave them to find
the setup one, which is the kind of "technically it is there" that makes a
feature feel missing. Optional chaining on `$route` because the designer is
mounted in unit tests without a router.

Not included: Edit support & donation. Its modal (CnEditSupportModal) exists
in @conduction/nextcloud-vue but is not exported from the package index, so
it cannot be imported, and `manifest.support` was rejected by the v2 schema's
`additionalProperties: false`. Both are fixed in
ConductionNL/nextcloud-vue#748; the button lands here once that reaches a
published release. Shipping three working buttons now beats shipping four
where one is wired to nothing.

Browser-verified against a live instance: both buttons render on the app
page, Walkthrough routes to /builder/pet-store/walkthrough, and Setup wizard
routes to ?mode=setup and opens ON the Setup steps tab.

eslint exit 0, 27 applicationDetail tests, 14 WalkthroughDesigner tests,
11 WalkthroughDesignerHost tests all pass.

* feat(walkthrough): the tour now builds the pet store and ends on the docs

The tour walked a user to the Store and had them clone a template, then
stopped. Cloning is not building, so a new user finished the guided tour
without having made a schema, a page, or anything of their own.

It now builds the canonical pet store, which is the sample domain the academy
tutorials already use, so the tour and the docs teach the same thing:

  welcome -> Apps -> create Pet Store -> add a Pet schema -> add an index
  page over Pet -> open the app and add a pet -> done

Each step keeps a real `advanceOn`, anchored to routes that exist
(VirtualApps, SchemaDesigner, PageDesigner, BuilderHost) rather than to
invented ones, and the build steps carry `allowManualNext` so a user who does
it slightly differently is never trapped.

The final step now closes on a call to action that opens the documentation,
per the fleet rule that a walkthrough's last step points somewhere. It targets
the Documentation nav item, which already exists in the menu, so the CTA lands
on a real destination rather than a URL invented for the copy.

Voice-checked against the shared writing skill: no em-dashes, every sentence
under 16 words, every task starts with a verb, no praise, and no step whose
body restates its own task. check-manifest PASSES.

* fix(l10n,format): register FlowsWidget's string and satisfy prettier

Two CI gates caught real gaps in the FlowsWidget commit.

`test:l10n` found "No flows bound to this app yet." used in the component but
absent from l10n/en.json, so the string would have shipped untranslatable.
Added via the repo's own extractor, plus the Dutch translation — the gate only
checks en.json, but Dutch is required fleet-wide, and a key present only in
English is a string Dutch users read in English.

`format` found FlowsWidget.vue not prettier-clean. Fixed with prettier --write;
the only change is wrapping one call's arguments.

The extractor also picked up "This application has no flows yet.", a
pre-existing missing key from another component that was already failing this
gate.

* fix(l10n): rebuild the compiled l10n artefacts the browser actually loads

The previous commit added "No flows bound to this app yet." to l10n/en.json
and l10n/nl.json, and `test:l10n` went green — that check reads the JSON.

Nextcloud does not serve the JSON. It serves l10n/*.js, which is generated
from it, and those still lacked the key. The string would have rendered
untranslated in every language while two l10n gates reported success.

`check:l10n-js` is the gate that can see the difference, and it is the one
that failed. Regenerated via the repo's own `l10n:build`; en.js and nl.js now
carry the key.
This repository's only issue forms lived under `.forgejo/issue_template/`.
GitHub is the fleet's only host, so those forms are invisible to everyone
filing an issue here.

Two approved fleet changes make this urgent:

1. `.forgejo/` is being removed fleet-wide. Without this port that removal
   would delete the only issue forms this repo has, leaving contributors
   with a blank issue box.
2. The shared library's `DEFAULT_FORGE` moves from `codeberg` to `github`.
   The in-product "Request a feature" deep-link then targets a GitHub Issue
   Form named exactly `feature-request.yml`. If that file is absent GitHub
   silently drops every pre-filled field instead of erroring, so the app
   context (app, page, surface, object, spec-ref) would be lost without a
   single visible failure.

Copies all four templates to `.github/ISSUE_TEMPLATE/`, keeping the
filenames identical. `.forgejo/` is deliberately left untouched; its removal
is a separate later change.

Conversion is lossless: Forgejo's issue-template schema is derived from
GitHub's, and every construct used here (markdown/input/textarea/dropdown
blocks, `render: shell`, `validations.required`, `labels`, `assignees`,
`title`) is valid GitHub issue-form syntax. Nothing was dropped or reworded.
The top-level `type: "Feature"` in feature-request.yml was verified against
the ConductionNL org issue types, where "Feature" exists and is enabled.

No `config.yml` was added: `.forgejo/issue_template/` has no equivalent.
* fix(docs): pass secrets to the reusable documentation workflow

A called workflow receives no secrets from its caller unless they are passed
explicitly or inherited. Without `secrets: inherit` the callee sees an empty
`secrets.CF_API_TOKEN`, its "Publish to the Cloudflare Worker" step skips
itself on its own guard, and the run finishes green having written only
gh-pages — which nothing serves. The live docs site never changes and no
check goes red to say so.

Measured on planninq run 32715324775: all three jobs green, GitHub Pages
deploy success, Worker publish skipped, warn step reporting the Worker was
not updated.

* fix(docs): map the Cloudflare secrets explicitly instead of inheriting all

`secrets: inherit` handed the reusable documentation workflow every secret
this repo holds — the Nextcloud signing cert and key, the appstore token, the
deploy keys — for the sake of two Cloudflare values.

It also would not have worked. The org secrets are CLOUDFLARE_API_TOKEN /
CLOUDFLARE_ACCOUNT_ID and `inherit` passes secrets under their original names,
while the callee reads CF_API_TOKEN / CF_ACCOUNT_ID — so the publish step
would still have skipped itself and the run would still have gone green over
an unchanged live site.

Maps the two names explicitly instead, so nothing else crosses the boundary.
Depends on ConductionNL/.github#568, which declares both as optional secrets
on the callee: an explicit mapping only compiles for names the callee declares.
… Vue Flow canvas (#449)

2.15.0 is the first release that carries the four `@vue-flow/*` dependencies
(`core`, `background`, `minimap`, `node-resizer`) required by `CnGraphCanvas`,
`CnFlowDetail` and `CnFlowEditModal`.

The declared range `^2.6.2` already *allowed* 2.15.0, and this repo's lockfile
happens to have already floated up to 2.15.0 — so the canvas does currently
ship here. But nothing enforced that: the range still permitted any 2.6.x
build, i.e. a build with no `@vue-flow/*` deps, so any lockfile regeneration or
resolution could silently drop the canvas out of the bundle again. This is
exactly what happened in openregister, where the caret range allowed 2.15.0
while the lockfile pinned an older build and the canvas never reached the
bundle. Raising the floor makes the requirement explicit.

Verified after `npm run build`:
- installed @conduction/nextcloud-vue: 2.15.0 (unchanged; range 2.6.2 -> 2.15.0)
- node_modules/@vue-flow/{core,background,minimap,node-resizer} present
- 47 distinct `vue-flow__*` classes in the built js/ + css/ output

Only package.json and package-lock.json change; js/ build output is gitignored.
…ist (#454)

The mapping read `secrets.CLOUDFLARE_API_TOKEN` / `secrets.CLOUDFLARE_ACCOUNT_ID`,
which are not secrets anywhere in this org. Mapping from a non-existent secret is
not an error - it yields an empty string - so the callee's "Publish to the
Cloudflare Worker" step skipped itself on its own guard and the run stayed green
while the live docs site kept its pre-rename build.

The real org secrets are CF_API_TOKEN / CF_ACCOUNT_ID, the same names the callee
declares and the same ones ConductionNL/.github deploy-docs.yml reads directly.
Only the mapping values change; the keys stay.

The comment justifying the explicit mapping claimed the names differ on each
side. They do not, and that claim is what produced the bug. Replaced with the
reason that still holds: `secrets: inherit` would hand the callee every secret
this repo holds for the sake of two Cloudflare values.
GitHub is the only host this organisation publishes to. No local checkout has
a Codeberg git remote, so nothing is pushed there and no workflow under
.forgejo/ has ever run for this repository.

Issue templates: the 4 templates under `.forgejo/issue_template/` were
already ported to `.github/ISSUE_TEMPLATE/` and were verified present there
before deletion (including `feature-request.yml`, which the in-product
"Request a feature" deep-link targets by that exact filename).

This repository had no `.forgejo` release workflow. Its release path,
`.github/workflows/release.yml`, is untouched.

.github/workflows/ is untouched — that is the live CI. Any CODEBERG_TOKEN
reference lived only inside the deleted files and goes with them.

Removes 6 file(s) under .forgejo/.
* feat(app-page): add the Support & donation editor to the app page

Completes the app-level editor row. The app page now edits all four pieces of
an app's chrome — its settings, its first-run wizard, its guided tour and its
support note — rather than sending the user into the running app's orange edit
menu for the last one.

The button opens `CnEditSupportModal` on a CLONE of the resolved manifest, so
cancelling costs nothing: the copy is simply dropped. The manifest is resolved
from the active version through the app's own endpoint rather than read off the
Application record, because an Application carries no manifest — it lives on
the ApplicationVersion, and `obApp.manifest` is undefined.

Requires @conduction/nextcloud-vue 2.15.1, bumped here. The modal shipped
inside the library for months but was never exported from its package index,
so no consumer could mount it; ConductionNL/nextcloud-vue#748 exports it and
2.15.1 is the first release that carries it. Verified against the published
tarball rather than the repo: `CnEditSupportModal` is present in
dist/esm/index.js and the v2 schema carries the `support` block.

Also drops the stale comment that said these modals "cannot be imported" —
that was true when it was written and is not any more.

* fix(l10n): register the support-button strings, source and compiled

`test:l10n` caught two strings the support button introduced that no catalogue
carried: "Support & donation" and "Failed to load settings".

Added to l10n/en.json via the repo's own extractor, translated on the Dutch
side, and — the part that matters — regenerated l10n/*.js.

Nextcloud does not serve the JSON. It serves the compiled l10n/*.js built from
it, so a key present only in the JSON is a string the browser never receives.
That is the same gap that made `test:l10n` pass and `check:l10n-js` fail
earlier on this branch's predecessor: two l10n gates, only one of which can see
the artefact the runtime loads.

Both now pass, and the key is present in en.js and nl.js.
…461)

* fix(e2e): a scheduled fixture flow must name the identity it runs as

Two e2e tests are red on development, both refused at creation with 400:

    creating the fixture flow must succeed (got 400)
    seeding the definition must succeed (got 400)

openregister's TriggerScheduleNode now refuses a schedule trigger whose
config carries no 'runAs' (ADR-099). Nobody is present when a schedule
fires, so there is no session to take an identity from, and the flow's
owner is deliberately not used as a fallback — authoring a flow is not
consent to unattended execution as its author.

Both fixtures posted 'openregister.trigger-schedule' with an empty config,
so both are refused. The value is validated against real accounts via
userManager->get(), so it cannot be an arbitrary label; RUN_AS tracks the
account the suite authenticates as, from the same environment variable
playwright.config.ts reads.

Not caused by #453, which is simply the commit these failures first appear
on: the two commits between the last green e2e and this one are CI and docs
config only, and the 400 comes from openregister's validator rather than
from anything in this app. The same contract change is failing integriq's
JobToFlowGenerator test.

* fix(e2e): the schedule trigger needs cron AND runAs, not either

The first commit added runAs alone, and CI still returned 400 — the error
simply moved. Probed against a live instance to establish the whole
contract rather than inferring it from one message:

    {cron, runAs} -> 201
    {cron}        -> 400  'must carry a "runAs" naming the user its runs act as'
    {runAs}       -> 400  'must carry a "cron" expression'

Both are mandatory and each is refused separately, so an error naming one
says nothing about the other. These fixtures sent config: {}, so they were
always missing both; fixing one at a time reads as 'the fix did not work'
when it was half a fix.

FIXTURE_CRON is a five-field expression because macros like @hourly are
refused. Nothing here waits for the schedule to fire — the flows are
created disabled or driven directly — so the time only has to be valid.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
The committed sbom.cdx.json dates from this app's scaffold commit in May 2026
and has never been regenerated, while composer.lock and package-lock.json have
moved many times underneath it. A stale SBOM asserts a dependency set that is
no longer true while still looking authoritative.

The SBOM is generated per run by the shared quality workflow, published as the
sbom-<app> artifact and, as of ConductionNL/.github#572, attached to stable
releases. It is never committed — see the hydra sbom-generation spec
(ConductionNL/hydra#617). hermiq already ignores it; the app template shipped
the file despite already carrying the rule, which is how this app inherited it.

Refs ConductionNL/.github#572, ConductionNL/hydra#617

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

* spec(runtime): REQ-OBR-002/003 described an architecture the app abandoned

Both requirements mandated a NESTED CnAppRoot inside the Buildiq shell, with
the outer CnAppNav, header and chrome staying visible, mounted by
BuilderHost.vue, and an OUTER router forwarding an opaque path suffix to an
INNER one.

The product deliberately does the opposite, and says so in its own source.
appinfo/routes.php maps the bare `/builder/{slug}` to `dashboard#builder`, a
standalone page booting the `src/builder.js` webpack entry, whose header states
the reason:

    It is deliberately NOT the Buildiq SPA: rendering the app inside Buildiq's
    shell nests one NcContent in another (double chrome) and, worse, shares
    Buildiq's router — which has none of the app's page routes, so page content
    never resolves.

BuilderHost.vue still exists and is still registered, but only ever mounts for
builder sub-paths falling through to the SPA catch-all — never for the runtime
route these scenarios navigate to. So `[data-testid="buildiq-builder-host"]`
was genuinely absent while the app rendered correctly, and the two e2e tests
could not pass however they were written. They were left permanently skipped
with a note recommending exactly this rewrite. This is that rewrite.

REQ-OBR-002 now describes the standalone shell, and its scenario asserts the
property the old wording had BACKWARDS: one shell, not two.

REQ-OBR-003 now describes the app's own router resolving its own routes, and
drops the outer/inner forwarding machinery, which has no counterpart in the
shipped design. Its scenario is written around opening a row rather than
deep-linking `00000000-0000-0000-0000-000000000000` — a uuid that exists in no
fixture, so the old scenario could not have been asserted even had the
architecture matched.

THE TEST IS REAL, NOT A STUB

The REQ-OBR-002 test now opens `/builder/hello-world`, asserts the app's own
seeded index content, and then COUNTS mounted CnAppRoot instances via the
existing componentTree helper, requiring exactly one. Counting is the point: the
abandoned design would show two, and a plain visibility check cannot tell those
apart. It also asserts the SPA's builder-host wrapper has count 0 on this route.

No test is written for REQ-OBR-003: "REQ-OBR-004 — the seeded index lists the
three sample messages and opens one" already clicks a row, waits for the URL to
move onto the manifest's `/messages/:id` route, and asserts the detail renders.
That IS the rewritten scenario; a second test would add a passing assertion
without adding coverage.

Verified with the repo's own gate — `npm run format`
(prettier --check "**/*.{js,ts,vue,css,scss}") passes across the tree.

* fix(e2e): a schedule-trigger fixture must carry a cron and a runAs

Two tests failed on flow creation, not on anything they assert:

    creating the fixture flow must succeed (got 400)
    seeding the definition must succeed (got 400)

Both fixtures build `openregister.trigger-schedule` nodes with `config: {}`.
OpenRegister validates that config on save now and refuses an empty one: the
node requires a five-field `cron`, and TriggerScheduleNode::validateActingIdentity()
requires a `runAs` naming an existing account — "nobody is present when a
schedule fires, so there is no session to take an identity from, and the flow's
owner is not used as a fallback".

These fixtures exist to be exported and re-imported, never to fire, but they
still have to be documents the platform accepts.

`runAs` is the admin, which is the account the e2e session actually runs as, so
the fixture names something true rather than a placeholder that happens to
exist.

THIRD APP HIT BY THE SAME UPSTREAM CHANGE

integriq's JobToFlowGenerator emitted schedule triggers with no `runAs` (fixed
in ConductionNL/integriq#1568) and its PHPUnit went red on the node's own
validateConfig. decidiq and learniq were untouched. Worth noting for anyone
else seeing a sudden 400 on flow-create: the request shape did not change, the
node's contract did.

Verified: `npm run format` clean across the tree.

* test(e2e): two real runtime bodies, and the header's defect is fixed

THE DEFECT THIS FILE'S HEADER DESCRIBED NO LONGER EXISTS

It read: "DocumentActions filters attachments by object['@self'].schema, which
OpenRegister returns as the NUMERIC schema id ("21"), while a
runtime.documents[] entry declares a schema SLUG ("hello-message"). The two
never match, so the surface renders nothing for every real object."

The component now injects `cnObjectContext` — whose `schema` is the manifest
slug — and resolves candidates through `objectSchemaKeys()`, which collects
`[ctx.schema, obj.schema, self.schemaSlug, self.schema]` and matches any of
them. Slug and numeric id both hit.

A skip reason outliving its cause is how this file came to carry twelve tests
asserting nothing, so it is corrected rather than left to age further. It is the
second such case in this repo today: the `buildiq#41` quarantine cited across 16
files names a PR that MERGED on 2026-07-27.

TWO STUBS BECOME REAL TESTS

Both were `goto('/applications')` + `expect(main).toBeVisible()` — bodies that
would pass without ever reaching a runtime object.

  REQ-DDT-004 "no attachments renders nothing" now opens a real object detail on
  the seeded `hello-world` runtime app (which declares no runtime.documents[] —
  exactly the fixture the scenario needs) and asserts `.ob-document-actions` has
  count 0. The component gates its whole root on `v-if="schemaAttachments
  .length"`, so "renders nothing" is an ABSENT element, not an empty one.

  REQ-DDT-005 "runtime surface degrades without requests" asserts what the DOM
  cannot show: that no request to /apps/docudesk/ is issued at all. A surface
  that renders empty while still calling Docudesk on every object detail is the
  failure this scenario exists to catch, and it is invisible in markup.

THE REQ-DDT-003 STUBS ARE LEFT SKIPPED, DELIBERATELY

They need a published app carrying an attachment — real fixture work, not a
skip to remove. Their notes now say that instead of citing the retired defect.
Enabling them as they stand would produce green tests asserting nothing and
would start crediting their scenarios with coverage they do not have.

Verified: `npm run format` clean across the tree.

* test(e2e): a real nldesign degradation test, and 16 stale quarantine notes

THE QUARANTINE CITED ACROSS THIS SUITE IS STALE

Sixteen spec files deferred to "Conduction/buildiq#41: buildiq admin UI not
functional in this build". #41 is a PULL REQUEST that MERGED on 2026-07-27, and
47 spec files in this suite already pass against that same UI —
applicationDetailOverview.spec.ts alone has 9 passing tests.

Every one of those notes now says what actually blocks its test: the body is a
stub. Leaving them pointed at a merged PR is how a file ends up carrying twelve
tests that assert nothing, which this repo has already done twice.

ONE STUB BECOMES A REAL TEST

REQ-NTS-005 "designer degrades when nldesign is missing" was
`goto('/applications')` + `expect(main).toBeVisible()` — it asserts nothing
about themes and passes on any page that renders.

It now opens the page designer, scrolls to the Theme section, and asserts BOTH
halves of degrading: that the absence is EXPLAINED (`.ob-theme-section__hint`
visible) and that the control which cannot work is disabled rather than
silently inert (`Change` is `:disabled="!nldesignAvailable"`).

Deterministic, not hopeful: this app's CI installs openregister and docudesk and
NOT nldesign (code-quality.yml `additional-apps`), so `nldesignAvailable` is
false and the degraded branch is the one under test.

WHAT IS DELIBERATELY LEFT SKIPPED

The other stubs stay disabled with corrected notes. They need real fixtures — a
published app carrying a theme, a seeded token-set catalogue — and enabling them
as they stand would produce green tests asserting nothing AND start crediting
their scenarios with coverage they do not have. gate-19 counts `skip`/`fixme`
as off, so while skipped they credit nothing; that is the honest state until
bodies exist.

Verified: `npm run format` clean across the tree. The one eslint finding on the
new import is `import-extensions/extensions`, which fires 71 times across
tests/e2e already — no file in this repo uses the `.ts` extension, so the
import follows the established convention rather than inventing a new one.

* test(e2e): dismiss the walkthrough that was covering the runtime row

Both new runtime tests timed out at 30s, and not for want of the element:

    locator.click: Test timeout of 30000ms exceeded
    waiting for getByText('Welcome to Buildiq').first()
      52 x waiting for element to be visible, enabled and stable

The page snapshot shows the runtime app rendered correctly — the "Messages"
heading, the table, and the "Welcome to Buildiq" cell all present. The click
spent its whole timeout on actionability, which is what a covering overlay
looks like.

The runtime app declares a walkthrough that pops a beat AFTER navigation
settles. `dismissOverlays()` from appFixture checks instantaneously and races
it; `dismissFirstVisitOverlays()` from support/overlays polls with `waitFor()`,
which is why buildiq-runtime.spec.ts drives this same route successfully with
it. Switched, and the row is scrolled into view before the click.

`dismissOverlays` stays imported — the designer tests in this file still use it
on a surface that has no walkthrough.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Part of the 2026-08-25 fleet structure audit (ADR-100 Decision 2: the
repository root is a closed set; generated files are never tracked).

Ignore rules added: .stale/ /.e2e-state/ .phpunit.result.cache

Untracked (kept on disk, now ignored where a rule covers them):
  - "s*buildiq\""

`.stale/` was missing from ALL 19 fleet repos and is the one that
matters most operationally: agent scratch there grew unbounded and
filled the dev disk once already.

Refs ConductionNL/hydra ADR-100.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
The docs site carried 58 npm advisories, 2 critical - the largest single
source of dependabot alerts in this repo (84 of 122). None are fixable by
upgrading: they sit in Docusaurus's own build toolchain, and npm audit
reports fixAvailable:false across the whole @docusaurus/* cascade because
no published release moves them.

Refreshing the lockfile changed nothing (58 -> 58); it was already at the
newest versions the declared ranges permit. So the remedy is overrides.

Added 22, each the newest release within the major the tree already
resolves, so no consumer sees a new API:

    58 advisories -> 30, both criticals cleared

Three candidates were deliberately excluded. js-yaml, uuid and ws each
resolve to TWO majors in this tree, and forcing one version collapses the
older consumer. js-yaml proved it: overriding 3.14.2 to 4.3.1 removed
safeLoad, which gray-matter still calls, and the build died parsing front
matter. That was caught by building, not by auditing - npm audit was
perfectly happy with the broken tree.

Verified: npx docusaurus build exits 0 and emits 47 HTML pages.

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

* fix(templates): repoint four dead sourceUrl links to the renamed repo

The four seeded application templates all carried

  https://codeberg.org/Conduction/concurrentie-analyse/src/branch/main/
    app-builder/README.md#user-stories

which returns HTTP 404. Confirmed with curl rather than assumed.

The repository was not deleted, it was renamed: `concurrentie-analyse` is now
`ConductionNL/market-intelligence` on GitHub. Found by reading the git remote
of the local checkout that still uses the old directory name, since a GitHub
search for the old name finds only unrelated repos.

Verified before repointing, not after:

  app-builder/README.md .... exists on main (46,313 bytes)
  #user-stories anchor ..... `## User Stories` is a real heading (line 515)

Left as-is deliberately: this repo is PRIVATE. That is the right target
anyway. `sourceUrl` is defined in openbuild_register.json as "Link back to the
originating user-story / RFP / blog post for traceability", and the only thing
that reads it is EditTemplateMetadataDialog — an admin edit field, not a link
rendered to end users. Traceability wants the true origin; pointing it at some
public stand-in would make it accurate-looking and wrong.

Three other Codeberg references in this app were checked and left alone: the
forge-type dropdown in RoadmapPageEditor.vue, where `codeberg` is a valid
option a user can pick, is not a stale link.

* fix(templates): drop sourceUrl rather than point a public app at a private repo

Correcting the previous commit on this branch. Repointing the dead Codeberg
link to `ConductionNL/market-intelligence` fixed the 404 for us and left it
broken for everyone else: buildiq is PUBLIC (visibility=PUBLIC), these four
templates ship inside it, and market-intelligence is private. Every admin
outside Conduction would click through to a login wall.

My earlier reasoning — that this is admin-only traceability metadata so the
private target is fine — does not survive the app being open source. The
templates are public data; who reads the field does not change who can reach
the URL.

The alternative was pointing at https://openbuild.conduction.nl/docs/intro,
the app's only live public docs page. That is worse: it resolves, so it looks
right, while claiming these user stories came from a generic intro page they
did not come from. False provenance beats no provenance only until someone
follows it.

So the field is removed. `sourceUrl` is optional — ApplicationTemplate.required
is [slug, title, description, useCase, category, manifest, isSeeded, version]
— and all four templates still carry every required key.

If the origin material is ever published, the honest fix is to add the field
back pointing at the real public URL.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(l10n): translate the 36 untranslated manifest strings

The manifest is data the renderer walks, not source the l10n extractor scans,
so CnAppNav's `menu[].label`, the setup wizard and CnWalkthrough's step copy
looked up keys that were never in the catalogue. A missing key falls back to
the English source and nothing reports it, so a Dutch user reads English.

Thirty-six strings: the setup wizard, the whole pet-store tour, and the nav.

Product names stay untranslated on purpose. "Pet Store" is the app the user is
told to create BY NAME, and "Pet" is the schema they type, so translating
either would make the instruction not match what they must enter. The prose
around them is Dutch: "Noem die Pet Store", "Voeg een schema toe met de naam
Pet". Same reasoning keeps Apps, App, Manifest, Exports and Agents as they are,
and translates the ones that genuinely have Dutch: Winkel, Manifestlagen,
Geschiedenis, Rondleidingontwerper.

nl.json ONLY. Adding the same keys to en.json is the obvious move and it is
wrong in this fleet: check-l10n-parity.js is a ratchet over every required
locale (the official language of every European country, plus Russian and
Turkish), so one new English source key demands a real translation in about
thirty languages. keepiq#449 shows the failure mode — eleven new en.json keys
produced "+568 more missing" across the other locales.

Verified: 0 manifest strings missing Dutch.

* fix(l10n): rebuild nl.js, the browser loads the artifact not the JSON

Same omission as keepiq#449 on the same day: thirty-six keys added to
l10n/nl.json, l10n/nl.js never regenerated. nl.js was 1,009 keys against
nl.json's 1,045, which is exactly the additions sitting only in source.

The .json is source; the .js is what the runtime loads via OC.L10N.register. A
catalogue correct in JSON and stale in JS is a translation nobody receives, and
every check that reads the JSON calls it done. That is why `check:l10n-js`
exists, and it is what caught the keepiq case in CI.

Checked this by comparing key counts across all three of my open l10n branches
rather than waiting for CI to tell me twice.

Ran `l10n:build`; did not hand-edit the generated file.

Verified: l10n:build PASS, check:l10n-js PASS.
Those were the only conflicts.

The merge keeps BETA's version string (`0.6.2-beta.20260820211351`) rather than development's
(`0.6.1-unstable.20260826085824`). Development's is numerically lower, so taking it would have
published a downgrade; the release job bumps from here anyway.

Everything else takes development's content.

Conflicts were resolved mechanically and each result was checked: the XML and
JSON were re-parsed, no conflict markers remain, and the version substitution
was asserted to have matched exactly once. Any repo whose conflicts extended
beyond these files was left alone rather than auto-resolved.
github-actions Bot and others added 2 commits August 27, 2026 09:24
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
… enables (#475)

## The upgrade

`package.json` and `package-lock.json` both move to 2.19.0. The lockfile is the
part that matters: `^2.15.1` already PERMITTED 2.19.0, so the range looked fine
while `npm ci` kept installing 2.15.1. A range is not an installed version.

Verified all three agree afterwards — declared `^2.19.0`, locked 2.19.0,
installed 2.19.0 — and that the installed copy actually contains the feature
being relied on (`hasError()` in CnStatsBlock), rather than trusting the
version number.

Nothing broke across the four-minor jump: 141 test files / 1378 tests pass,
eslint and prettier are clean, and the webpack build compiles.

## What it unblocks

`ApplicationDetailDashboard` sets `this.error` on a failed insights load and
then never renders it — the property appears nowhere in the template. So a
non-404 failure left the three data KPIs showing their initialised zeros:
"0 active users, 0 objects, 0 audit events", with nothing on screen to say the
read had failed.

The three tiles now pass `:error`, so they show a dash and "Unavailable".

Deliberately unchanged:

- The 404 branch. It sets `versionNoLongerAccessible`, which drives a real
  banner ("This version is no longer accessible. Switch to production?"), and
  zeroes the KPIs on purpose. That state is already communicated, and `error`
  stays null there, so this change does not touch it.
- The fourth CnStatsBlock. It is a hardcoded `loading` placeholder with
  `:count="0"` for Storage — a permanent spinner, not a tile bound to fetched
  data.

Completes the fleet sweep started in decidiq#918, pipelinq#1462, learniq#643
and shillinq#1268. buildiq was the one app that had to wait, because its
lockfile pinned a version predating the prop.

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

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/buildiq @ 56c6bde

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

Quality workflow — 2026-08-27 09:13 UTC

Download the full PDF report from the workflow artifacts.

rubenvdlinde and others added 2 commits August 27, 2026 12:28
gate-16 failed the development->beta release PR (#476) on one changed
method missing @SPEC: ApplicationDetailActions.vue::onSettingsOpen.

It is real behaviour, not glue, so it gets a real @SPEC rather than an
exclusion -- the same application-detail-ui spec its sibling setFlows
already carries. The docblock records why the fetch is lazy (the flows are
read only inside this modal, and most visits never open it) and what the
three guards buy: the request happens exactly once, never on close, never
when a previous open already filled the list, never while one is in flight.

Verified with the real gate (ConductionNL/.github check_spec_coverage.py)
against origin/beta: count=1 before, count=0 after.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Brings development's onSettingsOpen annotation across, which is what the
release PR's Hydra Gates leg was failing on. The only conflict was the
version line again: beta's 0.6.2-beta.20260820211351 is kept over
development's lower 0.6.1-unstable, so the release is not a downgrade.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/buildiq @ 2986d30

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

Quality workflow — 2026-08-27 11:59 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 4623db1 into beta Aug 27, 2026
86 checks passed
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.

2 participants