Skip to content

Release: merge development into beta - #79

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

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

Conversation

@github-actions

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

Copy link
Copy Markdown
Contributor

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

Merging this PR will trigger the beta release workflow.

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

rubenvdlinde and others added 30 commits July 13, 2026 15:50
…ct-table column fallback' (#166) from chore/ncvue-beta209 into development
…hemas, all manifest-mutating tools refused' (#167) from wip/mcp-adoption into development
The per-app icon endpoint took ~2s, and the builder requests it twice per page load — the
slowest thing on the cowboy dashboard by far.

IconService fetched the Application, serialized it to an array, threw the entity away, and
handed a bare UUID string to OpenRegister's FileService::getFile(). getFile() accepts an
ObjectEntity OR a string — but given a STRING it calls objectMapper->find($uuid), and a
bare UUID carries no register/schema, so OR searches every magic table to find which one
owns the object. On this instance that is ~1,960 tables.

We already hold the entity, so keep it and pass it through. Only an ObjectEntity is passed
(findAll() can also yield plain arrays, which getFile() would reject with a TypeError);
anything else still falls back to the UUID string — correct, just slow.

Net: ~2.0-2.8s -> 0.87s per icon (baseline request overhead on a loaded box), live-verified,
still serving the app's own icon.

Fixes a test-fake drift while here: the FileService stub declared `getFile(string $object)`
— narrower than the real `ObjectEntity|string|null` — so passing the entity, the whole
point of this fix, threw only in tests while prod worked. A stub that narrows the real
signature hides the bug it should catch.
…le, not a bare UUID (2s -> 0.87s)' (#168) from fix/icon-entity-scan into development
…I; MCP vs API vs RAG assessment

Adds openbuild/test-scenarios/ with the first scenario: exercise every
OpenBuild action-menu capability (add/alter schema, create/design pages,
overrides, import, publish, versions, export, permissions, template,
settings) by instructing the AI companion, and assess how the agent
perceives page state — whether MCP tools suffice or it needs the OR API
and/or RAG. Depends on a tool-capable chat provider (anthropic-agent-provider
or a tool-capable Ollama model).
…hannel next to MCP; hybrid apps out of MCP scope

Per direction: OpenRegister already supports RAG, so for virtual apps
RAG is available alongside MCP by design (not a gap to close) — the agent
uses RAG to read the viewed item's field values, which the cnAiContext
snapshot carries identity for but not content. Hybrid apps (large JSON
core manifest) are explicitly out of the OpenBuild MCP scope for now.
… coverage + MCP/API/RAG assessment' (#169) from test/agent-driven-scenarios into development
Also removes the dead widget() registry helper (pre-existing
no-unused-vars lint error, zero callers).
…beta.212' (#170) from chore/ncvue-beta212 into development
…r' (#171) from chore/ncvue-beta213-lockstep into development
Fixed ignored change-requests from PR https://codeberg.org/Conduction/openbuild/pulls/96

Works through the change requests that were raised on #96 but never
applied before it merged. Each was re-verified against current code
before acting (several claims had gone stale or were inaccurate).

Blockers
- ApplicationDeletionService — 153 lines of destructive code, no tests:
  FIXED. Added tests/Unit/Service/ApplicationDeletionServiceTest.php
  (preserve vs purge, empty-batch, zero-progress, cap-exhaustion).
- ApplicationDeletionService purge cap hit silently: FIXED. Log a warning
  when MAX_PURGE_ROUNDS is exhausted so the downstream register-orphan is
  diagnosable. Cap kept at 100k (raising it risks request timeout; async
  deletion is the real large-register fix) — deferred by decision.
- AppDeleteDialogSlot / DeleteAppDialog untested: FIXED. Added vitest
  specs; coverage 85.18% -> 86.07%, ratchet green.
- DeleteAppDialog missing i18n strings: MOSTLY STALE. The 4 flagged
  strings were already in l10n/en.json (added after the review); removed
  the genuinely-dead "Permanently delete...and data" key. Also extracted
  3 unrelated ExportDialog strings that were actually reddening the gate.

Concerns
- ApplicationPublishControllerTest deleteData forwarding unverified:
  FIXED. Old mock constrained only 2 of 3 args; now asserts the 3rd
  positionally (later reworked to drive via the request param, below).
- deleteData query-param footgun: HARDENED. The "?deleteData=false ->
  true" claim is FALSE on NC35 (the dispatcher maps 'false'->false), but
  the general stringy-bool risk is real, so destroy() now reads the raw
  param safe-by-default: only '1'/'true' purge, anything else preserves.
- einddatum type narrowed to "string": FIXED. Restored ["string","null"]
  (it was intentionally the one nullable date field; narrowing would fail
  existing rows holding null on next save). No migration needed.
- SeedApplicationTemplates idempotency froze templates on upgrade: FIXED.
  Implemented version-based upsert (isSeeded rows only, version_compare,
  admin-created templates never clobbered); added an `updated` count.
- builderDesigner route regex maintenance trap: FIXED via guard. Instead
  of a breaking URL refactor or a (quarantined) Playwright test, added a
  RoutesTest that derives the designer routes from src/manifest.json and
  fails in CI if the regex drifts from them.

Minor
- SeedApplicationTemplates return-on-DoesNotExist skips remaining slugs:
  DOCUMENTED, no behaviour change. The suggested `continue` would regress
  the deferred contract; the return is correct because every slug targets
  one hardcoded schema. Added a comment stating the invariant + when to
  revisit.
- Global v-tooltip directive registration hard to discover: FIXED (docs).
  Pointer comments in src/main.js and src/registerDirectives.js.
- "-dark" slug reservation undocumented: FIXED. Enforced for APP slugs
  only (schema pattern + SlugValidator::validateAppSlug) and documented;
  version slugs are deliberately unaffected. Added tests.
