Skip to content

Release: merge development into beta - #79

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

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

juanclaude-conduction and others added 30 commits July 30, 2026 16:03
…urface

The file sat behind a blanket quarantine citing Conduction/openbuild#41
('openbuild admin UI not functional in this build'). That reason is stale --
the detail page, its Manifest / Version history / Diff sidebar tabs, the
builder host and the per-app Schemas route all render live.

Un-skipping alone would have been worse than the skip: the bodies asserted
almost nothing (expect(page.locator('main')).toBeVisible() standing in for
'the seeded index page renders'), and several wrapped their only real
assertion in 'if (await x.count() > 0)' so they passed having asserted
nothing. Every body is rewritten against the requirement text.

Adds tests/e2e/support/componentTree.ts: the negative half of REQ-OBR-006a
('the nested CnAppRoot is NOT mounted on the schemas route') is not
expressible in DOM selectors -- not-mounted and mounted-but-loading look
identical from outside. Walks the Vue 3 tree from container.__vue_app__, the
only handle that survives a production build (__VUE_PROD_DEVTOOLS__ = false).
…multi-boot scenarios

- REQ-OBR-002 asserted the outer nav via '#app-navigation-vue, .app-navigation';
  nc-vue's CnAppNav stamps data-testid="cn-nav" and neither of those exists.
- REQ-OBR-007a is a PRODUCT GAP, not a test problem: src/manifest.json menu[]
  has five entries and none routes to /builder/:slug/schemas; BuilderHost.vue
  renders no navigation; menu-layout.json is empty; and the l10n key the
  requirement mandates (openbuild.builder.menu.schemas) is absent from en/nl.
  The ROUTE works and is covered by the two REQ-OBR-006a tests. Skipped with
  that evidence rather than left red for a feature that was never built.
- Five scenarios boot the SPA twice or poll the API; the 30s project default is
  sized for single-navigation tests. Per-test budgets added with the reason
  written down; every assertion inside keeps its own tight timeout.
Un-quarantined (reason was stale or never applied):
- builder-host: /builder/hello-world mounts the nested CnAppRoot and renders
  the seeded index; bodies were already real, so they run as written.
- applicationCard: only ever read the applications index, so 'builder host
  blank' never applied. Tests 3 and 4 claimed to target hello-world but
  selected '[data-slug="hello-world"], .ob-app-card' + .first() -- no
  data-slug attribute is rendered, so the alternation always collapsed to
  'whatever card is first'. Retargeted via the slug chip. Test 4 asserted only
  length>0 and not-/undefined/, which the string 'Version null' would satisfy;
  now a format assertion.
- promoteDestructive static block: it is an fs.stat, opens no browser.

Reasons corrected, still skipped (each names its real blocker):
- versionRouting x3, schema-access-scopes-rbac: need ApplicationVersion chains
  and Newman-provisioned users this suite does not create -- AND target
  selectors absent from src/ (.ob-schema-designer, [data-app-version],
  .note-stub), so seeding alone would leave them green-but-dead.
- rbac-403: needs rbac-outsider; its deny assertion also counts
  [data-app-slug]/[data-testid=builder-host-<slug>], neither of which src/
  emits, so it would pass vacuously.
- version-rollback: drives a one-big-textarea editor that no longer exists.
- promoteDestructive live block: TODO_PROMOTE_BUTTON_SELECTOR was never wired.

Records a product defect found while doing this: ApplicationCard's status badge
and version chip are dead -- /api/applications returns productionVersion as a
UUID string, the card requires an object, so every card reads Draft / 'Version
-' regardless of state.
… for 3 more

iconUpload was quarantined for '#41 no detail page renders'. The detail page
renders; the file was written against a surface that never existed:
  - it looked for .ob-icon-preview img / [data-testid=icon-preview] img, neither
    of which is in src/. The real markup is src/dialogs/IconUploadSection.vue
    (.ob-icon-section__file-input / __preview-img / __remove-btn / __error),
    mounted by ApplicationIconTab.vue;
  - it never opened the sidebar, so the Icons tab could not be in the DOM, so
    its own 'if (!iconUiExists) test.skip(...)' hatch fired every single run;
  - two of its three tests were permanent no-op test.skip('pending deploy')
    bodies;
  - it clicked [data-slug=hello-world], an attribute ApplicationCard.vue never
    renders, silently falling back to an arbitrary card.
Rewritten as three asserting tests: the tab mounts both variants with an
SVG-only picker; a non-SVG pick is rejected inline AND sends no POST; an SVG
upload persists (read back independently) and previews from the icon endpoint.

Reasons corrected, still skipped:
  - page-designer: navigates /applications/<slug>/design (no such route) and
    asserts .application-editor__* (no such class); its 'Design tab is default'
    assertion encodes the tab pair the app never shipped.
  - export-zip: polls [data-test=export-job-row] (absent) and uses the slug as
    the detail route param, which takes the object id.
  - application-editor: SUPERSEDED -- its round-trip is now driven against the
    real sidebar editor by REQ-OBR-005 in openbuild-runtime.spec.ts.
1) 'navigates to a hello-message detail page' timed out clicking a message.
   The nested CnAppRoot mounts with appId 'openbuild-hello-world', so nc-vue's
   first-visit support dialog had never been seen for THAT app id and opened
   over the virtual app. Measured: 55 click retries, every one reporting
   'cn-support-dialog subtree intercepts pointer events'. It is a real modal
   (aria-modal + backdrop), so swallowing the click is correct behaviour --
   the shared dismissFirstVisitOverlays() helper is the right fix, not a
   library change.

2) 'navigates to the form page' looked for input[name="title"] /
   [data-field="title"] input. nc-vue's CnFormPage wraps each field in
   [data-field-key="<key>"] and names the control field-<key>, so neither
   form was ever emitted. Retargeted, and strengthened rather than merely
   made to find something: it now asserts the form page rendered, that title
   exposes an EDITABLE control, and that the second declared field (body)
   rendered too -- proving the declared form rendered, not one stray input.
…r the detail-overview UI blocks

- 'Application insights — endpoint surface' was quarantined for a UI reason it
  could not have been affected by: both tests are request-only contract checks
  (400 on an invalid window enum with the spec-defined body; 404 on an unknown
  appUuid, specifically without the public,max-age=60 header a 200 carries).
- The two UI blocks keep their skip but name the real blockers: they opt out of
  the shared storageState and form-log-in per test, which playwright.config.ts
  documents as the thing that trips Nextcloud's brute-force throttle and drops
  every later spec onto /login; and their pill/deep-link scenarios need a
  development->staging->production chain that this instance does not have, so
  they would hit their own pillCount<2 guards regardless.
- Recorded that 14.5 ends on 'void req' under 'assertion is best-effort' — it
  captures the request it exists to check and discards it, so un-skipping as
  written would report deep-link coverage while asserting nothing.
- Fixed pre-existing operator-linebreak lint in the same file.
Menu icons across the fleet had drifted into meaninglessness: a scan of 21
manifest-shipping apps found 120 distinct icons for 262 distinct labels, with
one glyph standing for as many as 18 unrelated concepts
(`icon-category-monitoring`) and the same concept drawn differently per app —
Store was `icon-category-integration` in one app and
`icon-category-organization` in another.

Moves this app's menu onto the shared vocabulary: MDI PascalCase names, one
concept to one icon. Tier A entries (Dashboard, Documentation, Settings, Store,
Features & roadmap) now match every other Conduction app, which is the whole
point — a glyph should mean the same thing wherever a user meets it.

Two defect classes are fixed along the way:

* Icon names that do not exist in vue-material-design-icons at all. They could
  never resolve — rendering a help-circle at best, nothing at all in the
  navigation.
* Menu entries that rendered with NO icon, because CnAppNav resolves an MDI name
  only through the registry `registerIcons()` populates, with no fallback for a
  name the app never registered. Apps that relied on legacy `icon-*` classes
  registered nothing at all and were fine until the first MDI name appeared.

src/icons.js is generated from the app's own manifests and register files, so
every name the app references is registered and the migration stands on its own
against the CURRENTLY RELEASED @conduction/nextcloud-vue — it does not wait on
the library-side vocabulary (ConductionNL/nextcloud-vue#563).

Verified: 0 menu entries render without an icon (was 51 fleet-wide), every icon
import resolves against the app's own node_modules, and hydra's gate-60
icon-vocabulary check passes with no failures or warnings.

Spec: ADR-077 (ConductionNL/hydra#408).

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

Two product defects, both 100% dead surfaces, both found by driving the e2e.

1) ApplicationCard's status badge and version chip were inert. GET
   /api/applications returns productionVersion as a bare UUID STRING, but the
   card's productionVersion computed bailed unless typeof pv === 'object'. So
   statusKey always fell back to 'draft' and productionSemver to '-' for EVERY
   card, whatever the app's real state -- hello-world rendered 'Draft /
   Version -' while its production ApplicationVersion is
   {status: 'published', semver: '1.0.0'}. REQ-OBR-007b's 'newly published
   Application shows published badge' was unsatisfiable from the list.

   Fixed server-side: attachProductionVersionDetail() resolves the UUIDs in ONE
   extra query (all applicationVersion rows fetched once and indexed, mirroring
   ApplicationVersionsController::index) and projects
   {uuid, slug, name, semver, status} as productionVersionDetail. Only those
   fields -- the full row carries the whole manifest blob, which would bloat a
   list response for data no card reads. productionVersion is deliberately left
   a UUID string: ApplicationDetailHeader, ApplicationDetailDashboard,
   ApplicationVersionsTab, promoteVersionDefaults and useApplicationVersion all
   depend on that shape. The resolve fails soft AND logs, because a silent catch
   would recreate the exact defect being fixed.

2) DocumentActions and TrackLinkAction could never match a manifest entry.
   They compared the object's @self envelope against manifest slugs, but @self
   names register/schema by NUMERIC ID (measured: {'register':'15','schema':'21'})
   while runtime.documents[].schema is 'hello-message' and
   runtime.externalForms[] carries slugs. '21' === 'hello-message' is never
   true, so both widgets rendered nothing for every object, on every app --
   invisibly, since an empty filter result is exactly what they render blank by
   design.

   Fixed by comparing on a normalised key set (src/utils/objectSchemaKeys.js):
   CnDetailPage provides cnObjectContext, whose register/schema come from the
   page's manifest config -- the slugs, the same vocabulary the entries use.
   The @self ids stay in the set so a host mounting a widget without detail-page
   context still matches. What the attach dialogs WRITE is unchanged: REQ-DDT-001
   specifies the slug, so the numeric side is the wrong one and is what gets
   normalised away.