…: false` stub)

The resolve block sets `fallback.path` to `path-browserify` (needed by the
@nextcloud/dialogs FilePicker chunk), but a later block re-assigned
`resolve.fallback` with `path: false`, clobbering it. Its comment claimed the
FilePicker code path never runs — no longer true: nextcloud-vue's
CnFilesWidgetForm opens the folder picker via `pickNodes()`, which calls
`path.join`. With `path` stubbed to an empty module, `path.join` was undefined
and the picker threw "path.join is not a function" at runtime.

Remove the conflicting `path: false` override so `path` stays polyfilled by
path-browserify. Production build verified.
…o GitHub

Brings all Codeberg development (page-designer #174 fix, AI-companion, node_modules
strip, and the full 07-xx feature work) together with GitHub's unique commits
(nc-vue beta bumps, agent-driven test scenarios). Conflicts in the 4 page-designer/
builder files resolved to Codeberg's validated versions (1178 vitest tests green).

History is flattened at this import: Codeberg's commit history carried a 169 MB
webpack-cache blob (docs/node_modules/.cache) that exceeds GitHub's 100 MB file
limit, so the full ancestry could not be pushed. Granular history remains on the
Codeberg mirror; docs/node_modules is now gitignored.
Picks up the CnOpenBuildEditButton glyph fix (cube → OpenBuild stacked-layers).
Consumers on the 24h min-release-age hold must install with --min-release-age=0
until the hold elapses.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
H2: disable redirect following in RemoteTemplateStoreService::fetch
(allow_redirects => false) so a public host cannot redirect to a
private/metadata address with the registry Bearer token attached.

DoS (business-rules engine):
- FeelParser: max source length, max token count (bounds AST nodes), and a
  recursion-depth guard across parseOr/parseNot/parsePrimary.
- ExpressionEvaluator: evaluation recursion-depth backstop.
- RuleEngineService: call-rule-set re-entry guard (cycle + depth), fail-closed
  before any log write or side effect; maskPii depth cap.
- RulesController::evaluate: reject oversized payloads (413) before logging.

Covers openspec change harden-xss-dos-csrf groups 0-1. Unit tests added for
every guard.

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
createFromTemplate provisions a per-app OpenRegister register (admin-only in
OR, issue #157) — the same fan-out ApplicationCreationController::wizard
performs. Mirror the wizard's guard: add #[UserRateLimit(10/3600)] and an admin
gate so a non-admin gets a clear 403 instead of an opaque provisioning failure,
and the endpoint cannot be used as an unthrottled register/schema-sprawl
amplifier (DoS #4).

Adds ApplicationsControllerTest (401 anonymous, 403 non-admin, rate-limit
attribute present). Covers harden-xss-dos-csrf group 2.

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
Remove the unjustified NoCSRF opt-outs from state-changing endpoints:
- SettingsController::create / ::load (#[NoCSRFRequired] dropped) — create is
  the supply-chain lever (a forged request could repoint registry_url /
  registry_token); the SPA already sends the NC request token so nothing
  legitimate breaks.
- PreferencesController::setPreference (@NoCSRFRequired docblock dropped).

The read-only settings#index and preferences#getPreference GETs keep their
no-CSRF posture (a browser navigation cannot carry a request token).

Tests assert the writes are CSRF-protected and the read GETs still opt out.
Covers harden-xss-dos-csrf group 3.

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
Add dompurify as a direct dependency and route the two client-side v-html
sinks through it:
- DocumentTemplateAttachmentDialog::onPreview sanitizes the Docudesk preview
  (full HTML profile) before binding — a template authored by one user renders
  in another's session, so it is a cross-user XSS sink.
- iconCatalogues::resolveAppIcon sanitizes the author-supplied <svg> branch
  (SVG profile) before preview and before persistence.

Vitest specs assert script/event-handler payloads are neutralized and benign
markup/SVG preserved. Covers harden-xss-dos-csrf group 4. (M7 — bumping the
transitive dompurify past the advisory line — remains a separate finding.)

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
Wrap-up for harden-xss-dos-csrf (group 5):
- Register the change in the five capability specs (OpenSpec changes link;
  flip openbuild-template-catalogue + app-icon-management to in-progress).
- Move the createFromTemplate gate/rate-limit tests into the existing
  CreateFromTemplateTest (the standalone file I added collided with the
  repo's ApplicationsControllerTest class — removed); make authenticateAs
  grant admin by default so the existing clone-flow tests exercise the
  admin-gated path, add a non-admin 403 case + rate-limit reflection check.
- Fix Step4Review.spec assertions to match DOMPurify-normalized SVG output
  (assert preserved path data / fallback equality, not byte-exact).
- Reconcile phpmd.baseline.xml for the renamed methods (evaluate→evaluateNode,
  parsePrimary→parsePrimaryInner) + ExpressionEvaluator class complexity.

Full suite green: PHP 607 unit + phpcs/psalm/phpstan; vitest 1184; eslint clean.

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
PHPCS flagged the $depth parameter added in the DoS-hardening change as
missing its @param line. Doc-only fix; no behaviour change.

Assisted-by: ClaudeCode:claude-opus-4-8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Robert Zondervan <robert@conduction.nl>
Market deepdive round 2 (research logged to spectr register 2444):
- public-forms-runtime: anonymous tokenized form submission, spam guard,
  URL prefill, per-record edit links (Forms#358/#805/#624, Baserow 2.2)
- automation-approval-steps: human-in-the-loop approval action compiling
  to OpenRegister approval chains (consume, not rebuild)
- component-blocks: reusable manifest blocks across pages and apps
  (Appsmith#1911, Budibase#18726/7, ToolJet#4850)
- app-theming: per-app theme tokens with WCAG contrast guardrails
  (Appsmith#3095, largest single market ask)
- agent-workspace: persistent named agents over existing MCP tools with
  transparent run log (2026 table stakes)
- automation-document-action: generateDocument action consuming docudesk
  via owner-impersonated call to the pinned route (Forms#103)

All changes openspec validate clean, kind: code, tasks within cap.
spec: market-deepdive round 2 — six ff changes (public forms, approvals, blocks, theming, agents, documents)
Enhances core application stability and safety features
…flicts)

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

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

Archives change public-forms-runtime; syncs public-form-access (new) and
openbuild-runtime (added requirement) canonical specs.
rubenvdlinde and others added 30 commits August 1, 2026 10:24
…d coverage (#77)

Groundwork for the role-scoped permission suites (versionRouting 9.2,
schema-access-scopes-rbac). Adds grantAppRoles(), which writes an Application's
`permissions` block through OpenRegister's object API — there is no openbuild
permissions endpoint — carrying the WHOLE record forward, because OR saves are
PUT-semantic and omitted properties are dropped rather than left alone.

It works, and is not enough. Measured on a live instance:

  - the grant lands and reads back as {owners:[user:admin],
    editors:[group:rbac-editors], viewers:[group:rbac-viewers]};
  - rbac-viewer is in rbac-viewers and rbac-editor in rbac-editors per OCS,
    and both groups exist;
  - PermissionResolver::matchesCaller() classifies `group:` principals and
    intersects them against the caller's groups, so the grammar is right;
  - yet GET /api/applications returns 200 with an EMPTY list for BOTH users.

Something below openbuild's permission layer filters the object out, most
likely OpenRegister-level object visibility — a separate grant from the manifest
permissions block. Filed as #76.

Committed as groundwork rather than held back: it encodes the verified
permission shape and the PUT-semantics trap, so whoever picks up #76 starts from
measured facts instead of re-deriving them. The role-scoped scenarios stay
skipped until a member can actually see the app.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
A UUID is OpenRegister METADATA, not an object property, so
findAll(filters: ['uuid' => ...]) matches nothing — hermiq's SkillService
documents exactly this rule in a comment I failed to apply. Every declared
connector silently failed to resolve.

Caught by a dry-run publish, not by tests: the first real artefact was 4 files
with connectors {declared: 0} despite 25 declared bindings. Now 46 files with
declared 42 / missing 0, verified against a live instance.

Adds a  counter to the descriptor. 'declared 0' was indistinguishable
from an app that declared nothing, which is precisely how this hid; 'declared 42,
missing 4' is diagnosable. The counter also surfaces a legitimate case: the
 schema is admin-read-gated, so a non-admin publish drops sources — the
exporter correctly exports only what the caller may read, and now says so.

Warnings upgraded from debug to warning for the same reason.
fix(app-repo): resolve connectors with find(), not a uuid filter
… for two months (#74)

* ci: point reusable-workflow calls at ConductionNL, not the non-existent Conduction org

All 8 reusable-workflow calls referenced `Conduction/.github`. That GitHub org
does not exist (the 2026-06-01 rename went the wrong way; `GET /orgs/Conduction`
is a 404). Actions cannot resolve the ref, so every run produced ZERO jobs and
failed instantly. No ESLint, PHPCS, PHPMD, PHPStan, Psalm, licence scan,
security scan, SBOM or PHPUnit has run on this repo since.

The tell: in `gh run list --json name`, an unresolved run's `name` is the raw
path (`.github/workflows/code-quality.yml`) rather than the workflow's declared
`name:` (`Code Quality`).

Also fixes a 9th dead reference a `uses:`-only grep misses: the `additional-apps`
input cloned `https://github.com/Conduction/openregister.git`, which would have
404'd inside the PHPUnit / Newman / Playwright legs even after the `uses:` fix.

`.forgejo/workflows/` is deliberately untouched — on Codeberg the org really is
`Conduction`, so those refs are correct.

* fix(deps): resync package-lock.json so `npm ci` resolves again

Every npm-based quality job (ESLint, Stylelint, License (npm), Security (npm))
died before running a single check:

  npm error `npm ci` can only install packages when your package.json and
  npm error package-lock.json ... are in sync.
  npm error Missing: pinia@4.0.2 from lock file
  npm error Missing: vite@8.2.0 from lock file
  ... + the whole esbuild/rolldown/lightningcss platform-binary fan-out

Cause: `@nextcloud/vue` carries a nested `vue-router@5.2.0` whose
`peerDependenciesMeta`-optional peers are `pinia: ^3.0.4 || ^4.0.2` and
`vite: ^7.3.0 || ^8.0.0`. Those versions have since been published, so npm's
ideal tree now includes them while the committed lockfile predates them. The
lockfile was simply never regenerated — nobody noticed, because the workflow
that would have caught it produced zero jobs.

Regenerated with the npm major this repo pins (`engines.npm: ^10.0.0`) on
Node 20. npm@11 must NOT be used here: it reports this lockfile as "up to
date" and produces one that npm@10 `ci` then rejects.

The diff is additive and does not touch the app's own dependency graph:
  - 0 packages removed
  - 0 nested version changes
  - 1 version change: @napi-rs/wasm-runtime 1.1.6 -> 1.2.2 (a wasm shim)
  - ~60 additions, all platform binaries pulled in by the optional peers above
Vue, pinia (top-level 2.3.1), @nextcloud/vue and @conduction/nextcloud-vue are
all unchanged, so the produced bundle — and the e2e runs verified against it —
are unaffected.

Fixed by resyncing rather than by `--legacy-peer-deps` or an `overrides` block:
suppressing the resolution would leave the lockfile lying about the tree that
`npm ci` actually builds.

* fix(quality): clear everything the revived Code Quality gates surfaced

With the workflow refs and lockfile fixed, all 23 jobs actually run. This
clears what they found.

Stylelint (40 errors, was blocked behind `npm ci`)
  `rule-empty-line-before` across ThemePickerDialog, WorkflowAttachmentDialog
  and app.css. Applied the repo's own `stylelint-fix`.