E2E for (1) is now data-driven rather than vocabulary-only: applicationCard and
REQ-OBR-007b compare the rendered badge and chip against the API's resolved
productionVersionDetail, so a regression cannot pass as 'one of the three
words'.
The requirement says BuilderHost SHALL surface a Schemas entry in the outer
shell while in a virtual app's builder context, routing to
/builder/{slug}/schemas via the key openbuild.builder.menu.schemas. The ROUTE
has worked all along; only the affordance was missing, so the designer was
reachable by typing a URL and from a deep link PageDesignerHost builds by hand,
but never from the navigation.

Why an href and not a route name: CnAppNav.itemTo() builds {name, query} only --
it has no params support -- and /builder/:slug/schemas is parameterised by
definition, so no STATIC manifest entry could ever address it. item.action is a
fixed library enum, not a callback. That leaves item.href, which itemHref()
supports and NcAppNavigationItem renders as a real anchor. Trade-off stated in
the source rather than hidden: an href is a full page load, not a router.push.
Adding params support to itemTo() would let this become a route entry and is the
better long-term fix -- it belongs in nc-vue.

main.js now wraps the merged manifest in reactive() so the menu edit is
observed; CnAppRoot documents that it hands CnAppNav the live manifest BY
IDENTITY precisely so async menu updates re-render. BuilderHost publishes the
entry on mount and on slug change, and removes it on unmount -- without that
teardown the entry would linger on every other page still pointing at the last
app opened, which is worse than no affordance.

Adds both mandated l10n keys (en: Schemas, nl: Schema's) as a two-line diff.
Un-skips the REQ-OBR-007a e2e as two real tests (the entry exists, is
app-scoped, and lands on SchemaDesigner; and it is absent outside the builder
context), plus 6 unit tests for the menu module.

Also corrects the Docudesk note: the id-vs-slug defect it described as blocking
is fixed, but those 7 bodies are stubs (goto + main-is-visible), so they stay
skipped rather than report coverage they do not have.
The first pass migrated menus — the cross-app chrome users meet in every app.
Everything else kept its legacy `icon-*` classes: page and tab icons, widget
headers, and the `actions[]` / `headerActions[]` entries.

Those render through the same CnIcon registry and carry the same hazard: a
legacy name with no CSS_ICON_TO_MDI bridge entry falls through to the raw
Nextcloud class, and on NC34+ light themes several of those ship a baked white
background-image — an invisible glyph, wherever it appears, not just in the nav.

Action icons name a VERB rather than a domain noun, so the vocabulary gained an
actions family (view / create / edit / delete / run / test / publish / upload /
download / refresh / …). Where a label named no concept the conversion used the
CSS bridge target, which is exactly the glyph already on screen — those entries
change dialect, not appearance.

src/icons.js is regenerated so every name the manifests reference is registered.

Verified: gate-60 icon-vocabulary clean (0 failures, 0 warnings) with the gate
now walking EVERY icon field rather than menu entries only; every icon import
resolves; eslint clean.

Spec: ADR-077.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…he cards actually get their data

My first attempt fixed the wrong endpoint. attachProductionVersionDetail() on
ApplicationsController::listMine was correct and verified live, but the cards
never see it: the virtual-apps index is a manifest type:index page over
register:openbuild/schema:application, so CnIndexPage fetches its rows from
OpenRegister's GENERIC objects endpoint and never calls /api/applications at
all. Caught by driving it -- the two data-driven applicationCard assertions
failed in 4.4s, far too fast to be a timeout.

Adds src/store/productionVersions.js: a de-duplicated, reactive uuid -> version
index that ApplicationCard consults for a UUID-string productionVersion. Two
alternatives were measured and rejected, and the reasons are in the file:
one /applications/{slug}/versions call per card is N requests from a grid; one
bulk OR call for applicationVersion returns 262 rows on this instance, each
carrying its whole manifest blob, for five scalar fields per card. Reading
/api/applications is ONE request, already RBAC-filtered, and carries no
manifests -- which is what makes the controller change load-bearing rather than
dead code.

The store never throws (an empty map means cards fall back to the placeholder,
the pre-existing behaviour) but it does WARN, because a silent catch here would
recreate the exact defect being fixed.

11 new unit tests: the index keys by UUID, N cards issue ONE request, it reads
the RBAC-filtered endpoint and not the bulk OR list, it accepts both the bare
array and {results:[...]}, and a failed lookup leaves the map empty, warns, and
does not throw.
…ecture; revert the nav entry

Driving the suite showed REQ-OBR-002/003/006a and REQ-OBR-007a all rest on a
premise the product deliberately abandoned. appinfo/routes.php maps the bare
/builder/{slug} to dashboard#builder -- a STANDALONE page booting src/builder.js
(its own webpack entry). That file says why outright: 'deliberately NOT the
OpenBuild SPA: rendering the app inside OpenBuild's shell nests one NcContent in
another (double chrome) and, worse, shares OpenBuild's router -- which has none
of the app's page routes'. src/views/BuilderHost.vue only mounts for sub-paths
falling to the SPA catch-all, never for the bare runtime route.

That is why data-testid=openbuild-builder-host was genuinely absent while the
app rendered: the failed run's snapshot shows the virtual app's Messages index
with all three seeded rows and no builder-host wrapper. It was never a timeout.

So the REQ-OBR-007a nav entry is reverted. I built it, and it could not trigger:
an entry published from BuilderHost cannot appear on the only route the
requirement is about. Shipping a feature that cannot fire is worse than not
shipping it. The findings from the attempt are recorded at the skip
(CnAppNav.itemTo has no params support; item.action is a fixed enum; the l10n
key is absent), so whoever designs the real affordance -- probably on the
standalone shell's own menu -- starts with them.

Two further real defects found and recorded rather than papered over:
- The raw JSON manifest editor is empty for EVERY app and its Save writes to a
  dead field. Evidence: the OR application row has no USAGE:
  /usr/bin/manifest export [-|URL|FILENAME]
  /usr/bin/manifest import -|URL|FILENAME key at all;
  under ADR-002 the manifest lives on the ApplicationVersion, which
  ApplicationsController itself notes 'returns null for every app'. The
  validation half of REQ-OBR-005 is unaffected and still passes.
- The version-history empty state has no fixture: every Application has at least
  one version because the wizard provisions production in the same transaction.
  Not weakened into 'renders something' -- the empty state IS the scenario.

The Docudesk id-vs-slug fix and its regression tests are untouched.
…lookup

The card badge/chip assertions failed while the product was correct. The card
paints its placeholder first and swaps to the real status when the shared
production-version lookup resolves; a one-shot textContent() read caught the
placeholder. Proof it was the assertion and not the app: the failure snapshot
Playwright captured shows the hello-world card reading

  "Hello World Virtual Published ... Version 1.0.0 Owner /hello-world"

i.e. exactly what the test demanded, a moment after it looked.

Switched to expect(locator).toHaveText(...), which polls. Nothing is weakened --
the assertions still pin the badge to the production version's REAL status and
the chip to its REAL semver, they just wait for the settled state. Applied to
both applicationCard assertions and to REQ-OBR-007b's two.
…EQ-OBR-008a/009a

The Version history panel renders its EMPTY state -- 'No versions yet -- create
a draft to start a new version.' -- for an app that demonstrably has one:

  GET /api/applications/hello-world/versions
  -> [{name: '1.0.0', slug: 'production', semver: '1.0.0', status: 'published'}]

while .version-history__row resolves to 0 elements across 44 polls over 20s.

Evidenced from the failure snapshot, not inferred: the tab mounts, its heading
and empty-state paragraph are both present, so this is VersionHistory.vue not
receiving/keeping rows -- not a missing tab and not a timeout. The sibling Diff
tab reads obApp.slug from the same mixin and passes, which rules out the obvious
cause. Both REQ-OBR-009a scenarios are blocked behind it: rollback and its
confirmation modal are per-row actions in a panel that renders no rows.

Skipped with that evidence rather than left red, and the assertions stay at this
path for whoever fixes the panel.
Same defect class as the ApplicationCard fix, second location. Spec C moved
status off the Application and onto the ApplicationVersion, but
ApplicationDetailHeader.applicationStatus still read application.status -- the
legacy field. Once the card was fixed the two disagreed for the same app:
hello-world's card read 'Published' (its production version) while the detail
header read 'Draft'. REQ-OBR-007b requires the editor header to carry 'the same
badge' as the list row, so this was a real inconsistency, surfaced by the
REQ-OBR-007b test rather than papered over by relaxing it.

Reads the resolved production version's status first, keeping the
Application-level value as the fallback for legacy rows predating the versioned
model. The header already resolves productionVersion from its own fetched
versions list, so this adds no request.
…verage

test(e2e): un-quarantine runtime coverage — real assertions, honest skips
…d (4/4) (#59)

Both were quarantined on #41. Re-measured against the Vue 3 build on the
disposable :8099 instance; neither was blocked by anything #41 covered.

applicationCard — green 4/4 completely untouched. It only ever reads the
Applications INDEX, which #41's blockers (builder host / detail / editor /
version pages) never affected. The quarantine was applied file-by-file across
the whole e2e directory rather than to the surfaces actually broken.

builder-undo-redo — was 0/8; now 12 pass, 1 self-skips. Three harness defects,
each measured rather than guessed:

  - the first-open support dialog mounts a `.modal-mask` over the designer, and
    `.page-list-editor__add` sits under it. elementFromPoint over the Add button
    returned `<h2 class="dialog__name">`, so every click retried until timeout.
    Fixed with the shared suppressSupportDialog() (+ dismissOverlays()).
  - REQ-BUR-005 opened the Schemas page WITHOUT `?_version=production`, so the
    designer fell back to the legacy `openbuild-{slug}` register a
    wizard-created app does not have. The schema was created but never
    attached ("Schema created, but could not be attached to register
    openbuild-pw-undo-redo", with a Nextcloud login page as the response body),
    the field editor never rendered, and the Add-field click waited out the
    timeout. The real in-app nav carries the marker via buildVersionedRoute().
  - it then drove Save with the added field still unnamed. Save is gated on
    fieldNamesUnique, which rejects any unnamed property — the button was
    correctly disabled and the click timed out. The field is now named, with a
    run-unique suffix: this test saves, so a fixed name made the NEXT run stage
    a duplicate that the same gate correctly refused (passed in isolation,
    failed on re-run).

Also made the schema row lookup `.first()`: the designer namespaces a created
schema to `{app}-{slug}`, which still contains the bare slug, so every previous
run's schema matched the filter too.

Verified: 12 passed / 1 skipped, twice consecutively, on Vue 3 at :8099.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
… under Vue 3 (#60)

`ApplicationDetailActions.vue` registered the export dialog as

    const ExportDialog = () => import('../dialogs/ExportDialog.vue')

which is Vue 2's async-component form. Vue 3 accepts a plain function as a
FUNCTIONAL component, so this registered a component whose render function
returns a Promise. It rendered nothing — no error, no warning, no failed
request. Clicking Export set `exportOpen = true` and the `v-if` passed; the
dialog simply never appeared. Export was unreachable from the app detail page
for every user, and `ExportDialog.vue`, `ExportJobsTab.vue` and the whole ZIP
pipeline behind it were dead UI.

It was the last bare `() => import(…)` component registration in src/ — every
sibling dialog in this file is imported eagerly — so the Vue 3 migration missed
exactly one call site. Fixed with `defineAsyncComponent()`, keeping the lazy
load the original intended.

Found by driving the quarantined export-zip e2e suite rather than reading it:
the suite blamed openbuild#41, and the assertion that actually failed
(`combobox[name=/target/i]` not found) looked like ordinary selector drift.

tests/e2e/export-zip.spec.ts gains an ACTIVE regression guard asserting the
dialog mounts with its Version/Target/License pickers and a reachable
"Start export" — the exact thing that broke. Its comboboxes carry no accessible
name in this @nextcloud/vue version, so the guard matches the `<label for>`
text instead of a role+name lookup.

The original ZIP round-trip block stays skipped: it polls a
`[data-test="export-job-row"]` attribute that exists nowhere in src/ and needs a
background job to reach `succeeded`. That reason was already documented in the
file and is unrelated to this defect.

Verified: the dialog opens live on the Vue 3 instance (3 pickers, "Start
export"); guard green; component-blocks + builder-undo-redo + applicationCard
re-run green (18 passed) against the rebuilt bundle.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…(8 pass, 3 honest skips) (#61)

Both blamed openbuild#41. Neither was blocked by it.

applicationDetailOverview — the KPI, activity and structural-widget rows were
addressed as `.ob-detail-header__*`. Those rows are rendered by
ApplicationDetailDashboard.vue under an `ob-detail-dashboard__` prefix; only the
hero, controls and version pills belong to ApplicationDetailHeader.vue. The UI
was built and rendering the whole time — the selectors named a component that
does not own it, so three scenarios could never match. The window toggle is a
`role="group"` of NcButtons marked with `aria-pressed`, not a `__window-btn`
with an `--active` class.

Also fixed here:
  - the block form-logged-in per test instead of inheriting globalSetup's
    storageState. Nextcloud's brute-force throttle fires after a handful of
    near-simultaneous form logins from one IP, which is why the rest of the
    suite stopped doing that; the now-dead loginAs helper is removed.
  - REQ-OBADO-003 captured the insights re-fetch it exists to verify and then
    threw it away (`void req`, "best-effort"). That is the green-but-dead shape:
    it would have reported coverage while proving only that a button
    highlights. The request IS the requirement, so it is now asserted.
  - the activity scenario matched `.ob-detail-dashboard__activity`, the WRAPPER
    that contains the empty state, which made its own "never both at once"
    assertion fail against a correct UI. It matches the chart now.

The three remaining skips are honest: the pill-strip and promote-affordance
scenarios need a development -> staging -> production chain, and hello-world has
one version here, so their own `pillCount < 2` guards fire.

page-designer — rewritten onto the real designer. It navigated to
`/applications/hello-world/design`, which is not a route (the designer is
`/builder/:slug/pages`), and asserted `application-editor__*` classes that exist
nowhere in src/. The add-page -> save -> render journey now drives
PageDesignerHost, verifies the save reached the STORED manifest rather than the
editor buffer, and asserts the new route renders in the builder host. It also
gains a real REQ-OBPD-002 assertion: Confirm stays disabled until a page type is
chosen from the closed enum.

Its second scenario ("edits survive a Design <-> Raw JSON tab switch") is
deliberately NOT rewritten: that tab pair is spec drift — the page designer
never shipped a Design/Raw-JSON toggle, and the raw manifest editor is a sidebar
tab on the app DETAIL page. Rewriting it would invent coverage for a surface
that does not exist; the drift is recorded in the file header.

Verified: 8 passed / 3 skipped on the Vue 3 instance, twice consecutively.
Both specs seed and reset their own fixtures, so re-runs are idempotent.

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

`openSaveAsTemplate()` read the manifest off the Application record:

    this.saveTemplateManifest = this.obApp.manifest
        || (this.obApp.currentVersion && this.obApp.currentVersion.manifest)
        || {}

An Application carries NEITHER field. The manifest lives on the
ApplicationVersion, and `GET /api/applications` returns no `manifest` and no
`currentVersion` for any app — the seeded hello-world included, so this was
never app-specific. The capture therefore always fell through to `{}`,
SaveAsTemplateDialog validated an empty object, and the dialog opened showing

    The captured manifest is invalid and cannot be published:
    /version must be a string /menu must be an array /pages must be an array

with Save permanently disabled. Saving an app as a template was impossible for
EVERY application. Now resolved through the endpoint that owns the resolution,
`GET /api/applications/{slug}/manifest`, with the old fields kept as fallbacks.

Found the same way as the Export defect in #60: by driving a quarantined suite
instead of reading it.

Test changes:

save-as-template — un-quarantined and NARROWED. Its capture half is live and is
now asserted end to end (dialog → ApplicationTemplate record in OpenRegister,
org-local, carrying the source manifest and no object rows), with a per-run
baseline reset so the slug-collision guard cannot block a re-run. Its clone
round-trip is NOT rewritten: nothing in src/ calls
`POST /api/applications/from-template/{templateSlug}` any more, so an org-local
template cannot be cloned from the UI at all. Its "viewer" test was renamed and
trimmed — it ran on the ADMIN session (so it proved scoping, not rights-gating)
and its seeded-card assertions passed against an empty locator, since the
GitHub-only gallery renders no seeded cards.

template-gallery — un-quarantined and rewritten. It expected four locally
seeded cards and a clone flow; commit f8e0eec ("keep GitHub-only") made the
Templates tab a server-backed GitHub search, so the two cards a run finds are
repos, not templates. My earlier triage called this a fixture gap — it was not:
all four fixtures are seeded and correct. It now covers the tab pair, asserts
the search is forwarded to OpenBuild's own endpoint (never github.com directly),
and tolerates GitHub being unreachable or rate-limiting.

⚠️ Recorded in both files: the four seeded ApplicationTemplates and the
from-template endpoint are now an ORPHANED CAPABILITY — seeded on every install,
routed, and unreachable from the UI. That is a product decision, not a test fix.

builder-undo-redo — REQ-BUR-005 baselined its field-row count the instant the
detail mounted, catching a partially-painted list; it saves a property per run,
so the schema grows and the race got easier to hit. It now polls until two
consecutive reads agree.

Verified on the Vue 3 instance: 13 passed / 5 skipped across save-as-template,
template-gallery, page-designer, applicationDetailOverview and export-zip;
builder-undo-redo + component-blocks green; REQ-BUR-005 green twice in a row.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…onnectors[] binding

v1 emits four file kinds; everything that makes an app WORK — the shared data
registers it binds, the OpenConnector configs feeding them, its automations and
its skills — is left behind. spectr is the sharp case: its manifest references
spectr-live 109 times and it has no meaningful per-app companion schemas, so v1
serialises it to a manifest plus ZERO schemas/ entries and reports success.

Connectors are bound EXPLICITLY (21-connectors.json, ADR-037 fragment mirroring
20-data-registers.json) rather than inferred from register targets. Inference
needed no schema change but would have made an app's published surface depend on
which OTHER objects happened to target a shared register — the same app would
export differently on two instances.
Serializer gains four channels — data-registers/, connectors/, automations/,
skills/ — and stamps formatVersion 2.0. Parser accepts BOTH majors: a v1 repo
parses byte-identically to before, a v2 repo additionally yields channels.

Collectors are total, mirroring collectCompanionSchemas(): a missing source
yields no entries rather than an exception, so serialisation never blocks a
publish. The descriptor's per-channel counts are what stop that becoming a
silently empty artefact — spectr under v1 serialised to a manifest plus ZERO
schemas and reported success.

Connectors come from the EXPLICIT Application.connectors[] binding, with
one-level dependency resolution (a synchronization's source/mapping) reported
separately from declared entries. Secret stripping is defence in depth, not the
primary control: credentials live in OpenRegister's broker and configs reference
them by UUID — verified against the live instance, where source 23 carries
host/port/user/dbname plus a credential UUID and no password field at all.

Parser channel reading is deliberately LENIENT where companion-schema parsing is
strict: a companion schema is load-bearing, a channel entry is additive, so one
unreadable connector must not make a valid repository unimportable.

formatVersion had NO test coverage before this change — nothing asserted it, so
the field governing whether a repo parses at all could change silently. Now
pinned in both directions, and the v1 back-compat test was verified to fail
without the fix.
…don't exist

Pre-existing gate-30 failure, confirmed by running the gates on origin/development
without any of this change: deepLinks[0] declared schemaSlug 'example' (no such
schema) and urlTemplate /apps/openbuild/#/examples/{uuid} (no matching page
route). A deep link to nothing — template leftover, removed rather than pointed
somewhere arbitrary.

Also fixes the @SPEC anchors: gate-46 slugifies an apostrophe, so
"the app's whole configuration" is ...the-app-s-whole..., not ...the-apps-whole...
…(+5 tests) (#65)

Enabler A of the remaining-work plan. Several suites needed more than one
ApplicationVersion to assert anything at all, and hello-world ships exactly one
(`production`), so they skipped themselves or asserted nothing.

New shared fixture, tests/e2e/support/versionChain.ts: a dedicated app carrying
development --promotesTo--> staging --promotesTo--> production. Two contract
details, both learned by driving the API rather than reading it — version create
REQUIRES a `manifest` (422 without it), and `register` may be omitted so the
chain inherits production's register (manifest-only versioning, REQ-OBV-107)
rather than minting one per version. Never pointed at hello-world: promotion is
data-affecting and other suites assert against that app.

versionRouting — 9.1 and 9.3 un-quarantined (0 -> 2 passing):
  - 9.1 asserted the designer had mounted via
    `[data-testid="schema-designer"], .ob-schema-designer, h2, h3` filtered on
    /schema|design|version/i. The first two exist nowhere in src/, so it would
    have passed on any page with a matching heading. Retargeted at the real
    `.openbuild-schema-list`.
  - it also navigated `${BASE}/index.php/apps/openbuild/...`; that prefix breaks
    the SPA route (the schema list never mounts). Every other spec uses the
    pretty form, and so does this one now.
  - 9.3 accepted "any of three signals", ended on
    `expect(productionActive).toBe(false)` — which passes when NO signal exists —
    and console.warned that the signal was missing. Measured: a version-less URL
    leaves the URL untouched and its manifest request carries no version marker,
    so the most-upstream fallback is NOT observable from outside; it is unit-
    covered in tests/composables/useApplicationVersion.spec.js. Rewritten to
    assert what is real: a version-less URL still resolves and renders, and does
    not fall back to production while an upstream exists.
  - 9.2 still needs the rbac-viewer user (Enabler B).

applicationDetailOverview — now 9 passing, 1 skipped (was 7/3). Pointed at the
chain fixture, so the pill-click `?_version=` scenario and the
Promote-affordance scenario (REQ-OBADO-012) run for real instead of hitting
their own `pillCount < 2` guards. The chain renders as
`development`, `staging`, `* production` with promote affordances on exactly the
two non-terminal pills.

promoteDestructive — STILL skipped, reason now VERIFIED rather than inferred:
clicking `.ob-detail-header__pill-promote` opens nothing. `onPromoteClick()`
looks for `window.openbuild.openPromoteDialog`, defined nowhere in src/, then
emits a `promote` event no parent handles, and logs "promote dialog not
registered — deferred". PromoteVersionDialog.vue is built and unit-tested but
imported by nothing, so the affordance ships to users and no-ops. That is
unfinished work, not a regression; wiring the call site is a feature change with
destructive semantics. Also flagged for whoever writes it: these scenarios end
by clicking Confirm, which performs a REAL promotion (empty-start wipes the
target register) and would make the suite non-idempotent.

version-rollback — STILL skipped, reason measured: its rollback surface DOES
exist (`.version-history__row` / `.version-history__btn--danger` in
src/views/VersionHistory.vue), but it reaches the app via `[data-slug]`, an
attribute ApplicationCard.vue never renders, and edits "the first textarea on
the page" when the manifest editor is a sidebar tab on the detail page.

Verified: 11 passed / 2 skipped across versionRouting + applicationDetailOverview.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ne rbac-403 (#66)

Enabler B of the remaining-work plan.

globalSetup now provisions the four RBAC fixture users (rbac-owner / -editor /
-viewer / -outsider, with the credentials the specs already expected) and their
groups through the OCS provisioning API, then mints ONE stored session per role
under tests/e2e/.auth/{id}.json.

Doing it here rather than in the specs is the whole point: the permission suites
used to form-log-in per test, and consecutive logins from a single IP trip
Nextcloud's brute-force throttle — once it fires, every later spec falls back to
/login and the run goes false-red. Each role logs in exactly once, sequentially,
with 1.5s of spacing.

Two things measured rather than assumed while building it:
  - OCS v2 answers `statuscode: 200` on user creation; the v1 shape answers 100
    (created) / 102 (exists). The check accepts all three, so a re-run is
    idempotent instead of noisy.
  - waiting on `#header` after submit is unreliable — the landing page differs
    per user and the selector races the theming bundle. It waits on the URL
    leaving /login instead, with a 60s budget because a throttled login stalls
    well past any shorter one.

Provisioning failures are logged, never thrown: a missing RBAC user must not
take down the suites that do not need one.

rbac-403 — un-quarantined, 2/2 green. Both of its documented blockers are gone:
  - it attaches the stored outsider session instead of logging in per test;
  - its deny assertions targeted `[data-app-slug="…"]` and
    `[data-testid="builder-host-hello-world"]`, neither of which is emitted
    anywhere in src/ (BuilderHost.vue stamps `data-testid="openbuild-builder-host"`,
    with no slug). Both counts were 0 for an admin who CAN see the app, so the
    assertions proved nothing. They now assert what an outsider actually gets,
    measured against the instance: an application list with no cards and no
    mention of the slug, and an "App not found" screen with no builder host
    mounted.

Verified: rbac-403 2/2 green, twice.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…d to a dead field (#67)

* fix(detail): the raw manifest editor showed {} for every app and saved to a dead field

`ApplicationManifestTab.vue` seeded its buffer from
`JSON.stringify(app.manifest || {}, null, 2)`. Under the versioned model
(ADR-002) the manifest lives on the ApplicationVersion, and the Application
record carries no `manifest` key at all — ApplicationsController says so itself:
"reading `applicationArray['manifest']` directly returns null for every app".

So the integrator-facing raw JSON editor displayed `{}` for EVERY application,
and Save then wrote that through `obPatchApp({ manifest })` onto a field nothing
reads back. Empty on read, dead on write.

This is the same root cause as the "Save as template" defect fixed in #63 — the
Application record is not where the manifest lives — now found in a third
consumer. The tab loads and saves through `/api/applications/{slug}/manifest`,
which resolves the active version. Note the endpoint's asymmetry, which the code
comments: GET returns the manifest bare, PUT expects it wrapped in
`{ manifest }`. A failed load falls back to the record and surfaces the error
rather than blanking the editor.

The defect was diagnosed (not fixed) by whoever wrote the skip on
`REQ-OBR-005 — a valid edit is PUT to OR and survives a reload`; that test is
now un-skipped and green. It asserts the editor loads the REAL manifest (the
exact thing that broke), that a valid edit reaches the SERVER, and that it
survives a reload — then restores the fixture.

tests/e2e/application-editor.spec.ts is deleted. It was kept as a signpost to
this REQ-OBR-005 coverage while that coverage was skipped; now that the
replacement runs, the pointer is just a second, dead name for the same scenario.

Verified: 18 passed / 13 skipped across openbuild-runtime, save-as-template and
rbac-403 on the Vue 3 instance.

* fix(detail): load and save the manifest tab through the version manifest API

The code half of the previous commit (its `git add` aborted on the deleted
pointer file, so only the deletion landed). Repoints ApplicationManifestTab at
GET/PUT /api/applications/{slug}/manifest and un-skips the REQ-OBR-005
persistence test that the defect had blocked.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
feat(app-repo): v2 format carries the app's whole configuration
…ial probe (#71)

Adds tests/e2e/github-store.spec.ts, covering what a granted GitHub credential
actually buys: the store search runs on the AUTHENTICATED path — outcome `ok`,
not rate-limited, `brokerCredentialAvailable` true — and returns a non-empty
catalogue, instead of the anonymous path that rate-limits quickly and makes the
gallery render its "GitHub is rate-limiting anonymous browsing" note.

This file is the ONE deliberate exception to the instance-isolation rule, and
says so loudly: it targets the instance that HOLDS a credential (the shared dev
box by default, overridable via OPENBUILD_GITHUB_STORE_URL), because the
disposable e2e container has no credentials at all
(`/api/credentials` → `{"results":[]}`). It is strictly READ-ONLY there —
request-level GETs, no application/version/credential/object created, and it
never drives Install/Clone, which writes.

Request-level rather than UI, deliberately: the shared instance does not run
this branch's build (nothing deploys to it from here, by standing rule), so
asserting against its DOM would measure whichever checkout happens to be
installed. Driving it proved the point — `.template-gallery` never mounts there.
The credential's effect is visible in the API contract regardless of frontend
build. UI-level gallery behaviour stays covered against the disposable instance
in template-gallery.spec.ts.

Also asserts the listing never leaks a token value.

Two things learned while building it, both recorded in the file:
  - form logins against the shared instance trip Nextcloud's brute-force
    throttle (it starts bouncing to /login?direct=1&user=… and stays tripped),
    so this uses Basic auth and never the login form;
  - arming a non-GET route guard BEFORE login breaks the sign-in flow, since the
    login POST and its redirect chain are session setup rather than data writes.

⚠️ CAVEAT — not yet observed green. The assertions were verified by hand against
the live endpoint (outcome ok, rateLimited false, brokerCredentialAvailable
true, 2 cards), but the shared instance went to 503 before the spec itself could
run authenticated, so both tests currently SKIP on the capability probe. That is
the gate behaving correctly, not a pass. Re-run once the instance is back:
    npx playwright test tests/e2e/github-store.spec.ts

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Both of globalSetup's `occ` helpers hardcoded the container:

    docker exec -u www-data nextcloud php occ openbuild:seed-hello-world-fixture
    docker exec -u www-data nextcloud php occ config:system:set ratelimit… \
      && docker exec nextcloud apache2ctl graceful

`nextcloud` is the SHARED dev box on :8080. The suite is driven with
PLAYWRIGHT_BASE_URL pointing at the disposable container on :8099, so every run
seeded fixtures into — and set a system config on, and gracefully restarted
Apache in — an instance the tests were not asserting against.

A cross-instance write on every single run, and silent: it only surfaced
because :8080 went into maintenance and the seed started logging
"hello-world fixture seed failed (specs needing it will fail)" while the run
otherwise looked fine. Same class of drift as the base-URL bug that
tests/e2e/support/baseUrl.ts documents, and the same failure mode as the
incident that had a session's numbers retracted.

The container is now resolved from the PUBLISHED PORT of the instance under
test (`docker ps --filter publish=<port>`), not by name. Name matching is what
makes this trap easy: `docker ps -f name=nextcloud` is a SUBSTRING match and
will happily return `nextcloud`, `ob-vue3-e2e-nextcloud` or a stale
`nextcloud-old`. The port is the one thing that cannot disagree with the URL the
tests actually talk to.

When the container cannot be resolved, both helpers SKIP with an actionable
message (set OPENBUILD_E2E_CONTAINER, or the per-command overrides) and
deliberately do NOT fall back to a hardcoded name — the fallback is the bug.
The rate-limit helper says so explicitly, since it writes config and restarts
Apache and must never guess an instance.

globalSetup also now logs which instance and container it resolved, so a
mismatch is visible in the first line of any run rather than inferred from a
failure three specs later.

Verified: `instance under test: http://localhost:8099 (container: ob-vue3-e2e)`,
the hello-world seed succeeds against it, and applicationCard stays 4/4 green.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
rubenvdlinde and others added 30 commits August 16, 2026 14:26
)

gate-49 controller-exception-translation reported
`AgentsController::loadRunsForAgent()` as calling a service method that may
throw a tracked exception with no try/catch and no `@throws`.
`RegisterMapper::find()` / `SchemaMapper::find()` raise
DoesNotExistException when the `openbuild` register or the `agentRun`
schema is absent, and `\Exception` when their RBAC check refuses.

PROPAGATION IS THE CORRECT ANSWER HERE, not a catch. The helper returns
`array`, so it cannot answer with a JSONResponse; and swallowing the
failure to return `[]` would render an empty run list indistinguishable
from a genuinely empty history — in the one endpoint whose stated purpose
is that agent runs are "transparently logged and reviewable". The caller
`runs()` already wraps the whole body in `catch (Throwable)`, logs the
cause and answers HTTP 500, which is the honest status for a broken
install. So the docblock now says what was already true, which is exactly
what the gate's `@throws` branch is for.

This is also why the sibling `loadAgent()` / `loadApplication()` helpers
may return `null` and this one may not: there, `null` becomes a 404 the
caller can act on. That reasoning is recorded in the docblock rather than
left for the next reader to re-derive.

No exclusion comment was used. The change is 17 lines, all docblock —
mechanically confirmed (`git diff` adds no non-comment line).

Measured with the gate's own script (ConductionNL/.github@main,
`scripts/run-hydra-gates.sh --full`), over the repo's lib/Controller files:

  before  [gate-49] controller-exception-translation: FAIL — 1 controller method(s) missing try/catch or @throws
  after   [gate-49] controller-exception-translation: PASS

Repo failing-gate count 7 -> 6; no other gate verdict changed.
phpcs, phpmd (app ruleset + unusedparams) and phpstan are clean on the
file; psalm reports the same 4 pre-existing `UndefinedClass
OCA\OpenRegister\Contract\ObjectServiceInterface` errors as it does on the
unmodified file at `origin/development` — an out-of-container autoload
artefact, unchanged by this commit.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…HPUnit 35→0 (#221)

PR #208 (`refactor/adr-084-type-hint-the-contract`) landed half-applied on
`development` at e58292d. Run 31937314387 was `cancelled` at run level with
`PHP Quality (phpstan)` and the three PHP 8.4 PHPUnit cells `failure`; the
three PHP 8.3 cells were `cancelled`, so CI never gave a verdict on them.
Measured locally: 8.3 and 8.4 are identical (849 tests, 41 errors,
4 failures each), so the 8.3 cells were failing too.

lib/ — phpstan 14 -> 0 over 116 analysed files
  - 10x `is_array($results|$rows) === false` after `findAll()`. The contract
    declares `findAll(): array`, so the guard is statically dead. It was dead
    before the refactor too (openregister's own `findAll` is `: array`); the
    refactor only made the declared type visible to phpstan, because the leaf
    used to reach the service through `$container->get()` = `mixed`. Removed,
    keeping the `=== []` / `count() === 0` half where one existed.
  - `SeedHelloWorldFixture::create()` declared `: Db\ObjectEntity` while
    returning what `saveObject()` now answers. Retyped to
    `ObjectEntityInterface`; every caller only uses `getUuid()`.
  - `ApplicationsController::resolveApplicationBySlug()` `@return` tuple
    retyped for element 0. The callers that need the concrete entity for the
    audit write still narrow with `instanceof`.
  - `AppRepoSerializer::findConnector()` kept an `is_array($found)` branch on
    a `find()` result that the contract types as `?ObjectEntityInterface`.
    Returns `$found->getObject()` directly.

tests/ — 41 errors + 4 failures -> 0 (8 remaining are the container's
missing ext-zip, identical on the base)
  - Four call sites carried a `objectService:` named argument appended after
    a positional argument that already filled the slot, two of them bound to
    a variable declared LATER in the file (`$provider`, `$entity`). Removed.
  - `AbstractToolHandler` and `OpenBuildToolProvider` gained `$objectService`
    as parameter #5; the test constructions still passed the old positional
    order. Fixed, naming `permissionResolver:` where it now shifts.
  - `AppChannelApplierTest`'s `saveObject` expectation was one constraint
    short of the contract's parameter list, shifting `failIfExists` onto
    `$currentUser` so the never-overwrite assertion never matched.
  - `ApplicationsControllerDiffVersionsTest` mocked the concrete
    `Service\ObjectService`; mocks the contract now.

Four RBAC tests were passing while testing nothing. `requireWriteRole()`
reads the INJECTED object service, but the tests only wired the container,
so the gate answered `not_found` — the same code those tests assert for a
downstream reason. `testUpsertPageAllowedForOwner`,
`testPromoteVersionAllowedForExplicitOwner`,
`testUpsertPageAcceptsValidRoute` and `testAddWidgetAcceptsKnownWidgetType`
were green without the gate ever being cleared. The double is now shared
between the injection and the container stub, and the reason is recorded at
each site so it cannot be undone silently.

No baseline entry, no `@phpstan-ignore`, no skipped test, no widening of
`ObjectServiceInterface::saveObject`. No `saveObject()`/`updateObject()`
call site is touched, so object identity is carried exactly as before —
`git diff cd07105..e58292d` shows #208 changed none of them either.

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

Burning down `@nextcloud/no-deprecated-library-props`, one of the four autofixes
withheld in the eslint 10 migration (ConductionNL/.github#455). Like the
openconnector one before it, this turned out not to be lint debt.

`NcDialog` in `@nextcloud/vue` 9 declares `noClose`. `canClose` appears **ZERO**
times in the shipped component — it is not a prop, so all four bindings fell
through as plain attributes and did nothing. Every one of these dialogs has been
dismissible while the work it guards is still running:

  ExportDialog           :canClose="!submitting"
  ImportDataWizard       :canClose="!importing"
  CopilotGenerateDialog  :canClose="state !== 'planning' && state !== 'executing'"
  PromoteVersionDialog   :canClose="true"

⚠️ `noClose` is the INVERSE of `canClose`, and three of these are EXPRESSIONS,
not literals. A rename that kept the value would have inverted the guard —
locking the dialog exactly when it should be closable and vice versa. That is
the second instance of this shape in two apps, and it is precisely why the
autofix was withheld:

  :canClose="!submitting"   ->  :noClose="submitting"
  :canClose="!importing"    ->  :noClose="importing"
  :canClose="a && b"        ->  :noClose="!a || !b"      (De Morgan, not a rename)
  :canClose="true"          ->  :noClose="false"

Verified
  - `@nextcloud/no-deprecated-library-props` suppressions 4 -> 0, and
    `--prune-suppressions` removed the entries rather than leaving them stale.
  - eslint on src: 0 errors before and after; 138 warnings before and after.
  - vitest: 1378 passed.
Four files were failing prettier --check on development: IconUploadSection.vue
and three e2e specs. Pre-existing — this branch touches nothing else.

Worth noting for the next person: the first --write left two of the four still
failing --check, and a SECOND --write converged them. So "I ran prettier and it
is still red" is not necessarily a config problem; run it again before going
looking for one.

prettier --check over the job's own glob now reports all matched files clean.
style: satisfy the Frontend Check (format) job
…re not true

Both failures were in the collection, not the app.

Request 3 called openregister's approval-steps list with NO headers and got
412 "CSRF check failed", so the array assertion then ran against the error
body. Nextcloud exempts a request from CSRF when it carries
`OCS-APIRequest: true`, which is exactly what every other collection in this
repo sends to openregister. Added, with Accept beside it.

Request 4 asserted a bare 404 for the approve path, and got 405. The premise
was wrong rather than the app: openregister's AppHost::Routes::standard()
appends the SPA catch-all at `/{path}` as a GET route, so a POST to an
unrouted path matches that pattern but not its verb and Nextcloud answers
Method Not Allowed. Both codes prove the same thing — openbuild serves no
approve endpoint, which is the ADR-022 point — and 404 alone would only hold
in an app with no catch-all. The assertion now accepts either and says why.

Verified openbuild's routes carry no approval entry at all, so the thing being
asserted is still true.
fix(newman): the approval-steps collection asserted two things that are not true
…hange, planning only) (#226)

schedules-editor shipped ScheduleEditDialog.vue / SchedulesSection.vue /
schedules.js reading and writing the classic OpenConnector dialect directly
(GET .../openconnector/synchronization, action: "openconnector:synchronization").
That dialect is being retired fleet-wide in favour of OpenRegister's native
Flow engine (ADR-065); the fleet-wide policy is being recorded in parallel as
hydra change adr-092-openconnector-dialect-retirement (renumbered from an
earlier ADR-091 attempt, which collided with an unrelated, already-merged
ADR-091). Actual cutover is gated on openregister's flow-sync-decomposition
change landing real decomposed nodes — that change has no tasks.md yet, so
implementation has not started there either.

This is a NEW change (not folded into schedules-editor): schedules-editor's
own tasks.md is fully checked off and its code is already merged
(0b6eddc), so it reads as closed work rather than in-progress. Planning
only — no code changes. Audits the current classic-dialect call sites,
flags AutomationEditDialog.vue as an adjacent surface with the identical
call that is NOT in scope here, defines the open design questions for the
Flow-native target shape, and gates all real implementation tasks behind
the two blocking dependencies above.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Hydra Gates stays red on gate-66 (13 ADR-083 violations) — that is a dependency
-shape question about how this app reaches OpenRegister, not something to fold
into a gates sweep. Two of three gates are cleared here.

## gate-26 — the coverage existed and nothing could read it

All three views ARE driven by e2e specs; the component names just appear only in
COMMENTS, never in executable text:

  * DashboardIndex  <- dashboard-overview.spec.ts (name in the file docblock)
  * FeaturesRoadwap <- features-roadmap.spec.ts   (name in the file docblock)
  * TemplateGallery <- openbuild-template-catalogue.spec.ts (name in an inline
    comment on line 101)

FeaturesRoadmap is the sharpest case: its manifest page is `type: "roadmap"`
with NO `component` key, so the component name genuinely appears nowhere in any
executable text in the repo.

Each spec now binds its route to a constant named after the component and
navigates through it — 11 `goto()` calls rewritten across the three files. URLs
unchanged. gate-26: 3 findings -> PASS, 5 pages all proven. prettier clean.

## gate-7 — six methods, two different reasons, both checkable

RulesController `evaluate` / `schema` / `testAll`: authorization is delegated to
OpenRegister's schema RBAC. Every read goes through this controller's private
`query()`, which calls `searchObjectsBySlug(..., _rbac: true,
_multitenancy: false)`. The tenancy opt-out is deliberate and already documented
there — `openbuild` is a SYSTEM-WIDE register, not org-scoped, so a true org
filter would throw and break resolution for every caller. `_rbac: true` is the
guard and it is explicit, which is the opposite of the usual gate-7 finding.

StoreController `search` / `install`, ShopController `githubInstall`: these
address no openbuild-owned object at all. The slug identifies a template in an
EXTERNAL catalogue (the configured store registry, or a GitHub repo), so there
is nothing of another tenant's to reach by guessing it, and the install path
CREATES a new app owned by the calling user rather than reading an existing one.

Accounting: 6 findings, 6 new tags, 0 remaining. The tree now shows 8 because it
already carried 2 — reconciled against `development` rather than trusting the
total, and each tag confirmed to sit on the method it was written for.
fix(gates): clear gate-7 (6) and gate-26 (3)
gate-66 (openregister-dependency-shape) reported 13 ADR-083 rule 1
violations. Reproduced locally on the same tree — 13 findings over 134
files, now 0 over 134 (not a zero-file run).

MCP handlers (7 files, 8 lookups). AbstractToolHandler already had
OpenRegister's published ObjectServiceInterface injected as a
constructor property (ADR-084), and used it for the per-Application RBAC
gate — while every handler body resolved a SECOND instance of the same
service out of the container by string name. The provider test's own
docblock documented the split. The property is now protected and the
bodies use it. UpsertSchemaHandler is the exception: OpenRegister
publishes no contract for SchemaMapper/RegisterMapper (lib/Contract/
holds only ObjectServiceInterface and ObjectEntityInterface), so the
lookup stays and the availability question is asked out loud instead,
turning an opaque internal_error into a stated reason.

ExportsController, ExportJobService, JobOwnerImpersonator. These already
guarded the reach with `$container->has('OCA\OpenRegister\...')`. That
is not a behaviour change either way: NC's SimpleContainer::has() IS
`isset($this->container[$id]) || class_exists($id)`
(server/lib/private/AppFramework/Utility/SimpleContainer.php:50). The
guard is now spelled class_exists(), which is the idiom ADR-083 and the
rest of this app already use, and which a reader can evaluate without
routing the answer through the container.

Separately — the defect that leaves every export at status "queued":

JobOwnerImpersonator::impersonate() reads the object to discover WHO to
impersonate. That read necessarily precedes the impersonation, so the
caller is still the background job's session, which is nobody. An
RBAC-checked read is evaluated as `Anonymous` and refused by any schema
that does not grant anonymous read:

  OpenBuild: owner impersonation lookup failed for object <uuid>:
  User 'Anonymous' does not have permission to 'read' objects in
  schema 'Export Job'

It is a chicken-and-egg, not a permission decision. The `fail`
transition that should have recorded why is refused for the same reason,
so the job never even reaches `failed` — it sits at `queued`, looking
exactly like a job nobody picked up. That is what
tests/e2e/export-flows-and-agents.spec.ts:489 reports in CI.

The opt-out is one read: the id is not user input but the argument the
pipeline enqueued for a job it created; exactly one field is consumed
(getOwner()); and the outcome is strictly MORE restrictive, because the
work then runs AS that owner with every write RBAC-checked against them.
Failing the lookup does not deny the write — it runs the job as Anonymous,
the weaker identity.

The test uses a hand-written fake rather than a PHPUnit mock on purpose:
the production call passes named arguments, and a generated mock cannot
observe those — it sees its own defaults (_rbac => true) whether or not
the fix is present, so it would pass on broken code. Positive control
run both ways: with find($objectId) it reports _rbac => true and fails;
with the fix it passes.

Also fixes three pre-existing phpcs errors in OpenBuildToolProvider's
constructor docblock (standing rule).

Verified: 850 unit tests green (was 849), phpcs clean of errors across
lib/Mcp, gate-66 0/134.
E2E — 2 of the 3 failures.

builder-host "detail page must render the seeded body text". The seed is
correct and the text really is on the object; what is missing from the
page is the whole Data widget. From the CI trace of job 95207190738 the
object response completed at 16.171s and the schema at 16.181s — ten
milliseconds apart, OBJECT FIRST — and nc-vue's CnDetailPage flips
shouldRenderAutoBody as soon as the object lands, materialises the auto
body EXACTLY ONCE, and materializeAutoBody() DROPS the Data widget when
currentSchema is still null. Nothing rebuilds it when the schema arrives
and fetchSchema fails silently, so there is no error state either. On a
developer box the schema usually wins the race, which is why this only
ever failed in CI.

MessageDetail now ships the ejected default grid, byte-for-byte nc-vue's
own defaultDetailGrid() — which is also exactly what OpenBuild's edit
button writes the moment anyone edits the page. That takes the
explicit-grid path, where the widget's schema arrives through
CnPageRenderer's read-through context and fills in reactively. It changes
no pixels; it removes the race. THE nc-vue DEFECT IS NOT FIXED BY THIS:
every other auto-body detail page still has it. Reported separately.

app-icon-management "Remove in the dark slot". The trace shows the
sidebar opening (assertion PASSED at 1148937ms) and the tab click issued
42ms later never returning; the snapshot taken 120s on has no sidebar and
no tablist at all, ending on a "Open sidebar" button. The page closes the
sidebar again while it is still hydrating. The tab BUTTON stays in the
DOM, which is why the log says "not stable" then "not visible" rather
than the honest "not found". `.app-sidebar__toggle` is a TOGGLE and
`isVisible()` is an instant probe, so the old code could also click it
shut. The open-and-click is now one idempotent retried step. The twin
helper in iconUpload.spec.ts was not doing anything smarter — it burns
~4s on an overlay ci-seed.sh already suppressed and the page settles
meanwhile. That is luck, not a guard.

Newman — 9 of the 12 assertions, and none of them was an openbuild
security defect.

rbac (3). A per-request Basic override does not change who the request
is. Newman keeps ONE cookie jar per run, and Nextcloud consults
Authorization only when there is no session (OC::handleRequest calls
handleLogin() strictly in the `else` of isLoggedIn(), base.php:1054-1066).
The Setup folder authenticates as admin, so the outsider requests
executed AS ADMIN — who sees every app and clears getManifest through the
audited bypass. Worse than the two red assertions: 2.1/2.2/3.2/4.2 were
PASSING VACUOUSLY for the same reason, measuring admin rather than the
role they name. The outsider requests move to {{outsiderBase}}
(127.0.0.1 — same server, other origin, empty jar), the mechanism this
repo already measured for the anonymous case, and a new positive control
asserts the acting user really is rbac-outsider before anything is
concluded from it. Only two origins are trusted, so the viewer/editor
requests stay vacuous; that is stated in the collection rather than
hidden.

page-editor (2). Written against the pre-ADR-002 model: it PUT `manifest`
onto the OR Application object, which has no such property (it lives on
ApplicationVersion). OR accepted and echoed it — which is why request 2's
own assertion passed — while GET .../manifest reads the production
version and never saw it. The same absence is why an invalid manifest
came back 200: no property, nothing to validate. Both requests now use
openbuild's own PUT /api/applications/{slug}/manifest.

And that endpoint really did accept an unrenderable manifest.
REQ-OBPD-009 puts the refusal in the CLIENT, so the guarantee stopped at
the browser and an API caller could brick an app with `{version, menu}`
and no `pages`. saveManifest now applies the SAME two structural rules
AppRepoParser::validateManifest() has always applied on the import path.
Positive control run: with the check disabled the new unit test reports
500, with it 400.

templates-marketplace (2). The request demanded "different user + same
slug -> 201", citing REQ-OBTC-004 for owner-scoped uniqueness. The spec
says the opposite (spec.md:254-261 — an existing slug in the ORGANISATION
is rejected), the controller deliberately scopes org-wide "to prevent
squatting", the {{second_user}} it needed is created by nothing, and
createFromTemplate is admin-only so a real `tester` would have got 403,
never 201. It now asserts the contract that is actually specified.

docudesk-documents (2). Not an openbuild defect and not a docudesk one
either: ci-seed.sh never configured docudesk's template register, because
the helper that does it lives in global-setup.ts and Playwright hooks do
not run on the Newman leg. Item 1 passed throughout only because
TemplatesController catches RegisterNotConfiguredException while
CorrespondenceController lets it out as a 500. ci-seed.sh now configures
it — optionally, skipping cleanly when docudesk is absent, and reading
the value back rather than trusting the write.

NOT FIXED, QUARANTINED — versioning "REQ-OBV-005 diff" (3 assertions).
It carried "STILL RED, DELIBERATELY" and it was right to. REQ-OBV-005
defines the refs as a version SLUG, `current:<slug>` or
`history:<slug>:<rev>` returning {from:{manifest,semver,savedAt},…};
resolveVersionBlob() accepts `draft` or a UUID and returns
{manifest,version,publishedAt}. On top of that the folder diffs two
sibling snapshot rows, which ADR-002 retired, so `v2_uuid` is never
assigned and the request leaves with an empty `to` — the 404 is correct.
Rewriting the assertions against today's UUID behaviour would pass and
would delete the only executable record that endpoint and spec disagree.
Moved verbatim to tests/integration/quarantine/ (CI globs the directory
non-recursively) with a README stating the divergence and what has to be
true before it comes back. This is a DEFERRAL, not a fix.

Verified: 851 unit tests green (was 849), phpcs 0 errors over lib/ +
tests/Unit, prettier clean, and the full hydra-gates suite exits 0 with
62 of 70 gates reporting (gate-19 advisory only). Local psalm is not a
usable instrument here — it resolves OCA\OpenRegister\ to tests/Stubs/,
which has no Contract/, so all 193 of its errors are that one missing
interface; CI installs the real openregister and is green.
My own change reddened phpmd, which `development` passes. Three findings,
all mine, read from the PR's CI log (33 KB via `gh api .../logs` — `gh run
view --log` returns empty and exits 0 here):

  SeedHelloWorldFixture.php:470  ExcessiveMethodLength  buildManifest() 119 lines
  SeedHelloWorldFixture.php:536  MissingImport          new \stdClass()
  UpsertSchemaHandler.php:41     ExcessiveMethodLength  handle() 115 lines

The MessageDetail page — and the long rationale for why its body grid is
ejected — moves into buildMessageDetailPage(), which is where that
explanation belonged anyway; buildManifest() goes 119 -> 53. `stdClass`
is imported. UpsertSchemaHandler's availability probe moves into
schemaMappersAvailable() with the reasoning on the helper; handle() goes
115 -> 96, against a threshold of 100.

Verified with the project's OWN command and a positive control, because a
zero-finding phpmd run looks identical to a zero-FILE one: both rulesets
over lib/ report 0 findings, and the same invocation with the threshold
forced to 5 reports `handle() has 96 lines of code` — so the files really
were read. 49 unit tests over the three touched classes green; phpcs 0
errors.
…what it does not declare

ExportJobService::queue() has always WRITTEN `flows` and `applicationSlug`
onto the ExportJob record. The exportJob schema never DECLARED either, and
OpenRegister stores only the properties a schema declares — silently.

Measured, not inferred, against a live NC + OpenRegister:

  POST /api/objects/openbuild/export-job carrying `flows`, `applicationSlug`
  and a nonsense key returned 200 and ECHOED ALL THREE BACK. An immediate
  fresh GET of that same object returned none of them — only applicationUuid,
  applicationVersion, includeSeedData, license, status, target.

The save response is therefore not evidence of what was stored; only a
read-back is. Positive control on the same instance: `dataRegisters`, which
IS declared (fragment 30-) and has the identical array-of-objects shape
(required + additionalProperties:false), round-trips intact through a fresh
read. So the shape is fine — the declaration was simply missing.

Two live consequences, both silent:

1. `flows` — `is_array($job['flows'] ?? null)` was false on every job, so
   FlowAndAgentExportBundler::bundleFlows() returned immediately and no bound
   flow was ever written into lib/Settings/flows/. The export job still
   transitioned to `succeeded` and produced a downloadable ZIP, so nothing
   looked wrong. This is the e2e failure "the bound flow must be in the ZIP —
   this is the whole feature", red on `development` as well as here.

2. `applicationSlug` — RunExportJob:150 fell back to its `'exported-app'`
   default on EVERY export, so each exported app was scaffolded with appId,
   namespace and name `exported-app` instead of the real slug, and
   bundleAgents() then queried agents whose applicationSlug is `exported-app`,
   matched none, and recorded no skip. Agents were never exported either.

Declaration-only: no PHP changes, because the writing and reading code was
already correct.

The exportJob schema version is bumped alongside the fragment. OpenRegister
SKIPS the import when the deployed version is >= the declared one, so a
property added without a bump reaches nothing but a fresh install. 1.1.0 is
the same target PR #219 picked, for the same reason.
fix(ci): green the E2E, Newman and Hydra Gates jobs — at their real causes
…nt after it

The two E2E runs of the SAME sha (6349aa6) disagreed on
`version-rollback.spec.ts:314`: the pull_request run failed it in 36.6s,
the push run passed it in 49.7s with 192/192. Identical code, so this is a
race, not a defect — and the failing shape is the one this file already
documents.

`openVersionHistory()` is already an idempotent open-and-click under
toPass(), and it works: it returns only once the panel body is on screen.
What it could not cover was the caller's OWN assertion, made after it
returned:

    await openVersionHistory(page, await appUuid(page))
    await expect(page.locator('.version-history__row').first()).toBeVisible(...)

The detail page re-mounts the sidebar after first paint — the helper's own
comment block records this — and when that re-mount lands in the window
between the helper returning and the caller asserting, there is no longer
anything that can re-open it. The caller then polls a hidden element for 20s
and reports the app hiding its rows. The CI log shows exactly that: the
locator resolved to a real
`<li class="version-history__row version-history__row--current">` on all 36
polls, `hidden` every time.

So move the same assertion inside the loop via an opt-in `requireRows`, and
drop the now-redundant external one at both call sites. Nothing is weakened:
the condition asserted is identical, and for these two callers
`.version-history__empty` correctly stops counting as success. The third
call site keeps the either/or default — it asserts toHaveCount(), which
counts DOM nodes regardless of visibility, which is why it never flaked.

Type-checked with tsc --noEmit (exit 0). Positive control, because a clean
tsc run and a zero-file run print the same nothing: the same file with
`requireRows` misspelled fails TS2561, exit 2.
`quality / Frontend Check (format)` was the one red job on this PR. Fixed by
running the project's OWN command (`npm run format`, i.e. the quoted
`prettier --check "**/*.{js,ts,vue,css,scss}"`), not a hand-rolled prettier
invocation — an unquoted glob in an npm script silently narrows what is
checked, and this fleet has been bitten by that before.

The check named exactly one file, this one, and the fix is layout only: the
ternary assigning `panelBody` is reflowed onto its own lines. Verified from
the DIFF rather than the exit code, because an autofix that rewrites a string
literal is a semantic change wearing a formatting costume — on shillinq that
shape orphaned 17 translations across 36 locales by editing a `t()` key. Here
both selector literals are byte-identical, no `t()` is involved, and no
Playwright selector text moved.

Re-verified after the reflow: `npm run format` exit 0, `tsc --noEmit` exit 0.
…ry-flake

test(e2e): fix the version-history flake — require rows inside the toPass() retry
…it could not answer its own question (#231)

* test(export): make the stuck-export failure decisive — the job census was taken after the worker, so it could not answer its own question

`export-flows-and-agents.spec.ts` fails on `development` with

    the export job must finish — last status "queued";
    last worker pass: worker ok: (no output); job list: 112 total, 0 RunExportJob

and that message has been read as proof that the enqueue never happened. It is
not proof of anything. `runExportJobWorker()` took the census AFTER running
`background-job:worker`, and a QueuedJob is DELETED from `oc_jobs` once it
executes — so `0 RunExportJob` is what you see whether the job never existed or
ran to completion. The helper's own comment says the census exists to
disambiguate exactly those two cases; in that order it cannot.

- census taken BEFORE the worker pass, and both are reported;
- on a non-terminal status the failure now also reports the DEPLOYED exportJob
  schema's version and whether it carries `x-openregister-lifecycle`. Without
  that block OR's TransitionEngine finds no state machine and returns silently,
  the job row is consumed, and the object sits at "queued" with no log line —
  #219.

Read from the schema API, not the object: this OpenRegister build exposes no
`available-actions` on an object read at all (measured, including
`?_extend=all`), so probing for that key would report "absent" on a healthy
instance and read as evidence.

No assertion is relaxed and no skip is added; the failure only gets louder.

* docs(export): the #219 bump landed in #229 — say what a MISSING lifecycle means now

Merging development brought in #229, which shipped `exportJob` 0.1.0 -> 1.1.0.
The probe's docblock and its failure string still told the reader that a missing
`x-openregister-lifecycle` meant #219 was unfixed. On the merged base that is no
longer true, and a comment that survives the change it describes is the half of
a diff git cannot check.

A MISSING reading now means the instance under test never converged onto the
bumped schema — a narrower fault than the one #219 described. The probe keeps
its value; only the conclusion it licenses changes.

---------

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

Three shop-install bugs (credential forwarding to the skills channel, the
missing ApplicationVersion/productionVersion link, the missing BuiltAppRoute
on publish), plus a fourth found while fixing the tests:

linkProductionVersion() patched `productionVersion` in with a hand-built
four-field payload. OpenRegister's saveObject() is PUT, not PATCH — on the
update path SaveObject::prepareObjectForUpdate() calls
fillMissingSchemaPropertiesWithNull() and then setObject() replaces the stored
data outright, with no merge against the existing object. That write NULLed
`owner`, `status`, `version` and `templateOrigin` off the Application persisted
one statement earlier, on every local-template, remote-registry and
GitHub-shop install. It now re-saves the whole stored object with the one
field patched in, mirroring ApplicationPublishController::setStatus().

Test doubles assert the real write counts (exactly(3) for an install,
exactly(2) for a publish) with each write pinned by register/schema/uuid, and
the saveObject double echoes the persisted object back the way OpenRegister
does — returning a bare ['uuid' => ...] is what made the wipe invisible.

phpmd: installFromTemplateArray() (114 lines) and collectConnectors() (104)
split along real seams into materialiseApplication(), collectDirectReferences()
and exportReferencedConnector(). Six UndefinedVariable findings fixed too —
$realUuid !== '' against a possible null read the wrong way. No suppressions,
no threshold changes.
…233)

The shared Code Quality workflow maps `phpcs` exit 1 to success, so these
have been shipping unnoticed on `development`. Errors 10 -> 0; the raw
`phpcs` exit code goes 1 -> 0. The 98 warnings are untouched.

- 5x PEAR.Commenting.FunctionComment.MissingParamTag in ExportService.
  Three signatures had grown parameters the docblocks never gained:
  `__construct()` was missing `$flowAndAgentBundler`, and both
  `generateAppZip()` and `buildScaffoldMap()` were missing `$flows` and
  `$applicationSlug`. Tags are inserted in signature order with the types
  the code actually passes on (`array<int,mixed>` for `$flows`, matching
  `FlowAndAgentExportBundler::bundle()`).
- 4x Generic.Files.LineLength (>150) in TemplateSeedService::seed(): the
  `@return` description continuation lines were indented to the width of
  the array-shape type. Re-indented, no wording changed.
- 1x Squiz.PHP.DisallowInlineIf.Found in
  FlowAndAgentExportBundler::bundleAgents(). The ternary is expanded into
  a guard WITHOUT an `else`, deliberately: an if/else here would trade a
  PHPCS error for a PHPMD `ElseExpression` violation, which this fleet's
  shared ruleset also enforces.

No behaviour change. lint, phpmd (both rulesets, with the baseline),
psalm (same 4 findings once line-number shift is normalised) and phpstan
are unchanged. PHPUnit is 851 tests / 2543 assertions / 8 errors /
1 skipped on both sides with an identical failing-test-name set; those 8
are a pre-existing `ZipArchive` extension absence in the runner, not
introduced here. FlowAndAgentExportBundlerTest passes 6/6 on both sides,
and both branches of the rewritten conditional were checked against the
original expression with a negative control.
Composer had no package-ecosystem entry at all, so composer dependencies
got no release-age cooldown whatsoever, unlike npm which has had one for a
while. Adds cooldown.default-days: 2 with a conduction/* exclude, matching
the fleet-wide floor gate-93 (composer-cooldown-config) enforces.

See ConductionNL/hydra openspec/changes/composer-dependency-cooldown and
ADR-093 (proposed, ConductionNL/hydra#591).

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Replaces the per-channel release callers with a single release.yml that
calls ConductionNL/.github/.github/workflows/release.yml@main for all
three channels.

Removed: release-beta.yml release-development.yml release-stable.yml
…e the modal

The button hard-coded flowId 'new', which made CnFlowEditModal a creator that
could never edit: a flow saved a minute earlier was unreachable from this
surface. FlowPickerDialog lists the selected application's flows (fetched
directly, not via the editor's singleton store, so it cannot race the modal's
own open('new')) — pick one to edit it, or start a new one.
…nged methods

- The three picker strings extracted into l10n/en.json (test:l10n gate).
- prettier --write on FlowPickerDialog.
- gate-16: @SPEC on openFlows / onFlowPicked / the picker's mounted().
feat(automations): 'Edit flows…' can finally edit — flow picker before the modal
…repo-format-v2 (#255)

* feat(github-sync): publish and pull an app's flows and agents in app-repo-format-v2

buildiq-* repositories previously carried a manifest and companion schemas
but none of the flows or agents that make the app do anything (the same
"reports success, ships nothing that runs" defect apply-v2-channels closed
for connectors/registers/automations/skills). AppRepoSerializer gains two
new channels, `flows/<uuid>.json` and `agents/<uuid>.json`, collected by
calling the existing, tested FlowAndAgentExportBundler (openbuild-exporter)
through a thin adapter, FlowAgentChannelCollector — the bundler itself is
untouched. AppChannelApplier gains FlowChannelProvisioner (creates each
flow via FlowService::save() and rebinds it onto the local application's
flows[], tracking the published uuid as the new optional `sourceUuid`
property) and AgentChannelProvisioner (writes each agent at its published
uuid, always overwriting applicationSlug to the local application's own
slug). Both channels skip-if-already-applied on a repeat pull.

Also fixes a bug found live during round-trip verification:
GitHubCatalogService::fetchChannelFiles()'s fetch-side prefix allowlist was
never extended to match AppRepoParser's new channels, so github/pull (and
the shop-install path, which shares this method) never actually downloaded
either channel from a published repository — the exact "parser can read it,
fetch never downloaded it" defect this method's own docblock already warned
about once. Verified live against a real disposable GitHub repository:
publish → pull into a second local application confirmed a real Flow
entity created, sourceUuid persisted (not silently dropped, per the
ADR-037 lesson), and an agent's applicationSlug correctly overwritten to
the local app — before AND after the fetch-prefix fix, and idempotent on a
second pull.

Also fixes two pre-existing bugs in the unit-test bootstrap that blocked
running the suite against real OpenRegister classes: tests/bootstrap.php
loaded the OpenRegister stub file before attempting to bootstrap the real
Nextcloud/OpenRegister classes, so the stub's own `implements
ObjectEntityInterface` clause fataled even in-container; and the stub file
never defined that interface at all for the fully-out-of-container path.

Archives openspec change app-repo-format-flow-agent-export and folds its
spec deltas into new canonical specs (github-app-repo-format,
app-channel-application), reconciled with the still-unarchived
apply-v2-channels/app-repo-format-v2/github-app-repo-format changes so the
canonical files reflect what is actually shipped.

* revert(tests): restore bootstrap.php/openregister-stubs.php to development's state

The previous commit reordered tests/bootstrap.php (NC bootstrap before the
OpenRegister stub require) and added a stub ObjectEntityInterface
definition, to unblock a local ad hoc docker container where the unmodified
files fatal with "Interface not found". Real CI (development's actual
PHPUnit matrix, all 6 legs) proved that diagnosis wrong in the environment
that matters: it ran the ORIGINAL, unmodified files at 854/854 passing
before this branch, and the reordering broke it to 79 errors — the
reordering let the REAL OpenRegister::Db::ObjectEntity class load ahead of
the test stub, and PHPUnit's onlyMethods(['getId']) mocks fail against the
real class's magic __call-based accessors, which is exactly what the stub
class's own explicitly-declared methods exist to avoid.

Whatever makes the unmodified files work in CI's real checkout layout
(server/apps/<app> against a full nextcloud/server clone) isn't reproduced
by an ad hoc custom_apps bind-mount, and chasing that further isn't worth
risking the suite CI actually runs. Reverted in full; the live round-trip
verification of this branch's actual feature (flows/agents publish+pull)
did not depend on either file and stands on its own.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Part of the fleet-wide taxonomy pass. Measured live against
`/apps/hermiq/api/agents/tools`: **87 of 177 tools across the fleet declared
no subject or action**, so the agent grant matrix fell back to guessing from
the tool id and rendered them as ungroupable one-off rows.

🔴 The reason this went unnoticed is that an undeclared field here is
INVISIBLE. `ToolRegistryFacade::describeTools()` deliberately returns null
rather than inferring a subject from the id — the right call, since a consumer
cannot tell an inferred subject from a real one — so a descriptor missing these
keys produces no error, no warning and no failing test.

⚠️ The action vocabulary is deliberately NOT closed to CRUD, because the matrix
GRANTS on this value. A tool that promotes a release, starts a meeting, renders
a forecast or converts a file says so, rather than being filed under the
nearest CRUD verb — flattening those would be a security statement, not a
naming one.

Verified live end-to-end: the endpoint now reports **0** undeclared of 177,
across 16 distinct actions and 79 subjects.
…eric id (#258)

FlowAndAgentExportBundler::bundleAgents() filtered agents by two hardcoded
numeric class constants (register 206, schema 5060) — this dev instance's
auto-increment ids for openbuild/agent, which are not stable across a fresh
install. On any instance whose ids differ, the filter silently matches zero
agents: no exception, no skip record, just an export that "succeeded" having
bundled nothing.

Confirmed live today: app-repo-format-flow-agent-export (#255) wired this
same method into the buildiq-* GitHub round trip, and its live test on a
fresh instance hit exactly this failure mode.

Replace the numeric ids with the slugs 'openbuild'/'agent', mirroring the
pattern already used by AgentsController, ObjectSchemaSlugResolver, and
AppChannelApplier::credentialExists() — ObjectService::findAll() already
resolves a string filter value through RegisterMapper/SchemaMapper, which
support slug lookup, register-scoped.

Live-verified on a fresh isolated instance (register openbuild=16, schema
agent=115 — neither matches the old hardcoded values): reverting to the old
constants reproduced the silent-zero-agents bug on that instance, restoring
the fix bundled the agent again.

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

feat(tools): declare subject and action on the agent tool catalogue
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