PHPCS (14 errors)
  Alignment, control-structure spacing and long-condition closing comments.
  Applied the repo's own `phpcbf`, then hand-fixed the two it cannot:
  an uncapitalised inline comment and a missing docblock on
  `AppRepoParser::parseCompanionSchemas()`.

PHPStan — and the reason nobody saw these
  `composer phpstan`, `psalm` and `phpmd` all ended in
  `|| echo '<tool> not installed, skipping...'`, so they exited 0 no matter
  what. The CI job for PHPStan reported SUCCESS on the previous run while the
  log contained `[ERROR] Found 2 errors` and two `##[error]` annotations — a
  gate that runs, finds real problems, and reports green. Dropped the
  swallowing `|| echo` from phpstan and psalm. Both are now genuinely clean:

  - AutomationCompilerService::apply() had a stray early `return $provenance;`
    that orphaned the block below it. The dead half is where the load-bearing
    "DO NOT tidy this back into `'ruleSetSlug' => $ruleSetSlug`" comment lives
    — precisely the warning a future reader needed, stranded in unreachable
    code. Behaviour was unaffected (both halves were identical); removed the
    duplicate and kept the documented one.
  - SetupController::runAction() applied `?? 0` to `$result['updated']`, which
    the seeder's return shape guarantees is always a non-null int.

Psalm (2 errors)
  `GenericHealthController` / `GenericMetricsController` are bound as string
  class names in lazy closures and resolved at request time. psalm.xml already
  carries a documented per-class allow-list for exactly these dynamically
  loaded OpenRegister classes; these two were simply missing from it. Added
  them there rather than suppressing at the call site.

Features Check
  `docs/features.json` predated `openspec/features.overlay.json`, which the
  extractor treats as the authoritative curated list. Regenerated.

NOT fixed — stated plainly rather than hidden
  `composer phpmd` keeps its `|| echo` fallback. Removing it exposes 15
  violations that the committed `phpmd.baseline.xml` does not cover
  (TooManyMethods, ExcessiveClassComplexity, 3x CyclomaticComplexity,
  ExcessiveMethodLength, ElseExpression, ...). Those need real refactoring of
  GitHubAppSyncService / TemplateSeedService / IconService and should not ride
  along in a CI-restoration PR. I did NOT regenerate the baseline to bury them.
  The fallback message now says so out loud instead of claiming PHPMD is
  "not installed". This is the top follow-up.

* fix(deps): pin @conduction/nextcloud-vue 2.1.0-vue3.13 to get off proprietary vue3-apexcharts

The revived licence gate caught a real exposure: `vue3-apexcharts` was MIT up
to and including 1.8.0, and at 1.9.0 (2025-10-13) moved to a proprietary
dual-license — free only under $2M USD annual revenue, forbidding
"sublicensing under different terms" (which is what redistributing inside an
EUPL-1.2 app is) and requiring a paid OEM licence for "No-code dashboards",
"Embedded BI tools" and "White-labeled apps or SDKs".

Not the same as the `apexcharts` entry in .license-overrides.json: that one is
a genuine license-checker misread of an MIT package, and the core
apexcharts@4.7.0 is still MIT. Only the Vue 3 wrapper changed licence. The
identical `Custom: <image-url>` symptom made them look alike.

The dependency is transitive through @conduction/nextcloud-vue, and the fix
already existed upstream:

    2.1.0-vue3.7  -> vue3-apexcharts ~1.10.0   (proprietary)
    2.1.0-vue3.13 -> vue3-apexcharts ~1.8.0    (MIT)

This repo declared `^2.1.0-vue3.7`. A caret does not move an already-resolved
prerelease in a lockfile, so the pin held us on the proprietary line even
though a fixed release had shipped. Now pinned EXACTLY, without a caret — a
caret floats prereleases, and that is how this drifted in the first place.

Verified from the lockfile rather than the manifest:
  - node_modules/vue3-apexcharts: 1.10.0 -> 1.8.0
  - exactly one copy in the tree; zero 1.9.0+ entries at any nesting depth
  - installed package.json now declares `"license": "MIT"` (was
    `"see LICENSE in LICENSE"`), which is what license-checker reads

No override was added. .license-overrides.json is unchanged and contains no
vue3-apexcharts entry — the gate passes because the dependency changed.

Lockfile churn is exactly two entries: the nc-vue bump and the apexcharts
downgrade. 0 additions, 0 removals, 0 nested version changes.

---------

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

* fix(app-repo): fetch the v2 channels — the parser was being fed a v1 file set

fetchRepoFiles() only ever fetched the descriptor, manifest and schemas/. The
serializer emits data-registers/, connectors/, automations/ and skills/, and the
parser knows how to read them — but nothing fetched them, so a v2 repository
installed as if it carried nothing but a manifest, and reported success.

Caught by round-tripping the real published artefacts, not by tests:

  buildiq-spectr  fetched  2 files -> 0 data-registers, 0 connectors
                  (the repository holds 46 blobs)
  buildiq-hydra   fetched  2 files -> 0 skills
                  (the repository holds 748 blobs, 94 skills)

After the fix:

  buildiq-spectr  45 files -> dataRegisters 1, connector kinds 4
  buildiq-hydra  746 files -> dataRegisters 1, skills 94

Uses ONE recursive tree call rather than a contents walk per directory: a v2
artefact can carry ~750 blobs and per-directory listing would multiply round
trips before a single file is read. Bounded at 2048 with truncation LOGGED — an
install that quietly drops half an app is the exact failure this format exists
to prevent.

* fix(app-repo): declare missingCount in the collectConnectors() return shape

phpstan level 5 rejects reading $connectors['missingCount'] at line 185 because
the annotation never declared it — although both the real return and the $empty
early-return have always contained it. The docblock was the thing that was wrong.

Pre-existing on development, not introduced here: this PR touches only
GitHubCatalogService. It surfaced because openbuild has no vendor/ checked out,
so the local gate suite never runs phpstan and CI is the first place it fires.

---------

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

Every openbuild schema declared

    "authorization": { "create": ["admin"], "update": ["admin"], "delete": ["admin"] }

— non-empty, and with no `read` key. OpenRegister treats that as fail-closed:
`MagicRbacHandler::buildRbacConditionsSql()` bypasses filtering only for an
EMPTY block; a populated block with no `read` rule falls through to the owner
condition alone, so every non-admin caller saw zero rows. Not a bug in OR —
deliberate, and commented as such at MagicRbacHandler:1031.

That is the whole of #76. An owner could grant a colleague editor or viewer on
an app and they still saw an empty list, because OR filtered the objects out
one layer below openbuild's own permission check.

Adds `"read": ["authenticated"]` to all 15 schemas — 6 in the monolith and 9
across the register.d fragments, which were missed by the first pass and would
have left business rules, automations, component blocks and the agent
workspace owner-only.

`authenticated` requires $userId !== null (MagicRbacHandler:414), so anonymous
callers are NOT granted. This is intentionally the coarse layer: appinfo/routes.php
already documents that OR's schema read rule is a group ACL, not a row filter,
and that the per-app `permissions` block is enforced by /api/applications. Both
layers verified live.

Measured on the disposable instance after a FORCED re-import:

  caller          OR object API   openbuild /api/applications
  admin                      21   21
  rbac-editor          0 -> 21    1  (granted editor on pw-verchain)
  rbac-viewer          0 -> 21    1  (granted viewer on pw-verchain)
  rbac-outsider        0 -> 21    0  (no grant)
  anonymous                   0   401

Diagnosis trail: ConductionNL/openregister#2252.

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

* feat(app-repo): apply the v2 channels on install — they were parsed, then dropped

Six steps carry an app between instances: serialize -> bind -> push -> fetch ->
parse -> apply. Five were built. The sixth never was, so installing a published
v2 app produced an app holding its manifest and NOTHING that makes it run, and
reported success.

Verified against the code with a positive control before writing any of this:

  $template['manifest'] / ['version']  (control)  7 hits
  $template['connectors']                         0
  $template['automations']                        0
  $template['skills']                             0
  $template['dataRegisters']                      3, all in the export/zip path

Both entry points confirmed by reading them: pull() persisted manifest +
companion schemas only; installFromTemplateArray() read exactly slug + manifest.

This is the fourth time in this programme that one half of a round trip was
extended and the other left behind. Publish looked perfect every time, because
publish is the half that kept getting extended.

Three rules shape the implementation:

  NEVER OVERWRITE. Connectors are shared infrastructure - one source can serve
  several apps - so a colliding uuid is skipped and reported. Enforced with
  saveObject(failIfExists: true) so the guarantee lives in the call rather than
  in a preceding existence check that could drift or race.

  NEVER CLAIM ATOMICITY. OpenRegister has no cross-object transaction, so one
  failing item must not cost the caller the rest. ChannelApplyReport enforces
  created + skipped + failed === declared and THROWS when it does not hold, so a
  dropped item is arithmetically impossible to hide. The 64-skill silent cap this
  programme already shipped is what that identity exists to prevent.

  NEVER DROP SILENTLY. Every channel is bounded; truncation is logged AND counted.

Two defects found while writing the tests, both of which would have shipped:

  1. Collision was detected by MESSAGE TEXT. A plain PHP 'Unknown named parameter
     $failIfExists' error therefore reported itself as a benign 'already exists'
     - a wiring bug wearing the costume of an expected outcome, and the reason
     three tests were briefly green for the wrong reason. Now caught BY TYPE
     (ObjectExistsException), and the stale test stub that hid it is fixed.

  2. The credential lookup called findAll(filters:, register:, schema:), which is
     not the real signature - findAll takes a $config array. credentialExists()
     swallows a failed lookup and returns true (an inconclusive lookup must never
     manufacture an absence claim), so needsCredentials would have been silently
     empty forever. Register/schema confirmed against the live instance
     (credential-broker / brokeredcredential) with a positive control, not assumed.

Skills delegate to hermiq's SkillBundleInstaller by repo coordinates rather than
being reimplemented, so frontmatter byte-fidelity and the ADR-068 aux-file rules
keep living in exactly one place. openconnector and hermiq stay OPTIONAL -
OpenBuild declares only openregister - and degrade with a machine-readable reason
while every other channel still applies.

The collision test is mutation-checked: flipping failIfExists to false turns it
red. phpstan is run explicitly, because openbuild ships no vendor/ and the local
40-gate suite silently SKIPS phpstan - green there never meant phpstan passed.

* fix(gates): @SPEC on the new report methods, repoint archived anchors, declare persistApplication throw

gate-16 wanted @SPEC on every new public method. gate-46 and gate-49 were
PRE-EXISTING on development (36 identical dangling anchors; persistApplication
untouched) and surfaced only because this change touches the file — fixed here
per the repo rule rather than left for later.

The 36 anchors pointed at retrofit-2026-05-24-annotate-openbuild, which has since
been ARCHIVED; repointed to the archive path. persistApplication already wraps its
save — the unguarded call is normaliseObject() AFTER it, so the contract is now
declared rather than the behaviour quietly changed.

* fix(gates): document persistApplication exception contract for gate-49

The save is wrapped in catch(Throwable), which covers OpenRegister's
ValidationException and DoesNotExistException — the gate's heuristic matches on
named exceptions and cannot see that a Throwable catch subsumes them. Documenting
the actual contract is what the gate is for, so it is now stated rather than the
behaviour changed to suit the checker.

* fix(app-repo): read channels where the parser ACTUALLY puts them, plus psalm/phpcs

THE BUG THIS COMMIT FIXES WOULD HAVE MADE THE WHOLE CHANGE A NO-OP.

AppRepoParser nests the v2 channels under `$payload['channels']`. The applier
read them from the top level, so every channel resolved to [] and reported
`declared: 0` — an install that does nothing and returns success, which is
precisely the failure this change exists to end. Found by reading the parser
while preparing the live probe, NOT by the unit tests: I had written the test
fixtures in the same wrong shape, so tests and implementation agreed with each
other while both disagreed with the real producer.

The durable fix is the new test, not the one-line change:
testAppliesTheChannelShapeTheParserActuallyProduces drives the REAL AppRepoParser
over a v2 file map and feeds its output straight into the applier, so the two
shapes cannot drift apart again without a red suite. Mutation-checked — reverting
channelOf() to the top-level read turns it red.

Lesson worth keeping: a hand-written fixture that mirrors the implementation's
assumption cannot detect a shape mismatch with the real producer. It only ever
tests that the code agrees with itself.

Also: adoptCounts() now takes truncated as the BOOL it is (hermiq knows
truncation happened, not how many items it missed) and absorbs any shortfall
between our declared count and the source's outcomes as a NAMED skip, so the
balance identity holds and the cause is stated rather than the difference
silently disagreeing. psalm needed ObjectExistsException on the cross-app
suppression list; two inline comments needed capitals.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…n the RBAC suite (#84)

* fix(rbac): grant authenticated read on every openbuild schema (#76)

Every openbuild schema declared

    "authorization": { "create": ["admin"], "update": ["admin"], "delete": ["admin"] }

— non-empty, and with no `read` key. OpenRegister treats that as fail-closed:
`MagicRbacHandler::buildRbacConditionsSql()` bypasses filtering only for an
EMPTY block; a populated block with no `read` rule falls through to the owner
condition alone, so every non-admin caller saw zero rows. Not a bug in OR —
deliberate, and commented as such at MagicRbacHandler:1031.

That is the whole of #76. An owner could grant a colleague editor or viewer on
an app and they still saw an empty list, because OR filtered the objects out
one layer below openbuild's own permission check.

Adds `"read": ["authenticated"]` to all 15 schemas — 6 in the monolith and 9
across the register.d fragments, which were missed by the first pass and would
have left business rules, automations, component blocks and the agent
workspace owner-only.

`authenticated` requires $userId !== null (MagicRbacHandler:414), so anonymous
callers are NOT granted. This is intentionally the coarse layer: appinfo/routes.php
already documents that OR's schema read rule is a group ACL, not a row filter,
and that the per-app `permissions` block is enforced by /api/applications. Both
layers verified live.

Measured on the disposable instance after a FORCED re-import:

  caller          OR object API   openbuild /api/applications
  admin                      21   21
  rbac-editor          0 -> 21    1  (granted editor on pw-verchain)
  rbac-viewer          0 -> 21    1  (granted viewer on pw-verchain)
  rbac-outsider        0 -> 21    0  (no grant)
  anonymous                   0   401

Diagnosis trail: ConductionNL/openregister#2252.

* test(e2e): un-skip versionRouting 9.2 and record the measured blockers on the RBAC suite

9.2 — ENABLED, four scenarios replacing one that asserted almost nothing.

Two things unblocked it. globalSetup now provisions the rbac-* fixture users
and mints one storageState each, so no spec form-logs-in (four consecutive
logins is exactly what trips Nextcloud's brute-force throttle) — `loginAs` is
gone from this file. And openbuild#76: until every schema carried a `read`
rule, non-admins saw ZERO objects, so "viewer gets 404" passed for the wrong
reason and no 200 assertion was reachable at all.

The old body could not fail meaningfully. It located the schema list with
`.ob-schema-list` / `[data-testid="schema-list"]`, neither of which exists in
src/, so "must NOT be visible" held on any page including a correct one; and it
downgraded a missing not-found UI to a console.warn. Now asserted:

  - viewer + staging -> 404 with the body pinned EXACTLY, plus a regex check
    that the envelope names no authorisation reason
  - non-member + staging -> byte-identical to the viewer's, and identical again
    to an unknown version slug (that indistinguishability IS REQ-OBVR-003)
  - editor + staging -> 200 with a manifest. The positive control: without it a
    broken fixture, a missing chain or a blanket denial all look like a pass
  - viewer UI -> `.openbuild-schema-list` (the REAL selector) absent, no stack trace

Setup grants the roles via grantAppRoles(), which had been merged with no
caller — so the editor control now actually exercises an editor.

schema-access-scopes-rbac — STAYS SKIPPED, with honest blockers.

Its three recorded blockers are all resolved (fixture users exist; the version
chain is seeded; the feature and its copy are real — the warning is an
NcNoteCard sibling of .openbuild-access-editor, and `.note-stub` never existed).
A fourth, found by driving it rather than reading it, is not: the schema
designer is unreachable for a non-admin. rbac-editor lands on the first-time
setup wizard with `.openbuild-schema-list` count 0, because /api/setup/status is
admin-only and useSetupStatus read its 403 as "nothing done". Fixed upstream in
ConductionNL/nextcloud-vue#574; unblocks on a published bump.

The comment also records a defect found while measuring, filed as #83:
availableGroups feeds the dropdown `group:`-prefixed values while
authorLockedOut compares bare gids, so the lock-out warning fires for members
too — exactly the REQ-OBDSA-004 scenario. Noted so this suite is not simply
un-skipped and declared green once the wizard blocker lifts.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…, default off) (#82)

* fix(events): resolve the schema slug listeners compare against (gated)

ProductionVersionGuardListener and AutomationCleanupListener each carried a
private extractSchemaSlug() that probed for ObjectEntity::getSchemaSlug() — a
method that does not exist — and then fell back to `@self.schema`, which is the
schema's numeric id. Both then compared that id with `!==` against a slug
literal ('application', 'automation'), so the comparison was always true and
neither handler body has ever executed. No exception, no log line.

Replaces both helpers with one shared ObjectSchemaSlugResolver that resolves
the id to a slug via SchemaMapper::find() (request-cached, and memoised here
including misses so a hot write path does not become an N+1).

The resolver matches the REGISTER as well as the schema. A schema slug is not
unique instance-wide: this instance carries two distinct schemas with the slug
`automation` (ids 71 and 5103), so matching on the schema slug alone would fire
OpenBuild's handlers for another app's objects. Mirrors the register+schema
pair pattern already shipped in petstore and planix.

GATED, DEFAULT OFF — `openbuild.listener_slug_contract`.

Correcting the comparison is not behaviour-neutral. ProductionVersionGuardListener
is a FAIL-CLOSED validation guard: it fails OPEN today, so mismatched production
versions are never blocked, and waking it starts REJECTING writes that currently
succeed. AutomationCleanupListener starts DELETING compiled artifacts on
automation delete. Neither path has ever run, so neither has been exercised
against real data. The flag lets the fix ship and be reviewed without switching
all of that on in one deploy, mirroring openregister#2248's approach to the
sibling ObjectTransitionedEvent defect.

Not addressed here: DocumentGenerationListener and AutomationApprovalTriggerListener
have the same id-vs-slug defect via schemaOf(), but they feed the id into
automation trigger matching rather than a literal comparison, so they need a
separate change.

* test(events): repair the constructor break and pin the default-off gate

Both listeners gained two constructor params (the slug resolver and the
opt-in contract) but neither test was updated — 7 ArgumentCountError errors,
so CI was red on all six PHP/NC combinations.

The old tests were also not a control. They fed `'@self' => ['schema' =>
'automation']` — a SLUG — where MagicMapper writes a numeric ID, which is the
exact reason these listeners never ran in production. Passing the fixture the
production shape would not produce is how a dead listener tests green. The
fixtures now carry ids and the resolver decides, which is what the shipped
code does.

Added a default-off test per listener asserting that nothing happens at all —
not even the slug lookup. That is the merge-safety assertion: the production
version guard is fail-closed and currently fails open, so waking it starts
rejecting writes that succeed today.

739 tests, 0 failures (was 737 with 7 errors).

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ns can use the app (#87)

Picks up ConductionNL/nextcloud-vue#576: a 401/403 from /api/setup/status now
empties the unmet-steps lists, so a non-admin is no longer shown the
first-time-setup wizard.

Why this bump matters more than the version delta suggests: after #81 gave
non-admins OpenRegister-level read on the schemas, an editor still could not
use OpenBuild. They landed on "Welcome to OpenBuild / Set up this app" with
.openbuild-schema-list count 0 — the setup endpoints are admin-only, they
answer 403, and useSetupStatus read that as "setup unfinished".

Also corrects a drift found on the way: node_modules held 2.1.0-vue3.7 while
package.json and the lockfile both said 2.1.0-vue3.13. Nobody had run an
install since that bump, so local builds were linking a library three versions
behind the pin. This commit was built from a clean `npm ci`.

New: tests/e2e/non-admin-access.spec.ts, a regression suite for the OUTCOME
rather than any one layer. Two consecutive fixes for this looked green while
the app stayed broken for the user — openbuild#76's grant, then a nc-vue fix
that short-circuited `completed`, which CnAppRoot never reads. The only
assertion that would have caught both is "a non-admin sees the app", so that
is what these three tests assert:

  - an editor reaches the schema designer AND no setup wizard (both halves —
    asserting the wizard's absence alone passes on a blank page)
  - an editor sees the app they were granted
  - an outsider sees none — the control proving openbuild's row-level filter
    still runs on top of OR's coarse `authenticated` read grant

Verified live against the disposable instance: 3 passed.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…he list pagination (#86) (#88)

* test(e2e): scope wizard buttons to the dialog — "Next" also matches the list pagination

All 8 createApplicationWizard scenarios failed, reproducibly, with the wizard's
Next button "visible, enabled and stable" and every click swallowed:

    <div class="dialog__actions"> from <div ... data-testid-modal="cn-wizard-dialog">
    subtree intercepts pointer events

That reads as a broken dialog. It is not. `getByRole('button', { name: /^next$/i })
.first()` was never finding the wizard's Next at all.

The applications list BEHIND the modal renders a pagination control whose
button is also labelled "Next" (`.cn-pagination__nav`). It comes first in DOM
order, so `.first()` took it. Measured at the moment of failure:

    Next #1  y=1318  secondary  .cn-pagination__nav          <- picked, off-screen
    Next #2  y= 616  PRIMARY    .dialog__actions [cn-wizard-dialog]

Viewport is 720px tall. Playwright judged the pagination button visible and
enabled (it has a box and is not display:none), scrolled to it, and the modal
overlay then intercepted the click — so the error named the DIALOG while the
target was a page element underneath it.

It only started failing once the seeded fixture apps grew past one page and the
pagination appeared, which is why it looked like a wizard regression. It is not
version-related either: reproduced identically on @conduction/nextcloud-vue
2.1.0-vue3.7 and 2.1.0-vue3.15.

Every wizard action button is now looked up inside
`[data-testid-modal="cn-wizard-dialog"]` via a `wizard(page)` helper — Next,
Create and Back. "Add app" stays page-scoped; it genuinely lives on the page.

8/8 pass.

Closes #86.

* fix(schema-designer): offer bare group ids — a `group:`-prefixed scope granted nobody

Application permission buckets carry `user:<uid>`, `group:<gid>` or a bare gid
(useRole.js). `availableGroups` filtered out only the `user:` form and passed
`group:rbac-editors` straight through to the Access editor's dropdown — so the
value an admin picked was also the value written into the schema's read rule.

OpenRegister matches read rules against getUserGroupIds(), which returns BARE
gids. A prefixed rule therefore matched nobody: any group scope configured
through this UI silently denied everyone, including the person who set it.

Measured on a live instance against the same schema, restoring between runs:

    read: ["authenticated"]        -> a member of rbac-editors saw  22 objects
    read: ["group:rbac-editors"]   -> the same member saw            0
    read: ["rbac-editors"]         -> the same member saw           22

The same mismatch made `authorLockedOut` fire for members, because it compares
the selected values against getCurrentUserGroups() (also bare) — so the
REQ-OBDSA-004 "a member editor sees NO warning" scenario could never pass. That
is the symptom this was filed under; the dead scope underneath it is worse.

availableGroups now strips the prefix, leaves an already-bare gid alone, and
dedupes when a group appears in both forms.

Every pre-existing test in SchemaDesigner.access.spec.js seeded permissions
with bare gids, which is why 13 green tests sat over a scope that granted
nothing in production. Added 4 covering the prefixed form, including the
member-sees-no-warning case.

17/17 pass.

Closes #83.

* test(e2e): replace two guessed blocker notes with measured ones

Neither suite changes behaviour; both had blocker comments that were partly
wrong, and a wrong blocker is worse than none — it sends the next person to fix
something that is already fixed, or to retarget a selector when the fixture is
what is missing.

schema-access-scopes-rbac — two of the five blockers are now gone:
  - the designer is reachable for non-admins (nc-vue 2.1.0-vue3.15; the earlier
    #575 attempt was inert, guarded now by non-admin-access.spec.ts);
  - prefixed gids are normalised (#83).
What actually blocks it is a DRAFT-VERSION SCHEMA. Measured: the version chain
carries exactly one schema, `pw-verchain-production-hello-message`, and no
`pw-verchain-staging-*`. Every remaining scenario needs a non-production copy —
004/007 because production is owner-only read-only by design, 006 because it
compares the two copies. versionChain.ts creates VERSIONS, not their schemas.

version-rollback — the file said retargeting two selectors would be enough. It
would not. Driven live against the full pw-verchain chain with the tab mounted:

    .ob-versions-tab              1     (the tab IS there)
    .version-history              1     (the component renders)
    .version-history__empty       1     (and renders EMPTY)
    .version-history__row         0
    .version-history__btn--danger 0

VersionHistory lists PUBLISH SNAPSHOTS, not ApplicationVersions. The old note
had the dependency backwards — rows come from publishing, not from having
versions — so a three-version app still shows zero. What it needs is a fixture
that publishes at least twice, which is new work, not a retarget.

Also recorded there, since reading cannot reveal it: the detail route takes the
UUID not the slug, and the sidebar tabs render WITHOUT clicking
.app-sidebar__toggle — a toggle click times out at 5s because they are already
open, so the original "open the sidebar first" step actively hangs.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…n a field the response lacks (#89)

VersionHistory fetches `/apps/openbuild/api/applications/{slug}/versions`, then
filtered the result with

    raw.filter(r => r.applicationUuid === this.applicationUuid)

as "IDOR defence-in-depth". That endpoint does not return `applicationUuid`.
Measured:

    GET /api/applications/pw-verchain/versions
    -> 3 rows, each { name, slug, manifest, manifestDelta, baseRef, register,
                      semver, status } — no applicationUuid on any of them

ApplicationVersionsTab passes BOTH app-slug and application-uuid, so the filter
removed every row and the "Version history" tab rendered
`.version-history__empty` for every application, always.

Filtering a server-scoped response against a field that response does not carry
is not defence in depth, it is an unconditional deny. The filter now applies
only to the unscoped `/applicationversions?applicationUuid=` endpoint, where the
field does exist and the check is meaningful.

Verified live before the instance was lost: 3 rows rendered, empty-state gone.

This also corrects a claim I committed earlier in version-rollback.spec.ts —
that VersionHistory "lists publish SNAPSHOTS, not versions, so it needs a
fixture that publishes twice". That was wrong. It lists exactly the versions
versionChain.ts already creates; they were being filtered out.

versionRouting 9.2 — the viewer-UI assertion was wrong twice over:

  1. It PASSED for the wrong reason. Before the setup-wizard fix a non-admin
     never reached the builder at all, so "no schema list" held because nothing
     rendered for anyone.
  2. It cannot distinguish the roles. Measured side by side, the viewer (DENIED
     staging) and the editor (ALLOWED staging) render an IDENTICAL surface:
     `.openbuild-schema-list` count 1, reading "No schemas yet".

No data leaks — the list is empty for both — so this is a UX gap (the builder
renders no version-not-found state), not a security one. The assertion now
checks what it can actually detect: that no schema of the forbidden version is
NAMED. The gate itself is covered by the three request-level tests, which is
where it is enforced. 6/6 pass.

version-rollback.spec.ts — rewritten against the verified contract
(ApplicationVersionsTab.onRollback: manifest copied over, version relabelled
`<version>-rollback-<hex>`, status forced to draft), and left SKIPPED because it
has never been executed: the disposable instance was destroyed by a disk-full
event before it could run once. Enabling it is deleting one `.skip` — but do
that with a run, not on the strength of the comment.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…, not the contract (#91)

Ran it against the shared dev instance. It never reached its assertions, and the
reason is not the rollback contract:

    aside.app-sidebar                       width 0   (closed)
    section.app-sidebar__tab[role=tabpanel] display:none
    button[aria-label="Open sidebar"]       present AND visible
      -> clicking it TIMES OUT on actionability, at 1280x720 AND at 1920x1080

The sidebar will not open, so every tab-scoped assertion is unreachable — even
though the tab's content is mounted underneath it. That is a UI defect to chase
on its own, not something a selector change fixes.

The product fix this spec depends on IS confirmed on both instances:
`?tab=history` deep-links the tab and `.version-history__row` count is 3, where
the panel previously rendered empty for every app. The data is right; only the
chrome is unreachable.

Three dead ends recorded so the next attempt does not repeat them:
  - getByText('Version history') resolves the tab button's LABEL SPAN, which is
    display:none once the tab strip collapses to icons — waiting on its
    visibility waits forever (18 resolutions, all "hidden"). FIVE nodes carry
    that exact text; the deepest is the panel's own <h3>.
  - getByRole('tab', …) finds nothing — these are not ARIA tabs.
  - [aria-label*="sidebar" i] matches "Close sidebar" FIRST; the control is
    labelled exactly "Open sidebar".

And the one route that does work: /applications/{uuid}?tab=history mounts the
tab content directly.

Also carried over from the same run, both measured rather than guessed:
  - a 150s budget for this describe, from GET /api/applications taking ~6.9s on
    the shared box against ~0.3s on a disposable one (28 apps, 200+ schemas);
  - the sidebar's open/closed state is per-user UI state and differs between
    instances, so a fixed "open it first" or "click it directly" step is wrong
    either way.

Stays skipped: its assertions remain unexecuted, and shipping an unexecuted spec
as coverage is the failure mode this file already documents three times.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…unaccounted for" (#90)

* fix(app-repo): count an idempotent skill re-install as applied, not "unaccounted for"

A regression I introduced with hermiq's idempotent installer, caught by reading
the seam rather than by any failing test.

The applier read only `installed` from hermiq's response. Once hermiq stopped
duplicating skills, a re-install of a bundle already present reports

    installed: 0   updated: 0   unchanged: 94

so the applier accounted for 0 of 94 declared items and absorbed the shortfall as
"not-accounted-for-by-source" — a loud failure banner on a perfectly good run,
and precisely the kind of false alarm that trains people to ignore the report.

"Present as intended" is installed + updated + unchanged. The source's own
breakdown is now carried through unflattened in `sourceCounts`, so a first install
and a no-op re-run stay distinguishable instead of collapsing into one total.

Mutation-checked: reverting to `created: $installed` turns the new test red
(0 vs 2).

The skills channel moved into its own SkillChannelDelegate — talking to another
app across an optional-dependency boundary is a different responsibility from
applying OpenRegister channels, and phpmd flagged the applier at 51 > 50 when the
mapping was added inline. A real split, not a suppression; the applier's tests
compose the REAL delegate so the degradation and count assertions keep biting.

754 tests OK, phpstan 0, phpcs 0, phpmd 0.

* style: capitalise an inline comment (phpcs)

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…rontend jobs with it (#92)

Six jobs have been red on development: Vue Quality (eslint), Vue Quality
(stylelint), License (npm), Security (npm), SBOM, and Quality Report. They share
one cause, and it is not any of the tools they are named after.

Every one of those jobs runs `npm ci` first, and `npm ci` refused to install:

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

So no linter ever ran. The jobs were not reporting lint failures — they were
reporting that the install step died before reaching a linter, which reads
identically on the dashboard.

The missing entries are TRANSITIVE: nothing declares pinia@4 or vite@8, and the
declared ranges are ^2.1.7 and ^5.4.0. They arrived with the
@conduction/nextcloud-vue 2.1.0-vue3.15 bump (#87), whose lockfile regeneration
did not capture the new transitive graph.

This is invisible locally: an existing node_modules already contains the packages,
so `npm run lint` passes on a developer machine and `npm ls` reports the tree
resolves. Only `npm ci` — which installs from the LOCKFILE rather than the tree —
can see it. Reproduced under CI's node 20 rather than the local node 22.

The diff is purely additive and verified as such:

    declared dependency versions changed:  0
    packages added:                       63
    packages removed:                      0

Verified under node:20 after the fix:

    npm ci        OK (previously EUSAGE)
    npm run lint  clean
    npm stylelint clean
    npm audit --audit-level=critical --omit=dev  exit 0
      (10 vulns: 5 low, 4 moderate, 1 high — all below the critical gate)

The one licence outside my first hand-written allowlist replica, domain-browser
5.7.0 (Artistic-2.0, via node-polyfill-webpack-plugin), is on the shared
workflow's real allowlist — my replica was incomplete, not the licence.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
… the schema does not have (#94)

`onRollback` did:

    obPatchApp({ manifest, version: `${v}-rollback-${hex}`, status: 'draft' })

`obPatchApp` PUTs the whole Application object at
/api/objects/openbuild/application/{uuid}. The `application` schema declares 15
properties and NEITHER `manifest` NOR `version` is one of them, so OpenRegister
dropped both. Only `status` survived — and since a rolled-back app is normally
already a draft, the entire feature was a visible no-op. Measured live, with a
control that changes a KNOWN property so "the write landed" is not assumed:

    PUT description: 'CONTROL_MARKER_XYZ789'   -> persisted   (PUT landed)
        version:     'PROBE-rollback-deadbeef' -> None        (DROPPED)
        manifest:    {probeMarker: ...}        -> None        (DROPPED)

ApplicationManifestTab already hit this exact trap and moved to
PUT /api/applications/{slug}/manifest, leaving a comment saying so. The versions
tab was never updated. It now uses that same route; the endpoint round-trip is
verified independently ({"status":"ok","target":"version"}).

The `<version>-rollback-<hex>` label is deliberately NOT reinstated: there is no
`version` property to hold it, and inventing one is a schema change, not a bug
fix. `shortHex()` goes with it — the label was its only caller.

WHY THE SPEC COULD NOT SEE THIS, AND WHY IT WAS SKIPPED FOR SO LONG

The blocker note this file carried was wrong, and it was mine. It said the
sidebar "refuses to open — a UI defect to chase on its own" and that
`getByRole('tab', …)` "finds nothing — these are not ARIA tabs".

What was actually on screen was a MODAL. CnAppRoot offers the setup wizard
whenever every REQUIRED step is met but at least one OPTIONAL step is not
(`optionalSetupGating`, REQ-SETUP-NV-012) and opens it as a full `modal-mask`.
OpenBuild trips it permanently: its `store` step carries NO `required` key — the
word "optional" lives only in the title string — so `optionalUnmet` is never
empty. Users dismiss it once via localStorage; every Playwright context is
FRESH, so it reopened in every test. That mask sat over the sidebar toggle
(hence "visible but not actionable") and marked the background aria-hidden
(hence the missing tab role). One cause, both symptoms.

`document.elementsFromPoint(<centre of the toggle>)` named it in one probe,
after three wrong theories. New `suppressSetupWizard()` helper, applied to the
four specs that were exposed: this one, applicationDetailOverview,
non-admin-access, versionRouting.

THE SPEC NOW HAS TO BE ABLE TO FAIL

Two defects in my own test, both caught before shipping:

  - Every seeded snapshot carries the same empty manifest, so "restored the
    snapshot" and "did nothing" were byte-identical and the assertion could
    never fail. It now plants a distinct manifest FIRST and asserts it is gone.
  - The target was read as "first non-production row in the API response",
    which is a DIFFERENT row than the first Roll back button on screen — so the
    spec failed against a WORKING fix. It now reads the semver from the DOM row
    that owns the button it clicks.

It also compares menu/pages only: the GET returns an EFFECTIVE manifest with
`runtime` injected and `name` added, so a whole-document match is impossible.

And it gets its OWN fixture (`pw-rollback`). It mutates the active manifest
twice per run, and versionRouting.spec.ts drives `pw-verchain` and reads
/manifest?_version= off it.

Three-way verified on the current spec: pre-fix FAILS (the planted manifest
survives the rollback), fixed PASSES 2/2.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ed a field the schema does not have (#95)

* fix(rollback): rolling back restored NOTHING — it wrote to two fields the schema does not have

`onRollback` did:

    obPatchApp({ manifest, version: `${v}-rollback-${hex}`, status: 'draft' })

`obPatchApp` PUTs the whole Application object at
/api/objects/openbuild/application/{uuid}. The `application` schema declares 15
properties and NEITHER `manifest` NOR `version` is one of them, so OpenRegister
dropped both. Only `status` survived — and since a rolled-back app is normally
already a draft, the entire feature was a visible no-op. Measured live, with a
control that changes a KNOWN property so "the write landed" is not assumed:

    PUT description: 'CONTROL_MARKER_XYZ789'   -> persisted   (PUT landed)
        version:     'PROBE-rollback-deadbeef' -> None        (DROPPED)
        manifest:    {probeMarker: ...}        -> None        (DROPPED)

ApplicationManifestTab already hit this exact trap and moved to
PUT /api/applications/{slug}/manifest, leaving a comment saying so. The versions
tab was never updated. It now uses that same route; the endpoint round-trip is
verified independently ({"status":"ok","target":"version"}).

The `<version>-rollback-<hex>` label is deliberately NOT reinstated: there is no
`version` property to hold it, and inventing one is a schema change, not a bug
fix. `shortHex()` goes with it — the label was its only caller.

WHY THE SPEC COULD NOT SEE THIS, AND WHY IT WAS SKIPPED FOR SO LONG

The blocker note this file carried was wrong, and it was mine. It said the
sidebar "refuses to open — a UI defect to chase on its own" and that
`getByRole('tab', …)` "finds nothing — these are not ARIA tabs".

What was actually on screen was a MODAL. CnAppRoot offers the setup wizard
whenever every REQUIRED step is met but at least one OPTIONAL step is not
(`optionalSetupGating`, REQ-SETUP-NV-012) and opens it as a full `modal-mask`.
OpenBuild trips it permanently: its `store` step carries NO `required` key — the
word "optional" lives only in the title string — so `optionalUnmet` is never
empty. Users dismiss it once via localStorage; every Playwright context is
FRESH, so it reopened in every test. That mask sat over the sidebar toggle
(hence "visible but not actionable") and marked the background aria-hidden
(hence the missing tab role). One cause, both symptoms.

`document.elementsFromPoint(<centre of the toggle>)` named it in one probe,
after three wrong theories. New `suppressSetupWizard()` helper, applied to the
four specs that were exposed: this one, applicationDetailOverview,
non-admin-access, versionRouting.

THE SPEC NOW HAS TO BE ABLE TO FAIL

Two defects in my own test, both caught before shipping:

  - Every seeded snapshot carries the same empty manifest, so "restored the
    snapshot" and "did nothing" were byte-identical and the assertion could
    never fail. It now plants a distinct manifest FIRST and asserts it is gone.
  - The target was read as "first non-production row in the API response",
    which is a DIFFERENT row than the first Roll back button on screen — so the
    spec failed against a WORKING fix. It now reads the semver from the DOM row
    that owns the button it clicks.

It also compares menu/pages only: the GET returns an EFFECTIVE manifest with
`runtime` injected and `name` added, so a whole-document match is impossible.

And it gets its OWN fixture (`pw-rollback`). It mutates the active manifest
twice per run, and versionRouting.spec.ts drives `pw-verchain` and reads
/manifest?_version= off it.

Three-way verified on the current spec: pre-fix FAILS (the planted manifest
survives the rollback), fixed PASSES 2/2.

* fix(exports): the Exports tab was empty for every app — the filter used a field the schema does not have

ExportJobsList fetched its rows with

    '?filter[applicationSlug]=' + this.applicationSlug

which is wrong twice over, so the list was empty for every application, always:

  1. `export-job` declares 18 properties and `applicationSlug` is NOT one of
     them — it is `applicationUuid`. Nothing ever wrote a slug onto these
     objects, so no stored row could carry one.
  2. The `filter[...]` bracket syntax is not what the endpoint reads. Measured
     against the same 5 stored jobs:

       ?applicationUuid=<uuid>        -> 1   (correct)
       ?filter[applicationUuid]=<u>   -> 0
       ?_filter[applicationUuid]=<u>  -> 5   (ignored entirely)

Same failure as the rollback fix in #94 and the VersionHistory fix before it:
querying a field the schema does not declare. The code names it, so it looks
real; only the schema says otherwise.

`applicationSlug` is still correct for the SUBMIT endpoint
(/api/applications/{slug}/exports), so both props are kept and ExportJobsTab now
passes the uuid alongside it.

Rows were also keyed `:key="job.uuid"`. `uuid` is not a property either, so
EVERY key was undefined — identical keys let Vue reuse the wrong <tr> as
statuses change under the 2s poll. Keyed on the OR object id instead.

globalSetup: "already exists" is not the same as "usable"

Role accounts were provisioned with POST /cloud/users and OCS 102 ("already
exists") was accepted as the state we want. On a long-lived instance the account
may PREDATE this harness and carry a different password: creation is skipped,
every later login fails, and the specs run that role UNAUTHENTICATED — which
reads as a product change rather than a fixture gap.

Measured on the shared dev box: rbac-owner/editor/viewer authenticated 200,
rbac-outsider 401, and versionRouting's "non-member must receive 404" failed
with 401. globalSetup now PROVES the credentials work and repairs them when they
do not. Live: "rbac-outsider existed with a different password — reset 200, now
usable".

NOT fixed here, and it blocks the ZIP round-trip spec: a submitted export never
leaves `queued`. Driven by hand — submit 202, the queue entry is consumed,
`occ background-job:execute` reports "Job executed!" — and the job object still
reads status=queued with no errorMessage. OR ships
Service\Lifecycle\TransitionEngine and no "TransitionEngine unavailable" warning
is logged, so the transition runs and simply does not advance the job. That is a
product blocker underneath export-zip.spec.ts, and a DIFFERENT one than the
reason recorded in that file.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Aligns this app on the current nc-vue release and pins it EXACTLY — no
caret. A caret on a prerelease is how the fleet previously drifted onto a
proprietary `vue3-apexcharts`, so the range operator is removed rather
than merely retargeted.

Verified from the LOCKFILE (not package.json):

  - `@conduction/nextcloud-vue` resolves to exactly `2.1.0-vue3.16`,
    a single instance, no nested duplicate.
  - `vue3-apexcharts` resolves to `1.8.0` — below the 1.9.0 boundary at
    which that package became proprietary and stopped permitting
    sublicensing, which our EUPL-1.2 apps require. nc-vue itself pins it
    as a direct `~1.8.0` dependency, so the tilde cannot reach 1.9.0.
    No licence override was added: core `apexcharts` is MIT and reports
    an identical `Custom: <url>` symptom, so an override would mask the
    real signal.

Install sequence: `rm -rf package-lock.json node_modules`, then
`npm install` (npm 11), `npm install --package-lock-only` (npm 10),
`npm ci` (npm 10).

Opt-ins evaluated (applied only where the app actually has the pattern):

  - `@nextcloud/initial-state` `overrides` entry — NOT PRESENT in this
    app, so nothing to drop. vue3.16 relaxes that peer to
    `^2.2.0 || ^3.0.0`; this app already declared `^2.2.0`, which
    satisfied the old peer too.
  - local `vue/no-multiple-template-root: 'off'` — NOT PRESENT in this
    app; the shared preset now disables it.
  - e2e base-URL resolver — this app's resolver already refuses to
    default to `localhost:8080`, so switching to nc-vue's shared one
    would be churn without behaviour change. Left alone.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…e 3 lint gate

Both unit failures were tests still asserting behaviour that a later, correct
production fix had deliberately replaced. Neither could be noticed, because
no app's JS unit suite has ever run in CI — the shared `quality.yml`
`frontend-checks` input defaults to `"[]"` and the job is guarded on
`!= '[]'`, so the matrix always resolved empty and reported "skipping".

Baseline on the unmodified base at load ~3 was 2 failures, not 1:

  SaveAsTemplateAction > openSaveAsTemplate gathers schemas + templates …
  views/ExportJobsList > filters by the applicationSlug prop

1. SaveAsTemplateAction — the mock was call-ORDER dependent.

   The spec queued one `axiosMock.get.mockResolvedValueOnce(...)` for the
   templates read. `openSaveAsTemplate()` now issues TWO GETs: it resolves
   the manifest from `/api/applications/{slug}/manifest` FIRST, then reads
   the templates. The one-shot response was therefore consumed by the
   manifest call, and the templates read fell through to the generic
   `{ data: application }` default — which has no `results` array, so
   `existingTemplates` came back `[]`.

   Production is right. The manifest GET was added because an Application
   record carries neither `manifest` nor `currentVersion`, so the old
   `obApp.manifest` read always fell through to `{}` and saving ANY app as
   a template was impossible. The mock is now routed by URL, which removes
   the ordering dependency and lets both responses be asserted; the
   manifest endpoint is now asserted explicitly too.

2. ExportJobsList — the spec pinned the exact bug that was fixed.

   It asserted `filter[applicationSlug]=my-app`. Commit c1d22f4 (#95)
   replaced that with a plain `?applicationUuid=`, for two independently
   sufficient reasons measured against real stored jobs: `export-job`
   declares no `applicationSlug` property at all, and the `filter[...]`
   bracket syntax is not what the endpoint reads. Production is right; the
   expectation is corrected, and both ruled-out shapes are now pinned
   negatively so neither can come back.

No assertion was weakened, skipped, or given a longer timeout.

LINT — adopt `@conduction/nextcloud-vue/eslint`

`eslint --print-config` on this Vue 3 app showed ZERO `vue/no-deprecated-*`
rules armed and `vue/no-multiple-template-root` armed at `[2]`. Spreading
`conductionVue3Fixes` last arms 21 deprecation rules and disarms the three
inverted Vue-2 rules (the two hand-rolled local disables are now redundant
and were removed).

Positive control, because "0 deprecation findings" is meaningless without
one: injecting a `beforeDestroy()` hook into PageDesigner.vue errors with
the shared preset and is COMPLETELY SILENT on the base config — the same
configuration that let four `beforeDestroy` hooks survive openconnector's
Vue 3 migration as silent memory leaks. Sentinel reverted.

The preset surfaced 9 findings, all fixed (backlog is now zero, not
deferred): 5 `vue/v-on-event-hyphenation` + 4 `comma-dangle`.

The hyphenation fixes were applied by hand, never via `--fix`, and only
after proving they are semantics-preserving. `@vue/compiler-sfc` compiles
BOTH `@update:dataSource` and `@update:data-source` to the identical
handler key `"onUpdate:dataSource"` (Vue 3 camelizes a static v-on
argument). The removed config comment asserted the opposite — that the
hyphenated form is "silently DEAD" — and that claim is false. The four
edited components each have their own passing specs.

Verified by deliberate break (both reverted):
- templates response -> SENTINEL: reds SaveAsTemplateAction
- applicationUuid prop -> SENTINEL: reds ExportJobsList
Positive control for the 0/0 lint baseline: an injected `var` + unused
binding reports 2 errors, so the clean baseline is real and not a
misconfigured lint run.

After: 140 files / 1364 tests pass, twice, at load ~3-4.
`npm run lint`: 0 errors, 0 warnings.
…eset

fix(tests,lint): two stale specs pinned removed contracts; arm the Vue 3 lint gate
…ing violations (#99)

Removes the '|| echo' from composer phpmd and regenerates phpmd.baseline.xml as a ratchet.
test:all (and test:unit where present) ended in '|| echo Tests require
Nextcloud environment, skipping...', so phpunit's exit status was
discarded unconditionally and check:strict could never fail on a test
failure. This repo's suite runs standalone, so the message was untrue here.

Positive control, PHP 8.3.32 container, vendor/ freshly installed, adding
one deliberately failing test under tests/:
  old composer.json + failing test -> test:all never named, swallowed
  new composer.json + failing test -> check:strict fails NAMING test:all
  new composer.json, test removed  -> test:all passes

Also passes --no-coverage: this repo's phpunit.xml requests coverage, and
without a driver PHPUnit warns and exits non-zero, which would have made
the newly-live gate red for an environment reason rather than a code one.
shillinq and decidesk already do this.

Tooling only - no findings fixed here.
…paced code (#102)

Every Conduction repo enables rulesets/design.xml/DevelopmentCodeFragment, and
it has never reported anything in any of them. The cause is a config gap, not a
phpmd bug: PDepend resolves an unqualified call inside a namespaced file to the
current-namespace-qualified image, so `var_dump($x)` written inside
`namespace OCA\MyApp\Service;` reaches the rule as
`OCA\MyApp\Service\var_dump` and never matches the `unwanted-functions` list.
All of our production PHP is namespaced, so with the default the rule is dead.

The rule's own `ignore-namespaces` property is the switch. This mirrors the
configuration already merged in openregister (ConductionNL/openregister#2286).

Proof, phpmd 2.15.0 / PHP 8.3.32, against this repo's own phpmd.xml:
  namespaced probe class calling var_dump()  -> exit 2, DevelopmentCodeFragment
  same class with the call removed           -> exit 0, no finding
Before the change the identical namespaced probe exited 0.

Blast radius on this repo: measured 0 new findings over the scanned path on
the base branch, with a per-run positive control (dropping the namespaced probe
into the same extracted tree does produce exit 2, so the zero is a true zero).
Nothing is baselined or suppressed here.
…-> 2026-08-01) (#103)

The lockfile pinned roave/security-advisories to a commit from 2026-03-20,
so the metapackage's conflict rules — and therefore the protection
against installing known-vulnerable dependency versions — were frozen
at that date. Refreshed to the 2026-08-01 tip.

composer audit --locked: clean before and after.
Jobs without timeout-minutes fall back to GitHub's 360-minute default, so
a hung runner burns six hours of Actions minutes before it is reaped.

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

Jobs that only call a reusable workflow (job-level `uses:`) are untouched --
they inherit their bound from the called workflow.
…he app-local proxy (ADR-080)

StoreController now extends AppHost's GenericStoreControllerBase and inherits
search() plus the SSRF-guarded, redirect-refusing, token-private fetch.
RemoteTemplateStoreService (331 lines) and its test file are deleted; the
behaviour and the SSRF controls live in OpenRegister's GenericStoreServiceTest.

Install stays here, and only install — cloning a template into a local virtual
app is OpenBuild-specific and differently authorized from the connector-adapter
and agent-template installs in other apps.

Three things the tests caught that review would not have:

1. The suite could not load StoreController at all — `extends` is resolved by
   the AUTOLOADER, not the container, and the OR AppHost classes are stubbed
   here rather than autoloaded. Added stubs for GenericStoreService,
   StoreDescriptor and GenericStoreControllerBase. This is the same mechanism
   by which a missing sibling app 500s EVERY route in production, which is why
   ADR-080 restricts subclassing to apps declaring openregister a hard <app>
   dependency — this app qualifies (8 controllers already type-hint OR classes).

2. The search test's `getParam` mock returned one value for ANY key, so `kind`
   silently received the query string. Made it key-aware; the action reads `q`
   and `kind` separately.

3. tests/stubs/openregister-stubs.php defines a NO-OP SecurityService, because
   the real guard does DNS lookups that fail for .test fixture hostnames. Any
   "SSRF negative control" written against that stub passes regardless of what
   the guard does. Corrected the docblock to say so, and to point at the
   OpenRegister suite where the real guard is exercised. The new store stub is
   deliberately non-behaving for the same reason.

743/743 unit tests, 7/7 on the store controller.
StoreController is a plain Controller injecting GenericStoreService, with its
own ~30-line search() action, rather than subclassing a cross-app base. The
inheritance broke phpstan ('extends unknown class', which it refuses to let you
ignore), psalm, and the unit suite's class loading — one stub entry now covers
the injected type-hint instead.

phpstan OK, psalm no errors, 743/743 tests.
…settings

refactor(store): consume OpenRegister's GenericStoreService, delete the app-local proxy (ADR-080)
…110)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants