From 03560f218f4e844f89289d6a5ad1e59c4418001d Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 23 Jun 2026 20:04:20 +0200
Subject: [PATCH 001/391] feat(builder): load app data sources + rebuild routes
after in-app save
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two gaps made in-app page creation produce dead pages:
1. The standalone runtime never told CnAppRoot about the app's
OpenRegister registers/schemas, so the Edit-pages modal could only
offer free-text register/schema slugs — a created index page ended up
with an empty config and rendered nothing. Add loadDataSources():
best-effort fetch of this app's registers (slug prefix
openbuild-{slug}) and their schemas (slug + property keys for the
column picker), passed to CnAppRoot's new `dataSources` prop so the
editor shows real Register / Schema / Columns dropdowns. Failures
(RBAC/network) fall back to free-text, never blocking boot.
2. Pages added during an edit only got a vue-router route on full
reload, so a freshly-created menu item navigated nowhere. After a
successful save, rebuild the router from the saved manifest
(vue-router 3 matcher-swap idiom) so new pages are reachable
immediately.
Bumps info.xml 0.5.8 -> 0.5.9 for the immutable JS cache-bust.
---
appinfo/info.xml | 2 +-
src/builder.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 69 insertions(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 08ce7f225..9b0e89f5b 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.8
+ 0.5.9
agpl
Conduction
OpenBuild
diff --git a/src/builder.js b/src/builder.js
index e1df6f4ea..69b5c4dd9 100644
--- a/src/builder.js
+++ b/src/builder.js
@@ -84,6 +84,56 @@ function routesFromManifest(manifest) {
return routes
}
+/**
+ * Best-effort load of the app's OpenRegister registers + schemas, shaped for the
+ * in-app pages editor (CnAppRoot's `dataSources`). The editor turns these into
+ * Register / Schema / Columns dropdowns for index/detail pages so a created page
+ * renders a table. Only this app's registers (slug prefix `openbuild-{slug}`) are
+ * offered. Failures (RBAC, network) return null — the editor then falls back to
+ * free-text slug inputs, so this never blocks boot.
+ *
+ * @return {Promise} `{ registers: [{ value, label, schemas: [...] }] }` or null.
+ */
+async function loadDataSources() {
+ try {
+ const regUrl = generateUrl('/apps/openregister/api/registers') + '?_limit=1000'
+ const { data } = await axios.get(regUrl)
+ const all = (data && (data.results || data.registers)) || (Array.isArray(data) ? data : [])
+ const prefix = `openbuild-${slug}`
+ const mine = all.filter((r) => typeof r.slug === 'string' && r.slug.startsWith(prefix))
+ if (!mine.length) return null
+
+ // Resolve each register's schema ids to { value: slug, label, columns }.
+ const ids = [...new Set(mine.flatMap((r) => (Array.isArray(r.schemas) ? r.schemas : [])).filter((x) => typeof x === 'number'))]
+ const schemaById = {}
+ await Promise.all(ids.map(async (id) => {
+ try {
+ const { data: s } = await axios.get(generateUrl(`/apps/openregister/api/schemas/${id}`))
+ if (s && s.slug) {
+ schemaById[id] = {
+ value: s.slug,
+ label: s.title || s.slug,
+ columns: Object.keys(s.properties || {}),
+ }
+ }
+ } catch {
+ // skip a schema we can't read
+ }
+ }))
+
+ const registers = mine.map((r) => ({
+ value: r.slug,
+ label: r.title || r.slug,
+ schemas: (Array.isArray(r.schemas) ? r.schemas : []).map((id) => schemaById[id]).filter(Boolean),
+ }))
+ return registers.length ? { registers } : null
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('[openbuild:builder] could not load data sources for the pages editor', e)
+ return null
+ }
+}
+
/**
* Translate manifest label keys. Virtual-app manifests usually carry plain
* strings; t() returns them unchanged when no translation is registered.
@@ -125,6 +175,10 @@ async function boot() {
console.error('[openbuild:builder] failed to load manifest for ' + slug, e)
}
+ // Load the app's registers/schemas for the in-app pages editor (best-effort;
+ // null → the editor uses free-text register/schema fields).
+ const dataSources = await loadDataSources()
+
const router = new VueRouter({
mode: 'history',
base: generateUrl(`/apps/openbuild/builder/${slug}`),
@@ -142,6 +196,9 @@ async function boot() {
registry: { ...runtimeRegistry },
pageTypes: { ...defaultPageTypes },
translate: translateForApp,
+ // App registers/schemas so the Edit-pages modal offers Register /
+ // Schema / Columns dropdowns for index/detail pages (null → free text).
+ dataSources,
// Persist in-app edits (pages / menu / settings / sidebar / actions)
// back to the app's manifest. CnAppRoot's useManifestEditor mutates
// THIS same `manifest` object in place while editing, so on Save we
@@ -151,6 +208,17 @@ async function boot() {
persistManifestDelta: async () => {
const saveUrl = generateUrl(`/apps/openbuild/api/applications/${slug}/manifest`)
await axios.put(saveUrl, { manifest })
+ // Rebuild the router from the just-saved manifest so pages added
+ // or re-routed during this edit become navigable immediately —
+ // without it a freshly-created menu item points at a route that
+ // only exists after a full reload. Replacing `matcher` is the
+ // vue-router 3 reset idiom (keeps `*` ordered last correctly).
+ const fresh = new VueRouter({
+ mode: 'history',
+ base: generateUrl(`/apps/openbuild/builder/${slug}`),
+ routes: routesFromManifest(manifest),
+ })
+ router.matcher = fresh.matcher
},
},
}),
From a54e214c9b9a3ea0e9b662b326d1b83e1654e0ff Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 23 Jun 2026 21:49:10 +0200
Subject: [PATCH 002/391] chore: bump version 0.5.9 -> 0.5.10 (menu detail-page
exclusion)
Cache-bust for the rebuilt builder bundle carrying the nc-vue
CnEditMenuModal fix (detail pages no longer offered as menu targets).
---
appinfo/info.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 9b0e89f5b..8fe9ff2f6 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.9
+ 0.5.10
agpl
Conduction
OpenBuild
From e1304b477660236102a24b151128615bdbe158e1 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 23 Jun 2026 22:16:56 +0200
Subject: [PATCH 003/391] chore: bump version 0.5.10 -> 0.5.11 (beforeunload
unsaved-edit guard)
Cache-bust for the rebuilt builder bundle carrying the nc-vue CnAppRoot
beforeunload guard (warns before a refresh drops an unsaved/in-flight
in-app edit).
---
appinfo/info.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 8fe9ff2f6..ff4cb4eab 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.10
+ 0.5.11
agpl
Conduction
OpenBuild
From d94e7cd29ddcacc50664a37772070b2eadc17292 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 23 Jun 2026 22:44:07 +0200
Subject: [PATCH 004/391] fix(builder): make post-save router rebuild
best-effort; bump 0.5.12
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The manifest is already persisted by the save PUT, so a router-rebuild
error afterwards (e.g. a duplicate route the user created) must not
reject the save and leave the editor stuck 'dirty'. Wrap the matcher
rebuild in try/catch — log and continue; a reload still picks up routes.
Bumps info.xml to 0.5.12 for the rebuilt bundle (carries the nc-vue
Done=save edit-modal fix).
---
appinfo/info.xml | 2 +-
src/builder.js | 21 +++++++++++++++------
2 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index ff4cb4eab..2c11d3f58 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.11
+ 0.5.12
agpl
Conduction
OpenBuild
diff --git a/src/builder.js b/src/builder.js
index 69b5c4dd9..700cf64d1 100644
--- a/src/builder.js
+++ b/src/builder.js
@@ -213,12 +213,21 @@ async function boot() {
// without it a freshly-created menu item points at a route that
// only exists after a full reload. Replacing `matcher` is the
// vue-router 3 reset idiom (keeps `*` ordered last correctly).
- const fresh = new VueRouter({
- mode: 'history',
- base: generateUrl(`/apps/openbuild/builder/${slug}`),
- routes: routesFromManifest(manifest),
- })
- router.matcher = fresh.matcher
+ // Best-effort: the manifest is ALREADY persisted by the PUT above,
+ // so a router-build error here (e.g. a duplicate route the user
+ // created) must NOT reject the save — that would leave the editor
+ // stuck "dirty" and confuse the user. Log and move on.
+ try {
+ const fresh = new VueRouter({
+ mode: 'history',
+ base: generateUrl(`/apps/openbuild/builder/${slug}`),
+ routes: routesFromManifest(manifest),
+ })
+ router.matcher = fresh.matcher
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('[openbuild:builder] router rebuild after save failed (edit is saved; reload to pick up new routes)', e)
+ }
},
},
}),
From d6804827aac80dbace73beb568412bd68e48e472 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 23 Jun 2026 22:59:34 +0200
Subject: [PATCH 005/391] chore: bump version 0.5.12 -> 0.5.13 (Done-button
save spinner)
---
appinfo/info.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 2c11d3f58..72537e4ce 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.12
+ 0.5.13
agpl
Conduction
OpenBuild
From a15241509d091201a3ceed47be7eca191c59af49 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Tue, 23 Jun 2026 23:33:02 +0200
Subject: [PATCH 006/391] =?UTF-8?q?fix(builder):=20normalise=20pages=20?=
=?UTF-8?q?=E2=80=94=20object=20config=20+=20inline=20titles;=20bump=200.5?=
=?UTF-8?q?.14?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
normalizeManifestPages() runs at boot: (1) coerces each page.config to a
plain object (defends against the PHP empty-{}→[] round-trip so a page
never renders with an array config), and (2) defaults index/detail pages
to config.showTitle=true so the standalone runtime shows the page title
inline (CnIndexPage defaults it false, routing the title to an index
sidebar this runtime doesn't surface). Explicit showTitle is respected.
---
appinfo/info.xml | 2 +-
src/builder.js | 31 +++++++++++++++++++++++++++++++
2 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 72537e4ce..49a1e2959 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.13
+ 0.5.14
agpl
Conduction
OpenBuild
diff --git a/src/builder.js b/src/builder.js
index 700cf64d1..7a88d57ca 100644
--- a/src/builder.js
+++ b/src/builder.js
@@ -146,6 +146,34 @@ function translateForApp(key, vars) {
return t('openbuild', key, vars)
}
+/**
+ * Normalise a loaded manifest's pages for the standalone runtime, in place:
+ *
+ * 1. `config` MUST be a plain object. An empty `config: {}` round-trips through
+ * PHP/JSON as `[]` (PHP can't tell an empty object from an empty list), and a
+ * page rendered with an array config silently loses its register/schema.
+ * 2. Data pages (`index` / `detail`) default to `showTitle: true` so the app
+ * shows its page title inline — the standalone runtime renders the app as a
+ * real app, where a visible page header is expected (CnIndexPage's own
+ * default is `false`, which routes the title to an index sidebar that this
+ * runtime does not surface). An explicit `showTitle` is always respected.
+ *
+ * @param {object} manifest The resolved manifest (mutated in place).
+ * @return {void}
+ */
+function normalizeManifestPages(manifest) {
+ const pages = Array.isArray(manifest.pages) ? manifest.pages : []
+ for (const page of pages) {
+ if (!page || typeof page !== 'object') continue
+ if (!page.config || typeof page.config !== 'object' || Array.isArray(page.config)) {
+ page.config = {}
+ }
+ if ((page.type === 'index' || page.type === 'detail') && page.config.showTitle === undefined) {
+ page.config.showTitle = true
+ }
+ }
+}
+
/**
* Fetch the app manifest, build its router, and mount the standalone shell.
*
@@ -175,6 +203,9 @@ async function boot() {
console.error('[openbuild:builder] failed to load manifest for ' + slug, e)
}
+ // Normalise pages (config-as-object guard + inline page titles for data pages).
+ normalizeManifestPages(manifest)
+
// Load the app's registers/schemas for the in-app pages editor (best-effort;
// null → the editor uses free-text register/schema fields).
const dataSources = await loadDataSources()
From 9bb0d52fba86cb034bd8c3a211405aa3dcaf62f2 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Wed, 24 Jun 2026 06:42:35 +0200
Subject: [PATCH 007/391] fix(builder): serve the standalone runtime for
app-page deep links
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The runtime mounts a history-mode router, but only the bare /builder/{slug}
(and trailing-slash) were server-routed to it — a deep link or refresh on
an app page (/builder/{slug}/dogs, /dogs/123) fell to the SPA catch-all
and rendered OpenBuild's own shell (wrong nav, empty content). Add a
dashboard#builderPath route /builder/{slug}/{path} → builder() whose
`path` requirement allows slashes (multi-segment detail routes) but
excludes the designer sub-routes (pages/schemas/walkthrough) via negative
lookahead, so those still reach the SPA. Verified: /dogs + /dogs/123 load
openbuild-builder; /pages + /schemas load openbuild-main.
---
appinfo/routes.php | 11 +++++++++++
lib/Controller/DashboardController.php | 25 +++++++++++++++++++++++++
2 files changed, 36 insertions(+)
diff --git a/appinfo/routes.php b/appinfo/routes.php
index c1b02975a..8aa310dfe 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -118,6 +118,17 @@
// builder()) — the AppHost Routes::standard() guard throws on duplicate names.
['name' => 'dashboard#builderSlash', 'url' => '/builder/{slug}/', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],
+ // App-page deep links / refreshes within the standalone runtime. The
+ // runtime mounts a history-mode router, so a sub-path like
+ // /builder/{slug}/dogs (or /dogs/123 for a detail page) must serve the
+ // runtime template too — otherwise it falls to the SPA catch-all and
+ // renders OpenBuild's own shell (wrong nav, empty content). The `path`
+ // requirement allows slashes (`.+`, for multi-segment detail routes) but
+ // EXCLUDES the designer sub-routes (pages / schemas / walkthrough), which
+ // must stay in the SPA — they fall through to the catch-all because the
+ // negative lookahead makes them not match here.
+ ['name' => 'dashboard#builderPath', 'url' => '/builder/{slug}/{path}', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'path' => '(?!(?:pages|schemas|walkthrough)(?:/|$)).+']],
+
// Icon-serving endpoints (openbuild-nextcloud-nav REQ-OBICON-002 / REQ-OBICON-003).
// Both are #[NoAdminRequired] on the controller. The dark route uses a longer
// URL pattern ("{slug}-dark.svg") that is unambiguous — it cannot shadow the
diff --git a/lib/Controller/DashboardController.php b/lib/Controller/DashboardController.php
index da00e42af..8ec894bf2 100644
--- a/lib/Controller/DashboardController.php
+++ b/lib/Controller/DashboardController.php
@@ -146,6 +146,31 @@ public function builderSlash(string $slug): TemplateResponse
return $this->builder($slug);
}//end builderSlash()
+ /**
+ * Sub-path alias of {@see builder()} for the runtime's own page routes.
+ *
+ * The standalone runtime mounts a history-mode vue-router, so deep links
+ * and refreshes on an app page (e.g. `/builder/{slug}/dogs`,
+ * `/builder/{slug}/dogs/123`) must be served the runtime template too — the
+ * client router then resolves the page from the URL. The route's `path`
+ * requirement EXCLUDES the designer sub-routes (`pages`, `schemas`,
+ * `walkthrough`), which stay in the SPA via the catch-all. The `$path` is
+ * read client-side from `window.location`, so it is intentionally unused
+ * here beyond routing.
+ *
+ * @param string $slug The virtual app slug (path param).
+ * @param string $path The remaining app-route path (unused server-side).
+ *
+ * @return TemplateResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function builderPath(string $slug, string $path): TemplateResponse
+ {
+ unset($path);
+ return $this->builder($slug);
+ }//end builderPath()
+
/**
* Publish the caller's group IDs via IInitialState.
*
From c5808993f5651425f49fc2e96d7e1b62b4a995b1 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Wed, 24 Jun 2026 07:06:05 +0200
Subject: [PATCH 008/391] chore(npmrc): disable min-release-age cooldown
(temporary, active beta-release week)
Set min-release-age=0 so CI/local can npm-install freshly-published @conduction/* betas
(<24h old) while we cut multiple betas per day this week. Revert to 1 afterwards.
---
.npmrc | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.npmrc b/.npmrc
index 3942d3489..e4e8835b7 100644
--- a/.npmrc
+++ b/.npmrc
@@ -2,4 +2,4 @@
# 24h ago. Compromised first-party-Conduction packages are excluded via
# Dependabot cooldown (.github/dependabot.yml); for fresh @conduction/*
# releases, override per-install with `npm install --min-release-age=0`.
-min-release-age=1
+min-release-age=0
From 408e6cd98763d3b3703beae7bd88a26ffdce72da Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Wed, 24 Jun 2026 07:29:28 +0200
Subject: [PATCH 009/391] chore: bump version 0.5.14 -> 0.5.16 (drag-and-drop
tree editors)
Cache-bust for the rebuilt bundle carrying the nc-vue drag-and-drop +
inline-editing pages/menu tree editors (vuedraggable).
---
appinfo/info.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 49a1e2959..0599143e6 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.14
+ 0.5.16
agpl
Conduction
OpenBuild
From d5f554265f02a555aa7a5b263d1b45189532d558 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Wed, 24 Jun 2026 07:57:49 +0200
Subject: [PATCH 010/391] chore: bump version 0.5.16 -> 0.5.17 (pages-editor
panel + icons + slug)
---
appinfo/info.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 0599143e6..8f74effda 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.16
+ 0.5.17
agpl
Conduction
OpenBuild
From ddac88375b61a835da28050f00382dfe24b14bc9 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Wed, 24 Jun 2026 09:49:15 +0200
Subject: [PATCH 011/391] chore: bump version 0.5.17 -> 0.5.19 (row-click
detail nav + go-to-page button)
---
appinfo/info.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 8f74effda..4783eafae 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.17
+ 0.5.19
agpl
Conduction
OpenBuild
From c4e6c1f70fec2b32da7d108d70df754e976067ac Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Wed, 24 Jun 2026 10:14:00 +0200
Subject: [PATCH 012/391] chore: bump version 0.5.19 -> 0.5.20 (edit-mode
page-config cog)
---
appinfo/info.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 4783eafae..41fcacdf5 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.19
+ 0.5.20
agpl
Conduction
OpenBuild
From f64dd8d7d1a9ad5d0b5b53926e50d58a6de74b03 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Wed, 24 Jun 2026 10:31:49 +0200
Subject: [PATCH 013/391] chore: bump version 0.5.20 -> 0.5.21 (config-modal
toggle fix + table options)
---
appinfo/info.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 41fcacdf5..c3b896d0a 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.20
+ 0.5.21
agpl
Conduction
OpenBuild
From 57dddf5e48f8a3c9a140f09da27e58d7412f6b32 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sat, 27 Jun 2026 11:47:58 +0200
Subject: [PATCH 014/391] openspec: propose runtime-group-scoped-access
Adds group-scoped rendering to the OpenBuild runtime so a virtual app can show
some menus/pages/dashboards only to members of a Nextcloud group (e.g. a Pet
Store where only a "vets" group sees the medical menu, objects, and a vet
dashboard). CnAppNav already filters by `permission`; the runtime just never
injects the current user's groups. Object-level access stays enforced by
OpenRegister schema RBAC (medicalRecord.authorization.read=["vets"], verified
live). Found building the Pet Store demo (feature C).
---
.../runtime-group-scoped-access/design.md | 55 +++++++++++++++++
.../runtime-group-scoped-access/proposal.md | 60 ++++++++++++++++++
.../specs/openbuild-runtime/spec.md | 61 +++++++++++++++++++
.../runtime-group-scoped-access/tasks.md | 23 +++++++
4 files changed, 199 insertions(+)
create mode 100644 openspec/changes/runtime-group-scoped-access/design.md
create mode 100644 openspec/changes/runtime-group-scoped-access/proposal.md
create mode 100644 openspec/changes/runtime-group-scoped-access/specs/openbuild-runtime/spec.md
create mode 100644 openspec/changes/runtime-group-scoped-access/tasks.md
diff --git a/openspec/changes/runtime-group-scoped-access/design.md b/openspec/changes/runtime-group-scoped-access/design.md
new file mode 100644
index 000000000..567082511
--- /dev/null
+++ b/openspec/changes/runtime-group-scoped-access/design.md
@@ -0,0 +1,55 @@
+# Design — runtime group-scoped access
+
+## Context
+- `CnAppNav` already filters `manifest.menu[]` via `passesPermission(item)`
+ against a `permissions` prop (list of permission strings the user holds);
+ omitting the prop renders all items. `CnAppRoot` accepts/forwards
+ `permissions`.
+- `BuilderHost.vue` mounts the nested `CnAppRoot` for the virtual app. It does
+ not currently compute or pass `permissions`.
+- OpenRegister already enforces object-level RBAC from `schema.authorization`
+ server-side (verified: `medicalRecord.authorization.read = ["vets"]` hides
+ rows from non-vets; admin bypasses).
+
+## Decisions
+
+### 1. User-group source — server initial state, not a client call
+Per ADR-004 (initial state, not DOM/API for bootstrap data), provide the
+current user's group IDs via `IInitialState::provideInitialState('user-groups',
+...)` from a controller, read with `loadState('openbuild','user-groups')` at
+mount. Map to permission strings `group:` (plus `admin` when the user is in
+the admin group, and an `owner` marker when the user owns the application).
+Avoid a client round-trip to `/cloud/user/groups`.
+
+### 2. Permission vocabulary
+Permission strings on manifest `menu[]`/`pages[]`:
+- `group:` — visible to members of that NC group.
+- `admin` — admins only.
+- `owner` — the application's owner(s).
+Multiple permissions on an item = visible if the user holds ANY (OR semantics),
+matching `CnAppNav.passesPermission`. Items with no `permission` are always
+visible.
+
+### 3. Pages + dashboards
+`CnPageRenderer` filters routed pages the same way; a `permission`-gated page is
+not reachable for users without it. For dashboards, the runtime picks the
+landing dashboard as the highest-priority dashboard page whose `permission` the
+user satisfies (vet dashboard for vets, else the default), keeping a single
+default when no group-scoped dashboard matches.
+
+### 4. Security boundary (explicit)
+Menu/page hiding is UX only. The authoritative control is OpenRegister schema
+RBAC on the objects each page reads. Document in author guidance: "to make data
+vets-only, set `schema.authorization`; the `permission` field only hides
+navigation." This prevents authors from shipping client-only 'security'.
+
+## Risks
+- Initial-state group list can grow; cap to the groups referenced by the
+ manifest's `permission` fields to avoid leaking full membership.
+- Admin bypass must match OR's (admins see all menus + all objects) for a
+ consistent mental model.
+
+## Seed Data
+No new schemas. The Pet Store demo manifest gains `permission: "group:vets"` on
+the medical menu item(s) and a vet dashboard page; `medicalRecord.authorization`
+is set in OpenRegister (already done in the demo).
diff --git a/openspec/changes/runtime-group-scoped-access/proposal.md b/openspec/changes/runtime-group-scoped-access/proposal.md
new file mode 100644
index 000000000..812bd78c3
--- /dev/null
+++ b/openspec/changes/runtime-group-scoped-access/proposal.md
@@ -0,0 +1,60 @@
+---
+kind: code
+---
+
+## Why
+
+The OpenBuild runtime renders a virtual app's manifest the same way for every
+user. There is no way to scope parts of an app to a Nextcloud group — e.g. a
+Pet Store where only a **vets** group sees the medical menu items, the medical
+objects, and a dedicated vet dashboard.
+
+Two of the three layers already exist:
+
+- **Object visibility** is enforceable today via OpenRegister schema RBAC
+ (`schema.authorization.read = ["vets"]`). Verified on the Pet Store demo: a
+ user in `vets` reads `medicalRecord` objects; a user not in `vets` reads none;
+ admin bypasses. No OpenBuild change needed for this layer.
+- **Menu filtering** is supported by `CnAppNav` (`@conduction/nextcloud-vue`):
+ it filters `manifest.menu[]` items that declare a `permission` against a
+ `permissions` prop. **But the OpenBuild runtime never supplies that prop** —
+ it does not inject the current user's group memberships into the rendered app,
+ so a `permission`-tagged menu item either always shows or never shows.
+
+This change wires the missing user-context layer and adds the manifest surface
+to declare group-scoped menus and a group-scoped dashboard.
+
+## What Changes
+
+- **Inject runtime user context.** The runtime (`BuilderHost` / `CnAppRoot`
+ mount) resolves the current user's groups (server-provided initial state, not
+ a client call) and passes them as the `permissions` set to `CnAppNav` /
+ `CnPageRenderer`, so `permission`-gated menu items and pages are filtered
+ per user.
+- **Manifest: `permission` on menu items and pages.** A menu item or page may
+ declare `permission: "group:vets"` (or a list). The renderer shows it only
+ when the user holds that permission; admins/owners see everything.
+- **Group-scoped dashboard.** Support more than one dashboard page where a
+ non-default dashboard carries a `permission`; vets landing on the app see the
+ vet dashboard, others see the default.
+- **Guard, don't trust the client.** Menu/page hiding is a UX layer; the
+ authoritative control remains OpenRegister schema RBAC on the underlying
+ objects (already enforced server-side). Document this so authors don't treat
+ menu hiding as security.
+
+## Capabilities
+
+### Modified Capabilities
+- **openbuild-runtime** — inject current-user group context; filter menus/pages
+ by `permission`; support a group-scoped dashboard.
+
+### Referenced (no change here)
+- OpenRegister schema RBAC (`schema.authorization`) — the authoritative
+ object-level control; the Pet Store sets `medicalRecord.authorization.read =
+ ["vets"]`.
+
+## Impact
+
+- Apps with no `permission` fields render unchanged (prop omitted ⇒ all items
+ visible).
+- Unblocks Pet Store tutorial feature **C** (vets-only medical menu + dashboard).
diff --git a/openspec/changes/runtime-group-scoped-access/specs/openbuild-runtime/spec.md b/openspec/changes/runtime-group-scoped-access/specs/openbuild-runtime/spec.md
new file mode 100644
index 000000000..57ce7b740
--- /dev/null
+++ b/openspec/changes/runtime-group-scoped-access/specs/openbuild-runtime/spec.md
@@ -0,0 +1,61 @@
+# openbuild-runtime
+
+## ADDED Requirements
+
+### Requirement: The runtime MUST inject the current user's group context
+
+When rendering a virtual app, the OpenBuild runtime MUST resolve the current
+user's group memberships server-side (via initial state, not a client API call)
+and supply them to the manifest renderer as the set of permission strings the
+user holds (`group:`, plus `admin` for admins and `owner` for application
+owners). When no permission context is available the renderer MUST fall back to
+showing all items (no regression for apps without permission fields).
+
+#### Scenario: A vet's group context reaches the renderer
+- GIVEN the current user is a member of the `vets` group
+- WHEN the virtual app is rendered
+- THEN the renderer receives a permissions set containing `group:vets`
+
+#### Scenario: Apps without permissions render unchanged
+- GIVEN a manifest whose menu items and pages declare no `permission`
+- WHEN any user opens the app
+- THEN every menu item and page renders regardless of the user's groups
+
+### Requirement: Menu items and pages MUST be filterable by permission
+
+A manifest `menu[]` item or `pages[]` entry MAY declare a `permission`
+(string or list). The runtime MUST render that item/page only when the user
+holds at least one of the declared permissions; admins and application owners
+MUST see all items. A `permission`-gated page MUST NOT be routable for a user
+who lacks the permission.
+
+#### Scenario: Vets-only medical menu and page
+- GIVEN the medical menu item and its page declare `permission: "group:vets"`
+- WHEN a user in `vets` opens the app
+- THEN the medical menu item is visible and its page is reachable
+- AND WHEN a user not in `vets` (non-admin) opens the app
+- THEN the medical menu item is hidden and its page is not routable
+
+### Requirement: A group-scoped dashboard MAY be the landing page for its group
+
+When more than one dashboard page exists, the runtime MUST land the user on the
+highest-priority dashboard page whose `permission` the user satisfies, falling
+back to the default dashboard when none match.
+
+#### Scenario: Vets land on the vet dashboard
+- GIVEN a default dashboard and a `MedicalDashboard` page with `permission: "group:vets"`
+- WHEN a user in `vets` opens the app at its root
+- THEN the vet dashboard is shown
+- AND a non-vet user is shown the default dashboard
+
+### Requirement: Navigation hiding MUST NOT be treated as object security
+
+Permission-based hiding of menus and pages is a presentation concern only. The
+authoritative access control for the data a page reads MUST be enforced by
+OpenRegister schema RBAC (`schema.authorization`). The runtime MUST NOT rely on
+hidden navigation to protect objects.
+
+#### Scenario: Object access holds even if navigation is bypassed
+- GIVEN `medicalRecord.authorization.read = ["vets"]` in OpenRegister
+- WHEN a non-vet user requests medical objects directly (bypassing the hidden menu)
+- THEN OpenRegister returns no medical objects for that user
diff --git a/openspec/changes/runtime-group-scoped-access/tasks.md b/openspec/changes/runtime-group-scoped-access/tasks.md
new file mode 100644
index 000000000..58b2ebe76
--- /dev/null
+++ b/openspec/changes/runtime-group-scoped-access/tasks.md
@@ -0,0 +1,23 @@
+# Tasks — runtime group-scoped access
+
+## 1. Inject current-user group context
+- [ ] 1.1 Controller provides `user-groups` (and `is-admin`, owner markers) via `IInitialState::provideInitialState` on the builder/runtime page.
+- [ ] 1.2 `BuilderHost` reads it with `loadState('openbuild','user-groups')`, maps to `['group:', 'admin'?, 'owner'?]`, and passes as `permissions` to `CnAppRoot` (→ `CnAppNav` / `CnPageRenderer`).
+- [ ] 1.3 Unit/component test: `permissions` derived from initial state; admin gets `admin`; owner gets `owner`.
+
+## 2. Manifest permission surface
+- [ ] 2.1 Extend the manifest schema/validator: `menu[].permission` and `pages[].permission` (string | string[]).
+- [ ] 2.2 `CnPageRenderer` filters routed pages by `permission` (mirror `CnAppNav.passesPermission`); a gated page is not reachable without the permission.
+- [ ] 2.3 Multiple dashboards: pick the landing dashboard as the highest-priority dashboard page whose `permission` the user satisfies, else the default.
+- [ ] 2.4 Component test: vet sees medical menu + vet dashboard; non-vet does not; admin sees all.
+
+## 3. Author guidance (security boundary)
+- [ ] 3.1 Document that `permission` hides navigation only; object-level access MUST be set via OpenRegister `schema.authorization`. Add to the runtime/manifest docs.
+
+## 4. Pet Store demo wiring
+- [ ] 4.1 Add `permission: "group:vets"` to the medical menu item(s) and a `MedicalDashboard` page in the demo manifest.
+- [ ] 4.2 (Done in OpenRegister) `medicalRecord.authorization.read/create/update/delete = ["vets"]`.
+
+## 5. Verification
+- [ ] 5.1 Live: as a `vets` user the medical menu + vet dashboard show and medical objects load; as a non-vet they do not; admin sees everything.
+- [ ] 5.2 Frontend gates (ADR-004): initial-state (not DOM) for group data; no admin component in vue-router.
From 1e8db8ba2dca573c456541f055fcce5744dff4c3 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sat, 27 Jun 2026 13:07:24 +0200
Subject: [PATCH 015/391] fix(mcp): make OpenBuild app-building tools work for
any caller + real widget types
Two fixes so an AI agent can build apps over MCP (the tools previously failed):
1. Org-context: the handlers resolved the `openbuild` register via
searchObjectsBySlug() with multitenancy on, so MCP callers whose active org
was not the openbuild system org got "register slug not found in caller
organisation: openbuild". openbuild is a system-wide register; pass
_multitenancy: false (mirrors the REST controllers). Fixes AbstractToolHandler
(loadVersion + requireWriteRole), ListAppsHandler, GetAppManifestHandler.
2. Widget allow-list: AddWidgetHandler rejected the real OpenBuild widget types.
Replaced the bogus list (stat-counter, chart-bar, ...) with the actual types
the runtime renders (header, label, text, image, divider, tile, stat,
stats-block, delta, gauge, chart, object-list, table, data, related, files,
metadata, integration, map).
Verified live via the MCP endpoint: listApps returns pet-store, and
createApp -> upsertSchema -> upsertPage -> addWidget(stat/chart/object-list)
all succeed.
---
lib/Mcp/Handler/AbstractToolHandler.php | 10 +++++--
lib/Mcp/Handler/AddWidgetHandler.php | 34 +++++++++++++++--------
lib/Mcp/Handler/GetAppManifestHandler.php | 2 +-
lib/Mcp/Handler/ListAppsHandler.php | 2 +-
4 files changed, 31 insertions(+), 17 deletions(-)
diff --git a/lib/Mcp/Handler/AbstractToolHandler.php b/lib/Mcp/Handler/AbstractToolHandler.php
index 4b68518b5..f06aadcbe 100644
--- a/lib/Mcp/Handler/AbstractToolHandler.php
+++ b/lib/Mcp/Handler/AbstractToolHandler.php
@@ -162,7 +162,9 @@ protected function requireWriteRole(string $appSlug, bool $allowAdminBypass=true
}
$objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService');
- $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug]);
+ // openbuild is a system-wide register (not org-scoped); bypass the
+ // organisation filter so MCP callers in any org can resolve apps.
+ $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug], _rbac: true, _multitenancy: false);
if (is_array($apps) === false || $apps === []) {
return $this->errorResult(error: 'not_found', message: "No virtual app found for slug '{$appSlug}'.");
}
@@ -487,7 +489,7 @@ protected function extractUuid(array $item): string
*/
protected function loadVersion(object $objectService, string $appSlug, string $versionSlug): array
{
- $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug]);
+ $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug], _rbac: true, _multitenancy: false);
if (is_array($apps) === false || $apps === []) {
return ['error' => 'not_found', 'message' => "No virtual app found for slug '{$appSlug}'."];
}
@@ -498,7 +500,9 @@ protected function loadVersion(object $objectService, string $appSlug, string $v
$versions = $objectService->searchObjectsBySlug(
self::REGISTER_SLUG,
'applicationVersion',
- ['application' => $appUuid, 'slug' => $versionSlug]
+ ['application' => $appUuid, 'slug' => $versionSlug],
+ _rbac: true,
+ _multitenancy: false
);
if (is_array($versions) === false || $versions === []) {
return ['error' => 'not_found', 'message' => "No version '{$versionSlug}' found for app '{$appSlug}'."];
diff --git a/lib/Mcp/Handler/AddWidgetHandler.php b/lib/Mcp/Handler/AddWidgetHandler.php
index ca02143c4..d091f66c6 100644
--- a/lib/Mcp/Handler/AddWidgetHandler.php
+++ b/lib/Mcp/Handler/AddWidgetHandler.php
@@ -42,19 +42,29 @@ class AddWidgetHandler extends AbstractToolHandler
* @var array
*/
private const ALLOWED_WIDGET_TYPES = [
- 'stat-counter',
- 'data-table',
- 'chart-bar',
- 'chart-line',
- 'chart-pie',
- 'kanban-board',
- 'timeline',
- 'markdown',
- 'iframe',
- 'form-embed',
+ // Content & layout widgets.
+ 'header',
+ 'label',
+ 'text',
+ 'image',
+ 'divider',
+ 'tile',
+ // Statistic / metric widgets (OpenRegister-data-driven).
+ 'stat',
+ 'stats-block',
+ 'delta',
+ 'gauge',
+ 'chart',
+ // List / table widgets.
'object-list',
- 'object-detail',
- 'calendar',
+ 'table',
+ // Object-context widgets (detail pages).
+ 'data',
+ 'related',
+ 'files',
+ 'metadata',
+ // Integration / map.
+ 'integration',
'map',
];
diff --git a/lib/Mcp/Handler/GetAppManifestHandler.php b/lib/Mcp/Handler/GetAppManifestHandler.php
index 3bbecca4e..cfbd4dd7e 100644
--- a/lib/Mcp/Handler/GetAppManifestHandler.php
+++ b/lib/Mcp/Handler/GetAppManifestHandler.php
@@ -106,7 +106,7 @@ public function handle(array $args): array
*/
private function resolveApplicationBySlug(object $objectService, string $slug): array
{
- $routeResults = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'built-app-route', ['slug' => $slug]);
+ $routeResults = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'built-app-route', ['slug' => $slug], _rbac: true, _multitenancy: false);
if (is_array($routeResults) === false || $routeResults === []) {
return ['error' => 'not_found', 'message' => "No published virtual app found for slug '{$slug}'."];
}
diff --git a/lib/Mcp/Handler/ListAppsHandler.php b/lib/Mcp/Handler/ListAppsHandler.php
index 985afad26..45639f5ab 100644
--- a/lib/Mcp/Handler/ListAppsHandler.php
+++ b/lib/Mcp/Handler/ListAppsHandler.php
@@ -61,7 +61,7 @@ public function handle(array $args): array
$filters['status'] = $validation['statusFilter'];
}
- $rawApps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', $filters);
+ $rawApps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', $filters, _rbac: true, _multitenancy: false);
if (is_array(value: $rawApps) === false) {
$rawApps = [];
}
From e8fdf9db779c13f3b079dbd55e18c15fb7aeab19 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sat, 27 Jun 2026 16:38:30 +0200
Subject: [PATCH 016/391] feat(builder): brand the NC top-bar with the virtual
app's name + icon
A virtual app is rendered inside the host 'openbuild' app, so Nextcloud's
server-rendered top-bar showed 'OpenBuild' + the OpenBuild icon for every
built app. There is no supported API to retitle that chrome per virtual
app, so builder.js now patches the top-bar DOM (.app-menu__current-app-name,
.app-menu__current-app-icon, and the app-menu trigger aria-label) to the
app's own name + icon on boot, kept in sync with a MutationObserver so it
survives Nextcloud re-rendering the app menu. The icon uses the app's white
(dark-slot) variant with the light icon as a fallback.
Bumps version 0.5.40 -> 0.5.41.
---
appinfo/info.xml | 2 +-
src/builder.js | 56 +++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 54 insertions(+), 4 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 6df987686..6ca718a88 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]>
- 0.5.40
+ 0.5.41
agpl
Conduction
OpenBuild
diff --git a/src/builder.js b/src/builder.js
index e1df6f4ea..eea6572a7 100644
--- a/src/builder.js
+++ b/src/builder.js
@@ -96,6 +96,57 @@ function translateForApp(key, vars) {
return t('openbuild', key, vars)
}
+/**
+ * Rebrand the Nextcloud top-bar (app name + icon) to the virtual app's identity.
+ *
+ * The global top-bar is server-rendered chrome for the host `openbuild` app, so
+ * there is no supported API to retitle it per virtual app. We patch the DOM
+ * directly and keep it in sync with a MutationObserver, because Nextcloud's
+ * app-menu is a Vue component that can re-render (resize, unified-search and
+ * notification updates) and would otherwise reset our changes. `apply()` is
+ * idempotent — it only writes when the value differs — so it never loops on its
+ * own mutations.
+ *
+ * @param {string} appName The virtual app's display name.
+ * @param {string} appSlug The virtual app's slug, used for its icon endpoint.
+ */
+function brandTopBar(appName, appSlug) {
+ if (!appName || typeof document === 'undefined') {
+ return
+ }
+ // The coloured top-bar wants the white (dark-slot) icon; fall back to the
+ // light icon if the app has no dark variant uploaded.
+ const iconDark = generateUrl(`/apps/openbuild/icons/${appSlug}-dark.svg`)
+ const iconLight = generateUrl(`/apps/openbuild/icons/${appSlug}.svg`)
+ const apply = () => {
+ const nameEl = document.querySelector('.app-menu__current-app-name')
+ if (nameEl && nameEl.textContent !== appName) {
+ nameEl.textContent = appName
+ }
+ const iconEl = document.querySelector('.app-menu__current-app-icon')
+ if (iconEl) {
+ const src = iconEl.getAttribute('src')
+ if (src !== iconDark && src !== iconLight) {
+ iconEl.onerror = () => { iconEl.onerror = null; iconEl.setAttribute('src', iconLight) }
+ iconEl.setAttribute('src', iconDark)
+ iconEl.setAttribute('alt', appName)
+ }
+ }
+ const trigger = document.querySelector('[aria-label^="Open apps menu, currently in"]')
+ if (trigger) {
+ const label = t('openbuild', 'Open apps menu, currently in {app}', { app: appName })
+ if (trigger.getAttribute('aria-label') !== label) {
+ trigger.setAttribute('aria-label', label)
+ }
+ }
+ }
+ apply()
+ const header = document.querySelector('header#header') || document.body
+ if (header) {
+ new MutationObserver(apply).observe(header, { childList: true, subtree: true, characterData: true })
+ }
+}
+
/**
* Fetch the app manifest, build its router, and mount the standalone shell.
*
@@ -112,12 +163,11 @@ async function boot() {
if (data && typeof data === 'object' && Array.isArray(data.pages)) {
manifest = data
}
- // Reflect the app's identity in the browser tab (the global NC top-bar
- // still shows the host 'OpenBuild' app — a virtual app is not a real
- // Nextcloud app, so its name/icon can't replace the host chrome there).
+ // Reflect the app's identity in the browser tab and the global NC top-bar.
const appName = (manifest.name || manifest.title || slug)
if (appName) {
document.title = `${appName} – Nextcloud`
+ brandTopBar(appName, slug)
}
} catch (e) {
// Render an empty (but well-formed) shell; the app simply has no pages.
From f378a5855dfe519c888184dcb530bcdb03828f99 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sat, 27 Jun 2026 17:19:16 +0200
Subject: [PATCH 017/391] fix(builder): white paw icon (not cube) + remove
top-bar load flash
- Use the app's own light icon (/icons/{slug}.svg) forced white via CSS
filter instead of the -dark endpoint, which falls back to a generic
cube when no white variant is uploaded.
- Apply branding early with a slug-humanised name (one shared observer)
so the bar flips off 'OpenBuild' before the manifest request resolves.
---
src/builder.js | 81 +++++++++++++++++++++++++++++++++++++-------------
1 file changed, 61 insertions(+), 20 deletions(-)
diff --git a/src/builder.js b/src/builder.js
index eea6572a7..7e3b54197 100644
--- a/src/builder.js
+++ b/src/builder.js
@@ -96,6 +96,10 @@ function translateForApp(key, vars) {
return t('openbuild', key, vars)
}
+// Top-bar branding state. A single observer drives every (re-)apply so that the
+// early slug-based pass and the later manifest-name pass share one watcher.
+let topBarBrand = null
+
/**
* Rebrand the Nextcloud top-bar (app name + icon) to the virtual app's identity.
*
@@ -104,55 +108,92 @@ function translateForApp(key, vars) {
* directly and keep it in sync with a MutationObserver, because Nextcloud's
* app-menu is a Vue component that can re-render (resize, unified-search and
* notification updates) and would otherwise reset our changes. `apply()` is
- * idempotent — it only writes when the value differs — so it never loops on its
+ * idempotent — it only writes when a value differs — so it never loops on its
* own mutations.
*
+ * Call it twice: once early with a slug-humanised name (so the bar flips off
+ * "OpenBuild" before the manifest request resolves), then again with the real
+ * `manifest.name` to correct it. The second call only updates the shared state
+ * and re-applies; it does not create a second observer.
+ *
+ * The icon uses the app's own light icon (`/icons/{slug}.svg`) forced white with
+ * a CSS filter, because the coloured header needs a monochrome white glyph and
+ * apps rarely upload a dedicated white variant (the `-dark` endpoint falls back
+ * to a generic cube, which is why we do NOT use it here).
+ *
* @param {string} appName The virtual app's display name.
* @param {string} appSlug The virtual app's slug, used for its icon endpoint.
*/
function brandTopBar(appName, appSlug) {
- if (!appName || typeof document === 'undefined') {
+ if (typeof document === 'undefined') {
+ return
+ }
+ const icon = generateUrl(`/apps/openbuild/icons/${appSlug}.svg`)
+ if (topBarBrand) {
+ // Refine an existing brand (e.g. slug-name → real manifest name).
+ if (appName) {
+ topBarBrand.name = appName
+ }
+ topBarBrand.icon = icon
+ topBarBrand.apply()
return
}
- // The coloured top-bar wants the white (dark-slot) icon; fall back to the
- // light icon if the app has no dark variant uploaded.
- const iconDark = generateUrl(`/apps/openbuild/icons/${appSlug}-dark.svg`)
- const iconLight = generateUrl(`/apps/openbuild/icons/${appSlug}.svg`)
- const apply = () => {
+ const state = { name: appName || appSlug, icon }
+ state.apply = () => {
const nameEl = document.querySelector('.app-menu__current-app-name')
- if (nameEl && nameEl.textContent !== appName) {
- nameEl.textContent = appName
+ if (nameEl && state.name && nameEl.textContent !== state.name) {
+ nameEl.textContent = state.name
}
const iconEl = document.querySelector('.app-menu__current-app-icon')
- if (iconEl) {
- const src = iconEl.getAttribute('src')
- if (src !== iconDark && src !== iconLight) {
- iconEl.onerror = () => { iconEl.onerror = null; iconEl.setAttribute('src', iconLight) }
- iconEl.setAttribute('src', iconDark)
- iconEl.setAttribute('alt', appName)
- }
+ if (iconEl && iconEl.getAttribute('src') !== state.icon) {
+ iconEl.setAttribute('src', state.icon)
+ iconEl.setAttribute('alt', state.name || '')
+ // The header background is coloured; force any icon to white.
+ iconEl.style.filter = 'brightness(0) invert(1)'
}
const trigger = document.querySelector('[aria-label^="Open apps menu, currently in"]')
- if (trigger) {
- const label = t('openbuild', 'Open apps menu, currently in {app}', { app: appName })
+ if (trigger && state.name) {
+ const label = t('openbuild', 'Open apps menu, currently in {app}', { app: state.name })
if (trigger.getAttribute('aria-label') !== label) {
trigger.setAttribute('aria-label', label)
}
}
}
- apply()
+ topBarBrand = state
+ state.apply()
const header = document.querySelector('header#header') || document.body
if (header) {
- new MutationObserver(apply).observe(header, { childList: true, subtree: true, characterData: true })
+ new MutationObserver(state.apply).observe(header, { childList: true, subtree: true, characterData: true })
}
}
+/**
+ * Turn a slug into a human-readable title, e.g. `pet-store` → `Pet Store`. Used
+ * for the early top-bar pass before the manifest (with the real name) loads.
+ *
+ * @param {string} value The slug.
+ * @return {string}
+ */
+function humaniseSlug(value) {
+ return String(value || '')
+ .split(/[-_]+/)
+ .filter(Boolean)
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(' ')
+}
+
/**
* Fetch the app manifest, build its router, and mount the standalone shell.
*
* @return {Promise}
*/
async function boot() {
+ // Flip the top-bar off the host "OpenBuild" identity immediately using the
+ // slug, so there's no visible "OpenBuild" flash while the manifest (which
+ // carries the real display name) is still loading.
+ if (slug) {
+ brandTopBar(humaniseSlug(slug), slug)
+ }
let manifest = { version: '1.0.0', menu: [], pages: [] }
try {
let url = generateUrl(`/apps/openbuild/api/applications/${slug}/manifest`)
From 65acf062d597d226d6feed79b093959099eeb2c1 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 28 Jun 2026 10:11:13 +0200
Subject: [PATCH 018/391] feat(icon): app icon box -> layers-triple
OpenBuild composes apps from a layer system (base / admin / user manifest
deltas), so the layers glyph fits better than the cube. Swap app.svg,
app-dark.svg and app-store.svg to the MDI layers-triple path; colours and
hex frame unchanged.
---
img/app-dark.svg | 2 +-
img/app-store.svg | 2 +-
img/app.svg | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/img/app-dark.svg b/img/app-dark.svg
index 29885ed7d..f0c7563d4 100644
--- a/img/app-dark.svg
+++ b/img/app-dark.svg
@@ -1,3 +1,3 @@
-
+
diff --git a/img/app-store.svg b/img/app-store.svg
index 7d770d17d..fdcbeedae 100644
--- a/img/app-store.svg
+++ b/img/app-store.svg
@@ -1,6 +1,6 @@
-
+
diff --git a/img/app.svg b/img/app.svg
index d3c8eeb27..e91b80116 100644
--- a/img/app.svg
+++ b/img/app.svg
@@ -1,3 +1,3 @@
-
+
From b632e9b4576ef89363a1ee94e5eb0b7683aeca3d Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Mon, 29 Jun 2026 09:33:26 +0200
Subject: [PATCH 019/391] ci(openbuild): authenticate composer downloads
(GH_COMPOSER_TOKEN)
Set COMPOSER_AUTH on every composer install step so Composer fetches dist
zips via the authenticated GitHub API instead of anonymous codeload.github.com,
eliminating intermittent HTTP 400 throttle failures. Add secrets:inherit to
both reusable-workflow callers (app-tests.yml, app-tests-live.yml) so the
org-level secret propagates into the reusable jobs.
---
.forgejo/workflows/app-tests-live.yml | 1 +
.forgejo/workflows/app-tests.yml | 1 +
.forgejo/workflows/pre-merge-check-strict.yaml | 2 ++
.forgejo/workflows/tests-live.yml | 2 ++
.forgejo/workflows/tests.yml | 4 ++++
5 files changed, 10 insertions(+)
diff --git a/.forgejo/workflows/app-tests-live.yml b/.forgejo/workflows/app-tests-live.yml
index 00f11120e..df924cd2e 100644
--- a/.forgejo/workflows/app-tests-live.yml
+++ b/.forgejo/workflows/app-tests-live.yml
@@ -35,6 +35,7 @@ permissions:
jobs:
live:
uses: ./.forgejo/workflows/tests-live.yml
+ secrets: inherit
with:
app-id: openbuild
is-openregister: false
diff --git a/.forgejo/workflows/app-tests.yml b/.forgejo/workflows/app-tests.yml
index b525e941a..595bd8129 100644
--- a/.forgejo/workflows/app-tests.yml
+++ b/.forgejo/workflows/app-tests.yml
@@ -28,6 +28,7 @@ permissions:
jobs:
tests:
uses: ./.forgejo/workflows/tests.yml
+ secrets: inherit
with:
app-id: openbuild
run-e2e: ${{ github.event.inputs.run-e2e == 'true' }}
diff --git a/.forgejo/workflows/pre-merge-check-strict.yaml b/.forgejo/workflows/pre-merge-check-strict.yaml
index baca99bc6..23c520bf5 100644
--- a/.forgejo/workflows/pre-merge-check-strict.yaml
+++ b/.forgejo/workflows/pre-merge-check-strict.yaml
@@ -41,6 +41,8 @@ jobs:
fetch-depth: 0
- name: Install composer deps
+ env:
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}'
run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs
- name: Run lint + phpcs (the enforced gate)
diff --git a/.forgejo/workflows/tests-live.yml b/.forgejo/workflows/tests-live.yml
index d1a3ed637..a1c96a36d 100644
--- a/.forgejo/workflows/tests-live.yml
+++ b/.forgejo/workflows/tests-live.yml
@@ -122,6 +122,8 @@ jobs:
node-version: ${{ inputs.node-version }}
- name: Install composer deps (production)
+ env:
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}'
run: composer install --no-dev --no-interaction --no-progress --prefer-dist
- name: Install npm deps + Newman
diff --git a/.forgejo/workflows/tests.yml b/.forgejo/workflows/tests.yml
index 88b5833a2..00a26ec9c 100644
--- a/.forgejo/workflows/tests.yml
+++ b/.forgejo/workflows/tests.yml
@@ -82,6 +82,8 @@ jobs:
uses: https://github.com/actions/checkout@v4
- name: Install composer deps
+ env:
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}'
run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs
- name: Run unit suite (phpunit-unit.xml)
@@ -148,6 +150,8 @@ jobs:
|| echo "coverage driver: ABSENT (ratchet will no-op; see TESTING-CI-ROLLOUT.md)"
- name: Install composer deps
+ env:
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}'
run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs
- name: Run unit suite WITH coverage (clover)
From 2147ddbb57384ab7164273bd1a6d38b4b6a34455 Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Wed, 1 Jul 2026 12:09:38 +0200
Subject: [PATCH 020/391] fixed tutorial not targeting right elements
---
src/manifest.json | 4 ++--
src/views/TemplateGallery.vue | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/manifest.json b/src/manifest.json
index 9aed2571a..db5bc2881 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -18,8 +18,8 @@
"steps": [
{ "id": "welcome", "sinceVersion": "0.5.9", "placement": "center", "title": "Welcome to OpenBuild", "body": "Let's take a quick spin through the app builder — we'll create your first app from a template so you can see how the pieces fit. You'll do each step yourself.", "target": { "kind": "page", "ref": "Dashboard" }, "advanceOn": { "type": "manual" } },
{ "id": "go-apps", "sinceVersion": "0.5.9", "placement": "right", "body": "Apps are the heart of OpenBuild — every app you build lives here. Open Apps from the menu to see what's already there.", "task": "Click Apps in the menu", "target": { "kind": "nav-item", "ref": "VirtualApps" }, "advanceOn": { "type": "route-match", "route": "VirtualApps" } },
- { "id": "go-store", "sinceVersion": "0.5.9", "placement": "right", "body": "The fastest way to start is from a template. Open the Store to browse ready-made apps you can clone and make your own.", "task": "Click Store in the menu", "target": { "kind": "nav-item", "ref": "Store" }, "advanceOn": { "type": "route-match", "route": "Templates" } },
- { "id": "create-app", "sinceVersion": "0.5.9", "placement": "bottom", "allowManualNext": true, "body": "Pick a template and clone it — give your new app a name, then create it. Once it exists you can shape its schemas and pages.", "task": "Clone a template into a new app", "target": { "kind": "page", "ref": "Templates" }, "advanceOn": { "type": "object-created", "register": "openbuild", "schema": "application", "capture": { "applicationId": ":id" } } },
+ { "id": "go-store", "sinceVersion": "0.5.9", "placement": "right", "body": "The fastest way to start is from a template. Open the Store to browse ready-made apps you can clone and make your own.", "task": "Click Store in the menu", "target": { "kind": "nav-item", "ref": "Templates" }, "advanceOn": { "type": "route-match", "route": "Templates" } },
+ { "id": "create-app", "sinceVersion": "0.5.9", "placement": "bottom", "allowManualNext": true, "body": "Pick a template and clone it — give your new app a name, then create it. Once it exists you can shape its schemas and pages.", "task": "Clone a template into a new app", "target": { "kind": "page", "ref": "templates-grid" }, "advanceOn": { "type": "object-created", "register": "openbuild", "schema": "application", "capture": { "applicationId": ":id" } } },
{ "id": "done", "sinceVersion": "0.5.9", "placement": "center", "title": "You're all set", "body": "Nicely done — your first app exists. Open it from Apps to design its schemas and pages, and reopen this tour anytime from the … menu.", "target": { "kind": "page", "ref": "Dashboard" }, "advanceOn": { "type": "manual" } }
]
}
diff --git a/src/views/TemplateGallery.vue b/src/views/TemplateGallery.vue
index d4067a979..229627bc6 100644
--- a/src/views/TemplateGallery.vue
+++ b/src/views/TemplateGallery.vue
@@ -31,7 +31,7 @@
-
+
Date: Wed, 1 Jul 2026 12:13:14 +0200
Subject: [PATCH 021/391] fix(install): create register, schemas and templates
on fresh install
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On `app:enable`, OpenRegister's AppHost Bootstrap re-registers the class name
OCA\OpenBuild\Repair\InitializeSettings to the generic GenericInitializeSettings,
which imports via AppHostSettingsService and calls
ConfigurationService::importFromApp(appId:, force:) with a stale 2-argument
signature (OR `development` now requires 4). The import failed with
"Argument #2 ($data) not passed", so the openbuild register was never created and
SeedApplicationTemplates then aborted the install — the register and templates
only appeared after a manual admin re-import. It worked under
`occ maintenance:repair` only because AppHost Bootstrap is not autoloadable there,
so OpenBuild's own concrete step ran instead.
- Application.php: re-register the concrete SettingsService and InitializeSettings
after Bootstrap (last registration wins), so the install path uses the correct
4-arg importer + ADR-037 register.d fragment merge on every path.
- SeedApplicationTemplates: catch DoesNotExistException and defer seeding
(log + return) instead of throwing a fatal RuntimeException, so a not-yet-provisioned
register no longer aborts the whole install; fixture validation still fails loud.
- 10-business-rules.json: fix schema definitions that failed OR validation and never
imported — einddatum type ["string","null"] → "string", give defaultwaarde a
"type", and drop the invalid "_note" key from RuleSet.authorization.
Verified on a fresh install: the register, all 11 schemas (incl. rule-set and
decision-table) and the 4 templates now come up in a single app:enable.
---
lib/AppInfo/Application.php | 44 +++++++++++++++++++
lib/Repair/SeedApplicationTemplates.php | 17 +++++++
.../register.d/10-business-rules.json | 12 ++---
3 files changed, 67 insertions(+), 6 deletions(-)
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index a7e334f18..52c399622 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -32,6 +32,7 @@
use OCA\OpenBuild\Listener\HybridMetadataLockListener;
use OCA\OpenBuild\Listener\ProductionVersionGuardListener;
use OCA\OpenBuild\Mcp\OpenBuildToolProvider;
+use OCA\OpenBuild\Repair\InitializeSettings;
use OCA\OpenBuild\Sections\SettingsSection;
use OCA\OpenBuild\Service\AppNavigationService;
use OCA\OpenBuild\Service\PermissionResolver;
@@ -171,6 +172,49 @@ public function register(IRegistrationContext $context): void
userSession: $c->get('OCP\\IUserSession')
)
);
+ // SettingsService — bind the concrete OpenBuild implementation so it wins
+ // over the generic AppHost binding (last registration wins). When
+ // Bootstrap::register ran (above), it aliases this leaf class name to
+ // OpenRegister's generic AppHostSettingsService, whose loadConfiguration()
+ // calls ConfigurationService::importFromApp() with a stale 2-argument
+ // signature (OR `development` now requires 4) and skips the ADR-037
+ // register.d/ fragment merge. On the app-enable/install path the
+ // InitializeSettings repair step resolves THIS class directly, so under the
+ // generic binding the register import fails with
+ // "importFromApp(): Argument #2 ($data) not passed" — leaving the openbuild
+ // register uncreated until a manual re-import. Registering the concrete
+ // class here (mirroring the controllers above) guarantees every caller —
+ // the InitializeSettings repair step AND SettingsController — uses the
+ // correct 4-arg importer + fragment merge on all paths.
+ $context->registerService(
+ SettingsService::class,
+ static fn ($c): SettingsService => new SettingsService(
+ appConfig: $c->get('OCP\\IAppConfig'),
+ appManager: $c->get('OCP\\App\\IAppManager'),
+ container: $c,
+ groupManager: $c->get('OCP\\IGroupManager'),
+ userSession: $c->get('OCP\\IUserSession'),
+ logger: $c->get('Psr\\Log\\LoggerInterface')
+ )
+ );
+ // InitializeSettings repair step — bind OpenBuild's own class so it wins
+ // over the generic AppHost binding (last registration wins). Bootstrap
+ // above re-registers this exact class name to OpenRegister's
+ // GenericInitializeSettings, which imports via the broken generic
+ // AppHostSettingsService (2-arg importFromApp) and does NO register.d/
+ // fragment merge. info.xml declares OCA\OpenBuild\Repair\InitializeSettings
+ // as an / step; NC's repair runner resolves that
+ // class name through the container, so without this the generic (failing)
+ // step runs on install and the openbuild register is never created.
+ // Re-registering the concrete step here makes install use the correct
+ // 4-arg importer + fragment merge (via the concrete SettingsService above).
+ $context->registerService(
+ InitializeSettings::class,
+ static fn ($c): InitializeSettings => new InitializeSettings(
+ settingsService: $c->get(SettingsService::class),
+ logger: $c->get('Psr\\Log\\LoggerInterface')
+ )
+ );
$context->registerService(
SettingsController::class,
static fn ($c): SettingsController => new SettingsController(
diff --git a/lib/Repair/SeedApplicationTemplates.php b/lib/Repair/SeedApplicationTemplates.php
index f3d6fd9ec..105a35d8b 100644
--- a/lib/Repair/SeedApplicationTemplates.php
+++ b/lib/Repair/SeedApplicationTemplates.php
@@ -35,6 +35,7 @@
namespace OCA\OpenBuild\Repair;
use OCA\OpenRegister\Service\ObjectService;
+use OCP\AppFramework\Db\DoesNotExistException;
use OCP\App\IAppManager;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
@@ -150,6 +151,22 @@ public function run(IOutput $output): void
);
$output->info('Seeded ApplicationTemplate: '.$slug);
++$seeded;
+ } catch (DoesNotExistException $e) {
+ // The openbuild register / application-template schema is not
+ // provisioned yet — the configuration import (InitializeSettings)
+ // has not completed on this pass (e.g. install ordering). Defer
+ // seeding rather than aborting the whole install: a subsequent
+ // `occ maintenance:repair` (or the admin re-import) runs this step
+ // again once the register exists. Non-fatal by design.
+ $this->logger->warning(
+ 'OpenBuild: register/application-template schema not available yet — deferring template seeding',
+ ['slug' => $slug, 'exception' => $e->getMessage()]
+ );
+ $output->warning(
+ 'OpenBuild register/schema not available yet — deferring template seeding '
+ .'(completes on the next repair once the register exists).'
+ );
+ return;
} catch (Throwable $e) {
$this->logger->error(
'OpenBuild: failed to seed template',
diff --git a/lib/Settings/register.d/10-business-rules.json b/lib/Settings/register.d/10-business-rules.json
index 5aa4b4ec3..93a3b37a4 100644
--- a/lib/Settings/register.d/10-business-rules.json
+++ b/lib/Settings/register.d/10-business-rules.json
@@ -9,7 +9,6 @@
"title": "RuleSet",
"description": "A versioned container of business rules (DecisionTables and/or ConditionActionRules) that a consumer app evaluates against an input payload. Lifecycle (draft → test → active → archived) is declarative via x-openregister-lifecycle. Tenant-scoped by OR multitenancy.",
"authorization": {
- "_note": "Blocks unauthenticated OR-API writes. create=admin-only; update/delete=admin + object-owner (OR PermissionHandler owner bypass applies). The runtime evaluate endpoint is gated separately in RulesController per ADR-005.",
"create": [
"admin"
],
@@ -91,10 +90,7 @@
},
"einddatum": {
"title": "End Date",
- "type": [
- "string",
- "null"
- ],
+ "type": "string",
"format": "date",
"description": "Optional end-of-validity date."
},
@@ -280,7 +276,11 @@
"description": "Data type of the output column.",
"type": "string"
},
- "defaultwaarde": {}
+ "defaultwaarde": {
+ "title": "Default Value",
+ "description": "Optional default value for this output column, expressed as text.",
+ "type": "string"
+ }
}
}
},
From 9b5d2534d633dffa11f962d9b9f063d00a35c22a Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Wed, 1 Jul 2026 12:55:45 +0200
Subject: [PATCH 022/391] fixed failing image fetch
coincidentally also causing a infinite loop which drained system resources
---
src/components/ApplicationCard.vue | 16 +++++++++++++++-
.../dashboard/DashboardAppsListWidget.vue | 18 ++++++++++++++++--
src/views/TemplateGallery.vue | 11 +++++++++--
3 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/src/components/ApplicationCard.vue b/src/components/ApplicationCard.vue
index 61890f809..b36fe6328 100644
--- a/src/components/ApplicationCard.vue
+++ b/src/components/ApplicationCard.vue
@@ -46,6 +46,7 @@
From 4341c5c1e64f949686c9032e471f4a33954f6822 Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Thu, 2 Jul 2026 13:05:30 +0200
Subject: [PATCH 029/391] fix(templates): set NcModal labelId on the
clone/install dialog
The 'Use this template' dialog logged a Vue accessibility warning
([NcModal] needs a name or labelId). Point NcModal's label-id at the
existing dialog heading and factor the title into a dialogTitle computed
so it is not duplicated.
---
src/modals/CloneTemplateDialog.vue | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/src/modals/CloneTemplateDialog.vue b/src/modals/CloneTemplateDialog.vue
index 62a61214c..d3076f4b8 100644
--- a/src/modals/CloneTemplateDialog.vue
+++ b/src/modals/CloneTemplateDialog.vue
@@ -1,8 +1,13 @@
-
+
-
{{ remote ? t('openbuild', 'Install template') : t('openbuild', 'Use this template') }}
+
+ {{ dialogTitle }}
+
{{ remote ? t('openbuild', 'Install a new application from') : t('openbuild', 'Create a new application from') }}
{{ resolvedTitle }} .
@@ -60,6 +65,15 @@ export default {
}
},
computed: {
+ /**
+ * Title shown in the dialog heading and used as the NcModal `name`
+ * (required for accessibility — provides the modal's accessible label).
+ *
+ * @return {string} The translated dialog title.
+ */
+ dialogTitle() {
+ return this.remote ? t('openbuild', 'Install template') : t('openbuild', 'Use this template')
+ },
/**
* Observed behaviour of `resolvedTitle` (retrofit annotation).
*
@@ -97,6 +111,7 @@ export default {
/**
* Observed behaviour of `open` (retrofit annotation).
*
+ * @param value
* @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-2
*/
open(value) {
@@ -187,6 +202,7 @@ export default {
/**
* Observed behaviour of `setError` (retrofit annotation).
*
+ * @param message
* @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-2
*/
setError(message) {
From 9ffdb283abc278f4daefe976fbaf41a08b7ac01f Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Thu, 2 Jul 2026 13:05:57 +0200
Subject: [PATCH 030/391] fix(templates): stop vue-router warning on post-clone
redirect
After cloning, redirectAfterClone probed for a 'PageEditor' route via
$router.resolve(), which vue-router warns about when the named route
does not exist. Add a hasRoute() helper that checks the registered route
table before pushing, preserving the PageEditor -> VirtualApps ->
Dashboard fallback chain without the warning.
---
src/views/TemplateGallery.vue | 28 +++++++++++++++++++++-------
1 file changed, 21 insertions(+), 7 deletions(-)
diff --git a/src/views/TemplateGallery.vue b/src/views/TemplateGallery.vue
index 88e7dcc81..0aa74161e 100644
--- a/src/views/TemplateGallery.vue
+++ b/src/views/TemplateGallery.vue
@@ -365,19 +365,33 @@ export default {
return
}
// Feature-detect chain #5 page editor; fall back to the manifest-driven
- // virtual-app manager, then the dashboard.
- const editorRoute = this.$router.resolve({ name: 'PageEditor', params: { slug } })
- if (editorRoute?.resolved?.matched?.length > 0) {
- this.$router.push(editorRoute.resolved.fullPath)
+ // virtual-app manager, then the dashboard. Routes are registered from
+ // the manifest with `name = page.id` (see main.js#routesFromManifest),
+ // so probe by name against the registered route table first —
+ // $router.resolve() on an unknown name emits a vue-router warning.
+ if (this.hasRoute('PageEditor')) {
+ this.$router.push({ name: 'PageEditor', params: { slug } })
return
}
- const fallback = this.$router.resolve({ name: 'VirtualApps', params: { slug } })
- if (fallback?.resolved?.matched?.length > 0) {
- this.$router.push(fallback.resolved.fullPath)
+ if (this.hasRoute('VirtualApps')) {
+ this.$router.push({ name: 'VirtualApps', params: { slug } })
return
}
this.$router.push({ name: 'Dashboard' })
},
+ /**
+ * Whether a named route is registered on the router. Routes are built
+ * from the manifest (flat, `name = page.id`), so a shallow scan of
+ * `$router.options.routes` is sufficient and avoids the vue-router
+ * warning that `$router.resolve()` logs for unknown route names.
+ *
+ * @param {string} name The route name to check.
+ * @return {boolean} True when a route with that name is registered.
+ */
+ hasRoute(name) {
+ const routes = this.$router?.options?.routes || []
+ return routes.some((route) => route.name === name)
+ },
},
}
From 0af25105822c11c2bce722a6dd03ef9a4682f77e Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Thu, 2 Jul 2026 13:06:20 +0200
Subject: [PATCH 031/391] fix(wizard): correct icon-preview backgrounds in the
review step
The light icon preview had no explicit background so it inherited the
dark theme surface, and the dark preview used a bluish #1a1a2e. Pin the
light preview to #fff and the dark preview to #171717, and fix the light
caption colour so it stays legible on white regardless of theme.
---
src/dialogs/CreateApplicationWizard/Step4Review.vue | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/dialogs/CreateApplicationWizard/Step4Review.vue b/src/dialogs/CreateApplicationWizard/Step4Review.vue
index d2d84316f..955512af5 100644
--- a/src/dialogs/CreateApplicationWizard/Step4Review.vue
+++ b/src/dialogs/CreateApplicationWizard/Step4Review.vue
@@ -222,10 +222,13 @@ export default {
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
margin: 0;
+ /* Fixed backgrounds so each icon renders against the surface it targets,
+ independent of the active NC theme. */
+ background: #fff;
}
.wizard-step4__icon-preview--dark {
- background: #1a1a2e;
+ background: #171717;
}
.wizard-step4__icon-img {
@@ -236,7 +239,8 @@ export default {
.wizard-step4__icon-preview figcaption {
font-size: 0.75rem;
- color: var(--color-text-maxcontrast, #555);
+ /* Fixed to stay legible on the hardcoded #fff background regardless of theme. */
+ color: #555;
}
.wizard-step4__icon-preview--dark figcaption {
From a1d881ca18f2cbc98eb7d3089c719cefddfa4310 Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Thu, 2 Jul 2026 13:06:38 +0200
Subject: [PATCH 032/391] fix(wizard): upload the chosen icons when creating an
app
onSubmit only sent name/slug/description/preset/versions, so the light
and dark icons picked in step 1 were silently dropped and had to be
re-added from the Icons sidebar. After the app is created, upload each
selected icon via OpenRegister's filesMultipart endpoint and set the
icon/iconDark refs, mirroring the sidebar IconUploadSection flow (stable
app-icon.svg / app-icon-dark.svg filenames). Icon-upload failures are
non-fatal: the app already exists, so retrying would duplicate it.
---
src/dialogs/CreateApplicationWizard.vue | 81 ++++++++++++++++++++++++-
1 file changed, 79 insertions(+), 2 deletions(-)
diff --git a/src/dialogs/CreateApplicationWizard.vue b/src/dialogs/CreateApplicationWizard.vue
index 8d7e75b47..95d3efefa 100644
--- a/src/dialogs/CreateApplicationWizard.vue
+++ b/src/dialogs/CreateApplicationWizard.vue
@@ -63,6 +63,14 @@ import Step2Preset from './CreateApplicationWizard/Step2Preset.vue'
import Step3Custom from './CreateApplicationWizard/Step3Custom.vue'
import Step4Review from './CreateApplicationWizard/Step4Review.vue'
+// OR object coordinates for the created virtual app, and the stable attached
+// filenames used for its icons (must match IconUploadSection's sidebar flow so
+// re-uploads from the Icons tab overwrite the same files).
+const REGISTER = 'openbuild'
+const SCHEMA = 'application'
+const LIGHT_ICON_FILENAME = 'app-icon.svg'
+const DARK_ICON_FILENAME = 'app-icon-dark.svg'
+
export default {
name: 'CreateApplicationWizard',
@@ -187,21 +195,90 @@ export default {
versions: stepData.versions,
}
+ let applicationUuid
try {
const url = generateUrl('/apps/openbuild/api/applications/wizard')
const { data, status } = await axios.post(url, body)
if (status === 201 && data.applicationUuid) {
- this.$emit('created', data.applicationUuid)
- this.$emit('update:show', false)
+ applicationUuid = data.applicationUuid
} else {
this.reportError(data)
+ return
}
} catch (err) {
this.reportError(err.response?.data || {}, err)
+ return
+ }
+
+ // The app now exists. Upload the optional icons chosen in Step 1 —
+ // the wizard endpoint only persists name/slug/description/versions, so
+ // without this the icons the user picked are silently dropped. Failures
+ // here are non-fatal: the app is already created (retrying would
+ // duplicate it), and the icons can be (re)uploaded from the Icons
+ // sidebar tab.
+ try {
+ await this.uploadIcons(applicationUuid, stepData)
+ } catch (err) {
+ console.error('[CreateApplicationWizard] icon upload failed', err)
+ }
+
+ this.$emit('created', applicationUuid)
+ this.$emit('update:show', false)
+ },
+
+ /**
+ * Upload the light/dark icons selected in Step 1 to the freshly created
+ * app and set their refs on the Application record, mirroring the sidebar
+ * IconUploadSection flow.
+ *
+ * @param {string} objectUuid The created app's OR object UUID.
+ * @param {object} stepData The accumulated wizard data (holds icon Files).
+ * @return {Promise}
+ */
+ async uploadIcons(objectUuid, stepData) {
+ if (stepData.icon) {
+ await this.uploadIcon(objectUuid, stepData.icon, 'light')
+ }
+ if (stepData.iconDark) {
+ await this.uploadIcon(objectUuid, stepData.iconDark, 'dark')
}
},
+ /**
+ * Attach a single icon file to the app object and set its ref field.
+ *
+ * Two calls, matching IconUploadSection: (1) POST the file to OR's
+ * files-attached-to-object multipart endpoint, then (2) GET-merge-PUT the
+ * object with the new `icon`/`iconDark` ref — OR's PUT is a full replace,
+ * so we re-fetch fresh each time to avoid clobbering the sibling ref.
+ *
+ * @param {string} objectUuid The app's OR object UUID.
+ * @param {File} file The selected SVG file.
+ * @param {string} variant `'light'` or `'dark'`.
+ * @return {Promise}
+ */
+ async uploadIcon(objectUuid, file, variant) {
+ const filename = variant === 'dark' ? DARK_ICON_FILENAME : LIGHT_ICON_FILENAME
+ const field = variant === 'dark' ? 'iconDark' : 'icon'
+
+ const formData = new FormData()
+ formData.append('file', file, filename)
+ const uploadUrl = generateUrl(
+ `/apps/openregister/api/objects/${REGISTER}/${SCHEMA}/${objectUuid}/filesMultipart`,
+ )
+ await axios.post(uploadUrl, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })
+
+ const objectUrl = generateUrl(
+ `/apps/openregister/api/objects/${REGISTER}/${SCHEMA}/${objectUuid}`,
+ )
+ const { data } = await axios.get(objectUrl)
+ const obj = (data && data.results) ? data.results : data
+ await axios.put(objectUrl, { ...obj, [field]: { ref: filename } })
+ },
+
/**
* Surface a submit failure on the wizard (recoverable — keeps the dialog
* open so the user can correct and retry).
From 7ccdfb4693eed4634772683cb8b91f87c5086afc Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Thu, 2 Jul 2026 13:06:58 +0200
Subject: [PATCH 033/391] fix(apps): clean up registers on delete behind an
opt-in checkbox
Deleting an app left its per-version registers behind: RegisterMapper
refuses to delete a register that still has objects attached, so the
teardown swallowed the failure and the register (with its unique
organisation+slug) survived, breaking re-creation with the same slug
(wizard_rollback duplicate key).
Drain each register's objects before deleting it, and gate the whole
register teardown behind a new deleteData flag so data is preserved by
default. The destroy endpoint and deletion service accept deleteData
(default false); the detail-page DeleteAppDialog now offers an 'also
delete all data' checkbox and forwards it as a query param.
---
.../ApplicationPublishController.php | 16 +-
lib/Service/ApplicationDeletionService.php | 159 +++++++++++++++++-
src/components/ApplicationDetailActions.vue | 13 +-
src/dialogs/DeleteAppDialog.vue | 47 +++++-
4 files changed, 213 insertions(+), 22 deletions(-)
diff --git a/lib/Controller/ApplicationPublishController.php b/lib/Controller/ApplicationPublishController.php
index 0197b0c69..882a57926 100644
--- a/lib/Controller/ApplicationPublishController.php
+++ b/lib/Controller/ApplicationPublishController.php
@@ -127,17 +127,20 @@ public function unpublish(string $appUuid): JSONResponse
}//end unpublish()
/**
- * Delete an Application and everything it owns (versions, per-version
- * registers, routes). Owner-only (admin bypass). Wired as
- * `applicationPublish#destroy`.
+ * Delete an Application and the app wrapper it owns (versions, routes). When
+ * $deleteData is true, also delete the per-version registers and all their
+ * data; otherwise that data is preserved. Owner-only (admin bypass). Wired
+ * as `applicationPublish#destroy`.
*
- * @param string $appUuid Parent Application UUID (path param)
+ * @param string $appUuid Parent Application UUID (path param)
+ * @param bool $deleteData When true, also delete the underlying registers
+ * and every object stored in them (query param)
*
* @return JSONResponse 200 + `{deleted, orphanedResources}`, or an error envelope
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 10, period: 60)]
- public function destroy(string $appUuid): JSONResponse
+ public function destroy(string $appUuid, bool $deleteData = false): JSONResponse
{
$user = $this->userSession->getUser();
if ($user === null) {
@@ -160,7 +163,8 @@ public function destroy(string $appUuid): JSONResponse
$orphaned = $this->deletionService->deleteApplication(
appUuid: $appUuid,
- appSlug: (string) ($application['slug'] ?? '')
+ appSlug: (string) ($application['slug'] ?? ''),
+ deleteData: $deleteData
);
return new JSONResponse(
diff --git a/lib/Service/ApplicationDeletionService.php b/lib/Service/ApplicationDeletionService.php
index 1fbdfcc31..fd3f450fa 100644
--- a/lib/Service/ApplicationDeletionService.php
+++ b/lib/Service/ApplicationDeletionService.php
@@ -30,6 +30,7 @@
namespace OCA\OpenBuild\Service;
+use OCA\OpenRegister\Db\Register;
use OCA\OpenRegister\Db\RegisterMapper;
use OCA\OpenRegister\Service\ObjectService;
use OCA\OpenRegister\Service\RegisterService;
@@ -41,6 +42,21 @@
*/
class ApplicationDeletionService
{
+ /**
+ * Page size when draining a register's objects before deleting it.
+ *
+ * @var int
+ */
+ private const PURGE_BATCH_LIMIT = 500;
+
+ /**
+ * Safety cap on drain rounds per schema, so a delete that never removes a
+ * row cannot spin forever.
+ *
+ * @var int
+ */
+ private const MAX_PURGE_ROUNDS = 200;
+
/**
* Constructor.
*
@@ -60,18 +76,25 @@ public function __construct(
}//end __construct()
/**
- * Delete an Application plus its versions, per-version registers and routes.
+ * Delete an Application plus its versions and routes, and — only when
+ * $deleteData is true — its per-version registers and all their objects.
+ *
+ * By default ($deleteData false) the underlying registers and the data
+ * inside them are PRESERVED: the app wrapper is removed but the user's data
+ * survives in OpenRegister. The caller must opt in (checkbox) to wipe data.
*
- * @param string $appUuid The Application UUID.
- * @param string $appSlug The Application slug (for log context).
+ * @param string $appUuid The Application UUID.
+ * @param string $appSlug The Application slug (for log context).
+ * @param bool $deleteData When true, also delete the per-version registers
+ * and every object stored in them.
*
* @return array Resources that could not be removed (orphaned).
*/
- public function deleteApplication(string $appUuid, string $appSlug): array
+ public function deleteApplication(string $appUuid, string $appSlug, bool $deleteData = false): array
{
$orphaned = [];
- // 1. Versions + their per-version registers.
+ // 1. Versions + (optionally) their per-version registers.
$versions = $this->findChildren(
schema: ApplicationVersionService::APPLICATION_VERSION_SCHEMA,
field: 'application',
@@ -80,7 +103,7 @@ public function deleteApplication(string $appUuid, string $appSlug): array
foreach ($versions as $version) {
$versionUuid = (string) ($version['id'] ?? ($version['@self']['id'] ?? ''));
$registerSlug = (string) ($version['register'] ?? '');
- if ($registerSlug !== '') {
+ if ($deleteData === true && $registerSlug !== '') {
$this->deleteRegister(registerSlug: $registerSlug, orphaned: $orphaned);
}
@@ -170,6 +193,24 @@ private function deleteRegister(string $registerSlug, array &$orphaned): void
{
try {
$register = $this->registerMapper->find($registerSlug, _multitenancy: false);
+ } catch (Throwable $e) {
+ // The register is already gone (or was never provisioned) — nothing
+ // to tear down, and nothing to orphan.
+ $this->logger->info(
+ 'OpenBuild: deleteApplication register {slug} not found, skipping: {message}',
+ ['slug' => $registerSlug, 'message' => $e->getMessage()]
+ );
+ return;
+ }
+
+ // RegisterMapper::delete() refuses to remove a register that still has
+ // objects attached. Drain it first: otherwise the register — and its
+ // unique organisation+slug row — survives the teardown, and a later
+ // re-create with the same slug fails with a duplicate-key rollback
+ // (wizard_rollback at register-provision-*).
+ $this->purgeRegisterObjects(register: $register, registerSlug: $registerSlug, orphaned: $orphaned);
+
+ try {
$this->registerService->delete(register: $register);
} catch (Throwable $e) {
$this->logger->error(
@@ -180,6 +221,112 @@ private function deleteRegister(string $registerSlug, array &$orphaned): void
}
}//end deleteRegister()
+ /**
+ * Delete every object stored in a register, across all its schemas.
+ *
+ * Best-effort: per-schema query failures and per-object delete failures are
+ * logged and collected into $orphaned rather than aborting. Deletes are soft
+ * (OR default), which is enough to satisfy the register-delete guard — its
+ * object count excludes soft-deleted rows (`_deleted IS NULL`).
+ *
+ * @param Register $register The register to drain.
+ * @param string $registerSlug The register slug (for log context).
+ * @param array $orphaned Collector for failures.
+ *
+ * @return void
+ */
+ private function purgeRegisterObjects(Register $register, string $registerSlug, array &$orphaned): void
+ {
+ foreach (($register->getSchemas() ?? []) as $schemaId) {
+ $this->purgeRegisterSchema(registerSlug: $registerSlug, schemaId: $schemaId, orphaned: $orphaned);
+ }
+ }//end purgeRegisterObjects()
+
+ /**
+ * Drain all objects for a single register+schema pair, in batches.
+ *
+ * @param string $registerSlug The register slug.
+ * @param mixed $schemaId The schema identifier (id or slug).
+ * @param array $orphaned Collector for failures.
+ *
+ * @return void
+ */
+ private function purgeRegisterSchema(string $registerSlug, mixed $schemaId, array &$orphaned): void
+ {
+ for ($round = 0; $round < self::MAX_PURGE_ROUNDS; $round++) {
+ try {
+ $objects = $this->objectService->findAll(
+ config: [
+ 'filters' => [
+ 'register' => $registerSlug,
+ 'schema' => $schemaId,
+ ],
+ 'limit' => self::PURGE_BATCH_LIMIT,
+ ]
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ 'OpenBuild: deleteApplication failed to list objects in register {slug} schema {schema}: {message}',
+ ['slug' => $registerSlug, 'schema' => (string) $schemaId, 'message' => $e->getMessage()]
+ );
+ $orphaned[] = 'register-objects:'.$registerSlug;
+ return;
+ }
+
+ if ($objects === []) {
+ return;
+ }
+
+ // Track progress so a batch that can never be deleted (e.g. an
+ // append-only/archival schema) breaks the loop instead of spinning.
+ $progressed = false;
+ foreach ($objects as $object) {
+ $uuid = $this->extractUuid(item: $object);
+ if ($uuid === '') {
+ continue;
+ }
+
+ try {
+ $this->objectService->deleteObject(uuid: $uuid);
+ $progressed = true;
+ } catch (Throwable $e) {
+ $this->logger->error(
+ 'OpenBuild: deleteApplication failed to delete object {uuid}: {message}',
+ ['uuid' => $uuid, 'message' => $e->getMessage()]
+ );
+ $orphaned[] = 'object:'.$uuid;
+ }
+ }//end foreach
+
+ if ($progressed === false) {
+ return;
+ }
+ }//end for
+ }//end purgeRegisterSchema()
+
+ /**
+ * Extract an object UUID/id from a findAll result item (array or entity).
+ *
+ * @param mixed $item A rendered object array or an entity.
+ *
+ * @return string The object UUID/id, or '' when it cannot be determined.
+ */
+ private function extractUuid(mixed $item): string
+ {
+ if (is_array($item) === true) {
+ return (string) ($item['id'] ?? ($item['@self']['id'] ?? ''));
+ }
+
+ if (is_object($item) === true && method_exists($item, 'jsonSerialize') === true) {
+ $serialised = $item->jsonSerialize();
+ if (is_array($serialised) === true) {
+ return (string) ($serialised['id'] ?? ($serialised['@self']['id'] ?? ''));
+ }
+ }
+
+ return '';
+ }//end extractUuid()
+
/**
* Delete an object by UUID (best-effort).
*
diff --git a/src/components/ApplicationDetailActions.vue b/src/components/ApplicationDetailActions.vue
index db5e0d3f4..cbb546d9b 100644
--- a/src/components/ApplicationDetailActions.vue
+++ b/src/components/ApplicationDetailActions.vue
@@ -447,19 +447,24 @@ export default {
}
},
/**
- * Delete the app (Application + versions + per-version registers), then
- * navigate back to the apps list. Owner-only (enforced server-side too).
+ * Delete the app (Application + versions + routes), then navigate back to
+ * the apps list. Owner-only (enforced server-side too). When `deleteData`
+ * is true the underlying registers and all their data are wiped too;
+ * otherwise that data is preserved.
*
+ * @param {boolean} deleteData Whether to also delete all app data.
* @return {Promise}
*/
- async deleteApp() {
+ async deleteApp(deleteData = false) {
if (this.obAppRole !== 'owner' || !this.obApp || this.deleting) {
return
}
this.deleting = true
this.error = ''
try {
- await axios.delete(generateUrl(`/apps/openbuild/api/applications/${this.obAppUuid}`))
+ await axios.delete(generateUrl(`/apps/openbuild/api/applications/${this.obAppUuid}`), {
+ params: { deleteData: deleteData ? 1 : 0 },
+ })
this.deleteOpen = false
if (this.$router) {
this.$router.push({ name: 'VirtualApps' }).catch(() => {})
diff --git a/src/dialogs/DeleteAppDialog.vue b/src/dialogs/DeleteAppDialog.vue
index 2216ad3f1..5d531e5cb 100644
--- a/src/dialogs/DeleteAppDialog.vue
+++ b/src/dialogs/DeleteAppDialog.vue
@@ -2,9 +2,10 @@
- SPDX-License-Identifier: EUPL-1.2
- SPDX-FileCopyrightText: 2026 Conduction B.V.
-
- - DeleteAppDialog — owner confirmation for the destructive full delete of an
- - app (Application + versions + per-version registers). Kept in its own file
- - per ADR-004 gate-modal-isolation.
+ - DeleteAppDialog — owner confirmation for deleting an app (Application +
+ - versions + routes). By default the underlying registers and their data are
+ - PRESERVED; the owner must tick "also delete all data" to wipe them. Kept in
+ - its own file per ADR-004 gate-modal-isolation.
-->
- {{ t('openbuild', 'Permanently delete "{name}" and all of its versions and data? This cannot be undone.', { name: appName }) }}
+ {{ t('openbuild', 'Delete "{name}" and all of its versions? This cannot be undone.', { name: appName }) }}
+
+
+ {{ t('openbuild', 'Also permanently delete all data (the app\'s registers and everything stored in them)') }}
+
+
+ {{ deleteData
+ ? t('openbuild', 'All data will be permanently removed. The app slug becomes available again.')
+ : t('openbuild', 'The app is removed but its data is kept in OpenRegister.') }}
{{ t('openbuild', 'Cancel') }}
-
+
@@ -34,11 +45,12 @@
From 6e8577c1e48beb738970b21b3cc035978d2bbaec Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Thu, 2 Jul 2026 13:07:11 +0200
Subject: [PATCH 034/391] fix(apps): route the applications-table delete
through the app dialog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The table's built-in CnIndexPage delete did a generic object delete —
removing only the Application object (orphaning versions/registers/
routes) via the library dialog with no data checkbox. Override just
CnIndexPage's #delete-dialog slot (manifest page.slots) with a new
AppDeleteDialogSlot that reuses DeleteAppDialog and deletes through the
destroy endpoint, keeping the native Delete row action in its last
position with its trash icon. On success it evicts the row from the
shared object store so the table updates without a reload.
---
src/App.vue | 8 ++
src/components/AppDeleteDialogSlot.vue | 145 +++++++++++++++++++++++++
src/manifest.json | 3 +
3 files changed, 156 insertions(+)
create mode 100644 src/components/AppDeleteDialogSlot.vue
diff --git a/src/App.vue b/src/App.vue
index 667e0387b..69a48475a 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -52,6 +52,7 @@ import { CnAppRoot } from '@conduction/nextcloud-vue'
import { NcAppContent, NcButton, NcEmptyContent } from '@nextcloud/vue'
import { initializeStores } from './store/store.js'
import { useSettingsStore } from './store/modules/settings.js'
+import AppDeleteDialogSlot from './components/AppDeleteDialogSlot.vue'
export default {
name: 'App',
@@ -136,6 +137,13 @@ export default {
out[name] = component
}
}
+ // Slot-override component for CnIndexPage's `#delete-dialog` slot on
+ // the applications index (manifest page.slots["delete-dialog"]). Kept
+ // here rather than in the kind-tagged `registry` prop: CnPageRenderer
+ // resolves slot components by name against customComponents too, and
+ // CnAppRoot's registry validator throws on any kind it doesn't know.
+ // See AppDeleteDialogSlot.
+ out.AppDeleteDialogSlot = AppDeleteDialogSlot
return out
},
diff --git a/src/components/AppDeleteDialogSlot.vue b/src/components/AppDeleteDialogSlot.vue
new file mode 100644
index 000000000..7d50e8a74
--- /dev/null
+++ b/src/components/AppDeleteDialogSlot.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
diff --git a/src/manifest.json b/src/manifest.json
index db5bc2881..efad15567 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -80,6 +80,9 @@
"type": "index",
"title": "Apps",
"actionsComponent": "VirtualAppsActions",
+ "slots": {
+ "delete-dialog": "AppDeleteDialogSlot"
+ },
"config": {
"register": "openbuild",
"schema": "application",
From 3e879aea65578e7c82f59fdcaaeda386c9655025 Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Thu, 2 Jul 2026 15:21:42 +0200
Subject: [PATCH 035/391] fixed invalid endpoint call for export job
---
src/views/ExportJobsList.vue | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/views/ExportJobsList.vue b/src/views/ExportJobsList.vue
index 752e63846..41c8180f8 100644
--- a/src/views/ExportJobsList.vue
+++ b/src/views/ExportJobsList.vue
@@ -120,7 +120,9 @@ export default {
// Placeholder: real impl polls OR REST per ADR-022; the controller
// deliberately does not expose CRUD on ExportJob.
try {
- const response = await fetch('/index.php/apps/openregister/api/objects/openbuild/exportJob?filter[applicationSlug]=' + encodeURIComponent(this.applicationSlug))
+ // Schema slug is `export-job` (OpenRegister derives it from the
+ // "Export Job" title); the camelCase `exportJob` 404s.
+ const response = await fetch('/index.php/apps/openregister/api/objects/openbuild/export-job?filter[applicationSlug]=' + encodeURIComponent(this.applicationSlug))
if (!response.ok) {
return
}
From 92b722bc32bfc88abb17aeb68932f7ab45c62f83 Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Fri, 3 Jul 2026 10:32:03 +0200
Subject: [PATCH 036/391] feat(builder): register/schema/column dropdowns in
the pages editor
Provide dataSources (registers + their schemas + columns) to the
builder's CnAppRoot so the Edit-pages / page-config modals render
searchable Register / Schema / Columns dropdowns instead of free-text
slug inputs.
- useRegisterPicker: add fetchDataSources() building the library's
{ registers: [{ value, label, schemas: [{ value, label, columns }] }] }
shape; columns come from each schema's inline properties.
- builder.js: fetch it in boot() and pass as CnAppRoot :dataSources
(awaited before mount; provide() captures it once, non-reactively).
Degrades to the free-text fallback when the OpenRegister API fails.
---
src/builder.js | 16 ++++++++++++
src/composables/useRegisterPicker.js | 37 ++++++++++++++++++++++++++++
2 files changed, 53 insertions(+)
diff --git a/src/builder.js b/src/builder.js
index 8d1ac4993..81199b6f7 100644
--- a/src/builder.js
+++ b/src/builder.js
@@ -31,6 +31,7 @@ import {
import pinia from './pinia.js'
import { runtimeRegistry } from './runtimeRegistry.js'
import { registerDirectives } from './registerDirectives.js'
+import { useRegisterPicker } from './composables/useRegisterPicker.js'
import '@conduction/nextcloud-vue/css/index.css'
import './assets/app.css'
@@ -127,6 +128,20 @@ async function boot() {
console.error('[openbuild:builder] failed to load manifest for ' + slug, e)
}
+ // Registers/schemas (+ columns) for the in-app pages editor. Provided to
+ // CnAppRoot as `dataSources` so the edit-pages / page-config modals show
+ // searchable Register / Schema / Columns dropdowns instead of free-text slug
+ // inputs. Awaited before mount so the value is fully populated when
+ // CnAppRoot's provide() captures it (provide runs once, non-reactively).
+ // Best-effort: on failure the editor keeps its free-text fallback.
+ let dataSources = { registers: [] }
+ try {
+ dataSources = await useRegisterPicker({ appSlug: slug }).fetchDataSources()
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('[openbuild:builder] failed to load data sources for the pages editor', e)
+ }
+
const router = new VueRouter({
mode: 'history',
base: generateUrl(`/apps/openbuild/builder/${slug}`),
@@ -144,6 +159,7 @@ async function boot() {
registry: { ...runtimeRegistry },
pageTypes: { ...defaultPageTypes },
translate: translateForApp,
+ dataSources,
// Persist in-app edits (pages / menu / settings / sidebar / actions)
// back to the app's manifest. CnAppRoot's useManifestEditor mutates
// THIS same `manifest` object in place while editing, so on Save we
diff --git a/src/composables/useRegisterPicker.js b/src/composables/useRegisterPicker.js
index ef1fa50b8..2fd952501 100644
--- a/src/composables/useRegisterPicker.js
+++ b/src/composables/useRegisterPicker.js
@@ -137,10 +137,47 @@ export function useRegisterPicker(opts = {}) {
}
}
+ /**
+ * Build the `dataSources` object consumed by the library's page-config /
+ * edit-pages modals (provided down as `cnDataSources` by CnAppRoot). Its
+ * presence flips the Register / Schema / Columns fields from free-text slug
+ * inputs to searchable NcSelect dropdowns.
+ *
+ * Fetches every register and, in parallel, each register's schemas. The
+ * schemas endpoint returns each schema's `properties` inline (see
+ * OpenRegister `Schema::jsonSerialize`), so column names come for free from
+ * the property keys — no extra per-schema request.
+ *
+ * @return {Promise<{registers: Array<{value: string, label: string,
+ * schemas: Array<{value: string, label: string, columns: string[]}>}>}>}
+ * - the data-sources map (empty `registers` on failure).
+ */
+ async function fetchDataSources() {
+ const registers = await fetchRegisters()
+ if (!Array.isArray(registers) || registers.length === 0) {
+ return { registers: [] }
+ }
+ const mapped = await Promise.all(registers.map(async (r) => {
+ const registerSlug = r.slug || r.id
+ const schemas = await fetchSchemas(registerSlug)
+ return {
+ value: registerSlug,
+ label: r.title || registerSlug,
+ schemas: (Array.isArray(schemas) ? schemas : []).map((s) => ({
+ value: s.slug || s.id,
+ label: s.title || s.slug || s.id,
+ columns: Object.keys((s && s.properties) || {}),
+ })),
+ }
+ }))
+ return { registers: mapped }
+ }
+
return {
fetchRegisters,
fetchSchemas,
fetchSchemaProperties,
+ fetchDataSources,
resolveAppRegister,
}
}
From 4e4935d7feabffc589ddb8e67f45160dc78d9c34 Mon Sep 17 00:00:00 2001
From: SudoThijn
Date: Fri, 3 Jul 2026 14:17:51 +0200
Subject: [PATCH 037/391] fixed bug where loading into a sub-page in the
builder didnt work
---
appinfo/routes.php | 15 +++++++++++++
lib/Controller/DashboardController.php | 31 ++++++++++++++++++++++++++
2 files changed, 46 insertions(+)
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 52de62b68..dbf8e2969 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -121,6 +121,21 @@
// builder()) — the AppHost Routes::standard() guard throws on duplicate names.
['name' => 'dashboard#builderSlash', 'url' => '/builder/{slug}/', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],
+ // A published virtual app's OWN pages live at /builder/{slug}/{path}
+ // (e.g. /builder/pet-store/pets) — the standalone runtime (builder.js)
+ // builds its router from the app's manifest with base /builder/{slug}.
+ // The bare-runtime route's slug pattern excludes slashes, so without
+ // this route every deep in-app link falls to the SPA catch-all and
+ // renders OpenBuild's own shell (the nested BuilderHost that cannot
+ // resolve the app's pages) instead of the app. The `path` requirement
+ // is a negative lookahead that EXCLUDES the OpenBuild designer
+ // sub-routes (pages, schemas, schemas/{id}, walkthrough) so those keep
+ // falling through to the SPA catch-all; everything else serves the
+ // standalone runtime. `.+` (not `[^/]+`) so nested in-app routes like
+ // /pets/42 match. DISTINCT name (Routes::standard throws on dupes),
+ // placed before the engine-appended SPA catch-all so it wins.
+ ['name' => 'dashboard#builderDeep', 'url' => '/builder/{slug}/{path}', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'path' => '(?!pages$|pages/|schemas$|schemas/|walkthrough$|walkthrough/).+']],
+
// Icon-serving endpoints (openbuild-nextcloud-nav REQ-OBICON-002 / REQ-OBICON-003).
// Both are #[NoAdminRequired] on the controller. ORDER MATTERS: iconDark's
// pattern ("{slug}-dark.svg") is a SUBSET of iconLight's ("{slug}.svg") because
diff --git a/lib/Controller/DashboardController.php b/lib/Controller/DashboardController.php
index c2caf57ec..2bb5d5308 100644
--- a/lib/Controller/DashboardController.php
+++ b/lib/Controller/DashboardController.php
@@ -148,6 +148,37 @@ public function builderSlash(string $slug): TemplateResponse
return $this->builder(slug: $slug);
}//end builderSlash()
+ /**
+ * Deep-path alias of {@see builder()} for a virtual app's OWN page routes.
+ *
+ * A published virtual app renders in the standalone runtime (`builder.js`),
+ * whose router base is `/builder/{slug}` and whose routes come from the
+ * app's manifest — so its pages live at `/builder/{slug}/{path}` (e.g.
+ * `/builder/pet-store/pets`). The bare-runtime route's slug pattern excludes
+ * slashes, so without this alias every such deep link falls through to the
+ * SPA catch-all and renders OpenBuild's own shell (the nested BuilderHost,
+ * which cannot resolve the app's pages) instead of the app.
+ *
+ * The route's `{path}` requirement excludes the OpenBuild DESIGNER
+ * sub-routes (`pages`, `schemas`, `schemas/{id}`, `walkthrough`) so those
+ * keep falling through to the SPA catch-all as before; everything else
+ * serves the standalone runtime. Uses a DISTINCT route name (the AppHost
+ * Routes::standard() guard throws on duplicate names) while serving the
+ * exact same page. `$path` is consumed client-side by the runtime's router;
+ * the server ignores it.
+ *
+ * @param string $slug The virtual app slug (path param).
+ * @param string $path The remaining in-app route path (client-side only).
+ *
+ * @return TemplateResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function builderDeep(string $slug, string $path): TemplateResponse
+ {
+ return $this->builder(slug: $slug);
+ }//end builderDeep()
+
/**
* Publish the caller's group IDs via IInitialState.
*
From 984de27d57d11ec885acb4fcdae85357395c55f9 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 12:12:45 +0200
Subject: [PATCH 038/391] feat(schema): add x-schema-org markers to openbuild
schemas
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds schema-level x-schema-org markers so OR's cross-app semantic
resolver (ADR-048) can discover openbuild schemas by schema.org type.
- Application, ApplicationVersion -> schema:SoftwareApplication
- ApplicationTemplate, RuleSet, TestCase -> schema:CreativeWork
- HelloMessage -> schema:Message
- exportJob -> schema:Action
- RuleExecutionLog -> schema:Event
BuiltAppRoute, DecisionTable and ConditionActionRule are left
unmarked — no clean schema.org type fits them.
---
lib/Settings/openbuild_register.json | 5 +++++
lib/Settings/register.d/10-business-rules.json | 3 +++
2 files changed, 8 insertions(+)
diff --git a/lib/Settings/openbuild_register.json b/lib/Settings/openbuild_register.json
index 5841089d1..8f64e8371 100644
--- a/lib/Settings/openbuild_register.json
+++ b/lib/Settings/openbuild_register.json
@@ -15,6 +15,7 @@
"components": {
"schemas": {
"Application": {
+ "x-schema-org": "schema:SoftwareApplication",
"slug": "application",
"icon": "AppsBoxOutline",
"version": "0.5.0",
@@ -210,6 +211,7 @@
}
},
"ApplicationTemplate": {
+ "x-schema-org": "schema:CreativeWork",
"slug": "application-template",
"icon": "ViewGridOutline",
"version": "0.1.0",
@@ -357,6 +359,7 @@
}
},
"HelloMessage": {
+ "x-schema-org": "schema:Message",
"slug": "hello-message",
"icon": "MessageTextOutline",
"version": "0.1.0",
@@ -391,6 +394,7 @@
}
},
"ApplicationVersion": {
+ "x-schema-org": "schema:SoftwareApplication",
"x-openregister-notifications": {
"version-published": {
"trigger": {
@@ -641,6 +645,7 @@
}
},
"exportJob": {
+ "x-schema-org": "schema:Action",
"x-openregister-notifications": {
"export-succeeded": {
"trigger": {
diff --git a/lib/Settings/register.d/10-business-rules.json b/lib/Settings/register.d/10-business-rules.json
index 5aa4b4ec3..b19657c3f 100644
--- a/lib/Settings/register.d/10-business-rules.json
+++ b/lib/Settings/register.d/10-business-rules.json
@@ -3,6 +3,7 @@
"components": {
"schemas": {
"RuleSet": {
+ "x-schema-org": "schema:CreativeWork",
"slug": "rule-set",
"icon": "Sitemap",
"version": "1.0.0",
@@ -414,6 +415,7 @@
}
},
"RuleExecutionLog": {
+ "x-schema-org": "schema:Event",
"slug": "rule-execution-log",
"icon": "ClipboardText",
"version": "1.0.0",
@@ -506,6 +508,7 @@
}
},
"TestCase": {
+ "x-schema-org": "schema:CreativeWork",
"slug": "rule-test-case",
"icon": "CheckCircle",
"version": "1.0.0",
From c6413a0e3817b3738f3ec8617ef3c020af18dbd3 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 15:15:47 +0200
Subject: [PATCH 039/391] =?UTF-8?q?docs(openspec):=20data-registers-schema?=
=?UTF-8?q?-declaration=20=E2=80=94=20chain-head=20config=20change=20artif?=
=?UTF-8?q?acts?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../.openspec.yaml | 2 +
.../design.md | 305 ++++++++++++++++++
.../proposal.md | 114 +++++++
.../openbuild-application-register/spec.md | 92 ++++++
.../tasks.md | 26 ++
.../openbuild-application-register/spec.md | 3 +-
6 files changed, 541 insertions(+), 1 deletion(-)
create mode 100644 openspec/changes/data-registers-schema-declaration/.openspec.yaml
create mode 100644 openspec/changes/data-registers-schema-declaration/design.md
create mode 100644 openspec/changes/data-registers-schema-declaration/proposal.md
create mode 100644 openspec/changes/data-registers-schema-declaration/specs/openbuild-application-register/spec.md
create mode 100644 openspec/changes/data-registers-schema-declaration/tasks.md
diff --git a/openspec/changes/data-registers-schema-declaration/.openspec.yaml b/openspec/changes/data-registers-schema-declaration/.openspec.yaml
new file mode 100644
index 000000000..e089cfacb
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-05
diff --git a/openspec/changes/data-registers-schema-declaration/design.md b/openspec/changes/data-registers-schema-declaration/design.md
new file mode 100644
index 000000000..4ee65bce6
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/design.md
@@ -0,0 +1,305 @@
+## Context
+
+Per ADR-002, an OpenBuild `Application` (the logical app) already has two
+reference-shaped properties: `baseRef` (object `{ kind, id, manifestVersion? }`,
+pointing at an installed fleet app for hybrid apps) and, via its production
+version, `ApplicationVersion.register` (a plain string slug, pattern
+`^openbuild-[a-z0-9][a-z0-9-]*[a-z0-9]$`, naming the per-version register the
+app owns and that promotion copies/migrates). Neither shape fits a **shared
+data register**: `baseRef` is single-valued and app-authored-vs-fleet-app
+specific; `ApplicationVersion.register` is owned-and-versioned, exactly the
+opposite of what SPECTR-NEXTCLOUD-PLAN.md §4.2 asks for.
+
+`spectr` (Conduction's market-intelligence app, ADR-050) needs its
+`Application` to bind to a ~30-schema shared register that OpenConnector
+feeds continuously and that every version of the app — dev, staging,
+production — reads from unchanged. Copying 82k+/158k+ rows per version
+promotion is both wasteful and semantically wrong: the data is canonical and
+external to any one app version, not app-owned test/prod data.
+
+This spec (chain head, `kind: config`) declares the schema surface that makes
+such a binding expressible. It ships zero PHP/Vue/route code — the consumers
+(pickers, promotion-skip guarantee, export inclusion, designer UI) are the
+follower spec `data-registers-runtime` (`kind: code`), per ADR-032.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Add an optional `dataRegisters` array property to the `Application` schema
+ that lets an admin declare 0..N shared OR registers the app binds to.
+- Keep the shape minimal: a register reference plus an optional display
+ label — nothing a picker or export step can't consume directly.
+- Keep the property purely additive and backward compatible: every existing
+ `Application` object (including the seeded `hello-world` app) remains
+ schema-valid with `dataRegisters` absent.
+- Ship via a `register.d/` fragment (ADR-037) so this change never touches
+ the `openbuild_register.json` monolith and never collides with any other
+ concurrent OpenBuild change.
+
+**Non-Goals:**
+- No builder UI to add/remove a data-register binding (follower spec).
+- No change to `useRegisterPicker.js` or any page/schema editor (follower
+ spec).
+- No promotion-time code change — see "Declarative-vs-imperative decision"
+ below for why the invariant already holds without one, and why the
+ regression test that locks it in belongs to the follower.
+- No export-bundle change to `ExportService.php` (follower spec).
+- No new RBAC mechanism — see "RBAC" below.
+- No validation that a referenced register slug actually exists in
+ OpenRegister at save time. OR does not require a register to pre-exist for
+ another app's schema to reference its slug (multiple registers are queried
+ by slug string, not by hard FK); a dangling reference simply resolves to an
+ empty picker/export result at consume-time, which is exactly the same
+ failure mode `ApplicationVersion.register` already has if its register is
+ deleted out from under it. No new integrity mechanism is invented here.
+
+## Decisions
+
+### Decision 1: Property shape — array of `{ register, label? }` objects
+
+```jsonc
+"dataRegisters": {
+ "title": "Data Registers",
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "title": "Data Register Binding",
+ "required": ["register"],
+ "additionalProperties": false,
+ "properties": {
+ "register": {
+ "title": "Register Slug",
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
+ "minLength": 2,
+ "maxLength": 64,
+ "description": "Slug of the shared OpenRegister register this app binds to (e.g. `spectr`). Unlike ApplicationVersion.register, this register is NOT owned or provisioned by OpenBuild and carries NO `openbuild-` prefix convention — it is an existing register OpenConnector or another process feeds independently."
+ },
+ "label": {
+ "title": "Display Label",
+ "type": "string",
+ "maxLength": 128,
+ "description": "Optional human-readable label shown in the builder's data-source pickers instead of the raw register slug (e.g. `Spectr market intelligence data`). Purely a UI convenience; absent falls back to the raw slug."
+ }
+ }
+ },
+ "description": "Shared, non-versioned OpenRegister registers this Application binds to alongside its own per-version register (ADR-002 `ApplicationVersion.register`). Declared per SPECTR-NEXTCLOUD-PLAN.md §4.2 / hydra ADR-050 decision #2. Version promotion (`VersionPromotionService`) never reads or writes this property — it operates exclusively on `ApplicationVersion.register` — so promoting a version neither copies nor migrates any row in a data register. Sibling to `baseRef` on the `Application` schema; absent on every Application created before this property existed."
+}
+```
+
+**Why an array of objects, not an array of plain slug strings**: a plain
+`string[]` would satisfy the "which registers" question but not the picker
+UX question — SPECTR-NEXTCLOUD-PLAN.md §4.2 explicitly frames this as a
+builder-picker feature, and `useRegisterPicker.js` today already resolves a
+register's own metadata (name, schemas) by round-tripping to OR, but has no
+way to show a friendlier label than the raw slug for a register the app
+didn't create itself. An optional `label` costs one property and removes a
+follower-spec round-trip.
+
+**Why not reuse the `baseRef` shape (`{ kind, id, manifestVersion? }`)**:
+`baseRef` is deliberately polymorphic (`kind: "fleet-app"` today, room for
+other kinds later) because it names *what the app extends*. A data register
+binding has exactly one kind — "an OpenRegister register" — so the `kind`
+discriminator would be dead weight (a union-shaped field with one branch is
+worse than no union, and the task's own hard rule 3 rules out union types
+here regardless). `manifestVersion` (drift-detection for a fleet-app's
+bundled manifest) has no analogue for a data register — there is no
+"manifest" to drift.
+
+**Why not reuse `ApplicationVersion.register`'s plain-string shape
+verbatim (no label, `openbuild-` prefix pattern)**: the prefix pattern
+encodes ownership ("OpenBuild provisioned and names this register"), which is
+precisely untrue for a shared data register (`spectr`, or a municipality's
+`brp-personen`) — reusing the pattern would make every real-world consumer's
+first binding a validation failure.
+
+**Why `additionalProperties: false` on the item**: mirrors `baseRef`'s own
+posture in the same schema (`"additionalProperties": false`) — keeps the
+binding shape closed so a future property (e.g. a `readOnly` flag) is an
+explicit, reviewable schema change rather than silent passthrough.
+
+**Alternatives considered:**
+- *Single `dataRegister` (singular, not array)* — rejected: SPECTR-NEXTCLOUD-PLAN.md
+ §4.2 and ADR-050 both write `dataRegisters[]` explicitly, and `pipelinq`/`mydash`
+ (the named next consumers) are plausible multi-register cases (e.g. a
+ municipality app binding both a persons register and an addresses
+ register — see Seed Data below).
+- *Relation type (`x-openregister-relation`, like `productionVersion`)* —
+ rejected: OR relations resolve to another *object* (a row with a uuid) in a
+ known register/schema pair. A data-register binding refers to an entire
+ *register* (a container), not a row — there is no target object to point
+ a relation at. A plain string slug is the correct primitive, exactly as
+ `ApplicationVersion.register` already treats "which register" as a string,
+ not a relation.
+
+### Decision 2: Ship as a `register.d/` fragment, not a monolith edit
+
+Per ADR-037, this spec adds `lib/Settings/register.d/20-data-registers.json`
+(the next free ascending prefix after the existing `10-business-rules.json`)
+rather than editing `lib/Settings/openbuild_register.json` directly. The
+fragment's `components.schemas.Application.properties` object unions by key
+with the monolith's existing `Application.properties` — `SettingsService`'s
+`deepMergeConfig` recurses into shared keys (`Application`, then
+`properties`) and only adds the new `dataRegisters` key, leaving every
+existing property (and the `required` array, which this fragment does not
+touch) untouched. This keeps the change concurrency-safe against any other
+in-flight OpenBuild change per ADR-037's stated purpose.
+
+### Declarative-vs-imperative decision (ADR-031)
+
+- **The schema property itself is declarative** — `dataRegisters` is pure
+ schema metadata added to `lib/Settings/register.d/20-data-registers.json`.
+ No service class is introduced; this is the default case ADR-031 asks for,
+ identical in kind to how `baseRef`/`icon`/`iconDark`/`permissions` were
+ previously added to `Application` as schema-only patches (see
+ `openbuild-application-register` REQ-OBA-002/REQ-OBA-006).
+- **The follower's picker/export/promotion-guard work is imperative, and
+ that is correct, not an ADR-031 gap**:
+ - *Pickers* (`useRegisterPicker.js` + its Vue consumers) render UI —
+ exactly the same class ADR-031 already carves out in this repo's own
+ precedent ("the diff and version-history UI are unavoidably code",
+ `openbuild-versioning` proposal.md). There is no `x-openregister-*`
+ extension for "render a dropdown"; this was never a declarative
+ candidate.
+ - *Export inclusion* (`ExportService.php` bundling data-register schema
+ defs into the ZIP) matches ADR-031's explicit "What apps SHOULD still
+ write in PHP" bullet: "Document/PDF/document-template generation ... The
+ schema engine has no opinion on rendered output." A ZIP bundle is
+ rendered output.
+ - *Promotion-skip* needs no new code at all — see Decision 1's schema
+ description: `VersionPromotionService` (already an ADR-031 §Exceptions
+ file per its own docblock: "every branch in this file is classified
+ imperative") only ever reads/writes `ApplicationVersion.register`. It
+ has no code path that touches `Application.dataRegisters`, so the
+ "promotion never copies data-register rows" guarantee holds the moment
+ this schema patch merges — with zero lines changed in
+ `VersionPromotionService.php`. The follower spec's job is to add the
+ integration test that pins this down as a regression guard, not to
+ write new promotion logic.
+- No exception justification is needed for *this* spec, because this spec
+ contains no imperative code at all — the exception note above exists so
+ the follower spec's reviewer sees the reasoning already applied.
+
+### RBAC — no new mechanism
+
+`Application`, `ApplicationVersion`, and (per the `register.d/` precedent)
+`RuleSet` all carry their own OR-native `authorization` block
+(`create`/`update`/`delete` arrays of roles) directly on the schema that owns
+the data. `dataRegisters` is a **reference-only** property — it names a
+register slug and an optional label; it carries no read/write semantics of
+its own and grants nothing. Access to whatever schemas and objects actually
+live inside the referenced register continues to be governed exclusively by
+that register's own schemas' `authorization` blocks and OR's standard
+multi-tenant `organisation` scoping (ADR-022) — exactly as it is today for
+every register in the fleet. Publishing an `Application` that declares
+`dataRegisters: [{ register: "spectr" }]` does not itself grant the
+Application's viewers, editors, or the general public any access to
+`spectr`'s objects that they didn't already have; per SPECTR-NEXTCLOUD-PLAN.md
+§4.2 ("RBAC stays schema-level"), this is by design, not an oversight to be
+closed later.
+
+## Seed Data
+
+Realistic example objects an admin (or a seed migration in a later spec)
+would create once this property exists. UUIDs are nil placeholders; no
+object below is created by this change — it is a `kind: config` schema-only
+spec and touches no running instance.
+
+**The `spectr` case (first real consumer, per ADR-050):**
+
+```jsonc
+{
+ "uuid": "00000000-0000-0000-0000-000000000000",
+ "slug": "spectr",
+ "name": "Spectr",
+ "description": "Market intelligence: tenders, competitors, standards, features.",
+ "appType": "virtual",
+ "productionVersion": "00000000-0000-0000-0000-000000000000",
+ "dataRegisters": [
+ {
+ "register": "spectr",
+ "label": "Spectr market intelligence data"
+ }
+ ]
+}
+```
+
+**Generic municipality case (illustrates the multi-binding shape a
+`pipelinq`/`mydash`-style consumer, or a citizen-developer municipal app,
+would use — two shared national base registers bound alongside the app's
+own register):**
+
+```jsonc
+{
+ "uuid": "",
+ "slug": "vergunningen-",
+ "name": "Vergunningen ",
+ "description": "Permit intake for .",
+ "appType": "virtual",
+ "productionVersion": "",
+ "dataRegisters": [
+ {
+ "register": "brp-personen",
+ "label": "BRP personen (shared municipal register)"
+ },
+ {
+ "register": "bag-adressen",
+ "label": "BAG adressen"
+ }
+ ]
+}
+```
+
+Both examples validate against Decision 1's schema: `dataRegisters` is an
+array of `{ register, label? }`; `register` matches the kebab-case pattern
+in both cases; neither example's app-owned `ApplicationVersion.register`
+(e.g. `openbuild-spectr-production`, not shown) is confused with a bound
+data register — the two remain visibly distinct property surfaces.
+
+## Risks / Trade-offs
+
+- **[Risk]** A future spec could be tempted to add validation that a
+ `dataRegisters[].register` slug must already exist in OpenRegister at
+ save time, coupling `Application` saves to a live OR registry lookup.
+ → **Mitigation**: explicitly out of scope (see Non-Goals); `ApplicationVersion.register`
+ already sets the precedent of "just a string, resolved at consume time,
+ not at save time" and this spec follows it.
+- **[Risk]** Two OpenBuild apps could declare the same `dataRegisters[].register`
+ slug and both expect exclusive write access. → **Mitigation**: not a new risk
+ — this is already true of any two processes (OpenConnector syncs, other
+ apps) that reference the same OR register today; RBAC is schema-level, not
+ app-level, so concurrent readers of the same register are the expected
+ shape (this is the entire point of "shared"), and OpenBuild introduces no
+ writer of its own.
+- **[Trade-off]** The `label` property has no enforced relationship to the
+ register's actual OR-side display name — an admin could set a misleading
+ label. → Accepted: this is display-only UI sugar for the follower's picker,
+ same trust level as any other admin-entered free-text field on `Application`
+ (e.g. `name`, `description`).
+
+## Migration Plan
+
+None required. The property is optional and additive; OpenRegister's
+`ConfigurationService::importFromApp()` re-imports the merged register
+idempotently (the fragment-hash-folded version bump documented in ADR-037
+triggers the re-import). No existing `Application` object needs a backfill —
+absence of `dataRegisters` is a fully valid, already-common state (every
+Application created before this spec lands has no such property, identical
+in effect to an Application explicitly saved with `dataRegisters: []`).
+
+Rollback is likewise trivial: removing the fragment file reverts the schema
+on the next import; no data migration accompanies this change in either
+direction because no object has been seeded with the new property yet.
+
+## Open Questions
+
+- Should a later spec cap the number of `dataRegisters` entries per
+ Application (e.g. to bound picker-list growth)? Not addressed here —
+ no evidence of need yet; `spectr` binds exactly one.
+- ~~Export data-toggle granularity~~ **DECIDED 2026-07-05 (Ruben): per-binding
+ toggle.** Each `dataRegisters` binding gets an `includeData` choice in the
+ export flow (default: schema-defs-only). `data-registers-runtime` implements
+ it in the export dialog + `ExportService`; this head change deliberately adds
+ no schema field for it — the toggle is export-flow state, not Application
+ configuration.
diff --git a/openspec/changes/data-registers-schema-declaration/proposal.md b/openspec/changes/data-registers-schema-declaration/proposal.md
new file mode 100644
index 000000000..a3456121d
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/proposal.md
@@ -0,0 +1,114 @@
+---
+kind: config
+depends_on: []
+chain:
+ - data-registers-schema-declaration # this spec
+ - data-registers-runtime # next in chain
+---
+
+## Why
+
+`SPECTR-NEXTCLOUD-PLAN.md` §4.2 and hydra ADR-050 (decision #2) lock a new
+OpenBuild capability: an `Application` can bind to one or more **shared,
+non-versioned OpenRegister data registers** alongside its own per-version
+config register. The first concrete consumer is `spectr` (Conduction's
+market-intelligence app) — its ~30-schema, 82k+/158k+-row canonical dataset
+must be fed continuously by OpenConnector and read by every `Application`
+version without being copied on each promotion. `pipelinq` and `mydash` are
+named as the obvious next consumers once the capability exists.
+
+Per ADR-002, `ApplicationVersion.register` is already the per-version,
+app-owned register (`openbuild-{slug}-{versionSlug}`) — schemas and objects
+that a promotion is expected to copy or migrate. A shared data register is
+architecturally different: it is **not owned by the app**, it is **not
+versioned**, and promotion **must never touch it**. Today there is no
+property on `Application` (or anywhere in the OpenBuild schema surface) that
+lets an admin declare such a binding, so `spectr`'s data-register work is
+blocked until the schema exists.
+
+Per ADR-032 (spec sizing and chained-spec routing), this is a `kind: config`
+head: it declares the schema surface only. The code that *consumes* the new
+property — builder dataSources pickers on both hosts, the promotion-skip
+guarantee, export schema-def inclusion, and the designer UI field to
+add/remove bindings — lands in the follower spec `data-registers-runtime`
+(`kind: code`, `depends_on: [data-registers-schema-declaration]`). Declaring
+the schema first means `spectr` (and any other consumer) can start
+referencing `dataRegisters` in seed data and manual testing the moment this
+spec merges, while the picker/export/promotion-guard code follows in its own
+right-sized, single-surface review cycle.
+
+## What Changes
+
+- **NEW** optional `dataRegisters` array property on the `Application` schema
+ (`lib/Settings/openbuild_register.json`, added via a `register.d/` fragment
+ per ADR-037 — no edit to the monolith). Each entry is an object binding the
+ app to one existing OpenRegister register by slug, with an optional
+ human-readable label for picker UX. Absent on every existing Application
+ (backward compatible; no migration needed — an app with no declared data
+ registers behaves exactly as it does today).
+- **NEW** seed example on the `hello-world` Application record's `spectr`
+ sibling scenario is documented in `design.md` (Seed Data section) as
+ realistic reference data — not created as a live object in this change (no
+ running instance is touched by a `kind: config` schema-only spec).
+- **NO code changes.** No PHP service, no Vue component, no route is added or
+ modified. RBAC is unchanged — schema-level RBAC on the *referenced*
+ register continues to be the sole authorization surface; declaring
+ `dataRegisters` on an `Application` does not itself grant or widen any
+ access (see design.md's Declarative-vs-imperative + RBAC sections).
+
+### Capabilities
+
+#### New Capabilities
+
+_(none — this spec extends an existing schema; it introduces no new
+capability domain)_
+
+#### Modified Capabilities
+
+- `openbuild-application-register`: ADDED Requirement — the `Application`
+ schema gains an optional `dataRegisters` array property (shared data
+ register bindings), sibling to `baseRef`. No existing requirement's
+ behavior changes; this is purely additive.
+
+## Impact
+
+- **Changed files**: `lib/Settings/register.d/20-data-registers.json` (new
+ fragment, per ADR-037 — the shared `openbuild` register's `Application`
+ schema gains the `dataRegisters` property via deep-merge; no edit to
+ `lib/Settings/openbuild_register.json` itself).
+- **No PHP/Vue/route changes** — see "What Changes" above.
+- **No breaking changes** — purely additive optional property; every
+ existing `Application` object remains schema-valid with `dataRegisters`
+ absent.
+- **OpenRegister** — no new register, no new OR-side schema; this only adds
+ one property to the existing `Application` schema already imported into
+ the shared `openbuild` register.
+- **Downstream (out of scope here, tracked by the follower spec
+ `data-registers-runtime`)**:
+ - `src/composables/useRegisterPicker.js` (consumed by
+ `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`,
+ `ApplicationDetailActions.vue`) — today `fetchRegisters()` lists every
+ OR register instance-wide and hoists the app's own per-version register
+ to the top; the follower teaches it to also surface/label the
+ Application's declared `dataRegisters`.
+ - `lib/Service/VersionPromotionService.php` — today `forwardSchemaSetToOR()`
+ / `wipeTargetRegister()` / `copyRowsFromSource()` operate exclusively on
+ `ApplicationVersion.register` (the per-version register) and never touch
+ `Application`-level fields, so "promotion skips data registers" already
+ holds true by construction; the follower adds the explicit regression
+ test (and, if needed, a defensive guard) that locks this invariant in.
+ - `lib/Service/ExportService.php` — `generateAppZip()` bundles the
+ per-version register/manifest into the export ZIP today; the follower
+ adds shared data-register **schema defs** (never data) to that bundle.
+ - The version-promotion, page-designer-ui / schema-designer-ui, and
+ openbuild-exporter capabilities are the follower's spec-delta targets —
+ none of them change here.
+- **Foundational ADRs honoured** — ADR-002 (extends the versioned model
+ without altering it: `dataRegisters` is explicitly NOT the per-version
+ `register` field), ADR-022 (consume OR abstractions — a data register is
+ itself just another OR register; no new abstraction invented), ADR-031
+ (schema-declarative — this is a pure schema addition, no service class),
+ ADR-032 (kind: config head of a 2-spec chain — see frontmatter), ADR-037
+ (modular register fragments — ships as `register.d/20-data-registers.json`,
+ not a monolith edit), ADR-050 / SPECTR-NEXTCLOUD-PLAN.md §4.2 (the source
+ decision this spec implements).
diff --git a/openspec/changes/data-registers-schema-declaration/specs/openbuild-application-register/spec.md b/openspec/changes/data-registers-schema-declaration/specs/openbuild-application-register/spec.md
new file mode 100644
index 000000000..60e9f3694
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/specs/openbuild-application-register/spec.md
@@ -0,0 +1,92 @@
+## ADDED Requirements
+
+### Requirement: Application schema carries an optional dataRegisters binding array
+
+The system SHALL extend the `Application` schema in
+`lib/Settings/openbuild_register.json` (via a `register.d/` fragment per
+ADR-037 — see design.md Decision 2) with an optional `dataRegisters` array
+property, sibling to `baseRef`, of shape:
+
+```json
+{
+ "dataRegisters": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "required": ["register"],
+ "additionalProperties": false,
+ "properties": {
+ "register": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" },
+ "label": { "type": "string" }
+ }
+ }
+ }
+}
+```
+
+Each entry names a shared, non-versioned OpenRegister register (by slug)
+that the `Application` binds to alongside its own per-version register
+(`ApplicationVersion.register`, ADR-002). `dataRegisters` is declarative
+schema metadata only (ADR-031) — this requirement introduces no service
+class, no route, and no validation that a referenced register slug exists in
+OpenRegister at save time (see design.md Non-Goals). This is the schema
+surface SPECTR-NEXTCLOUD-PLAN.md §4.2 / hydra ADR-050 decision #2 locks for
+OpenBuild; the consumers (builder pickers, promotion-skip regression
+coverage, export inclusion, designer UI) are out of scope for this
+requirement and land in the follower spec `data-registers-runtime`.
+
+**ID:** REQ-OBA-010
+
+#### Scenario: Schema declares dataRegisters after install
+
+- **WHEN** the OpenBuild app is installed (or upgraded) and its repair step
+ runs
+- **THEN** the `Application` schema in the `openbuild` register exposes the
+ `dataRegisters` property with the shape above
+- **AND** the property is omittable — existing Application objects created
+ before this change remain schema-valid
+
+#### Scenario: Saving an Application with a dataRegisters binding round-trips
+
+- **WHEN** a client PUTs an Application via OR REST with
+ `dataRegisters = [{ "register": "spectr", "label": "Spectr market intelligence data" }]`
+- **THEN** OR persists the object and a subsequent GET returns the same
+ `dataRegisters` array byte-for-byte
+
+#### Scenario: Saving an Application with multiple bindings round-trips
+
+- **WHEN** a client PUTs an Application via OR REST with
+ `dataRegisters = [{ "register": "brp-personen" }, { "register": "bag-adressen", "label": "BAG adressen" }]`
+- **THEN** OR persists the object and a subsequent GET returns both entries,
+ in order, byte-for-byte
+- **AND** the entry without a `label` round-trips with no `label` key present
+
+#### Scenario: Application without dataRegisters is still accepted
+
+- **WHEN** a client saves an Application that omits `dataRegisters` entirely
+- **THEN** OR persists the object and returns 2xx — the property is optional
+ and defaults to an empty array on read
+
+#### Scenario: Binding missing the required register slug is rejected
+
+- **WHEN** a client PUTs an Application with
+ `dataRegisters = [{ "label": "No slug given" }]` (missing the required
+ `register` key)
+- **THEN** OR rejects the save with a 4xx citing the missing `register`
+ property under the failing array index
+
+#### Scenario: Binding with an unrecognised sub-property is rejected
+
+- **WHEN** a client PUTs an Application with
+ `dataRegisters = [{ "register": "spectr", "readOnly": true }]` (note the
+ unknown `readOnly` key)
+- **THEN** OR rejects the save with a 4xx citing the unknown property under
+ the failing `dataRegisters` array entry
+
+#### Scenario: Register slug pattern is enforced
+
+- **WHEN** a client PUTs an Application with
+ `dataRegisters = [{ "register": "Not_A-Valid-Slug!" }]`
+- **THEN** OR rejects the save with a 4xx citing the `register` value as not
+ matching the kebab-case pattern
diff --git a/openspec/changes/data-registers-schema-declaration/tasks.md b/openspec/changes/data-registers-schema-declaration/tasks.md
new file mode 100644
index 000000000..509587e07
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/tasks.md
@@ -0,0 +1,26 @@
+## 1. Schema fragment (register.d)
+
+- [ ] 1.1 Grep `lib/Settings/register.d/*.json` and `lib/Settings/openbuild_register.json` for any existing `dataRegisters` property name on the `Application` schema (ADR-012 dedup check) before authoring the fragment
+- [ ] 1.2 Create `lib/Settings/register.d/20-data-registers.json` declaring the optional `dataRegisters` array property on the `Application` schema — shape, titles, and descriptions exactly per design.md Decision 1 and specs `REQ-OBA-010` (array of `{ register, label? }`, `additionalProperties: false` on the item, English title + description on every property and sub-property per gate-28)
+- [ ] 1.3 Confirm the fragment's JSON path is scoped to `components.schemas.Application.properties.dataRegisters` only — no edit to `Application.required`, no edit to any other `Application` property, no edit to `ApplicationVersion` or any other schema
+
+## 2. Seed-data fixtures
+
+- [ ] 2.1 Add the two design.md seed-data examples (the `spectr` Application and the generic-municipality Application) as fixture JSON reusable by the follower spec's tests and by manual QA — no live object is created by this change
+
+## Quality reminders (run before requesting review — not tracked as tasks)
+
+- Run `openspec validate data-registers-schema-declaration --strict` and resolve any structural errors.
+- Validate both seed-data fixtures (task 2.1) against the merged `Application` schema with a jq/ajv check — both must pass.
+- Confirm the existing seeded `hello-world` Application (no `dataRegisters` field) still validates against the merged schema — the property must be truly optional.
+- Confirm `SettingsService::doLoadConfiguration()` picks up the new fragment on the next repair-step run and that OpenRegister's `ConfigurationService::importFromApp()` re-imports without error (ADR-037 fragment-hash version bump).
+
+## Acceptance Criteria
+
+- The `Application` schema in the `openbuild` register exposes an optional `dataRegisters` array property matching design.md Decision 1's shape after the repair step runs.
+- An Application saved with a valid `dataRegisters` binding (single or multiple entries) round-trips byte-for-byte via OR REST.
+- An Application saved without `dataRegisters` is accepted and reads back as an empty array — full back-compat with every pre-existing Application.
+- A `dataRegisters` entry missing the required `register` key is rejected with a 4xx.
+- A `dataRegisters` entry carrying an unrecognised sub-property is rejected with a 4xx (`additionalProperties: false`).
+- A `dataRegisters` entry whose `register` value fails the kebab-case pattern is rejected with a 4xx.
+- No PHP, Vue, or route file is touched by this change — only `lib/Settings/register.d/20-data-registers.json` plus seed-data fixture files.
diff --git a/openspec/specs/openbuild-application-register/spec.md b/openspec/specs/openbuild-application-register/spec.md
index f6687b2dd..2e9ad0ce8 100644
--- a/openspec/specs/openbuild-application-register/spec.md
+++ b/openspec/specs/openbuild-application-register/spec.md
@@ -15,8 +15,9 @@ scoping via OR's standard `organisation` field (ADR-022). Lifecycle relocates to
`status` enum and no state machine.
**OpenSpec changes**: [unify-apps-with-app-type](../../changes/archive/2026-06-20-unify-apps-with-app-type/) _(archived 2026-06-20)_
+[data-registers-schema-declaration](../../changes/data-registers-schema-declaration/)
-**Status**: done
+**Status**: in-progress
## Requirements
From 919e542f582f012a24d5b850f702011e7dfd0a96 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 15:28:59 +0200
Subject: [PATCH 040/391] =?UTF-8?q?feat(register):=20dataRegisters[]=20on?=
=?UTF-8?q?=20Application=20=E2=80=94=20shared=20data-register=20bindings?=
=?UTF-8?q?=20(chain=20head)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../register.d/20-data-registers.json | 41 ++++++++++++++++
.../seed-data.json | 47 +++++++++++++++++++
.../tasks.md | 8 ++--
3 files changed, 92 insertions(+), 4 deletions(-)
create mode 100644 lib/Settings/register.d/20-data-registers.json
create mode 100644 openspec/changes/data-registers-schema-declaration/seed-data.json
diff --git a/lib/Settings/register.d/20-data-registers.json b/lib/Settings/register.d/20-data-registers.json
new file mode 100644
index 000000000..f7dd19e72
--- /dev/null
+++ b/lib/Settings/register.d/20-data-registers.json
@@ -0,0 +1,41 @@
+{
+ "_comment": "ADR-037 register fragment — data-registers-schema-declaration (chain head, kind: config; hydra ADR-050 decision #2 / SPECTR-NEXTCLOUD-PLAN.md §4.2). Adds the optional dataRegisters array property to the shared `openbuild` register's Application schema, sibling to baseRef. SettingsService::deepMergeConfig recurses into components.schemas.Application.properties (both keyed objects already present in the base config) and adds only the new dataRegisters key — Application.required and every other Application property are untouched, and no other schema is touched. Each entry names a shared, non-versioned OpenRegister register this Application binds to alongside its own per-version register (ApplicationVersion.register, ADR-002); promotion never reads/writes this property. Declarative schema metadata only (ADR-031) — no service class, no route. The follower spec data-registers-runtime (kind: code) adds the builder data-source pickers, the promotion-skip regression test, and export schema-def inclusion. See design.md Decisions 1-2 and specs/openbuild-application-register/spec.md REQ-OBA-010.",
+ "components": {
+ "schemas": {
+ "Application": {
+ "properties": {
+ "dataRegisters": {
+ "title": "Data Registers",
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "title": "Data Register Binding",
+ "required": [
+ "register"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "register": {
+ "title": "Register Slug",
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
+ "minLength": 2,
+ "maxLength": 64,
+ "description": "Slug of the shared OpenRegister register this app binds to (e.g. `spectr`). Unlike ApplicationVersion.register, this register is NOT owned or provisioned by OpenBuild and carries NO `openbuild-` prefix convention — it is an existing register OpenConnector or another process feeds independently."
+ },
+ "label": {
+ "title": "Display Label",
+ "type": "string",
+ "maxLength": 128,
+ "description": "Optional human-readable label shown in the builder's data-source pickers instead of the raw register slug (e.g. `Spectr market intelligence data`). Purely a UI convenience; absent falls back to the raw slug."
+ }
+ }
+ },
+ "description": "Shared, non-versioned OpenRegister registers this Application binds to alongside its own per-version register (ADR-002 `ApplicationVersion.register`). Declared per SPECTR-NEXTCLOUD-PLAN.md §4.2 / hydra ADR-050 decision #2. Version promotion (`VersionPromotionService`) never reads or writes this property — it operates exclusively on `ApplicationVersion.register` — so promoting a version neither copies nor migrates any row in a data register. Sibling to `baseRef` on the `Application` schema; absent on every Application created before this property existed."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/openspec/changes/data-registers-schema-declaration/seed-data.json b/openspec/changes/data-registers-schema-declaration/seed-data.json
new file mode 100644
index 000000000..aa7747cb3
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/seed-data.json
@@ -0,0 +1,47 @@
+{
+ "_comment": "Seed-data fixture for task 2.1 of this change (data-registers-schema-declaration). Deliberately NOT placed under lib/Settings/register.d/ — SettingsService::doLoadConfiguration() only globs lib/Settings/register.d/*.json, so nothing in this file is ever merged into openbuild_register.json or passed to OpenRegister's ConfigurationService::importFromApp(). No live object is created by this change (design.md Seed Data section; proposal.md). This file reproduces design.md's two illustrative Application examples verbatim as machine-readable JSON, reusable by the follower spec data-registers-runtime's tests and by manual QA. Each object uses the ADR-001 @self envelope ({register, schema, slug}); `schema` is the Application schema's own `slug` value (`application`), matching the convention used by lib/Settings/register.d/10-business-rules.json's seed objects. The `spectr` example uses fully concrete, valid values (nil UUID, real kebab-case slugs) and validates cleanly against the merged Application schema. The `generic-municipality` example intentionally keeps design.md's angle-bracket placeholders (, , , ) as a fill-in-the-blanks template for a real municipality's admin/integrator — those placeholders do NOT satisfy the `register` slug pattern or `productionVersion` uuid format by design, exactly as design.md documents; substitute real values before creating either object for real.",
+ "objects": [
+ {
+ "@self": {
+ "register": "openbuild",
+ "schema": "application",
+ "slug": "spectr"
+ },
+ "uuid": "00000000-0000-0000-0000-000000000000",
+ "slug": "spectr",
+ "name": "Spectr",
+ "description": "Market intelligence: tenders, competitors, standards, features.",
+ "appType": "virtual",
+ "productionVersion": "00000000-0000-0000-0000-000000000000",
+ "dataRegisters": [
+ {
+ "register": "spectr",
+ "label": "Spectr market intelligence data"
+ }
+ ]
+ },
+ {
+ "@self": {
+ "register": "openbuild",
+ "schema": "application",
+ "slug": "vergunningen-"
+ },
+ "uuid": "",
+ "slug": "vergunningen-",
+ "name": "Vergunningen ",
+ "description": "Permit intake for .",
+ "appType": "virtual",
+ "productionVersion": "",
+ "dataRegisters": [
+ {
+ "register": "brp-personen",
+ "label": "BRP personen (shared municipal register)"
+ },
+ {
+ "register": "bag-adressen",
+ "label": "BAG adressen"
+ }
+ ]
+ }
+ ]
+}
diff --git a/openspec/changes/data-registers-schema-declaration/tasks.md b/openspec/changes/data-registers-schema-declaration/tasks.md
index 509587e07..22d7ef8a9 100644
--- a/openspec/changes/data-registers-schema-declaration/tasks.md
+++ b/openspec/changes/data-registers-schema-declaration/tasks.md
@@ -1,12 +1,12 @@
## 1. Schema fragment (register.d)
-- [ ] 1.1 Grep `lib/Settings/register.d/*.json` and `lib/Settings/openbuild_register.json` for any existing `dataRegisters` property name on the `Application` schema (ADR-012 dedup check) before authoring the fragment
-- [ ] 1.2 Create `lib/Settings/register.d/20-data-registers.json` declaring the optional `dataRegisters` array property on the `Application` schema — shape, titles, and descriptions exactly per design.md Decision 1 and specs `REQ-OBA-010` (array of `{ register, label? }`, `additionalProperties: false` on the item, English title + description on every property and sub-property per gate-28)
-- [ ] 1.3 Confirm the fragment's JSON path is scoped to `components.schemas.Application.properties.dataRegisters` only — no edit to `Application.required`, no edit to any other `Application` property, no edit to `ApplicationVersion` or any other schema
+- [x] 1.1 Grep `lib/Settings/register.d/*.json` and `lib/Settings/openbuild_register.json` for any existing `dataRegisters` property name on the `Application` schema (ADR-012 dedup check) before authoring the fragment
+- [x] 1.2 Create `lib/Settings/register.d/20-data-registers.json` declaring the optional `dataRegisters` array property on the `Application` schema — shape, titles, and descriptions exactly per design.md Decision 1 and specs `REQ-OBA-010` (array of `{ register, label? }`, `additionalProperties: false` on the item, English title + description on every property and sub-property per gate-28)
+- [x] 1.3 Confirm the fragment's JSON path is scoped to `components.schemas.Application.properties.dataRegisters` only — no edit to `Application.required`, no edit to any other `Application` property, no edit to `ApplicationVersion` or any other schema
## 2. Seed-data fixtures
-- [ ] 2.1 Add the two design.md seed-data examples (the `spectr` Application and the generic-municipality Application) as fixture JSON reusable by the follower spec's tests and by manual QA — no live object is created by this change
+- [x] 2.1 Add the two design.md seed-data examples (the `spectr` Application and the generic-municipality Application) as fixture JSON reusable by the follower spec's tests and by manual QA — no live object is created by this change
## Quality reminders (run before requesting review — not tracked as tasks)
From 6fc05b691b92a2d0968c91ff5be9cc36fe0c32d6 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 16:47:33 +0200
Subject: [PATCH 041/391] =?UTF-8?q?docs(openspec):=20data-registers-runtim?=
=?UTF-8?q?e=20=E2=80=94=20follower=20change=20artifacts=20(kind:=20code)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../data-registers-runtime/.openspec.yaml | 2 +
.../changes/data-registers-runtime/design.md | 432 ++++++++++++++++++
.../data-registers-runtime/proposal.md | 132 ++++++
.../specs/openbuild-exporter/spec.md | 70 +++
.../specs/page-designer-ui/spec.md | 48 ++
.../specs/version-promotion/spec.md | 54 +++
.../changes/data-registers-runtime/tasks.md | 52 +++
openspec/specs/openbuild-exporter/spec.md | 4 +
openspec/specs/page-designer-ui/spec.md | 4 +
openspec/specs/version-promotion/spec.md | 4 +-
10 files changed, 801 insertions(+), 1 deletion(-)
create mode 100644 openspec/changes/data-registers-runtime/.openspec.yaml
create mode 100644 openspec/changes/data-registers-runtime/design.md
create mode 100644 openspec/changes/data-registers-runtime/proposal.md
create mode 100644 openspec/changes/data-registers-runtime/specs/openbuild-exporter/spec.md
create mode 100644 openspec/changes/data-registers-runtime/specs/page-designer-ui/spec.md
create mode 100644 openspec/changes/data-registers-runtime/specs/version-promotion/spec.md
create mode 100644 openspec/changes/data-registers-runtime/tasks.md
diff --git a/openspec/changes/data-registers-runtime/.openspec.yaml b/openspec/changes/data-registers-runtime/.openspec.yaml
new file mode 100644
index 000000000..e089cfacb
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-05
diff --git a/openspec/changes/data-registers-runtime/design.md b/openspec/changes/data-registers-runtime/design.md
new file mode 100644
index 000000000..b12d07620
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/design.md
@@ -0,0 +1,432 @@
+## Context
+
+`data-registers-schema-declaration` (chain head, `kind: config`, merged on this
+branch) added an optional `dataRegisters` array property to the `Application`
+schema — each entry `{ register, label? }` names a shared, non-versioned
+OpenRegister register the app binds to alongside its own per-version register
+(`ApplicationVersion.register`, ADR-002). The head shipped the schema only
+(`lib/Settings/register.d/20-data-registers.json`); zero PHP, zero Vue, zero
+routes. This follower (`kind: code`, `depends_on:
+[data-registers-schema-declaration]`) wires the four consumers the head's own
+proposal named as out of scope for itself:
+
+1. **Pickers** — `src/composables/useRegisterPicker.js`, consumed by
+ `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`, and
+ `ApplicationDetailActions.vue`. Today `fetchRegisters()` lists every OR
+ register instance-wide and hoists the app's own per-version register to
+ the top; it has no notion of the Application's declared `dataRegisters`.
+2. **Promotion-skip regression coverage** — `lib/Service/VersionPromotionService.php`
+ already never touches `Application.dataRegisters` (verified by reading
+ `forwardSchemaSetToOR()`, `wipeTargetRegister()`, `copyRowsFromSource()` —
+ every method reads `$source['register']` / `$target['register']`, the
+ per-version field, exclusively). This spec adds the regression test that
+ locks the invariant in; it does not change the service.
+3. **Export** — `lib/Service/ExportService.php` bundles the per-version
+ register/manifest into the exported tree today (`generateAppZip()` →
+ `copyTemplate()` + `resolvePlaceholders()`); it has no notion of
+ `dataRegisters` at all.
+4. **Designer UI** — there is no UI path to populate `dataRegisters` on an
+ Application. `src/modals/AppSettingsModal.vue` is the existing owner-facing
+ settings surface (publish toggle, allow-user-overrides toggle), opened from
+ `ApplicationDetailActions.vue` and persisted via the `applicationContext`
+ mixin's `obPatchApp()` (a shallow-merge PUT to OR's
+ `/apps/openregister/api/objects/openbuild/application/{uuid}` — ADR-022,
+ no new backend route).
+
+**Codebase verification performed for this design** (all read in full before
+writing this document): `useRegisterPicker.js`; `IndexPageEditor.vue`,
+`DetailPageEditor.vue`, `LogsPageEditor.vue`, `ApplicationDetailActions.vue`;
+`src/views/PageDesigner.vue`; `src/builder.js`, `src/views/BuilderHost.vue`;
+`src/composables/useApplicationVersion.js`; `src/mixins/applicationContext.js`;
+`src/modals/AppSettingsModal.vue`, `src/dialogs/ExportDialog.vue`;
+`lib/Service/VersionPromotionService.php`, `lib/Service/ExportService.php`,
+`lib/Service/ExportJobService.php`, `lib/BackgroundJob/RunExportJob.php`,
+`lib/Controller/ExportsController.php`; the `exportJob` schema block in
+`lib/Settings/openbuild_register.json`; and the existing test files
+`tests/composables/useRegisterPicker.spec.js`,
+`tests/components/page-editor/IndexPageEditor.spec.js`,
+`tests/Unit/Service/VersionPromotionServiceTest.php`,
+`tests/Unit/Service/ExportServiceTest.php`.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Surface an Application's declared `dataRegisters` in the builder's
+ register/schema pickers, labelled per `binding.label ?? binding.register`,
+ via **one** logic change (the composable) rather than duplicating the merge
+ in every consumer.
+- Formally prove — spec Requirement + Scenario + PHPUnit test — that
+ `VersionPromotionService` never reads or writes `Application.dataRegisters`.
+- Let the exporter carry a bound data register's **schema** into the exported
+ app tree by default, and its **row data** only when the admin opts in
+ per binding.
+- Give an owner a way to add/remove `dataRegisters` bindings without hand-
+ editing the Application object via raw OR REST.
+- Keep every change additive and backward compatible: an Application with no
+ `dataRegisters` (every Application that predates the chain head) behaves
+ identically to today at every one of these four surfaces.
+
+**Non-Goals:**
+- No change to the `Application` schema itself — the head already shipped it.
+ This spec's only schema touch is one new property on the already
+ app-owned, already-imperative `exportJob` schema (see Decision 5).
+- No validation that a `dataRegisters[].register` slug resolves to a real,
+ reachable OR register at picker-render or export time — a dangling
+ reference resolves to "not found in the fetched list" (picker) or "no
+ schemas bundled" (export), exactly the existing failure mode for a deleted
+ `ApplicationVersion.register` (head design.md's own Non-Goals precedent).
+- No change to `src/builder.js` or `src/views/BuilderHost.vue` — see
+ Decision 3. Neither file has a register/schema picker or a
+ `dataSources`-loading routine today; there is nothing in either file for
+ this spec to extend.
+- No new RBAC mechanism. Access to a bound register's own objects continues
+ to be governed exclusively by that register's own schemas'
+ `authorization` blocks (head design.md's RBAC section, unchanged here).
+- No auto-import of a bundled data register's schema or row data into the
+ **exported** app's install process (no new ``) — see
+ Decision 5's non-ownership rationale.
+
+## Decisions
+
+### Decision 1: Picker merge lives entirely inside `useRegisterPicker.js`
+
+`useRegisterPicker(opts)` gains one new option, `opts.dataRegisters` (array of
+`{ register, label? }`, default `[]`). `fetchRegisters()` is extended, after
+its existing per-app-register hoist, to:
+
+1. Build a `Map` from `dataRegisters` (`label ??
+ register`).
+2. For every fetched register entry whose `slug`/`id` matches a key in that
+ map, set a `label` field on the entry to the resolved label (the raw
+ `title`/`slug` remains untouched — pickers that don't know about the new
+ field keep rendering exactly as before; consumers that want the friendlier
+ name read `entry.label || entry.title || entry.slug`).
+3. Re-sort so the order is: per-app register first (existing behaviour,
+ unchanged), then entries matching a `dataRegisters` binding (in the order
+ the Application declared them), then everything else in the order OR
+ returned it.
+4. When `opts.dataRegisters` is absent or `[]` (every existing call site,
+ until wired), steps 1-3 are no-ops and `fetchRegisters()` returns
+ byte-identical output to today — this is a regression-safe default, not a
+ breaking change to the composable's contract.
+
+**Wiring is mechanical, not logic-bearing**, at five call sites:
+- `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`: add a
+ `dataRegisters: { type: Array, default: () => [] }` prop; pass
+ `dataRegisters: props.dataRegisters` into the existing
+ `useRegisterPicker({ appSlug: props.appSlug })` call in `setup()`.
+- `PageDesigner.vue` (the parent that already passes `:app-slug="slug"` to
+ whichever sub-editor `subEditorFor(selectedPage.type)` resolves — see
+ Decision 2 for how it obtains the array): add `:data-registers="..."` next
+ to the existing `:app-slug="slug"` binding.
+- `ApplicationDetailActions.vue`: in `openSaveAsTemplate()`, extend
+ `useRegisterPicker({ appSlug: this.obApp.slug })` to also pass
+ `dataRegisters: this.obApp.dataRegisters || []` — `this.obApp` already
+ carries the field once the head's schema is live; no new fetch needed at
+ this call site.
+
+**Alternatives considered:**
+- *Duplicate the label/hoist logic in each of the three page-editor
+ components* — rejected: the task itself flags this as the anti-pattern to
+ avoid (6 edits instead of 1); it would also mean three independent
+ copies of the same sort/label algorithm to keep in sync on the next tweak.
+- *Merge inside `ApplicationDetailActions.vue` only, since it already holds
+ `this.obApp`, and have the three page editors read from a shared store
+ instead of the composable* — rejected: this repo has no Pinia store for
+ Application state on the page-designer route (ADR-004's "no custom stores"
+ rule plus the existing `useRegisterPicker` composable is already the
+ established single source of truth for register/schema option lists per
+ `page-designer-ui`'s own spec — REQ text "Register/schema backed editors
+ SHALL fetch their option lists"). Changing that contract is far larger than
+ this spec's scope.
+
+### Decision 2: `PageDesigner.vue` resolves the Application record with a small, dedicated fetch
+
+`PageDesigner.vue` today resolves `applicationVersion` via
+`useApplicationVersion(this.slug, versionSlug)` — that composable's public
+return shape is `{ applicationVersion, loading, error }`; it never exposes
+the **parent** Application record (it fetches the Application internally, in
+one branch only, purely to read `productionVersion`, and does not return it).
+Rather than widen `useApplicationVersion`'s contract — which is shared by
+"all four builder views" per its own header comment, only one of which
+(`PageDesigner`) needs `dataRegisters` — this spec adds a small, self-contained
+fetch in `PageDesigner.vue`: `GET
+/apps/openregister/api/objects/openbuild/application?slug=&_limit=1`
+(the exact call shape `useApplicationVersion.js` already uses internally),
+storing the result's `dataRegisters` (default `[]`) in a new
+`applicationDataRegisters` data field, invoked once in `created()` alongside
+the existing version resolution.
+
+**Alternatives considered:**
+- *Extend `useApplicationVersion()` to also return `application`* — rejected:
+ its `fetchBySlug()` branch (used whenever `?_version=` is present) never
+ fetches the Application today; adding it there too widens a
+ four-consumer-shared composable's contract for the benefit of exactly one
+ of those four consumers. A future spec that finds a second real need for
+ the Application record at that layer can revisit this trade-off with two
+ data points instead of one.
+- *Have `ApplicationDetailActions.vue` pass `dataRegisters` down via route
+ query or Vuex-style global state* — rejected: `PageDesigner.vue` is reached
+ directly by route (`/builder/{slug}/pages`), not always navigated to from
+ `ApplicationDetailActions.vue`; a route-independent fetch is the only
+ option that works regardless of entry point, and matches the existing
+ pattern (`PageDesigner` already independently resolves slug + version from
+ the route rather than expecting a parent to hand it state).
+
+### Decision 3: `src/builder.js` and `src/views/BuilderHost.vue` are explicitly NOT touched
+
+The task brief that scoped this spec named "the two builder-host dataSources
+loaders (`src/builder.js`, `src/views/BuilderHost.vue` `loadDataSources`)" as
+in-scope alongside the four picker consumers. Both files were read in full
+(current branch **and** `origin/development` HEAD — `git show
+origin/development:src/builder.js` / `:src/views/BuilderHost.vue`) as part of
+this design. Neither contains a `dataSources` prop, a `loadDataSources`
+function, or any register/schema-picker logic:
+
+- `src/builder.js` fetches the resolved manifest once
+ (`GET /api/applications/{slug}/manifest`) and hands it straight to
+ `h(CnAppRoot, { props: { manifest, registry, pageTypes, ... } })`.
+- `src/views/BuilderHost.vue` hands `bundled-manifest` +
+ `registry` + `options` (an `{ endpoint }` object) to a nested ``.
+
+Both hosts render an **already-resolved** manifest — the register + schema
+each page binds to was baked in at design time by the page editors (Decision
+1's surface); neither host re-opens a register picker at runtime. There is
+therefore no code at either call site for this spec to merge `dataRegisters`
+into. Inventing a new `dataSources`/`cnDataSources` cross-repo contract
+(threading a fresh prop through `CnAppRoot` from `@conduction/nextcloud-vue`)
+to give these two hosts a picker they don't otherwise have would be a
+materially larger, separate architectural change spanning another repo —
+exactly the scope creep ADR-032 warns against for a `kind: code` spec sized
+around "wire the schema the head declared." This is logged as a
+`DEFERRED_QUESTIONS` entry below rather than silently dropped.
+
+### Decision 4: Promotion-skip is a regression test, not a code change
+
+`VersionPromotionService::forwardSchemaSetToOR()`, `wipeTargetRegister()`, and
+`copyRowsFromSource()` each resolve their target exclusively via
+`$source['register']` / `$target['register']` — the per-version
+`ApplicationVersion.register` field — and never read `$source['dataRegisters']`
+or touch anything named `dataRegisters` (confirmed by reading the full
+current implementation). The invariant "promotion never copies a
+data-register row" therefore already holds with zero lines changed in this
+service, exactly as the head's design.md predicted. This spec's job is to:
+
+1. Add an ADDED Requirement + Scenario to `openspec/specs/version-promotion/`
+ stating the invariant formally (see `specs/version-promotion/spec.md` in
+ this change).
+2. Add a PHPUnit regression test to the existing
+ `tests/Unit/Service/VersionPromotionServiceTest.php` that constructs a
+ source ApplicationVersion whose parent Application carries a non-empty
+ `dataRegisters`, runs `promote()` with each of the three strategies, and
+ asserts the mocked `ObjectService`/`RegisterMapper` are never invoked with
+ the bound data register's slug — only with `source['register']` /
+ `target['register']`.
+
+No production code in `VersionPromotionService.php` changes. This keeps the
+regression test honest: it is provable to fail if a future change
+accidentally starts reading `Application.dataRegisters` inside the promotion
+flow.
+
+### Decision 5: Export bundles schema defs unconditionally, row data per-binding opt-in
+
+Per the head design.md's Open Questions resolution (Ruben, 2026-07-05):
+"Each `dataRegisters` binding gets an `includeData` choice in the export
+flow (default: schema-defs-only)." Concretely:
+
+- `ExportService::generateAppZip()` gains a step,
+ `bundleDataRegisterSchemas()`, called after `copyTemplate()` /
+ `resolvePlaceholders()`: for every entry in the source Application's
+ `dataRegisters`, resolve the named register (via `RegisterMapper`, already
+ a `VersionPromotionService` dependency — same injection pattern), read its
+ schema set, and write ONE reference file per binding into the exported tree
+ at `lib/Settings/data-registers/.schema.json` — the JSON
+ Schema definitions only, clearly namespaced away from the app's own
+ `_register.json` (it is not merged into the app's own
+ `components.schemas`, because the exported app does not own this register
+ any more than the source virtual app did).
+- **Schema defs are bundled for every binding, unconditionally** — there is
+ no "exclude this register from the export entirely" toggle; the head's
+ resolved decision language ("default: schema-defs-only") establishes
+ schema-defs as the floor, not an opt-in.
+- **Row data is opt-in per binding.** `exportJob` gains a new property,
+ `dataRegisters` (array of `{ register, includeData }`), mirroring the
+ existing `includeSeedData` boolean field's role (export-flow state
+ persisted on the async job record, not Application configuration — see the
+ head's design.md Open Questions: "the toggle is export-flow state, not
+ Application configuration"). `ExportDialog.vue` renders one
+ `NcCheckboxRadioSwitch` per binding the source Application declares
+ (labelled `binding.label ?? binding.register`), unchecked by default; on
+ submit, the payload's `dataRegisters` entries mirror the Application's
+ bindings 1:1, each carrying the resolved `includeData` flag.
+ `ExportJobService::queue()` persists the array onto the `ExportJob` record
+ (same pattern as `includeSeedData` today); `RunExportJob` reads it back via
+ `loadJob()` and forwards it to `generateAppZip()`. When `includeData` is
+ true for a binding, `bundleDataRegisterSchemas()` additionally writes
+ `lib/Settings/data-registers/.seed-data.json` — the
+ register's current rows in the same `{ "_comment", "objects": [...] }`
+ shape the head's own `seed-data.json` fixture uses.
+- **Neither file is wired into a `` or auto-import.** They are
+ reference material for whoever maintains the exported app next — exactly
+ as the running virtual app itself never auto-copies a bound register's
+ rows into its own namespace. Auto-importing here would silently re-create
+ the exact anti-pattern (a copy of canonical shared data forking on every
+ export) that motivated the head spec's non-ownership model in the first
+ place.
+
+**Alternatives considered:**
+- *Fold the bound register's schema straight into `_register.json`* —
+ rejected: that file already represents "schemas this app owns and the
+ exported app's own repair step imports on install." Merging a shared,
+ externally-fed register's schema into it would make the exported app
+ falsely appear to own/provision that register, silently reversing the
+ head's core non-ownership decision (Decision 1's "Why not reuse
+ `ApplicationVersion.register`'s ... pattern" argument applies identically
+ here).
+- *One export-wide "include all data-register data" toggle instead of
+ per-binding* — rejected: explicitly overridden by Ruben's 2026-07-05
+ decision recorded in the head's design.md; a municipality app binding both
+ `brp-personen` (sensitive, must stay schema-only) and a smaller reference
+ register illustrates why per-binding granularity matters.
+
+### Decision 6: Designer UI extends `AppSettingsModal.vue`, not a new modal
+
+`AppSettingsModal.vue` is already the owner-facing settings surface for
+Application-level toggles (`published`, `allowUserOverrides`), already
+modal-isolated per ADR-004, already wired through
+`ApplicationDetailActions.vue`'s `obPatchApp()` PUT. Adding a "Data
+registers" section (list of `{ register, label? }` rows with add/remove, no
+existence validation — matching the head's own save-time Non-Goal) is a
+natural extension of an existing, single-purpose settings surface rather than
+a new file. `ApplicationDetailActions.vue` binds the modal's
+`update:data-registers` event to `this.obPatchApp({ dataRegisters })` —
+identical shape to the existing `update:allow-overrides` →
+`setAllowOverrides()` wiring.
+
+**Alternatives considered:**
+- *A dedicated `DataRegistersModal.vue`* — rejected: `AppSettingsModal.vue`
+ is already exactly this kind of surface (simple property toggles/edits on
+ the Application object, one PUT on save-per-field); a second modal for one
+ more property section fragments the owner's settings experience across two
+ places for no isolation benefit (ADR-004's modal-isolation rule targets
+ inline markup inside a parent, not "one modal per property" granularity).
+
+## Declarative-vs-imperative decision (ADR-031)
+
+- **Pickers** (`useRegisterPicker.js` + its five call sites) render/populate
+ UI option lists — the same class the head's design.md already carves out
+ as never a declarative candidate ("There is no `x-openregister-*` extension
+ for 'render a dropdown'").
+- **Export bundling** (`ExportService::bundleDataRegisterSchemas()`) matches
+ ADR-031's "What apps SHOULD still write in PHP" bullet the head's design.md
+ already cited for `ExportService`: "Document/PDF/document-template
+ generation ... The schema engine has no opinion on rendered output." A
+ reference JSON file inside a ZIP is rendered output, identically classified
+ to the file this class already produces.
+- **Promotion-skip** needs no new code (Decision 4) — `VersionPromotionService`
+ is already an ADR-031 §Exceptions file per its own docblock ("every branch
+ in this file is classified imperative"). This spec adds a test, not a
+ behaviour.
+- **Designer UI** (`AppSettingsModal.vue` section + `ApplicationDetailActions.vue`
+ wiring) is UI + a plain OR REST PUT via the pre-existing `obPatchApp()`
+ helper — no new service class, no new route, no business logic beyond
+ "PUT this array back." This mirrors how `allowUserOverrides` (already on
+ the same modal) is wired.
+- **The `exportJob.dataRegisters` schema property itself is declarative** —
+ a schema-only patch to the already app-owned, already-imperative-in-purpose
+ `exportJob` schema, exactly like `includeSeedData` before it. No service
+ class is introduced by the property; `ExportJobService::queue()` already
+ has the exact `(bool) ($payload['includeSeedData'] ?? false)` pattern this
+ spec's `dataRegisters` field reuses.
+
+No exception justification is needed beyond what the head's design.md already
+established — this spec's imperative surfaces (pickers, export packaging) are
+the same two classes the head pre-cleared for its follower.
+
+## Seed Data
+
+**No new OpenRegister schema is introduced or modified on `Application`** —
+the head already shipped `dataRegisters`, and this spec adds no property to
+it. The head's own `openspec/changes/data-registers-schema-declaration/seed-data.json`
+(the `spectr` Application and the generic-municipality Application, both
+carrying populated `dataRegisters` arrays) already provides realistic
+fixtures for this spec's tests and manual QA — no new fixture is authored
+here; this spec's PHPUnit/vitest tests construct their own minimal in-memory
+`dataRegisters` arrays inline (standard practice for the existing
+`VersionPromotionServiceTest.php` / `ExportServiceTest.php` / composable
+specs, none of which read from a shared JSON fixture file today).
+
+The one schema this spec DOES touch — `exportJob` gains `dataRegisters`
+(array of `{ register, includeData }`) — is transient, per-request job
+state written by the export flow itself (`ExportJobService::queue()`), not
+admin-authored reference data an operator would hand-seed. The pre-existing
+sibling property `includeSeedData` on the same schema has no seed-data
+fixture anywhere in this repo for the same reason; this spec follows that
+precedent rather than stubbing an artificial "example ExportJob" fixture
+that no real workflow would create by hand.
+
+## Risks / Trade-offs
+
+- **[Risk]** A picker or export surface could be tempted to treat a
+ `dataRegisters` entry whose slug doesn't resolve in OR as an error rather
+ than a silent no-op. → **Mitigation**: explicitly out of scope (Non-Goals);
+ matches the existing, already-accepted failure mode for a deleted
+ `ApplicationVersion.register`.
+- **[Risk]** Bundling a data register's row data into an export ZIP
+ (`includeData: true`) could leak sensitive shared data (e.g. a
+ municipality's `brp-personen`) into a downloadable/GitHub-pushed artifact
+ if an owner opts in without understanding the register is shared, not
+ app-owned. → **Mitigation**: default is off; the per-binding label (from
+ the head's schema) is shown next to the toggle so the owner sees exactly
+ which shared register they are about to include row data for, not a bare
+ slug; RBAC on the referenced register's own schemas still gates who can
+ read that data in the first place (ADR-022) — this spec does not widen
+ access, only what an already-authorised exporter may bundle.
+- **[Risk]** Widening `useRegisterPicker.js`'s `fetchRegisters()` return
+ shape (adding a `label` field) could collide with a consumer that already
+ uses a property named `label` on register entries for something else. →
+ **Mitigation**: `fetchRegisters()`'s current return shape is OR's raw
+ register list (`{ id, slug, title, schemas, ... }` — no existing `label`
+ key per the current implementation and its own test fixtures); grepped
+ every current consumer's template/render code for `.label` reads on a
+ register entry — none exist today.
+- **[Trade-off]** The exported app's `lib/Settings/data-registers/*.json`
+ reference files are not consumed by any code in the exported app itself
+ (no repair step, no runtime read) — they exist purely as documentation for
+ a human maintaining the exported app. → Accepted: auto-consuming them would
+ require the exported app to either provision its own copy of a shared
+ register (reintroducing the exact per-app-copy problem `dataRegisters` was
+ designed to avoid) or take a runtime dependency back on the source
+ register's continued existence outside OpenBuild's own lifecycle — both
+ are bigger decisions than this spec's scope; the head's own design.md
+ leaves the same door open ("Open Questions" notes no cap or deeper
+ integration is addressed yet).
+
+## Migration Plan
+
+None required for `Application` — no schema change there. For `exportJob`,
+the new `dataRegisters` property is optional and additive (mirrors
+`includeSeedData`'s original rollout); every existing `ExportJob` object
+without it remains schema-valid, and `ExportJobService::queue()` defaults it
+to `[]` when the request payload omits it (identical fallback pattern to the
+existing `includeSeedData` read). No backfill of historical `ExportJob`
+records is needed — completed/failed jobs are not re-processed.
+
+Rollback is equally trivial: reverting the `exportJob` register.d fragment
+and the four code surfaces independently is safe in any order, since each is
+additive and none introduces a required field or a breaking change to an
+existing contract.
+
+## Open Questions
+
+- Should the exported app's `lib/Settings/data-registers/*.json` reference
+ files eventually be consumable by a future "re-attach to a shared
+ register" repair step for exported apps that want to keep receiving live
+ data post-export? Not addressed here — no consumer has asked for it yet;
+ flagged for a future spec if `spectr`'s own export path surfaces the need.
+- Should `dataRegisters[].includeData` also appear as a picker-visible hint
+ inside the builder itself (e.g. "this register's row data will be
+ exported") before the owner ever opens the export dialog? Not addressed
+ here — the export dialog is the only surface that currently needs to know
+ about `includeData`; deferred until real usage shows the builder-side hint
+ is needed.
diff --git a/openspec/changes/data-registers-runtime/proposal.md b/openspec/changes/data-registers-runtime/proposal.md
new file mode 100644
index 000000000..f26b4eb4d
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/proposal.md
@@ -0,0 +1,132 @@
+---
+kind: code
+depends_on: [data-registers-schema-declaration]
+---
+
+## Why
+
+`data-registers-schema-declaration` (chain head, merged on this branch) added the
+optional `dataRegisters` array property to the `Application` schema — the
+declarative surface that lets an admin name shared, non-versioned OpenRegister
+registers (e.g. `spectr`'s ~30-schema canonical dataset) the app binds to
+alongside its own per-version register. That spec shipped **zero** PHP/Vue code
+by design (per ADR-032, a `kind: config` head only declares schema). Today,
+declaring a `dataRegisters` binding on an `Application` object has no visible
+effect anywhere in OpenBuild: the builder pickers don't know it exists, the
+exporter doesn't bundle it, and there is no UI to add or remove a binding in
+the first place. `spectr`'s data-register work — the concrete consumer named in
+the head's proposal — stays blocked until this follower lands.
+
+This spec is that follower (`kind: code`, `depends_on:
+[data-registers-schema-declaration]`). It wires the four places in OpenBuild
+that need to become `dataRegisters`-aware:
+
+1. The builder's register/schema pickers surface the Application's declared
+ data registers, labelled, alongside its per-version register.
+2. A regression test formally locks in that version promotion never reads or
+ writes `Application.dataRegisters` (already true by construction per the
+ head's design.md — this spec adds the proof, not the guarantee).
+3. The exporter bundles bound data registers' schema definitions into the
+ exported app tree, with a per-binding opt-in to also bundle their row data.
+4. The Application settings surface gains a field to add/remove
+ `dataRegisters` bindings — today there is no UI path to populate the
+ property the head spec declared.
+
+## What Changes
+
+- **Pickers**: `useRegisterPicker.js` accepts the Application's declared
+ `dataRegisters` and labels/hoists matching entries in `fetchRegisters()`'s
+ result (per `binding.label ?? binding.register`). The four verified
+ consumers — `IndexPageEditor.vue`, `DetailPageEditor.vue`,
+ `LogsPageEditor.vue`, `ApplicationDetailActions.vue` — and their common
+ parent `PageDesigner.vue` (which resolves the Application record and passes
+ `appSlug` down today) thread the binding array through. This is a single
+ logic change in the composable; the four call sites + one parent only add
+ mechanical prop-passing (see design.md's Decision 1 for why this is not six
+ separate merges).
+- **Promotion-skip regression coverage**: `openspec/specs/version-promotion/`
+ gains an ADDED Requirement + Scenario asserting `VersionPromotionService`
+ never touches `Application.dataRegisters`, backed by a new PHPUnit
+ regression test in `VersionPromotionServiceTest.php`. No production code
+ changes — `forwardSchemaSetToOR()`, `wipeTargetRegister()`, and
+ `copyRowsFromSource()` already operate exclusively on
+ `ApplicationVersion.register` (verified by reading the current
+ implementation); this is a proof, not a fix.
+- **Export**: the exporter always bundles the **schema definitions** of every
+ register named in the source Application's `dataRegisters` into the
+ exported tree (reference-only — not auto-imported by the exported app's own
+ install process, preserving the "not owned by this app" contract). A new
+ **per-binding `includeData` toggle** in `ExportDialog.vue` (default off,
+ i.e. schema-defs-only) additionally bundles that binding's row data as a
+ reference fixture when explicitly opted in. The `exportJob` schema gains a
+ `dataRegisters` property (mirroring the existing `includeSeedData` field)
+ so the async `RunExportJob` background job can read the per-export choice.
+- **Designer UI**: `AppSettingsModal.vue` gains a "Data registers" section —
+ add/remove rows of `{ register slug, optional label }` — wired through
+ `ApplicationDetailActions.vue`'s existing `obPatchApp()` helper (a plain OR
+ REST PUT; no new backend route).
+- **NOT in scope**: `src/builder.js` and `src/views/BuilderHost.vue`. Both
+ were read in full — neither contains a register/schema picker or a
+ `dataSources`-loading routine to extend today (each simply hands an
+ already-resolved `manifest` / `bundled-manifest` to `CnAppRoot`). There is
+ no code at either call site for this spec to merge `dataRegisters` into;
+ see design.md's Decision 5 and this change's `DEFERRED_QUESTIONS`.
+- **NO schema change to `Application`** — the head already declared
+ `dataRegisters`; this spec only adds code that reads it (plus one small,
+ already-imperative `exportJob` schema property, exactly analogous to the
+ pre-existing `includeSeedData` field on that same schema).
+
+### Capabilities
+
+#### New Capabilities
+
+_(none — this spec extends three existing capabilities' code surfaces; it
+introduces no new capability domain)_
+
+#### Modified Capabilities
+
+- `version-promotion`: ADDED Requirement — `VersionPromotionService` never
+ reads or writes `Application.dataRegisters`; a regression test locks this
+ invariant in. No existing requirement's behavior changes.
+- `openbuild-exporter`: ADDED Requirement — the exporter bundles bound data
+ registers' schema definitions (always) and row data (opt-in per binding via
+ `includeData`) into the exported app tree.
+- `page-designer-ui`: ADDED Requirement — register/schema-backed sub-editors
+ surface the Application's declared `dataRegisters`, labelled, alongside the
+ per-version register.
+
+## Impact
+
+- **Changed files**:
+ - `src/composables/useRegisterPicker.js` — accept + apply `dataRegisters`.
+ - `src/components/page-editor/IndexPageEditor.vue`,
+ `DetailPageEditor.vue`, `LogsPageEditor.vue` — new `dataRegisters` prop,
+ threaded into `useRegisterPicker(...)`.
+ - `src/views/PageDesigner.vue` — resolve the Application's `dataRegisters`
+ and pass them to the active sub-editor.
+ - `src/components/ApplicationDetailActions.vue` — pass
+ `obApp.dataRegisters` into the `openSaveAsTemplate()` picker call and into
+ `ExportDialog`; wire the new settings-modal section to `obPatchApp()`.
+ - `src/modals/AppSettingsModal.vue` — add/remove `dataRegisters` bindings.
+ - `src/dialogs/ExportDialog.vue` — per-binding `includeData` toggle.
+ - `lib/Service/ExportService.php` — bundle bound data registers' schema
+ defs (+ optional row data) into the exported tree.
+ - `lib/Service/ExportJobService.php`, `lib/Controller/ExportsController.php`
+ — accept/persist the per-export `dataRegisters` choice.
+ - `lib/Settings/register.d/` — a new fragment adding `exportJob.dataRegisters`
+ (mirrors `includeSeedData`; does **not** touch `Application`).
+ - `lib/Service/VersionPromotionService.php` — read only (regression test
+ proves the existing code; no production-code edit expected).
+- **No breaking changes** — every new prop/property is optional and additive;
+ every existing Application/ExportJob object with no `dataRegisters` field
+ behaves exactly as it does today.
+- **OpenRegister** — no new register, no schema change to `Application`
+ (already shipped by the head); one additive property on the already
+ app-owned `exportJob` schema.
+- **Foundational ADRs honoured** — ADR-022 (designer UI change is a plain OR
+ REST PUT via the existing `obPatchApp()` helper — no new CRUD wrapper),
+ ADR-031 (declarative-vs-imperative classification in design.md — pickers +
+ export packaging are the sanctioned imperative surfaces the head's design.md
+ already carved out), ADR-032 (this is the `kind: code` follower closing the
+ 2-spec chain the head opened), ADR-037 (the `exportJob` fragment ships as
+ its own `register.d/` file, not a monolith edit).
diff --git a/openspec/changes/data-registers-runtime/specs/openbuild-exporter/spec.md b/openspec/changes/data-registers-runtime/specs/openbuild-exporter/spec.md
new file mode 100644
index 000000000..453404125
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/specs/openbuild-exporter/spec.md
@@ -0,0 +1,70 @@
+## ADDED Requirements
+
+### Requirement: Bound data registers' schema definitions are bundled into every export
+
+`ExportService::generateAppZip()` SHALL, for every `dataRegisters` binding the source Application declares, resolve the named register and write its schema definitions into the exported tree at `lib/Settings/data-registers/.schema.json` — one file per
+binding, containing JSON Schema definitions only. This file SHALL NOT be
+merged into the exported app's own `_register.json` and SHALL NOT be
+referenced by any `` in the exported `appinfo/info.xml` — it is
+reference documentation of a register the exported app does not own,
+consuming the same non-ownership contract `Application.dataRegisters`
+already establishes for the running virtual app. An Application with no
+`dataRegisters` SHALL produce an export tree with no
+`lib/Settings/data-registers/` directory at all — this requirement is fully
+additive and has no effect on an export that predates it.
+
+#### Scenario: Export bundles schema defs for every declared binding
+
+- **GIVEN** a published Application whose `dataRegisters` is
+ `[{ "register": "spectr", "label": "Spectr market intelligence data" }]`
+- **WHEN** the Application is exported (either target: `zip` or `github`)
+- **THEN** the exported tree contains
+ `lib/Settings/data-registers/spectr.schema.json` holding the `spectr`
+ register's current schema definitions
+- **AND** `lib/Settings/spectr_register.json` (an app-owned-looking filename)
+ is NOT created — the file lives only under the dedicated
+ `data-registers/` subdirectory
+
+#### Scenario: Export with no bindings produces no data-registers directory
+
+- **GIVEN** a published Application whose `dataRegisters` is absent or `[]`
+- **WHEN** the Application is exported
+- **THEN** the exported tree contains no `lib/Settings/data-registers/`
+ directory
+
+### Requirement: Per-binding includeData toggle controls data-register row-data inclusion
+
+The `exportJob` schema SHALL gain an optional `dataRegisters` array property
+(items shaped `{ register: string, includeData: boolean }`, default `[]`),
+populated by `ExportJobService::queue()` from the submit request body —
+mirroring the existing `includeSeedData` field's role as export-flow state
+persisted on the async job record, not Application configuration. For each
+binding whose `includeData` is `true`, `ExportService::generateAppZip()`
+SHALL additionally write
+`lib/Settings/data-registers/.seed-data.json` containing that
+register's current row data as a reference fixture, alongside (never instead
+of) that binding's schema-definitions file. A binding omitted from the
+export request's `dataRegisters`, or present with `includeData: false`,
+SHALL produce its schema-definitions file only — no row data SHALL be
+written for it under any circumstance where `includeData` is not explicitly
+`true`.
+
+#### Scenario: includeData true bundles row data alongside the schema
+
+- **GIVEN** a published Application bound to `spectr`, exported with request
+ body `dataRegisters: [{ "register": "spectr", "includeData": true }]`
+- **WHEN** the export completes
+- **THEN** the exported tree contains both
+ `lib/Settings/data-registers/spectr.schema.json` and
+ `lib/Settings/data-registers/spectr.seed-data.json`
+- **AND** neither file is referenced by any `` in the exported
+ `appinfo/info.xml`
+
+#### Scenario: includeData omitted defaults to schema-defs-only
+
+- **GIVEN** the same Application, exported with a request body that omits
+ `dataRegisters` entirely
+- **WHEN** the export completes
+- **THEN** the exported tree contains
+ `lib/Settings/data-registers/spectr.schema.json`
+- **AND** it does NOT contain `lib/Settings/data-registers/spectr.seed-data.json`
diff --git a/openspec/changes/data-registers-runtime/specs/page-designer-ui/spec.md b/openspec/changes/data-registers-runtime/specs/page-designer-ui/spec.md
new file mode 100644
index 000000000..a1e827f0c
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/specs/page-designer-ui/spec.md
@@ -0,0 +1,48 @@
+## ADDED Requirements
+
+### Requirement: Register/schema pickers surface the Application's declared dataRegisters
+
+`useRegisterPicker(opts)` SHALL accept an optional `opts.dataRegisters` array
+(shape `{ register, label? }`, default `[]`, matching the `Application`
+schema's `dataRegisters` property). `fetchRegisters()` SHALL label every
+returned register entry whose slug matches a `dataRegisters` binding with
+`binding.label ?? binding.register`, and SHALL order the result as: the
+per-app register first (existing behaviour, unchanged), then entries matching
+a `dataRegisters` binding in declaration order, then the remaining registers
+unchanged. `IndexPageEditor`, `DetailPageEditor`, and `LogsPageEditor` SHALL
+accept a `dataRegisters` prop and forward it into their `useRegisterPicker`
+call; `PageDesigner` SHALL resolve the active Application's `dataRegisters`
+and pass them to the mounted sub-editor. When `dataRegisters` is absent or
+empty, `fetchRegisters()` SHALL return output identical to its pre-existing
+behaviour — this requirement is additive and introduces no regression for an
+Application with no declared bindings.
+
+@e2e exclude component-contract spec — dataRegisters labelling/hoisting
+inside `fetchRegisters()` and the prop pass-through at each sub-editor are
+composable- and component-contract behaviour verified by Vitest unit tests
+(`useRegisterPicker.spec.js`, `IndexPageEditor.spec.js`); overall picker
+mounting and rendering inside the designer route is covered by the existing
+openbuild-page-designer Playwright tests
+
+#### Scenario: A bound data register is labelled in the picker
+
+- **GIVEN** an Application with
+ `dataRegisters: [{ "register": "spectr", "label": "Spectr market intelligence data" }]`
+- **WHEN** `IndexPageEditor` mounts and calls `fetchRegisters()`
+- **THEN** the `spectr` register entry in the returned list carries
+ `label: "Spectr market intelligence data"`
+
+#### Scenario: A bound data register without a label falls back to its slug
+
+- **GIVEN** an Application with `dataRegisters: [{ "register": "spectr" }]`
+ (no `label`)
+- **WHEN** a register/schema-backed sub-editor calls `fetchRegisters()`
+- **THEN** the `spectr` register entry's resolved label is `"spectr"`
+
+#### Scenario: An Application with no dataRegisters is unaffected
+
+- **GIVEN** an Application whose `dataRegisters` is absent
+- **WHEN** any of `IndexPageEditor`, `DetailPageEditor`, or `LogsPageEditor`
+ mounts and calls `fetchRegisters()`
+- **THEN** the returned list is unchanged from the pre-existing behaviour —
+ only the per-app register is hoisted, no entry carries a new `label` field
diff --git a/openspec/changes/data-registers-runtime/specs/version-promotion/spec.md b/openspec/changes/data-registers-runtime/specs/version-promotion/spec.md
new file mode 100644
index 000000000..b16a7d553
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/specs/version-promotion/spec.md
@@ -0,0 +1,54 @@
+## ADDED Requirements
+
+### Requirement: Promotion never reads or writes Application.dataRegisters
+
+`VersionPromotionService::promote()` and every private method it calls (`forwardSchemaSetToOR()`, `wipeTargetRegister()`, `copyRowsFromSource()`, `applyManifestAndSemver()`, `handlePromotionFailure()`) SHALL resolve their
+source and target register exclusively via `ApplicationVersion.register` (the
+per-version, app-owned register). None of these methods SHALL read, write, or
+otherwise reference the parent Application's `dataRegisters` property, under
+any of the three strategies (`start-with-source-data`,
+`migrate-existing-data`, `empty-start`). Promoting a version SHALL therefore
+neither copy, migrate, wipe, nor otherwise modify any row or schema in a
+register named in `Application.dataRegisters` — a shared data register bound
+to the app is invisible to the promotion flow in both directions.
+
+**ID:** REQ-OBVP-012
+
+#### Scenario: start-with-source-data leaves a bound data register untouched
+
+- **GIVEN** an Application whose `dataRegisters` includes
+ `{ "register": "spectr" }`, and a source ApplicationVersion whose
+ `promotesTo` target has 3 pre-existing rows in its own per-version register
+- **WHEN** an owner promotes with `strategy: "start-with-source-data"`
+- **THEN** the target's per-version register is wiped and repopulated from
+ the source's per-version register, exactly as REQ-OBVP-002 already
+ specifies
+- **AND** no read, write, lock, or delete operation is issued against the
+ `spectr` register at any point during the promotion
+
+#### Scenario: migrate-existing-data leaves a bound data register untouched
+
+- **GIVEN** the same Application as above, promoting with
+ `strategy: "migrate-existing-data"`
+- **WHEN** the promotion completes
+- **THEN** the target's per-version register schema set is aligned with the
+ source's, exactly as REQ-OBVP-003 already specifies
+- **AND** no operation of any kind touches the `spectr` register
+
+#### Scenario: empty-start leaves a bound data register untouched
+
+- **GIVEN** the same Application as above, promoting with
+ `strategy: "empty-start"`
+- **WHEN** the promotion completes
+- **THEN** the target's per-version register is wiped and left schema-only,
+ exactly as REQ-OBVP-004 already specifies
+- **AND** no operation of any kind touches the `spectr` register
+
+#### Scenario: A promotion failure does not archive or otherwise modify a bound data register
+
+- **GIVEN** an Application with a `dataRegisters` binding, whose promotion
+ fails mid-strategy (per REQ-OBVP-009)
+- **WHEN** `handlePromotionFailure()` flips the target ApplicationVersion's
+ `status` to `archived`
+- **THEN** only the target ApplicationVersion row is modified
+- **AND** the bound data register (and every object inside it) is unmodified
diff --git a/openspec/changes/data-registers-runtime/tasks.md b/openspec/changes/data-registers-runtime/tasks.md
new file mode 100644
index 000000000..e6ebd43b9
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/tasks.md
@@ -0,0 +1,52 @@
+## 1. Picker merge (single composable change)
+
+- [ ] 1.1 `src/composables/useRegisterPicker.js`: accept `opts.dataRegisters` (array of `{register, label?}`, default `[]`); in `fetchRegisters()`, label matching entries (`binding.label ?? binding.register`) and hoist them after the per-app register, per design.md Decision 1 — when `dataRegisters` is absent/empty, output must stay byte-identical to today
+- [ ] 1.2 Extend `tests/composables/useRegisterPicker.spec.js`: labelled match, slug-fallback when `label` absent, hoist ordering (per-app register, then matched bindings, then the rest), and a no-`dataRegisters`-passed regression case
+
+## 2. Wire the four verified consumers + PageDesigner
+
+- [ ] 2.1 `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`: add a `dataRegisters: { type: Array, default: () => [] }` prop and pass it into the existing `useRegisterPicker({ appSlug: props.appSlug })` call in `setup()`
+- [ ] 2.2 `src/views/PageDesigner.vue`: add an `applicationDataRegisters` data field populated by a small fetch (`GET /apps/openregister/api/objects/openbuild/application?slug=&_limit=1`, same call shape `useApplicationVersion.js` already uses) in `created()`; pass `:data-registers="applicationDataRegisters"` on the `` binding, next to the existing `:app-slug="slug"`
+- [ ] 2.3 `ApplicationDetailActions.vue`: in `openSaveAsTemplate()`, extend `useRegisterPicker({ appSlug: this.obApp.slug })` to also pass `dataRegisters: this.obApp.dataRegisters || []`
+- [ ] 2.4 Extend `tests/components/page-editor/IndexPageEditor.spec.js`: mounting with a `data-registers` prop passes it through to the mocked `useRegisterPicker` factory call
+
+## 3. Promotion-skip regression coverage
+
+- [ ] 3.1 Confirm (already verified in design.md) `VersionPromotionService.php` needs no production-code change — no task, proof only
+- [ ] 3.2 Add a PHPUnit regression test to `tests/Unit/Service/VersionPromotionServiceTest.php`: an Application/source carrying a `dataRegisters` binding promotes under all three strategies without any mock call referencing the bound register's slug — only `source['register']` / `target['register']` are touched
+
+## 4. Export: schema-defs + per-binding includeData toggle
+
+- [ ] 4.1 Add a `register.d/` fragment declaring `exportJob.dataRegisters` (array of `{register, includeData}`, default `[]`) — mirrors `includeSeedData`; no touch to `Application`
+- [ ] 4.2 `ExportService.php`: add `bundleDataRegisterSchemas()`, called from `generateAppZip()`, writing `lib/Settings/data-registers/.schema.json` for every bound register (schema defs only, never merged into `_register.json`) and `.seed-data.json` additionally when that binding's `includeData` is true
+- [ ] 4.3 `ExportJobService::queue()` / `ExportsController::submit()`: accept and persist the request's `dataRegisters` array onto the `ExportJob` record (same pattern as `includeSeedData`); `RunExportJob` forwards it from `loadJob()` into `generateAppZip()`
+- [ ] 4.4 `ExportDialog.vue`: render one `NcCheckboxRadioSwitch` per binding in the source Application's `dataRegisters` (label `binding.label ?? binding.register`), unchecked by default; submit payload mirrors the bindings 1:1 with the resolved `includeData` flags
+- [ ] 4.5 `ApplicationDetailActions.vue`: pass `:data-registers="obApp.dataRegisters || []"` into ``
+- [ ] 4.6 Add PHPUnit tests to `tests/Unit/Service/ExportServiceTest.php`: schema-defs file is always written for a bound register; seed-data file is written only when `includeData` is true; no `data-registers/` directory when `dataRegisters` is empty
+
+## 5. Designer UI: add/remove dataRegisters bindings
+
+- [ ] 5.1 `AppSettingsModal.vue`: add a "Data registers" section — list of `{register, label?}` rows with add/remove controls (register slug `NcTextField`, optional label `NcTextField`), emitting `update:data-registers` with the full array on any change
+- [ ] 5.2 `ApplicationDetailActions.vue`: wire `AppSettingsModal`'s `update:data-registers` to `this.obPatchApp({ dataRegisters })`, matching the existing `update:allow-overrides` → `setAllowOverrides()` pattern
+
+## 6. Spec-delta bookkeeping
+
+- [ ] 6.1 Append this change to the `**OpenSpec changes**` list and set `**Status**: in-progress` on `openspec/specs/version-promotion/spec.md` (update its `status:` frontmatter key), `openspec/specs/openbuild-exporter/spec.md`, and `openspec/specs/page-designer-ui/spec.md`
+
+## Quality reminders (run before requesting review — not tracked as tasks)
+
+- Run `openspec validate data-registers-runtime --strict` and resolve any structural errors.
+- Run `npm run test` (vitest) for the composable + component test changes.
+- Run the PHP test suite (`phpunit` / `composer test`, per this repo's existing scripts) for `VersionPromotionServiceTest.php` and `ExportServiceTest.php`.
+- Confirm every existing Application/ExportJob object with no `dataRegisters` field still round-trips unchanged through every touched surface (pickers, export, settings modal).
+- Confirm the exported tree for an Application with `dataRegisters` bindings contains no reference to those registers in `appinfo/info.xml`'s ``.
+
+## Acceptance Criteria
+
+- An Application's declared `dataRegisters` are labelled and hoisted (after the per-app register) in every register picker fed by `useRegisterPicker.js`, with zero behaviour change for an Application carrying no bindings.
+- A PHPUnit regression test proves `VersionPromotionService` never references a bound data register's slug under any of the three promotion strategies.
+- Exporting an Application with `dataRegisters` bundles each binding's schema definitions unconditionally, and its row data only when that binding's `includeData` was explicitly toggled on in the export dialog.
+- Neither bundled data-register file is wired into the exported app's own `` — they are reference-only.
+- An owner can add and remove `dataRegisters` bindings from the Application settings modal, persisted via the existing `obPatchApp()` OR REST PUT — no new backend route.
+- `src/builder.js` and `src/views/BuilderHost.vue` are unmodified by this change (see design.md Decision 3 and `DEFERRED_QUESTIONS`).
+- No change to the `Application` schema; the only schema touch is the additive `exportJob.dataRegisters` property.
diff --git a/openspec/specs/openbuild-exporter/spec.md b/openspec/specs/openbuild-exporter/spec.md
index 0adb225a3..46412b07e 100644
--- a/openspec/specs/openbuild-exporter/spec.md
+++ b/openspec/specs/openbuild-exporter/spec.md
@@ -15,6 +15,10 @@ schema bundle, no per-slug endpoint workaround, no nested mount — the exported
**is** the top-level app. Closes the loop on the hybrid model committed to in
`bootstrap-openbuild`.
+**OpenSpec changes**: [data-registers-runtime](../../changes/data-registers-runtime/)
+
+**Status**: in-progress
+
## Requirements
diff --git a/openspec/specs/page-designer-ui/spec.md b/openspec/specs/page-designer-ui/spec.md
index 278e5e656..6d07b883f 100644
--- a/openspec/specs/page-designer-ui/spec.md
+++ b/openspec/specs/page-designer-ui/spec.md
@@ -22,6 +22,10 @@ This capability is observed behaviour of the `PageDesigner`,
`page-editor/fields/*` builders. It is the frontend half of the
`openbuild-page-designer` backend capability.
+**OpenSpec changes**: [data-registers-runtime](../../changes/data-registers-runtime/)
+
+**Status**: in-progress
+
## Requirements
### Requirement: Controlled designer orchestrates pages, menu, undo/redo and save
diff --git a/openspec/specs/version-promotion/spec.md b/openspec/specs/version-promotion/spec.md
index 097497341..1211aec2d 100644
--- a/openspec/specs/version-promotion/spec.md
+++ b/openspec/specs/version-promotion/spec.md
@@ -1,5 +1,5 @@
---
-status: done
+status: in-progress
---
# version-promotion Specification
@@ -23,6 +23,8 @@ recovery. Default strategy is a pure function of chain position
(production-target → migrate; mid-chain → start-with-source-data; never
empty-start), implemented identically in PHP and JS.
+**OpenSpec changes**: [data-registers-runtime](../../changes/data-registers-runtime/)
+
## Requirements
### Requirement: Promotion endpoint accepts a strategy and targets `sourceVersion.promotesTo`
From 1abfa9229f9474f7f526c86cf992409bf0c2580e Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 17:02:18 +0200
Subject: [PATCH 042/391] fix(mcp): update stale stat-counter widgetType
fixtures to the real widget catalog (#99)
---
lib/Mcp/OpenBuildToolProvider.php | 2 +-
tests/Unit/Mcp/Handler/WriteHandlerValidationTest.php | 4 ++--
tests/Unit/Mcp/OpenBuildToolProviderTest.php | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/lib/Mcp/OpenBuildToolProvider.php b/lib/Mcp/OpenBuildToolProvider.php
index 8c6970e9e..59f15b69a 100644
--- a/lib/Mcp/OpenBuildToolProvider.php
+++ b/lib/Mcp/OpenBuildToolProvider.php
@@ -182,7 +182,7 @@ class OpenBuildToolProvider implements IMcpToolProvider
'id' => 'openbuild.addWidget',
'name' => 'Add a widget to a page',
'description' => 'Append a widget to a page\'s config.widgets array in the draft manifest.'
- .' widgetType is e.g. "stat-counter", "chart", "list". widgetConfig is widget-type-specific.'
+ .' widgetType is e.g. "stat", "chart", "table". widgetConfig is widget-type-specific.'
.' Defaults versionSlug to "development".',
'inputSchema' => [
'type' => 'object',
diff --git a/tests/Unit/Mcp/Handler/WriteHandlerValidationTest.php b/tests/Unit/Mcp/Handler/WriteHandlerValidationTest.php
index 70d6ea23d..6d8276ad7 100644
--- a/tests/Unit/Mcp/Handler/WriteHandlerValidationTest.php
+++ b/tests/Unit/Mcp/Handler/WriteHandlerValidationTest.php
@@ -567,7 +567,7 @@ public function testAddWidgetRejectsMissingPageId(): void
$result = $this->provider->invokeTool('openbuild.addWidget', [
'appSlug' => 'my-app',
- 'widgetType' => 'stat-counter',
+ 'widgetType' => 'stats-block',
]);
$this->assertTrue($result['isError']);
@@ -618,7 +618,7 @@ public function testAddWidgetAcceptsKnownWidgetType(): void
$result = $this->provider->invokeTool('openbuild.addWidget', [
'appSlug' => 'my-app',
'pageId' => 'home',
- 'widgetType' => 'stat-counter',
+ 'widgetType' => 'stats-block',
]);
// Widget type was valid; hit not_found (page not in manifest), not invalid_arguments.
diff --git a/tests/Unit/Mcp/OpenBuildToolProviderTest.php b/tests/Unit/Mcp/OpenBuildToolProviderTest.php
index 9e974676b..e82c3dda2 100644
--- a/tests/Unit/Mcp/OpenBuildToolProviderTest.php
+++ b/tests/Unit/Mcp/OpenBuildToolProviderTest.php
@@ -494,7 +494,7 @@ public function testAddWidgetForbiddenForNonOwner(): void
$result = $this->provider->invokeTool('openbuild.addWidget', [
'appSlug' => 'my-app',
'pageId' => 'home',
- 'widgetType' => 'stat-counter',
+ 'widgetType' => 'stats-block',
]);
$this->assertTrue($result['isError']);
From 73611522f30d6d1288d33503337c5bf7305226de Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 18:08:44 +0200
Subject: [PATCH 043/391] =?UTF-8?q?feat(register):=20dataRegisters[]=20?=
=?UTF-8?q?=E2=80=94=20shared=20data-register=20bindings=20on=20Applicatio?=
=?UTF-8?q?n=20(ADR-050)=20(#98)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../register.d/20-data-registers.json | 41 +++
.../.openspec.yaml | 2 +
.../design.md | 305 ++++++++++++++++++
.../proposal.md | 114 +++++++
.../seed-data.json | 47 +++
.../openbuild-application-register/spec.md | 92 ++++++
.../tasks.md | 26 ++
.../openbuild-application-register/spec.md | 3 +-
8 files changed, 629 insertions(+), 1 deletion(-)
create mode 100644 lib/Settings/register.d/20-data-registers.json
create mode 100644 openspec/changes/data-registers-schema-declaration/.openspec.yaml
create mode 100644 openspec/changes/data-registers-schema-declaration/design.md
create mode 100644 openspec/changes/data-registers-schema-declaration/proposal.md
create mode 100644 openspec/changes/data-registers-schema-declaration/seed-data.json
create mode 100644 openspec/changes/data-registers-schema-declaration/specs/openbuild-application-register/spec.md
create mode 100644 openspec/changes/data-registers-schema-declaration/tasks.md
diff --git a/lib/Settings/register.d/20-data-registers.json b/lib/Settings/register.d/20-data-registers.json
new file mode 100644
index 000000000..f7dd19e72
--- /dev/null
+++ b/lib/Settings/register.d/20-data-registers.json
@@ -0,0 +1,41 @@
+{
+ "_comment": "ADR-037 register fragment — data-registers-schema-declaration (chain head, kind: config; hydra ADR-050 decision #2 / SPECTR-NEXTCLOUD-PLAN.md §4.2). Adds the optional dataRegisters array property to the shared `openbuild` register's Application schema, sibling to baseRef. SettingsService::deepMergeConfig recurses into components.schemas.Application.properties (both keyed objects already present in the base config) and adds only the new dataRegisters key — Application.required and every other Application property are untouched, and no other schema is touched. Each entry names a shared, non-versioned OpenRegister register this Application binds to alongside its own per-version register (ApplicationVersion.register, ADR-002); promotion never reads/writes this property. Declarative schema metadata only (ADR-031) — no service class, no route. The follower spec data-registers-runtime (kind: code) adds the builder data-source pickers, the promotion-skip regression test, and export schema-def inclusion. See design.md Decisions 1-2 and specs/openbuild-application-register/spec.md REQ-OBA-010.",
+ "components": {
+ "schemas": {
+ "Application": {
+ "properties": {
+ "dataRegisters": {
+ "title": "Data Registers",
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "title": "Data Register Binding",
+ "required": [
+ "register"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "register": {
+ "title": "Register Slug",
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
+ "minLength": 2,
+ "maxLength": 64,
+ "description": "Slug of the shared OpenRegister register this app binds to (e.g. `spectr`). Unlike ApplicationVersion.register, this register is NOT owned or provisioned by OpenBuild and carries NO `openbuild-` prefix convention — it is an existing register OpenConnector or another process feeds independently."
+ },
+ "label": {
+ "title": "Display Label",
+ "type": "string",
+ "maxLength": 128,
+ "description": "Optional human-readable label shown in the builder's data-source pickers instead of the raw register slug (e.g. `Spectr market intelligence data`). Purely a UI convenience; absent falls back to the raw slug."
+ }
+ }
+ },
+ "description": "Shared, non-versioned OpenRegister registers this Application binds to alongside its own per-version register (ADR-002 `ApplicationVersion.register`). Declared per SPECTR-NEXTCLOUD-PLAN.md §4.2 / hydra ADR-050 decision #2. Version promotion (`VersionPromotionService`) never reads or writes this property — it operates exclusively on `ApplicationVersion.register` — so promoting a version neither copies nor migrates any row in a data register. Sibling to `baseRef` on the `Application` schema; absent on every Application created before this property existed."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/openspec/changes/data-registers-schema-declaration/.openspec.yaml b/openspec/changes/data-registers-schema-declaration/.openspec.yaml
new file mode 100644
index 000000000..e089cfacb
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-05
diff --git a/openspec/changes/data-registers-schema-declaration/design.md b/openspec/changes/data-registers-schema-declaration/design.md
new file mode 100644
index 000000000..4ee65bce6
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/design.md
@@ -0,0 +1,305 @@
+## Context
+
+Per ADR-002, an OpenBuild `Application` (the logical app) already has two
+reference-shaped properties: `baseRef` (object `{ kind, id, manifestVersion? }`,
+pointing at an installed fleet app for hybrid apps) and, via its production
+version, `ApplicationVersion.register` (a plain string slug, pattern
+`^openbuild-[a-z0-9][a-z0-9-]*[a-z0-9]$`, naming the per-version register the
+app owns and that promotion copies/migrates). Neither shape fits a **shared
+data register**: `baseRef` is single-valued and app-authored-vs-fleet-app
+specific; `ApplicationVersion.register` is owned-and-versioned, exactly the
+opposite of what SPECTR-NEXTCLOUD-PLAN.md §4.2 asks for.
+
+`spectr` (Conduction's market-intelligence app, ADR-050) needs its
+`Application` to bind to a ~30-schema shared register that OpenConnector
+feeds continuously and that every version of the app — dev, staging,
+production — reads from unchanged. Copying 82k+/158k+ rows per version
+promotion is both wasteful and semantically wrong: the data is canonical and
+external to any one app version, not app-owned test/prod data.
+
+This spec (chain head, `kind: config`) declares the schema surface that makes
+such a binding expressible. It ships zero PHP/Vue/route code — the consumers
+(pickers, promotion-skip guarantee, export inclusion, designer UI) are the
+follower spec `data-registers-runtime` (`kind: code`), per ADR-032.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Add an optional `dataRegisters` array property to the `Application` schema
+ that lets an admin declare 0..N shared OR registers the app binds to.
+- Keep the shape minimal: a register reference plus an optional display
+ label — nothing a picker or export step can't consume directly.
+- Keep the property purely additive and backward compatible: every existing
+ `Application` object (including the seeded `hello-world` app) remains
+ schema-valid with `dataRegisters` absent.
+- Ship via a `register.d/` fragment (ADR-037) so this change never touches
+ the `openbuild_register.json` monolith and never collides with any other
+ concurrent OpenBuild change.
+
+**Non-Goals:**
+- No builder UI to add/remove a data-register binding (follower spec).
+- No change to `useRegisterPicker.js` or any page/schema editor (follower
+ spec).
+- No promotion-time code change — see "Declarative-vs-imperative decision"
+ below for why the invariant already holds without one, and why the
+ regression test that locks it in belongs to the follower.
+- No export-bundle change to `ExportService.php` (follower spec).
+- No new RBAC mechanism — see "RBAC" below.
+- No validation that a referenced register slug actually exists in
+ OpenRegister at save time. OR does not require a register to pre-exist for
+ another app's schema to reference its slug (multiple registers are queried
+ by slug string, not by hard FK); a dangling reference simply resolves to an
+ empty picker/export result at consume-time, which is exactly the same
+ failure mode `ApplicationVersion.register` already has if its register is
+ deleted out from under it. No new integrity mechanism is invented here.
+
+## Decisions
+
+### Decision 1: Property shape — array of `{ register, label? }` objects
+
+```jsonc
+"dataRegisters": {
+ "title": "Data Registers",
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "title": "Data Register Binding",
+ "required": ["register"],
+ "additionalProperties": false,
+ "properties": {
+ "register": {
+ "title": "Register Slug",
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
+ "minLength": 2,
+ "maxLength": 64,
+ "description": "Slug of the shared OpenRegister register this app binds to (e.g. `spectr`). Unlike ApplicationVersion.register, this register is NOT owned or provisioned by OpenBuild and carries NO `openbuild-` prefix convention — it is an existing register OpenConnector or another process feeds independently."
+ },
+ "label": {
+ "title": "Display Label",
+ "type": "string",
+ "maxLength": 128,
+ "description": "Optional human-readable label shown in the builder's data-source pickers instead of the raw register slug (e.g. `Spectr market intelligence data`). Purely a UI convenience; absent falls back to the raw slug."
+ }
+ }
+ },
+ "description": "Shared, non-versioned OpenRegister registers this Application binds to alongside its own per-version register (ADR-002 `ApplicationVersion.register`). Declared per SPECTR-NEXTCLOUD-PLAN.md §4.2 / hydra ADR-050 decision #2. Version promotion (`VersionPromotionService`) never reads or writes this property — it operates exclusively on `ApplicationVersion.register` — so promoting a version neither copies nor migrates any row in a data register. Sibling to `baseRef` on the `Application` schema; absent on every Application created before this property existed."
+}
+```
+
+**Why an array of objects, not an array of plain slug strings**: a plain
+`string[]` would satisfy the "which registers" question but not the picker
+UX question — SPECTR-NEXTCLOUD-PLAN.md §4.2 explicitly frames this as a
+builder-picker feature, and `useRegisterPicker.js` today already resolves a
+register's own metadata (name, schemas) by round-tripping to OR, but has no
+way to show a friendlier label than the raw slug for a register the app
+didn't create itself. An optional `label` costs one property and removes a
+follower-spec round-trip.
+
+**Why not reuse the `baseRef` shape (`{ kind, id, manifestVersion? }`)**:
+`baseRef` is deliberately polymorphic (`kind: "fleet-app"` today, room for
+other kinds later) because it names *what the app extends*. A data register
+binding has exactly one kind — "an OpenRegister register" — so the `kind`
+discriminator would be dead weight (a union-shaped field with one branch is
+worse than no union, and the task's own hard rule 3 rules out union types
+here regardless). `manifestVersion` (drift-detection for a fleet-app's
+bundled manifest) has no analogue for a data register — there is no
+"manifest" to drift.
+
+**Why not reuse `ApplicationVersion.register`'s plain-string shape
+verbatim (no label, `openbuild-` prefix pattern)**: the prefix pattern
+encodes ownership ("OpenBuild provisioned and names this register"), which is
+precisely untrue for a shared data register (`spectr`, or a municipality's
+`brp-personen`) — reusing the pattern would make every real-world consumer's
+first binding a validation failure.
+
+**Why `additionalProperties: false` on the item**: mirrors `baseRef`'s own
+posture in the same schema (`"additionalProperties": false`) — keeps the
+binding shape closed so a future property (e.g. a `readOnly` flag) is an
+explicit, reviewable schema change rather than silent passthrough.
+
+**Alternatives considered:**
+- *Single `dataRegister` (singular, not array)* — rejected: SPECTR-NEXTCLOUD-PLAN.md
+ §4.2 and ADR-050 both write `dataRegisters[]` explicitly, and `pipelinq`/`mydash`
+ (the named next consumers) are plausible multi-register cases (e.g. a
+ municipality app binding both a persons register and an addresses
+ register — see Seed Data below).
+- *Relation type (`x-openregister-relation`, like `productionVersion`)* —
+ rejected: OR relations resolve to another *object* (a row with a uuid) in a
+ known register/schema pair. A data-register binding refers to an entire
+ *register* (a container), not a row — there is no target object to point
+ a relation at. A plain string slug is the correct primitive, exactly as
+ `ApplicationVersion.register` already treats "which register" as a string,
+ not a relation.
+
+### Decision 2: Ship as a `register.d/` fragment, not a monolith edit
+
+Per ADR-037, this spec adds `lib/Settings/register.d/20-data-registers.json`
+(the next free ascending prefix after the existing `10-business-rules.json`)
+rather than editing `lib/Settings/openbuild_register.json` directly. The
+fragment's `components.schemas.Application.properties` object unions by key
+with the monolith's existing `Application.properties` — `SettingsService`'s
+`deepMergeConfig` recurses into shared keys (`Application`, then
+`properties`) and only adds the new `dataRegisters` key, leaving every
+existing property (and the `required` array, which this fragment does not
+touch) untouched. This keeps the change concurrency-safe against any other
+in-flight OpenBuild change per ADR-037's stated purpose.
+
+### Declarative-vs-imperative decision (ADR-031)
+
+- **The schema property itself is declarative** — `dataRegisters` is pure
+ schema metadata added to `lib/Settings/register.d/20-data-registers.json`.
+ No service class is introduced; this is the default case ADR-031 asks for,
+ identical in kind to how `baseRef`/`icon`/`iconDark`/`permissions` were
+ previously added to `Application` as schema-only patches (see
+ `openbuild-application-register` REQ-OBA-002/REQ-OBA-006).
+- **The follower's picker/export/promotion-guard work is imperative, and
+ that is correct, not an ADR-031 gap**:
+ - *Pickers* (`useRegisterPicker.js` + its Vue consumers) render UI —
+ exactly the same class ADR-031 already carves out in this repo's own
+ precedent ("the diff and version-history UI are unavoidably code",
+ `openbuild-versioning` proposal.md). There is no `x-openregister-*`
+ extension for "render a dropdown"; this was never a declarative
+ candidate.
+ - *Export inclusion* (`ExportService.php` bundling data-register schema
+ defs into the ZIP) matches ADR-031's explicit "What apps SHOULD still
+ write in PHP" bullet: "Document/PDF/document-template generation ... The
+ schema engine has no opinion on rendered output." A ZIP bundle is
+ rendered output.
+ - *Promotion-skip* needs no new code at all — see Decision 1's schema
+ description: `VersionPromotionService` (already an ADR-031 §Exceptions
+ file per its own docblock: "every branch in this file is classified
+ imperative") only ever reads/writes `ApplicationVersion.register`. It
+ has no code path that touches `Application.dataRegisters`, so the
+ "promotion never copies data-register rows" guarantee holds the moment
+ this schema patch merges — with zero lines changed in
+ `VersionPromotionService.php`. The follower spec's job is to add the
+ integration test that pins this down as a regression guard, not to
+ write new promotion logic.
+- No exception justification is needed for *this* spec, because this spec
+ contains no imperative code at all — the exception note above exists so
+ the follower spec's reviewer sees the reasoning already applied.
+
+### RBAC — no new mechanism
+
+`Application`, `ApplicationVersion`, and (per the `register.d/` precedent)
+`RuleSet` all carry their own OR-native `authorization` block
+(`create`/`update`/`delete` arrays of roles) directly on the schema that owns
+the data. `dataRegisters` is a **reference-only** property — it names a
+register slug and an optional label; it carries no read/write semantics of
+its own and grants nothing. Access to whatever schemas and objects actually
+live inside the referenced register continues to be governed exclusively by
+that register's own schemas' `authorization` blocks and OR's standard
+multi-tenant `organisation` scoping (ADR-022) — exactly as it is today for
+every register in the fleet. Publishing an `Application` that declares
+`dataRegisters: [{ register: "spectr" }]` does not itself grant the
+Application's viewers, editors, or the general public any access to
+`spectr`'s objects that they didn't already have; per SPECTR-NEXTCLOUD-PLAN.md
+§4.2 ("RBAC stays schema-level"), this is by design, not an oversight to be
+closed later.
+
+## Seed Data
+
+Realistic example objects an admin (or a seed migration in a later spec)
+would create once this property exists. UUIDs are nil placeholders; no
+object below is created by this change — it is a `kind: config` schema-only
+spec and touches no running instance.
+
+**The `spectr` case (first real consumer, per ADR-050):**
+
+```jsonc
+{
+ "uuid": "00000000-0000-0000-0000-000000000000",
+ "slug": "spectr",
+ "name": "Spectr",
+ "description": "Market intelligence: tenders, competitors, standards, features.",
+ "appType": "virtual",
+ "productionVersion": "00000000-0000-0000-0000-000000000000",
+ "dataRegisters": [
+ {
+ "register": "spectr",
+ "label": "Spectr market intelligence data"
+ }
+ ]
+}
+```
+
+**Generic municipality case (illustrates the multi-binding shape a
+`pipelinq`/`mydash`-style consumer, or a citizen-developer municipal app,
+would use — two shared national base registers bound alongside the app's
+own register):**
+
+```jsonc
+{
+ "uuid": "",
+ "slug": "vergunningen-",
+ "name": "Vergunningen ",
+ "description": "Permit intake for .",
+ "appType": "virtual",
+ "productionVersion": "",
+ "dataRegisters": [
+ {
+ "register": "brp-personen",
+ "label": "BRP personen (shared municipal register)"
+ },
+ {
+ "register": "bag-adressen",
+ "label": "BAG adressen"
+ }
+ ]
+}
+```
+
+Both examples validate against Decision 1's schema: `dataRegisters` is an
+array of `{ register, label? }`; `register` matches the kebab-case pattern
+in both cases; neither example's app-owned `ApplicationVersion.register`
+(e.g. `openbuild-spectr-production`, not shown) is confused with a bound
+data register — the two remain visibly distinct property surfaces.
+
+## Risks / Trade-offs
+
+- **[Risk]** A future spec could be tempted to add validation that a
+ `dataRegisters[].register` slug must already exist in OpenRegister at
+ save time, coupling `Application` saves to a live OR registry lookup.
+ → **Mitigation**: explicitly out of scope (see Non-Goals); `ApplicationVersion.register`
+ already sets the precedent of "just a string, resolved at consume time,
+ not at save time" and this spec follows it.
+- **[Risk]** Two OpenBuild apps could declare the same `dataRegisters[].register`
+ slug and both expect exclusive write access. → **Mitigation**: not a new risk
+ — this is already true of any two processes (OpenConnector syncs, other
+ apps) that reference the same OR register today; RBAC is schema-level, not
+ app-level, so concurrent readers of the same register are the expected
+ shape (this is the entire point of "shared"), and OpenBuild introduces no
+ writer of its own.
+- **[Trade-off]** The `label` property has no enforced relationship to the
+ register's actual OR-side display name — an admin could set a misleading
+ label. → Accepted: this is display-only UI sugar for the follower's picker,
+ same trust level as any other admin-entered free-text field on `Application`
+ (e.g. `name`, `description`).
+
+## Migration Plan
+
+None required. The property is optional and additive; OpenRegister's
+`ConfigurationService::importFromApp()` re-imports the merged register
+idempotently (the fragment-hash-folded version bump documented in ADR-037
+triggers the re-import). No existing `Application` object needs a backfill —
+absence of `dataRegisters` is a fully valid, already-common state (every
+Application created before this spec lands has no such property, identical
+in effect to an Application explicitly saved with `dataRegisters: []`).
+
+Rollback is likewise trivial: removing the fragment file reverts the schema
+on the next import; no data migration accompanies this change in either
+direction because no object has been seeded with the new property yet.
+
+## Open Questions
+
+- Should a later spec cap the number of `dataRegisters` entries per
+ Application (e.g. to bound picker-list growth)? Not addressed here —
+ no evidence of need yet; `spectr` binds exactly one.
+- ~~Export data-toggle granularity~~ **DECIDED 2026-07-05 (Ruben): per-binding
+ toggle.** Each `dataRegisters` binding gets an `includeData` choice in the
+ export flow (default: schema-defs-only). `data-registers-runtime` implements
+ it in the export dialog + `ExportService`; this head change deliberately adds
+ no schema field for it — the toggle is export-flow state, not Application
+ configuration.
diff --git a/openspec/changes/data-registers-schema-declaration/proposal.md b/openspec/changes/data-registers-schema-declaration/proposal.md
new file mode 100644
index 000000000..a3456121d
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/proposal.md
@@ -0,0 +1,114 @@
+---
+kind: config
+depends_on: []
+chain:
+ - data-registers-schema-declaration # this spec
+ - data-registers-runtime # next in chain
+---
+
+## Why
+
+`SPECTR-NEXTCLOUD-PLAN.md` §4.2 and hydra ADR-050 (decision #2) lock a new
+OpenBuild capability: an `Application` can bind to one or more **shared,
+non-versioned OpenRegister data registers** alongside its own per-version
+config register. The first concrete consumer is `spectr` (Conduction's
+market-intelligence app) — its ~30-schema, 82k+/158k+-row canonical dataset
+must be fed continuously by OpenConnector and read by every `Application`
+version without being copied on each promotion. `pipelinq` and `mydash` are
+named as the obvious next consumers once the capability exists.
+
+Per ADR-002, `ApplicationVersion.register` is already the per-version,
+app-owned register (`openbuild-{slug}-{versionSlug}`) — schemas and objects
+that a promotion is expected to copy or migrate. A shared data register is
+architecturally different: it is **not owned by the app**, it is **not
+versioned**, and promotion **must never touch it**. Today there is no
+property on `Application` (or anywhere in the OpenBuild schema surface) that
+lets an admin declare such a binding, so `spectr`'s data-register work is
+blocked until the schema exists.
+
+Per ADR-032 (spec sizing and chained-spec routing), this is a `kind: config`
+head: it declares the schema surface only. The code that *consumes* the new
+property — builder dataSources pickers on both hosts, the promotion-skip
+guarantee, export schema-def inclusion, and the designer UI field to
+add/remove bindings — lands in the follower spec `data-registers-runtime`
+(`kind: code`, `depends_on: [data-registers-schema-declaration]`). Declaring
+the schema first means `spectr` (and any other consumer) can start
+referencing `dataRegisters` in seed data and manual testing the moment this
+spec merges, while the picker/export/promotion-guard code follows in its own
+right-sized, single-surface review cycle.
+
+## What Changes
+
+- **NEW** optional `dataRegisters` array property on the `Application` schema
+ (`lib/Settings/openbuild_register.json`, added via a `register.d/` fragment
+ per ADR-037 — no edit to the monolith). Each entry is an object binding the
+ app to one existing OpenRegister register by slug, with an optional
+ human-readable label for picker UX. Absent on every existing Application
+ (backward compatible; no migration needed — an app with no declared data
+ registers behaves exactly as it does today).
+- **NEW** seed example on the `hello-world` Application record's `spectr`
+ sibling scenario is documented in `design.md` (Seed Data section) as
+ realistic reference data — not created as a live object in this change (no
+ running instance is touched by a `kind: config` schema-only spec).
+- **NO code changes.** No PHP service, no Vue component, no route is added or
+ modified. RBAC is unchanged — schema-level RBAC on the *referenced*
+ register continues to be the sole authorization surface; declaring
+ `dataRegisters` on an `Application` does not itself grant or widen any
+ access (see design.md's Declarative-vs-imperative + RBAC sections).
+
+### Capabilities
+
+#### New Capabilities
+
+_(none — this spec extends an existing schema; it introduces no new
+capability domain)_
+
+#### Modified Capabilities
+
+- `openbuild-application-register`: ADDED Requirement — the `Application`
+ schema gains an optional `dataRegisters` array property (shared data
+ register bindings), sibling to `baseRef`. No existing requirement's
+ behavior changes; this is purely additive.
+
+## Impact
+
+- **Changed files**: `lib/Settings/register.d/20-data-registers.json` (new
+ fragment, per ADR-037 — the shared `openbuild` register's `Application`
+ schema gains the `dataRegisters` property via deep-merge; no edit to
+ `lib/Settings/openbuild_register.json` itself).
+- **No PHP/Vue/route changes** — see "What Changes" above.
+- **No breaking changes** — purely additive optional property; every
+ existing `Application` object remains schema-valid with `dataRegisters`
+ absent.
+- **OpenRegister** — no new register, no new OR-side schema; this only adds
+ one property to the existing `Application` schema already imported into
+ the shared `openbuild` register.
+- **Downstream (out of scope here, tracked by the follower spec
+ `data-registers-runtime`)**:
+ - `src/composables/useRegisterPicker.js` (consumed by
+ `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`,
+ `ApplicationDetailActions.vue`) — today `fetchRegisters()` lists every
+ OR register instance-wide and hoists the app's own per-version register
+ to the top; the follower teaches it to also surface/label the
+ Application's declared `dataRegisters`.
+ - `lib/Service/VersionPromotionService.php` — today `forwardSchemaSetToOR()`
+ / `wipeTargetRegister()` / `copyRowsFromSource()` operate exclusively on
+ `ApplicationVersion.register` (the per-version register) and never touch
+ `Application`-level fields, so "promotion skips data registers" already
+ holds true by construction; the follower adds the explicit regression
+ test (and, if needed, a defensive guard) that locks this invariant in.
+ - `lib/Service/ExportService.php` — `generateAppZip()` bundles the
+ per-version register/manifest into the export ZIP today; the follower
+ adds shared data-register **schema defs** (never data) to that bundle.
+ - The version-promotion, page-designer-ui / schema-designer-ui, and
+ openbuild-exporter capabilities are the follower's spec-delta targets —
+ none of them change here.
+- **Foundational ADRs honoured** — ADR-002 (extends the versioned model
+ without altering it: `dataRegisters` is explicitly NOT the per-version
+ `register` field), ADR-022 (consume OR abstractions — a data register is
+ itself just another OR register; no new abstraction invented), ADR-031
+ (schema-declarative — this is a pure schema addition, no service class),
+ ADR-032 (kind: config head of a 2-spec chain — see frontmatter), ADR-037
+ (modular register fragments — ships as `register.d/20-data-registers.json`,
+ not a monolith edit), ADR-050 / SPECTR-NEXTCLOUD-PLAN.md §4.2 (the source
+ decision this spec implements).
diff --git a/openspec/changes/data-registers-schema-declaration/seed-data.json b/openspec/changes/data-registers-schema-declaration/seed-data.json
new file mode 100644
index 000000000..aa7747cb3
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/seed-data.json
@@ -0,0 +1,47 @@
+{
+ "_comment": "Seed-data fixture for task 2.1 of this change (data-registers-schema-declaration). Deliberately NOT placed under lib/Settings/register.d/ — SettingsService::doLoadConfiguration() only globs lib/Settings/register.d/*.json, so nothing in this file is ever merged into openbuild_register.json or passed to OpenRegister's ConfigurationService::importFromApp(). No live object is created by this change (design.md Seed Data section; proposal.md). This file reproduces design.md's two illustrative Application examples verbatim as machine-readable JSON, reusable by the follower spec data-registers-runtime's tests and by manual QA. Each object uses the ADR-001 @self envelope ({register, schema, slug}); `schema` is the Application schema's own `slug` value (`application`), matching the convention used by lib/Settings/register.d/10-business-rules.json's seed objects. The `spectr` example uses fully concrete, valid values (nil UUID, real kebab-case slugs) and validates cleanly against the merged Application schema. The `generic-municipality` example intentionally keeps design.md's angle-bracket placeholders (, , , ) as a fill-in-the-blanks template for a real municipality's admin/integrator — those placeholders do NOT satisfy the `register` slug pattern or `productionVersion` uuid format by design, exactly as design.md documents; substitute real values before creating either object for real.",
+ "objects": [
+ {
+ "@self": {
+ "register": "openbuild",
+ "schema": "application",
+ "slug": "spectr"
+ },
+ "uuid": "00000000-0000-0000-0000-000000000000",
+ "slug": "spectr",
+ "name": "Spectr",
+ "description": "Market intelligence: tenders, competitors, standards, features.",
+ "appType": "virtual",
+ "productionVersion": "00000000-0000-0000-0000-000000000000",
+ "dataRegisters": [
+ {
+ "register": "spectr",
+ "label": "Spectr market intelligence data"
+ }
+ ]
+ },
+ {
+ "@self": {
+ "register": "openbuild",
+ "schema": "application",
+ "slug": "vergunningen-"
+ },
+ "uuid": "",
+ "slug": "vergunningen-",
+ "name": "Vergunningen ",
+ "description": "Permit intake for .",
+ "appType": "virtual",
+ "productionVersion": "",
+ "dataRegisters": [
+ {
+ "register": "brp-personen",
+ "label": "BRP personen (shared municipal register)"
+ },
+ {
+ "register": "bag-adressen",
+ "label": "BAG adressen"
+ }
+ ]
+ }
+ ]
+}
diff --git a/openspec/changes/data-registers-schema-declaration/specs/openbuild-application-register/spec.md b/openspec/changes/data-registers-schema-declaration/specs/openbuild-application-register/spec.md
new file mode 100644
index 000000000..60e9f3694
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/specs/openbuild-application-register/spec.md
@@ -0,0 +1,92 @@
+## ADDED Requirements
+
+### Requirement: Application schema carries an optional dataRegisters binding array
+
+The system SHALL extend the `Application` schema in
+`lib/Settings/openbuild_register.json` (via a `register.d/` fragment per
+ADR-037 — see design.md Decision 2) with an optional `dataRegisters` array
+property, sibling to `baseRef`, of shape:
+
+```json
+{
+ "dataRegisters": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "required": ["register"],
+ "additionalProperties": false,
+ "properties": {
+ "register": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" },
+ "label": { "type": "string" }
+ }
+ }
+ }
+}
+```
+
+Each entry names a shared, non-versioned OpenRegister register (by slug)
+that the `Application` binds to alongside its own per-version register
+(`ApplicationVersion.register`, ADR-002). `dataRegisters` is declarative
+schema metadata only (ADR-031) — this requirement introduces no service
+class, no route, and no validation that a referenced register slug exists in
+OpenRegister at save time (see design.md Non-Goals). This is the schema
+surface SPECTR-NEXTCLOUD-PLAN.md §4.2 / hydra ADR-050 decision #2 locks for
+OpenBuild; the consumers (builder pickers, promotion-skip regression
+coverage, export inclusion, designer UI) are out of scope for this
+requirement and land in the follower spec `data-registers-runtime`.
+
+**ID:** REQ-OBA-010
+
+#### Scenario: Schema declares dataRegisters after install
+
+- **WHEN** the OpenBuild app is installed (or upgraded) and its repair step
+ runs
+- **THEN** the `Application` schema in the `openbuild` register exposes the
+ `dataRegisters` property with the shape above
+- **AND** the property is omittable — existing Application objects created
+ before this change remain schema-valid
+
+#### Scenario: Saving an Application with a dataRegisters binding round-trips
+
+- **WHEN** a client PUTs an Application via OR REST with
+ `dataRegisters = [{ "register": "spectr", "label": "Spectr market intelligence data" }]`
+- **THEN** OR persists the object and a subsequent GET returns the same
+ `dataRegisters` array byte-for-byte
+
+#### Scenario: Saving an Application with multiple bindings round-trips
+
+- **WHEN** a client PUTs an Application via OR REST with
+ `dataRegisters = [{ "register": "brp-personen" }, { "register": "bag-adressen", "label": "BAG adressen" }]`
+- **THEN** OR persists the object and a subsequent GET returns both entries,
+ in order, byte-for-byte
+- **AND** the entry without a `label` round-trips with no `label` key present
+
+#### Scenario: Application without dataRegisters is still accepted
+
+- **WHEN** a client saves an Application that omits `dataRegisters` entirely
+- **THEN** OR persists the object and returns 2xx — the property is optional
+ and defaults to an empty array on read
+
+#### Scenario: Binding missing the required register slug is rejected
+
+- **WHEN** a client PUTs an Application with
+ `dataRegisters = [{ "label": "No slug given" }]` (missing the required
+ `register` key)
+- **THEN** OR rejects the save with a 4xx citing the missing `register`
+ property under the failing array index
+
+#### Scenario: Binding with an unrecognised sub-property is rejected
+
+- **WHEN** a client PUTs an Application with
+ `dataRegisters = [{ "register": "spectr", "readOnly": true }]` (note the
+ unknown `readOnly` key)
+- **THEN** OR rejects the save with a 4xx citing the unknown property under
+ the failing `dataRegisters` array entry
+
+#### Scenario: Register slug pattern is enforced
+
+- **WHEN** a client PUTs an Application with
+ `dataRegisters = [{ "register": "Not_A-Valid-Slug!" }]`
+- **THEN** OR rejects the save with a 4xx citing the `register` value as not
+ matching the kebab-case pattern
diff --git a/openspec/changes/data-registers-schema-declaration/tasks.md b/openspec/changes/data-registers-schema-declaration/tasks.md
new file mode 100644
index 000000000..22d7ef8a9
--- /dev/null
+++ b/openspec/changes/data-registers-schema-declaration/tasks.md
@@ -0,0 +1,26 @@
+## 1. Schema fragment (register.d)
+
+- [x] 1.1 Grep `lib/Settings/register.d/*.json` and `lib/Settings/openbuild_register.json` for any existing `dataRegisters` property name on the `Application` schema (ADR-012 dedup check) before authoring the fragment
+- [x] 1.2 Create `lib/Settings/register.d/20-data-registers.json` declaring the optional `dataRegisters` array property on the `Application` schema — shape, titles, and descriptions exactly per design.md Decision 1 and specs `REQ-OBA-010` (array of `{ register, label? }`, `additionalProperties: false` on the item, English title + description on every property and sub-property per gate-28)
+- [x] 1.3 Confirm the fragment's JSON path is scoped to `components.schemas.Application.properties.dataRegisters` only — no edit to `Application.required`, no edit to any other `Application` property, no edit to `ApplicationVersion` or any other schema
+
+## 2. Seed-data fixtures
+
+- [x] 2.1 Add the two design.md seed-data examples (the `spectr` Application and the generic-municipality Application) as fixture JSON reusable by the follower spec's tests and by manual QA — no live object is created by this change
+
+## Quality reminders (run before requesting review — not tracked as tasks)
+
+- Run `openspec validate data-registers-schema-declaration --strict` and resolve any structural errors.
+- Validate both seed-data fixtures (task 2.1) against the merged `Application` schema with a jq/ajv check — both must pass.
+- Confirm the existing seeded `hello-world` Application (no `dataRegisters` field) still validates against the merged schema — the property must be truly optional.
+- Confirm `SettingsService::doLoadConfiguration()` picks up the new fragment on the next repair-step run and that OpenRegister's `ConfigurationService::importFromApp()` re-imports without error (ADR-037 fragment-hash version bump).
+
+## Acceptance Criteria
+
+- The `Application` schema in the `openbuild` register exposes an optional `dataRegisters` array property matching design.md Decision 1's shape after the repair step runs.
+- An Application saved with a valid `dataRegisters` binding (single or multiple entries) round-trips byte-for-byte via OR REST.
+- An Application saved without `dataRegisters` is accepted and reads back as an empty array — full back-compat with every pre-existing Application.
+- A `dataRegisters` entry missing the required `register` key is rejected with a 4xx.
+- A `dataRegisters` entry carrying an unrecognised sub-property is rejected with a 4xx (`additionalProperties: false`).
+- A `dataRegisters` entry whose `register` value fails the kebab-case pattern is rejected with a 4xx.
+- No PHP, Vue, or route file is touched by this change — only `lib/Settings/register.d/20-data-registers.json` plus seed-data fixture files.
diff --git a/openspec/specs/openbuild-application-register/spec.md b/openspec/specs/openbuild-application-register/spec.md
index f6687b2dd..2e9ad0ce8 100644
--- a/openspec/specs/openbuild-application-register/spec.md
+++ b/openspec/specs/openbuild-application-register/spec.md
@@ -15,8 +15,9 @@ scoping via OR's standard `organisation` field (ADR-022). Lifecycle relocates to
`status` enum and no state machine.
**OpenSpec changes**: [unify-apps-with-app-type](../../changes/archive/2026-06-20-unify-apps-with-app-type/) _(archived 2026-06-20)_
+[data-registers-schema-declaration](../../changes/data-registers-schema-declaration/)
-**Status**: done
+**Status**: in-progress
## Requirements
From 75c98364a3f54f33ea674ecae21a48c4e2244c00 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 19:05:47 +0200
Subject: [PATCH 044/391] feat(data-registers): merge Application.dataRegisters
bindings into useRegisterPicker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
useRegisterPicker(opts) accepts opts.dataRegisters ({register, label?}[]);
fetchRegisters() labels matching entries (binding.label ?? binding.register)
and hoists them after the per-app register, in declaration order. Absent or
empty dataRegisters is a no-op — output stays byte-identical to today.
Covers task 1 of openspec/changes/data-registers-runtime.
---
src/composables/useRegisterPicker.js | 92 +++++++++++++++++--
tests/composables/useRegisterPicker.spec.js | 98 +++++++++++++++++++++
2 files changed, 182 insertions(+), 8 deletions(-)
diff --git a/src/composables/useRegisterPicker.js b/src/composables/useRegisterPicker.js
index ef1fa50b8..0ccf9f01a 100644
--- a/src/composables/useRegisterPicker.js
+++ b/src/composables/useRegisterPicker.js
@@ -37,12 +37,21 @@ const PICKER_HEADERS = () => ({
* @param {object} [opts] - Options.
* @param {string} [opts.appSlug] - Current Application slug. When set, the
* picker filters to the per-app register `openbuild-{slug}` first.
+ * @param {Array<{register: string, label?: string}>} [opts.dataRegisters] -
+ * The Application's declared shared data-register bindings
+ * (`Application.dataRegisters`, data-registers-schema-declaration). When
+ * set, `fetchRegisters()` labels matching entries with
+ * `binding.label ?? binding.register` and hoists them after the per-app
+ * register. Absent/empty is a no-op — `fetchRegisters()` then returns
+ * output byte-identical to the pre-existing (perApp-only) behaviour.
* @return {object} - { fetchRegisters, fetchSchemas, fetchSchemaProperties,
* resolveAppRegister }.
* @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-1
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-1.1
*/
export function useRegisterPicker(opts = {}) {
const appSlug = opts.appSlug || ''
+ const dataRegisters = Array.isArray(opts.dataRegisters) ? opts.dataRegisters : []
/**
* Resolve the per-app register slug for the current Application.
@@ -57,9 +66,14 @@ export function useRegisterPicker(opts = {}) {
/**
* Fetch the list of registers available to the page editor. When the
* current Application has a slug, the per-app register is hoisted to
- * the top so picker UX defaults to the right namespace.
+ * the top so picker UX defaults to the right namespace. When the
+ * Application declares `dataRegisters` bindings (design.md Decision 1),
+ * matching entries are labelled with `binding.label ?? binding.register`
+ * and hoisted immediately after the per-app register, in declaration
+ * order; every other entry keeps OR's original relative order.
*
* @return {Promise} - registers list.
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-1.1
*/
async function fetchRegisters() {
try {
@@ -73,17 +87,79 @@ export function useRegisterPicker(opts = {}) {
if (!Array.isArray(list)) {
return []
}
+
// Hoist the per-app register so it is the obvious default.
const perApp = resolveAppRegister()
- if (!perApp) {
- return list
+
+ // No dataRegisters bindings declared — regression-safe default:
+ // byte-identical to the pre-existing (perApp-only) behaviour.
+ if (dataRegisters.length === 0) {
+ if (!perApp) {
+ return list
+ }
+ const sorted = [...list].sort((a, b) => {
+ if ((a.slug || a.id) === perApp) return -1
+ if ((b.slug || b.id) === perApp) return 1
+ return 0
+ })
+ return sorted
+ }
+
+ // Map — label ?? register per design.md.
+ const labelByRegister = new Map()
+ dataRegisters.forEach((binding) => {
+ if (binding && binding.register) {
+ labelByRegister.set(binding.register, binding.label ?? binding.register)
+ }
+ })
+
+ // Declaration order of each binding, for the "matching entries,
+ // in the order the Application declared them" tier.
+ const declarationOrder = new Map()
+ dataRegisters.forEach((binding, index) => {
+ if (binding && binding.register && !declarationOrder.has(binding.register)) {
+ declarationOrder.set(binding.register, index)
+ }
+ })
+
+ const labelled = list.map((entry) => {
+ const key = entry && (entry.slug || entry.id)
+ if (key && labelByRegister.has(key)) {
+ return { ...entry, label: labelByRegister.get(key) }
+ }
+ return entry
+ })
+
+ // Tier 0: per-app register. Tier 1: dataRegisters bindings (in
+ // declaration order). Tier 2: everything else (OR's order).
+ function tierFor(entry) {
+ const key = entry && (entry.slug || entry.id)
+ if (perApp && key === perApp) {
+ return 0
+ }
+ if (key && declarationOrder.has(key)) {
+ return 1
+ }
+ return 2
}
- const sorted = [...list].sort((a, b) => {
- if ((a.slug || a.id) === perApp) return -1
- if ((b.slug || b.id) === perApp) return 1
- return 0
+
+ const indexed = labelled.map((entry, originalIndex) => ({ entry, originalIndex }))
+ indexed.sort((a, b) => {
+ const tierA = tierFor(a.entry)
+ const tierB = tierFor(b.entry)
+ if (tierA !== tierB) {
+ return tierA - tierB
+ }
+ if (tierA === 1) {
+ const keyA = a.entry.slug || a.entry.id
+ const keyB = b.entry.slug || b.entry.id
+ return declarationOrder.get(keyA) - declarationOrder.get(keyB)
+ }
+ // Tiers 0 (singleton) and 2 keep the original relative order.
+ return a.originalIndex - b.originalIndex
})
- return sorted
+
+ return indexed.map((i) => i.entry)
} catch {
return []
}
diff --git a/tests/composables/useRegisterPicker.spec.js b/tests/composables/useRegisterPicker.spec.js
index 49ab136db..461716527 100644
--- a/tests/composables/useRegisterPicker.spec.js
+++ b/tests/composables/useRegisterPicker.spec.js
@@ -122,6 +122,104 @@ describe('useRegisterPicker — REQ-OBFFUI-001', () => {
})
})
+ // ------------------------------------------------------------------ //
+ // fetchRegisters — dataRegisters labelling/hoisting //
+ // (data-registers-runtime REQ: page-designer-ui) //
+ // ------------------------------------------------------------------ //
+
+ describe('fetchRegisters — dataRegisters', () => {
+ it('labels a matching entry with binding.label when set', async () => {
+ const registers = [
+ { id: 'r1', slug: 'spectr' },
+ { id: 'r2', slug: 'other' },
+ ]
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ results: registers }),
+ })
+
+ const { fetchRegisters } = useRegisterPicker({
+ dataRegisters: [{ register: 'spectr', label: 'Spectr market intelligence data' }],
+ })
+ const result = await fetchRegisters()
+ const spectr = result.find((r) => r.slug === 'spectr')
+ expect(spectr.label).toBe('Spectr market intelligence data')
+ })
+
+ it('falls back to the raw slug when binding.label is absent', async () => {
+ const registers = [{ id: 'r1', slug: 'spectr' }]
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ results: registers }),
+ })
+
+ const { fetchRegisters } = useRegisterPicker({
+ dataRegisters: [{ register: 'spectr' }],
+ })
+ const result = await fetchRegisters()
+ expect(result[0].label).toBe('spectr')
+ })
+
+ it('hoists in order: per-app register, then dataRegisters bindings (declaration order), then the rest', async () => {
+ const registers = [
+ { id: 'r1', slug: 'zzz-unrelated' },
+ { id: 'r2', slug: 'bag-adressen' },
+ { id: 'r3', slug: 'openbuild-my-app' },
+ { id: 'r4', slug: 'brp-personen' },
+ ]
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ results: registers }),
+ })
+
+ const { fetchRegisters } = useRegisterPicker({
+ appSlug: 'my-app',
+ dataRegisters: [
+ { register: 'brp-personen', label: 'BRP personen' },
+ { register: 'bag-adressen', label: 'BAG adressen' },
+ ],
+ })
+ const result = await fetchRegisters()
+ expect(result.map((r) => r.slug)).toEqual([
+ 'openbuild-my-app',
+ 'brp-personen',
+ 'bag-adressen',
+ 'zzz-unrelated',
+ ])
+ })
+
+ it('does not label or reorder entries when dataRegisters is not passed (regression)', async () => {
+ const registers = [
+ { id: 'r1', slug: 'other' },
+ { id: 'r2', slug: 'openbuild-my-app' },
+ ]
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ results: registers }),
+ })
+
+ const { fetchRegisters } = useRegisterPicker({ appSlug: 'my-app' })
+ const result = await fetchRegisters()
+ expect(result).toEqual([
+ { id: 'r2', slug: 'openbuild-my-app' },
+ { id: 'r1', slug: 'other' },
+ ])
+ expect(result.every((r) => !('label' in r))).toBe(true)
+ })
+
+ it('does not label or reorder entries when dataRegisters is an empty array (regression)', async () => {
+ const registers = [{ id: 'r1', slug: 'other' }]
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ results: registers }),
+ })
+
+ const { fetchRegisters } = useRegisterPicker({ dataRegisters: [] })
+ const result = await fetchRegisters()
+ expect(result).toEqual(registers)
+ })
+ })
+
// ------------------------------------------------------------------ //
// fetchSchemas //
// ------------------------------------------------------------------ //
From 6b26b486c8d88e9bea60b8dcae11f786f77f982a Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 19:05:56 +0200
Subject: [PATCH 045/391] feat(data-registers): wire dataRegisters through the
picker consumers, PageDesigner, and save-as-template
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
IndexPageEditor, DetailPageEditor, LogsPageEditor: new dataRegisters prop,
forwarded into their useRegisterPicker({ appSlug, dataRegisters }) call.
PageDesigner: resolves the active Application's dataRegisters via a small,
dedicated fetch in created() (same call shape useApplicationVersion.js
already uses internally) and passes them to the mounted sub-editor as
:data-registers, alongside the existing :app-slug.
ApplicationDetailActions: openSaveAsTemplate()'s picker call now also
passes dataRegisters: obApp.dataRegisters || []. This same file also
threads :data-registers into and wires AppSettingsModal's
update:data-registers -> obPatchApp() (tasks 4.5 and 5.2) — bundled here
since it is one small file with three related edits.
Covers task 2 of openspec/changes/data-registers-runtime.
---
src/components/ApplicationDetailActions.vue | 26 ++++-
.../page-editor/DetailPageEditor.vue | 9 +-
.../page-editor/IndexPageEditor.vue | 9 +-
src/components/page-editor/LogsPageEditor.vue | 9 +-
src/views/PageDesigner.vue | 47 +++++++++
.../page-editor/IndexPageEditor.spec.js | 42 ++++++--
tests/views/PageDesigner.spec.js | 97 ++++++++++++++++++-
tests/vitest/SaveAsTemplateAction.spec.js | 23 ++++-
8 files changed, 242 insertions(+), 20 deletions(-)
diff --git a/src/components/ApplicationDetailActions.vue b/src/components/ApplicationDetailActions.vue
index 8f0e4c733..2ad6e8211 100644
--- a/src/components/ApplicationDetailActions.vue
+++ b/src/components/ApplicationDetailActions.vue
@@ -113,16 +113,19 @@
+ @update:allow-overrides="setAllowOverrides"
+ @update:data-registers="setDataRegisters" />
} dataRegisters The full updated bindings array.
+ * @return {Promise}
+ */
+ async setDataRegisters(dataRegisters) {
+ if (this.obAppRole !== 'owner' || !this.obApp) {
+ return
+ }
+ this.error = ''
+ try {
+ await this.obPatchApp({ dataRegisters })
+ } catch (e) {
+ this.error = `${t('openbuild', 'Failed to save settings')}: ${e.message || e}`
+ }
+ },
/**
* Delete the app (Application + versions + per-version registers), then
* navigate back to the apps list. Owner-only (enforced server-side too).
@@ -505,7 +527,7 @@ export default {
this.saveTemplateManifest = this.obApp.manifest
|| (this.obApp.currentVersion && this.obApp.currentVersion.manifest)
|| {}
- const picker = useRegisterPicker({ appSlug: this.obApp.slug })
+ const picker = useRegisterPicker({ appSlug: this.obApp.slug, dataRegisters: this.obApp.dataRegisters || [] })
this.saveTemplateSchemas = await picker.fetchSchemas(picker.resolveAppRegister())
this.existingTemplates = await this.loadExistingTemplates()
this.saveTemplateOpen = true
diff --git a/src/components/page-editor/DetailPageEditor.vue b/src/components/page-editor/DetailPageEditor.vue
index 144c602b6..399b5fe29 100644
--- a/src/components/page-editor/DetailPageEditor.vue
+++ b/src/components/page-editor/DetailPageEditor.vue
@@ -139,6 +139,12 @@ export default {
type: String,
default: '',
},
+ // The Application's declared `dataRegisters` bindings, forwarded into
+ // useRegisterPicker so the register picker labels/hoists them.
+ dataRegisters: {
+ type: Array,
+ default: () => [],
+ },
pageType: {
type: String,
default: 'detail',
@@ -149,9 +155,10 @@ export default {
* Observed behaviour of `setup` (retrofit annotation).
*
* @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-2.1
*/
setup(props) {
- const picker = useRegisterPicker({ appSlug: props.appSlug })
+ const picker = useRegisterPicker({ appSlug: props.appSlug, dataRegisters: props.dataRegisters })
return { picker }
},
data() {
diff --git a/src/components/page-editor/IndexPageEditor.vue b/src/components/page-editor/IndexPageEditor.vue
index 6ae5dff81..c9869df6e 100644
--- a/src/components/page-editor/IndexPageEditor.vue
+++ b/src/components/page-editor/IndexPageEditor.vue
@@ -118,6 +118,12 @@ export default {
type: String,
default: '',
},
+ // The Application's declared `dataRegisters` bindings, forwarded into
+ // useRegisterPicker so the register picker labels/hoists them.
+ dataRegisters: {
+ type: Array,
+ default: () => [],
+ },
pageType: {
type: String,
default: 'index',
@@ -132,9 +138,10 @@ export default {
* Observed behaviour of `setup` (retrofit annotation).
*
* @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-2.1
*/
setup(props) {
- const picker = useRegisterPicker({ appSlug: props.appSlug })
+ const picker = useRegisterPicker({ appSlug: props.appSlug, dataRegisters: props.dataRegisters })
return { picker }
},
data() {
diff --git a/src/components/page-editor/LogsPageEditor.vue b/src/components/page-editor/LogsPageEditor.vue
index 5fa728c44..6da24d19d 100644
--- a/src/components/page-editor/LogsPageEditor.vue
+++ b/src/components/page-editor/LogsPageEditor.vue
@@ -127,6 +127,12 @@ export default {
type: String,
default: '',
},
+ // The Application's declared `dataRegisters` bindings, forwarded into
+ // useRegisterPicker so the register picker labels/hoists them.
+ dataRegisters: {
+ type: Array,
+ default: () => [],
+ },
parentRoute: {
type: String,
default: '',
@@ -137,9 +143,10 @@ export default {
* Observed behaviour of `setup` (retrofit annotation).
*
* @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-2.1
*/
setup(props) {
- const picker = useRegisterPicker({ appSlug: props.appSlug })
+ const picker = useRegisterPicker({ appSlug: props.appSlug, dataRegisters: props.dataRegisters })
return { picker }
},
data() {
diff --git a/src/views/PageDesigner.vue b/src/views/PageDesigner.vue
index f2931dac4..ff81318fb 100644
--- a/src/views/PageDesigner.vue
+++ b/src/views/PageDesigner.vue
@@ -61,6 +61,7 @@
:config="selectedPage.config || {}"
:page-type="selectedPage.type"
:app-slug="slug"
+ :data-registers="applicationDataRegisters"
:parent-route="selectedPage.route || ''"
@update:config="onConfigUpdate" />
@@ -104,6 +105,8 @@
@@ -79,6 +211,12 @@ export default {
font-weight: 600;
}
+.app-settings__subtitle {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+}
+
.app-settings__row {
display: flex;
flex-direction: column;
@@ -90,4 +228,14 @@ export default {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
+
+.app-settings__hint--inline {
+ margin-left: 0;
+}
+
+.app-settings__data-register-row {
+ display: flex;
+ gap: 8px;
+ align-items: flex-end;
+}
diff --git a/tests/modals/AppSettingsModal.spec.js b/tests/modals/AppSettingsModal.spec.js
new file mode 100644
index 000000000..72a5c65b0
--- /dev/null
+++ b/tests/modals/AppSettingsModal.spec.js
@@ -0,0 +1,133 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Vitest unit tests for `src/modals/AppSettingsModal.vue`'s "Data registers"
+ * section (data-registers-runtime task 5.1).
+ *
+ * Covers:
+ * - existing dataRegisters bindings render as editable rows
+ * - addRow() appends an empty row (not yet emitted — no register slug)
+ * - editing a row's register slug emits update:data-registers with the
+ * full array
+ * - removeRow() drops the row and emits the shortened array
+ * - an empty label is omitted from the emitted payload rather than sent
+ * as ''
+ * - the dataRegisters prop changing (e.g. after obPatchApp()'s response)
+ * re-syncs the displayed rows
+ */
+
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import AppSettingsModal from '../../src/modals/AppSettingsModal.vue'
+
+const baseStubs = {
+ NcModal: {
+ name: 'NcModal',
+ props: ['name'],
+ template: '
',
+ },
+ NcButton: {
+ name: 'NcButton',
+ props: ['type', 'disabled'],
+ template: ' ',
+ },
+ NcCheckboxRadioSwitch: {
+ name: 'NcCheckboxRadioSwitch',
+ props: ['checked', 'type', 'disabled'],
+ template: ' ',
+ },
+ NcTextField: {
+ name: 'NcTextField',
+ props: ['value', 'label', 'disabled'],
+ template: ' ',
+ },
+}
+
+function mountModal(propsData = {}) {
+ return mount(AppSettingsModal, {
+ propsData: { open: true, ...propsData },
+ stubs: baseStubs,
+ })
+}
+
+describe('AppSettingsModal — Data registers section (data-registers-runtime task 5.1)', () => {
+ it('renders one row per existing dataRegisters binding', () => {
+ const wrapper = mountModal({
+ dataRegisters: [
+ { register: 'spectr', label: 'Spectr market intelligence data' },
+ { register: 'bag-adressen' },
+ ],
+ })
+ const rows = wrapper.findAll('.app-settings__data-register-row')
+ expect(rows).toHaveLength(2)
+ const textFields = wrapper.findAll('.nc-textfield-stub')
+ expect(textFields.at(0).element.value).toBe('spectr')
+ expect(textFields.at(1).element.value).toBe('Spectr market intelligence data')
+ expect(textFields.at(2).element.value).toBe('bag-adressen')
+ expect(textFields.at(3).element.value).toBe('')
+ })
+
+ it('renders no rows when dataRegisters is empty', () => {
+ const wrapper = mountModal({ dataRegisters: [] })
+ expect(wrapper.findAll('.app-settings__data-register-row')).toHaveLength(0)
+ })
+
+ it('addRow() appends an empty row without emitting (no register slug yet)', async () => {
+ const wrapper = mountModal({ dataRegisters: [] })
+ await wrapper.vm.addRow()
+ expect(wrapper.vm.rows).toHaveLength(1)
+ expect(wrapper.emitted('update:data-registers')[0][0]).toEqual([])
+ })
+
+ it('typing a register slug on a new row emits the full array', async () => {
+ const wrapper = mountModal({ dataRegisters: [] })
+ await wrapper.vm.addRow()
+ wrapper.vm.updateRow(0, 'register', 'spectr')
+ await wrapper.vm.$nextTick()
+ const emitted = wrapper.emitted('update:data-registers')
+ const last = emitted[emitted.length - 1][0]
+ expect(last).toEqual([{ register: 'spectr' }])
+ })
+
+ it('a non-empty label is included; an empty label is omitted from the payload', async () => {
+ const wrapper = mountModal({ dataRegisters: [{ register: 'spectr' }] })
+ wrapper.vm.updateRow(0, 'label', 'Spectr market intelligence data')
+ await wrapper.vm.$nextTick()
+ let last = wrapper.emitted('update:data-registers').pop()[0]
+ expect(last).toEqual([{ register: 'spectr', label: 'Spectr market intelligence data' }])
+
+ wrapper.vm.updateRow(0, 'label', '')
+ await wrapper.vm.$nextTick()
+ last = wrapper.emitted('update:data-registers').pop()[0]
+ expect(last).toEqual([{ register: 'spectr' }])
+ })
+
+ it('removeRow() drops the row and emits the shortened array', async () => {
+ const wrapper = mountModal({
+ dataRegisters: [{ register: 'spectr' }, { register: 'bag-adressen' }],
+ })
+ wrapper.vm.removeRow(0)
+ await wrapper.vm.$nextTick()
+ const last = wrapper.emitted('update:data-registers').pop()[0]
+ expect(last).toEqual([{ register: 'bag-adressen' }])
+ expect(wrapper.vm.rows).toHaveLength(1)
+ })
+
+ it('re-syncs displayed rows when the dataRegisters prop changes', async () => {
+ const wrapper = mountModal({ dataRegisters: [] })
+ expect(wrapper.vm.rows).toEqual([])
+ await wrapper.setProps({ dataRegisters: [{ register: 'spectr', label: 'Spectr' }] })
+ expect(wrapper.vm.rows).toEqual([{ register: 'spectr', label: 'Spectr' }])
+ })
+
+ it('a row mid-edit with no register slug is dropped from the emitted payload', async () => {
+ const wrapper = mountModal({ dataRegisters: [{ register: 'spectr' }] })
+ await wrapper.vm.addRow()
+ wrapper.vm.updateRow(1, 'label', 'still typing the slug')
+ await wrapper.vm.$nextTick()
+ const last = wrapper.emitted('update:data-registers').pop()[0]
+ expect(last).toEqual([{ register: 'spectr' }])
+ })
+})
From e24b73438fb6b7da5b9f27b8e1db389dd56bbb29 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 19:06:31 +0200
Subject: [PATCH 049/391] docs(data-registers-runtime): mark all tasks complete
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
All checkboxes in tasks.md ticked. Task 6.1's bookkeeping deliverable
(the OpenSpec changes list entry + in-progress status on
version-promotion/spec.md, openbuild-exporter/spec.md, and
page-designer-ui/spec.md) was already pre-seeded when this change was
scaffolded, before this session began — verified present, no edit needed.
---
.../changes/data-registers-runtime/tasks.md | 24 +++++++++----------
1 file changed, 12 insertions(+), 12 deletions(-)
diff --git a/openspec/changes/data-registers-runtime/tasks.md b/openspec/changes/data-registers-runtime/tasks.md
index e6ebd43b9..7603b9839 100644
--- a/openspec/changes/data-registers-runtime/tasks.md
+++ b/openspec/changes/data-registers-runtime/tasks.md
@@ -1,6 +1,6 @@
## 1. Picker merge (single composable change)
-- [ ] 1.1 `src/composables/useRegisterPicker.js`: accept `opts.dataRegisters` (array of `{register, label?}`, default `[]`); in `fetchRegisters()`, label matching entries (`binding.label ?? binding.register`) and hoist them after the per-app register, per design.md Decision 1 — when `dataRegisters` is absent/empty, output must stay byte-identical to today
+- [x] 1.1 `src/composables/useRegisterPicker.js`: accept `opts.dataRegisters` (array of `{register, label?}`, default `[]`); in `fetchRegisters()`, label matching entries (`binding.label ?? binding.register`) and hoist them after the per-app register, per design.md Decision 1 — when `dataRegisters` is absent/empty, output must stay byte-identical to today
- [ ] 1.2 Extend `tests/composables/useRegisterPicker.spec.js`: labelled match, slug-fallback when `label` absent, hoist ordering (per-app register, then matched bindings, then the rest), and a no-`dataRegisters`-passed regression case
## 2. Wire the four verified consumers + PageDesigner
@@ -12,26 +12,26 @@
## 3. Promotion-skip regression coverage
-- [ ] 3.1 Confirm (already verified in design.md) `VersionPromotionService.php` needs no production-code change — no task, proof only
-- [ ] 3.2 Add a PHPUnit regression test to `tests/Unit/Service/VersionPromotionServiceTest.php`: an Application/source carrying a `dataRegisters` binding promotes under all three strategies without any mock call referencing the bound register's slug — only `source['register']` / `target['register']` are touched
+- [x] 3.1 Confirm (already verified in design.md) `VersionPromotionService.php` needs no production-code change — no task, proof only
+- [x] 3.2 Add a PHPUnit regression test to `tests/Unit/Service/VersionPromotionServiceTest.php`: an Application/source carrying a `dataRegisters` binding promotes under all three strategies without any mock call referencing the bound register's slug — only `source['register']` / `target['register']` are touched
## 4. Export: schema-defs + per-binding includeData toggle
-- [ ] 4.1 Add a `register.d/` fragment declaring `exportJob.dataRegisters` (array of `{register, includeData}`, default `[]`) — mirrors `includeSeedData`; no touch to `Application`
-- [ ] 4.2 `ExportService.php`: add `bundleDataRegisterSchemas()`, called from `generateAppZip()`, writing `lib/Settings/data-registers/.schema.json` for every bound register (schema defs only, never merged into `_register.json`) and `.seed-data.json` additionally when that binding's `includeData` is true
-- [ ] 4.3 `ExportJobService::queue()` / `ExportsController::submit()`: accept and persist the request's `dataRegisters` array onto the `ExportJob` record (same pattern as `includeSeedData`); `RunExportJob` forwards it from `loadJob()` into `generateAppZip()`
-- [ ] 4.4 `ExportDialog.vue`: render one `NcCheckboxRadioSwitch` per binding in the source Application's `dataRegisters` (label `binding.label ?? binding.register`), unchecked by default; submit payload mirrors the bindings 1:1 with the resolved `includeData` flags
-- [ ] 4.5 `ApplicationDetailActions.vue`: pass `:data-registers="obApp.dataRegisters || []"` into ``
-- [ ] 4.6 Add PHPUnit tests to `tests/Unit/Service/ExportServiceTest.php`: schema-defs file is always written for a bound register; seed-data file is written only when `includeData` is true; no `data-registers/` directory when `dataRegisters` is empty
+- [x] 4.1 Add a `register.d/` fragment declaring `exportJob.dataRegisters` (array of `{register, includeData}`, default `[]`) — mirrors `includeSeedData`; no touch to `Application`
+- [x] 4.2 `ExportService.php`: add `bundleDataRegisterSchemas()`, called from `generateAppZip()`, writing `lib/Settings/data-registers/.schema.json` for every bound register (schema defs only, never merged into `_register.json`) and `.seed-data.json` additionally when that binding's `includeData` is true
+- [x] 4.3 `ExportJobService::queue()` / `ExportsController::submit()`: accept and persist the request's `dataRegisters` array onto the `ExportJob` record (same pattern as `includeSeedData`); `RunExportJob` forwards it from `loadJob()` into `generateAppZip()`
+- [x] 4.4 `ExportDialog.vue`: render one `NcCheckboxRadioSwitch` per binding in the source Application's `dataRegisters` (label `binding.label ?? binding.register`), unchecked by default; submit payload mirrors the bindings 1:1 with the resolved `includeData` flags
+- [x] 4.5 `ApplicationDetailActions.vue`: pass `:data-registers="obApp.dataRegisters || []"` into ``
+- [x] 4.6 Add PHPUnit tests to `tests/Unit/Service/ExportServiceTest.php`: schema-defs file is always written for a bound register; seed-data file is written only when `includeData` is true; no `data-registers/` directory when `dataRegisters` is empty
## 5. Designer UI: add/remove dataRegisters bindings
-- [ ] 5.1 `AppSettingsModal.vue`: add a "Data registers" section — list of `{register, label?}` rows with add/remove controls (register slug `NcTextField`, optional label `NcTextField`), emitting `update:data-registers` with the full array on any change
-- [ ] 5.2 `ApplicationDetailActions.vue`: wire `AppSettingsModal`'s `update:data-registers` to `this.obPatchApp({ dataRegisters })`, matching the existing `update:allow-overrides` → `setAllowOverrides()` pattern
+- [x] 5.1 `AppSettingsModal.vue`: add a "Data registers" section — list of `{register, label?}` rows with add/remove controls (register slug `NcTextField`, optional label `NcTextField`), emitting `update:data-registers` with the full array on any change
+- [x] 5.2 `ApplicationDetailActions.vue`: wire `AppSettingsModal`'s `update:data-registers` to `this.obPatchApp({ dataRegisters })`, matching the existing `update:allow-overrides` → `setAllowOverrides()` pattern
## 6. Spec-delta bookkeeping
-- [ ] 6.1 Append this change to the `**OpenSpec changes**` list and set `**Status**: in-progress` on `openspec/specs/version-promotion/spec.md` (update its `status:` frontmatter key), `openspec/specs/openbuild-exporter/spec.md`, and `openspec/specs/page-designer-ui/spec.md`
+- [x] 6.1 Append this change to the `**OpenSpec changes**` list and set `**Status**: in-progress` on `openspec/specs/version-promotion/spec.md` (update its `status:` frontmatter key), `openspec/specs/openbuild-exporter/spec.md`, and `openspec/specs/page-designer-ui/spec.md` — verified already pre-seeded on all three (change-list entry + in-progress status present) when this task began; no edit needed
## Quality reminders (run before requesting review — not tracked as tasks)
From a9efdf5dcc4172179b5df8b9635147568ca14476 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 19:09:03 +0200
Subject: [PATCH 050/391] docs(data-registers-runtime): tick tasks 1.2, 2.1-2.4
checkboxes
These were implemented and tested alongside tasks 1.1 (commit 75c98364)
and their own group's work (commit 6b26b486) but the checkboxes were
missed in the prior bookkeeping pass. All 17 tasks are now [x].
---
openspec/changes/data-registers-runtime/tasks.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/openspec/changes/data-registers-runtime/tasks.md b/openspec/changes/data-registers-runtime/tasks.md
index 7603b9839..e4a565a4f 100644
--- a/openspec/changes/data-registers-runtime/tasks.md
+++ b/openspec/changes/data-registers-runtime/tasks.md
@@ -1,14 +1,14 @@
## 1. Picker merge (single composable change)
- [x] 1.1 `src/composables/useRegisterPicker.js`: accept `opts.dataRegisters` (array of `{register, label?}`, default `[]`); in `fetchRegisters()`, label matching entries (`binding.label ?? binding.register`) and hoist them after the per-app register, per design.md Decision 1 — when `dataRegisters` is absent/empty, output must stay byte-identical to today
-- [ ] 1.2 Extend `tests/composables/useRegisterPicker.spec.js`: labelled match, slug-fallback when `label` absent, hoist ordering (per-app register, then matched bindings, then the rest), and a no-`dataRegisters`-passed regression case
+- [x] 1.2 Extend `tests/composables/useRegisterPicker.spec.js`: labelled match, slug-fallback when `label` absent, hoist ordering (per-app register, then matched bindings, then the rest), and a no-`dataRegisters`-passed regression case
## 2. Wire the four verified consumers + PageDesigner
-- [ ] 2.1 `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`: add a `dataRegisters: { type: Array, default: () => [] }` prop and pass it into the existing `useRegisterPicker({ appSlug: props.appSlug })` call in `setup()`
-- [ ] 2.2 `src/views/PageDesigner.vue`: add an `applicationDataRegisters` data field populated by a small fetch (`GET /apps/openregister/api/objects/openbuild/application?slug=&_limit=1`, same call shape `useApplicationVersion.js` already uses) in `created()`; pass `:data-registers="applicationDataRegisters"` on the `` binding, next to the existing `:app-slug="slug"`
-- [ ] 2.3 `ApplicationDetailActions.vue`: in `openSaveAsTemplate()`, extend `useRegisterPicker({ appSlug: this.obApp.slug })` to also pass `dataRegisters: this.obApp.dataRegisters || []`
-- [ ] 2.4 Extend `tests/components/page-editor/IndexPageEditor.spec.js`: mounting with a `data-registers` prop passes it through to the mocked `useRegisterPicker` factory call
+- [x] 2.1 `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`: add a `dataRegisters: { type: Array, default: () => [] }` prop and pass it into the existing `useRegisterPicker({ appSlug: props.appSlug })` call in `setup()`
+- [x] 2.2 `src/views/PageDesigner.vue`: add an `applicationDataRegisters` data field populated by a small fetch (`GET /apps/openregister/api/objects/openbuild/application?slug=&_limit=1`, same call shape `useApplicationVersion.js` already uses) in `created()`; pass `:data-registers="applicationDataRegisters"` on the `` binding, next to the existing `:app-slug="slug"`
+- [x] 2.3 `ApplicationDetailActions.vue`: in `openSaveAsTemplate()`, extend `useRegisterPicker({ appSlug: this.obApp.slug })` to also pass `dataRegisters: this.obApp.dataRegisters || []`
+- [x] 2.4 Extend `tests/components/page-editor/IndexPageEditor.spec.js`: mounting with a `data-registers` prop passes it through to the mocked `useRegisterPicker` factory call
## 3. Promotion-skip regression coverage
From 5b8a2657c331806e1b16ad4be5daa703fa9e2029 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sun, 5 Jul 2026 19:11:34 +0200
Subject: [PATCH 051/391] =?UTF-8?q?feat(data-registers):=20runtime=20?=
=?UTF-8?q?=E2=80=94=20pickers,=20promotion=20regression,=20export=20bundl?=
=?UTF-8?q?ing,=20designer=20UI=20(#101)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
l10n/en.json | 9 +-
lib/BackgroundJob/RunExportJob.php | 9 +-
lib/Service/DataRegisterExportBundler.php | 304 ++++++++++++
lib/Service/ExportJobService.php | 43 ++
lib/Service/ExportService.php | 83 +++-
.../30-export-job-data-registers.json | 38 ++
.../data-registers-runtime/.openspec.yaml | 2 +
.../changes/data-registers-runtime/design.md | 432 ++++++++++++++++++
.../data-registers-runtime/proposal.md | 132 ++++++
.../specs/openbuild-exporter/spec.md | 70 +++
.../specs/page-designer-ui/spec.md | 48 ++
.../specs/version-promotion/spec.md | 54 +++
.../changes/data-registers-runtime/tasks.md | 52 +++
openspec/specs/openbuild-exporter/spec.md | 4 +
openspec/specs/page-designer-ui/spec.md | 4 +
openspec/specs/version-promotion/spec.md | 4 +-
src/components/ApplicationDetailActions.vue | 26 +-
.../page-editor/DetailPageEditor.vue | 9 +-
.../page-editor/IndexPageEditor.vue | 9 +-
src/components/page-editor/LogsPageEditor.vue | 9 +-
src/composables/useRegisterPicker.js | 92 +++-
src/dialogs/ExportDialog.vue | 44 ++
src/modals/AppSettingsModal.vue | 160 ++++++-
src/views/PageDesigner.vue | 47 ++
tests/Integration/ExporterEndToEndTest.php | 18 +-
tests/Unit/BackgroundJob/RunExportJobTest.php | 63 +++
tests/Unit/Service/ExportJobServiceTest.php | 86 ++++
tests/Unit/Service/ExportServiceTest.php | 230 +++++++++-
.../Service/VersionPromotionServiceTest.php | 348 ++++++++++++++
.../page-editor/IndexPageEditor.spec.js | 42 +-
tests/composables/useRegisterPicker.spec.js | 98 ++++
tests/dialogs/ExportDialog.spec.js | 129 ++++++
tests/modals/AppSettingsModal.spec.js | 133 ++++++
tests/stubs/openregister-stubs.php | 76 +++
tests/views/PageDesigner.spec.js | 97 +++-
tests/vitest/SaveAsTemplateAction.spec.js | 23 +-
36 files changed, 2982 insertions(+), 45 deletions(-)
create mode 100644 lib/Service/DataRegisterExportBundler.php
create mode 100644 lib/Settings/register.d/30-export-job-data-registers.json
create mode 100644 openspec/changes/data-registers-runtime/.openspec.yaml
create mode 100644 openspec/changes/data-registers-runtime/design.md
create mode 100644 openspec/changes/data-registers-runtime/proposal.md
create mode 100644 openspec/changes/data-registers-runtime/specs/openbuild-exporter/spec.md
create mode 100644 openspec/changes/data-registers-runtime/specs/page-designer-ui/spec.md
create mode 100644 openspec/changes/data-registers-runtime/specs/version-promotion/spec.md
create mode 100644 openspec/changes/data-registers-runtime/tasks.md
create mode 100644 tests/dialogs/ExportDialog.spec.js
create mode 100644 tests/modals/AppSettingsModal.spec.js
diff --git a/l10n/en.json b/l10n/en.json
index 782b01d4f..62d0382c5 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -888,7 +888,14 @@
"Setup steps": "Setup steps",
"Setup wizard": "Setup wizard",
"Value": "Value",
- "Walkthrough": "Walkthrough"
+ "Walkthrough": "Walkthrough",
+ "Add data register": "Add data register",
+ "Data registers": "Data registers",
+ "Include row data for {label}": "Include row data for {label}",
+ "Register slug": "Register slug",
+ "Remove data register": "Remove data register",
+ "Shared, non-versioned OpenRegister registers this app binds to alongside its own per-version register (e.g. a dataset fed by OpenConnector). Not owned by this app — promotion and export treat them as reference-only.": "Shared, non-versioned OpenRegister registers this app binds to alongside its own per-version register (e.g. a dataset fed by OpenConnector). Not owned by this app — promotion and export treat them as reference-only.",
+ "This app is bound to shared data registers it does not own. Their schema definitions are always included as reference material. Row data is only bundled for a register when you switch it on below.": "This app is bound to shared data registers it does not own. Their schema definitions are always included as reference material. Row data is only bundled for a register when you switch it on below."
},
"plurals": ""
}
diff --git a/lib/BackgroundJob/RunExportJob.php b/lib/BackgroundJob/RunExportJob.php
index e90f55c04..2ebb81f29 100644
--- a/lib/BackgroundJob/RunExportJob.php
+++ b/lib/BackgroundJob/RunExportJob.php
@@ -136,6 +136,7 @@ private function extractJobUuid($argument): string
* @return void
*
* @spec openspec/changes/retrofit-2026-05-24-annotate-openbuild/tasks.md#task-33
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.3
*/
private function executePipeline(string $jobUuid): void
{
@@ -155,6 +156,11 @@ private function executePipeline(string $jobUuid): void
$applicationSlug = (string) ($job['applicationSlug'] ?? 'exported-app');
$license = (string) ($job['license'] ?? 'EUPL-1.2');
+ $dataRegisters = [];
+ if (is_array($job['dataRegisters'] ?? null) === true) {
+ $dataRegisters = $job['dataRegisters'];
+ }
+
if ($applicationUuid === '') {
throw new RuntimeException(
sprintf('OpenBuild RunExportJob: ExportJob %s has an empty applicationUuid', $jobUuid)
@@ -175,7 +181,8 @@ private function executePipeline(string $jobUuid): void
applicationUuid: $applicationUuid,
versionSlug: $applicationVersion,
context: $context,
- jobUuid: $jobUuid
+ jobUuid: $jobUuid,
+ dataRegisters: $dataRegisters
);
$pushResult = $this->maybePush(jobUuid: $jobUuid, job: $job);
diff --git a/lib/Service/DataRegisterExportBundler.php b/lib/Service/DataRegisterExportBundler.php
new file mode 100644
index 000000000..2edf45668
--- /dev/null
+++ b/lib/Service/DataRegisterExportBundler.php
@@ -0,0 +1,304 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @version GIT:
+ *
+ * @link https://conduction.nl
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
+ *
+ * @SPDX-License-Identifier: EUPL-1.2
+ * @SPDX-FileCopyrightText: 2026 Conduction B.V.
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenBuild\Service;
+
+use OCA\OpenRegister\Db\Register;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\ObjectService;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Bundles a source Application's bound `dataRegisters` (schema definitions
+ * always, row data opt-in per binding) into an exported app tree, at
+ * `lib/Settings/data-registers/.{schema,seed-data}.json`
+ * (spec openbuild-exporter, ADDED Requirements "Bound data registers'
+ * schema definitions are bundled into every export" + "Per-binding
+ * includeData toggle controls data-register row-data inclusion").
+ *
+ * Neither file is merged into the app's own `_register.json`, nor
+ * referenced by any `` — they are reference-only
+ * documentation of a register this app does not own (design.md Decision
+ * 5). A dangling `register` slug that RegisterMapper cannot resolve is
+ * skipped silently (no schemas bundled), matching the existing failure
+ * mode for a deleted `ApplicationVersion.register`.
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
+ */
+class DataRegisterExportBundler
+{
+ /**
+ * Constructor.
+ *
+ * @param RegisterMapper $registerMapper Resolves a bound data register's slug.
+ * @param SchemaMapper $schemaMapper Resolves a bound data register's schema definitions.
+ * @param ObjectService $objectService Reads a bound data register's row data (includeData opt-in).
+ * @param LoggerInterface $logger Logger.
+ */
+ public function __construct(
+ private readonly RegisterMapper $registerMapper,
+ private readonly SchemaMapper $schemaMapper,
+ private readonly ObjectService $objectService,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Bundle every `dataRegisters` binding into `$rootDir`.
+ *
+ * Each entry's shape is `{register: string, includeData?: bool}`, but
+ * this is untrusted data round-tripped through OR (ultimately read back
+ * from an `ExportJob` record by RunExportJob) — typed loosely here
+ * (rather than a PHPStan array-shape) so the defensive `is_array()` /
+ * `??` guards below stay meaningful instead of being flagged as dead
+ * code against an assumed-certain shape.
+ *
+ * @param string $rootDir Scratch directory (exported tree root).
+ * @param array $dataRegisters Bindings + per-export includeData choice.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
+ */
+ public function bundle(string $rootDir, array $dataRegisters): void
+ {
+ if ($dataRegisters === []) {
+ return;
+ }
+
+ $targetDir = $rootDir.'/lib/Settings/data-registers';
+
+ foreach ($dataRegisters as $binding) {
+ if (is_array($binding) === false) {
+ continue;
+ }
+
+ $registerSlug = (string) ($binding['register'] ?? '');
+ if ($registerSlug === '') {
+ continue;
+ }
+
+ try {
+ $register = $this->registerMapper->find($registerSlug, _multitenancy: false);
+ } catch (Throwable $e) {
+ // Dangling reference — no schemas bundled (Non-Goals precedent).
+ $this->logger->info(
+ 'OpenBuild export: dataRegisters binding "'.$registerSlug.'" did not resolve to a register — '
+ .'no schema definitions bundled: '.$e->getMessage()
+ );
+ continue;
+ }
+
+ $schemaDefinitions = $this->resolveRegisterSchemaDefinitions(register: $register, registerSlug: $registerSlug);
+
+ if (is_dir($targetDir) === false) {
+ mkdir($targetDir, 0o755, true);
+ }
+
+ $this->writeSchemaFile(targetDir: $targetDir, registerSlug: $registerSlug, schemaDefinitions: $schemaDefinitions);
+
+ if (((bool) ($binding['includeData'] ?? false)) === true) {
+ $this->writeSeedDataFile(targetDir: $targetDir, registerSlug: $registerSlug, register: $register);
+ }
+ }//end foreach
+ }//end bundle()
+
+ /**
+ * Resolve an already-loaded data register's schema definitions (JSON
+ * Schema shape only — title/description/type/required/properties),
+ * keyed by schema slug. A schema id that SchemaMapper cannot resolve is
+ * skipped (defensive; every id on `Register::getSchemas()` is expected
+ * to resolve in practice).
+ *
+ * @param Register $register The resolved data register.
+ * @param string $registerSlug Slug of the register (for logging only).
+ *
+ * @return array>
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
+ */
+ private function resolveRegisterSchemaDefinitions(Register $register, string $registerSlug): array
+ {
+ $definitions = [];
+ foreach ((array) $register->getSchemas() as $schemaId) {
+ try {
+ $schema = $this->schemaMapper->find($schemaId, _multitenancy: false);
+ } catch (Throwable $e) {
+ $this->logger->debug(
+ 'OpenBuild export: could not resolve schema '.((string) $schemaId).' in data register "'
+ .$registerSlug.'": '.$e->getMessage()
+ );
+ continue;
+ }
+
+ $schemaSlug = $schema->getSlug();
+ if ($schemaSlug === '') {
+ continue;
+ }
+
+ $definitions[$schemaSlug] = [
+ 'title' => $schema->getTitle(),
+ 'description' => $schema->getDescription(),
+ 'type' => 'object',
+ 'required' => $schema->getRequired(),
+ 'properties' => $schema->getProperties(),
+ ];
+ }//end foreach
+
+ return $definitions;
+ }//end resolveRegisterSchemaDefinitions()
+
+ /**
+ * Write `.schema.json` — the register's schema
+ * definitions, namespaced away from the app's own `_register.json`.
+ *
+ * @param string $targetDir `lib/Settings/data-registers` in the scratch tree.
+ * @param string $registerSlug Slug of the bound data register.
+ * @param array> $schemaDefinitions Schema definitions keyed by schema slug.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
+ */
+ private function writeSchemaFile(string $targetDir, string $registerSlug, array $schemaDefinitions): void
+ {
+ $payload = [
+ '_comment' => 'Reference-only schema definitions for the shared data register "'.$registerSlug.'", '
+ .'bound via Application.dataRegisters (data-registers-schema-declaration). This app does not own '
+ .'this register — these definitions are documentation for whoever maintains the exported app next. '
+ .'NOT merged into this app\'s own register, NOT referenced by any , NOT auto-imported.',
+ 'components' => [
+ 'schemas' => $schemaDefinitions,
+ ],
+ ];
+
+ $this->writeJsonFile(path: $targetDir.'/'.$registerSlug.'.schema.json', payload: $payload);
+ }//end writeSchemaFile()
+
+ /**
+ * Write `.seed-data.json` — the register's current row
+ * data, in the same `{ "_comment", "objects": [...] }` shape the head's
+ * own `seed-data.json` fixture uses. Only called when a binding's
+ * `includeData` is true.
+ *
+ * @param string $targetDir `lib/Settings/data-registers` in the scratch tree.
+ * @param string $registerSlug Slug of the bound data register (for logging only).
+ * @param Register $register The already-resolved data register.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
+ */
+ private function writeSeedDataFile(string $targetDir, string $registerSlug, Register $register): void
+ {
+ try {
+ $rows = $this->objectService->searchObjects(
+ query: ['@self' => ['register' => $register->getId()]],
+ _rbac: false,
+ _multitenancy: false
+ );
+ } catch (Throwable $e) {
+ $this->logger->info(
+ 'OpenBuild export: could not read row data for data register "'.$registerSlug.'": '.$e->getMessage()
+ );
+ return;
+ }
+
+ if (is_array($rows) === false) {
+ $rows = [];
+ }
+
+ $objects = [];
+ foreach ($rows as $row) {
+ $objects[] = $this->normaliseObjectArray(object: $row);
+ }
+
+ $payload = [
+ '_comment' => 'Reference-only row-data fixture for the shared data register "'.$registerSlug.'" — '
+ .'bundled because this binding\'s includeData was explicitly toggled on at export time. NOT '
+ .'auto-imported by this app\'s install process (no references this file).',
+ 'objects' => $objects,
+ ];
+
+ $this->writeJsonFile(path: $targetDir.'/'.$registerSlug.'.seed-data.json', payload: $payload);
+ }//end writeSeedDataFile()
+
+ /**
+ * Encode + write a JSON payload, logging (not throwing) on failure.
+ *
+ * @param string $path Absolute file path to write.
+ * @param array $payload Payload to encode.
+ *
+ * @return void
+ */
+ private function writeJsonFile(string $path, array $payload): void
+ {
+ $encoded = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($encoded === false) {
+ $this->logger->warning('OpenBuild export: failed to encode JSON for '.$path);
+ return;
+ }
+
+ file_put_contents($path, $encoded."\n");
+ }//end writeJsonFile()
+
+ /**
+ * Coerce an OR result entry to a plain associative array (mirrors
+ * VersionPromotionService::normaliseObjectArray()'s contract).
+ *
+ * @param mixed $object The OR object/result entry.
+ *
+ * @return array
+ */
+ private function normaliseObjectArray(mixed $object): array
+ {
+ if (is_array($object) === true) {
+ return $object;
+ }
+
+ if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) {
+ $serialised = $object->jsonSerialize();
+ if (is_array($serialised) === true) {
+ return $serialised;
+ }
+ }
+
+ if (is_object($object) === true && method_exists($object, 'getObject') === true) {
+ $inner = $object->getObject();
+ if (is_array($inner) === true) {
+ return $inner;
+ }
+ }
+
+ return [];
+ }//end normaliseObjectArray()
+}//end class
diff --git a/lib/Service/ExportJobService.php b/lib/Service/ExportJobService.php
index fdf6f9f0d..7b7e9d02b 100644
--- a/lib/Service/ExportJobService.php
+++ b/lib/Service/ExportJobService.php
@@ -78,6 +78,7 @@ public function __construct(
* @throws \InvalidArgumentException When required fields are missing.
*
* @spec openspec/changes/retrofit-2026-05-24-annotate-openbuild/tasks.md#task-33
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.3
*/
public function queue(
string $applicationSlug,
@@ -113,6 +114,7 @@ public function queue(
'githubRepo' => $githubRepo,
'githubVisibility' => $githubVisibility,
'includeSeedData' => (bool) ($payload['includeSeedData'] ?? false),
+ 'dataRegisters' => $this->sanitiseDataRegisters(raw: $payload['dataRegisters'] ?? []),
'license' => (string) ($payload['license'] ?? 'EUPL-1.2'),
'log' => [],
];
@@ -135,6 +137,47 @@ public function queue(
return $jobUuid;
}//end queue()
+ /**
+ * Normalise the submit request's `dataRegisters` choice onto the shape
+ * `{register: string, includeData: bool}` — mirrors the existing
+ * `includeSeedData` boolean-cast pattern above. Malformed entries (not
+ * an array, or missing/empty `register`) are dropped rather than
+ * rejected — no existence validation of the referenced register is
+ * performed here (matches the head spec's own Non-Goal for a dangling
+ * `Application.dataRegisters[].register` slug).
+ *
+ * @param mixed $raw The request payload's `dataRegisters` value.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.3
+ */
+ private function sanitiseDataRegisters(mixed $raw): array
+ {
+ if (is_array($raw) === false) {
+ return [];
+ }
+
+ $out = [];
+ foreach ($raw as $entry) {
+ if (is_array($entry) === false) {
+ continue;
+ }
+
+ $register = (string) ($entry['register'] ?? '');
+ if ($register === '') {
+ continue;
+ }
+
+ $out[] = [
+ 'register' => $register,
+ 'includeData' => (bool) ($entry['includeData'] ?? false),
+ ];
+ }
+
+ return $out;
+ }//end sanitiseDataRegisters()
+
/**
* Persist the ExportJob record via OR (best-effort; falls back to a no-op
* when OR is not available so unit tests can stub the path).
diff --git a/lib/Service/ExportService.php b/lib/Service/ExportService.php
index 558c1f2cf..c49c51a6e 100644
--- a/lib/Service/ExportService.php
+++ b/lib/Service/ExportService.php
@@ -84,14 +84,19 @@ class ExportService
/**
* Constructor.
*
- * @param IAppData $appData The app-data area for scratch + exports.
- * @param PlaceholderResolver $placeholderResolver Pure resolver for {{tokens}}.
- * @param LoggerInterface $logger Logger.
+ * @param IAppData $appData The app-data area for scratch + exports.
+ * @param PlaceholderResolver $placeholderResolver Pure resolver for {{tokens}}.
+ * @param LoggerInterface $logger Logger.
+ * @param DataRegisterExportBundler $dataRegisterBundler Bundles bound data registers into the exported
+ * tree (data-registers-runtime) — a dedicated
+ * collaborator so this class's own coupling/
+ * complexity stays within PHPMD's thresholds.
*/
public function __construct(
private IAppData $appData,
private PlaceholderResolver $placeholderResolver,
private LoggerInterface $logger,
+ private DataRegisterExportBundler $dataRegisterBundler,
) {
$this->templateRoot = dirname(__DIR__).'/Resources/template';
// 2026-01-01T00:00:00Z — fixed for deterministic ZIPs.
@@ -104,23 +109,32 @@ public function __construct(
* @param string $applicationUuid Source Application UUID.
* @param string $versionSlug Semver of the Application version.
* @param array $context Placeholder context: appId, appNamespace, etc.
- * @param string $jobUuid ExportJob UUID — used as the ZIP filename.
+ * @param string $jobUuid ExportJob UUID — used as the ZIP
+ * filename.
+ * @param array $dataRegisters Source Application's `dataRegisters` bindings + the
+ * per-export includeData choice for each
+ * (data-registers-runtime design.md Decision 5).
+ * Default `[]`. Untrusted shape — see
+ * bundleDataRegisterSchemas().
*
* @return string Absolute (local) path to the produced ZIP.
*
* @throws RuntimeException When packaging fails.
*
* @spec openspec/changes/retrofit-2026-05-24-annotate-openbuild/tasks.md#task-40
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
*/
public function generateAppZip(
string $applicationUuid,
string $versionSlug,
array $context,
string $jobUuid,
+ array $dataRegisters=[],
): string {
$scratchDir = $this->prepareScratchDir(jobUuid: $jobUuid);
$this->copyTemplate(source: $this->templateRoot, dest: $scratchDir);
$this->resolvePlaceholders(rootDir: $scratchDir, context: $context);
+ $this->bundleDataRegisterSchemas(rootDir: $scratchDir, dataRegisters: $dataRegisters);
// Audit-trail entry names only the source — never the PAT, never secret values.
$this->logger->info(
@@ -135,6 +149,67 @@ public function generateAppZip(
return $this->packageZip(sourceDir: $scratchDir, jobUuid: $jobUuid);
}//end generateAppZip()
+ /**
+ * Bundle every `dataRegisters` binding's schema definitions (always) and
+ * row data (only when that binding's `includeData` is true) into the
+ * exported tree (spec openbuild-exporter, ADDED Requirements "Bound
+ * data registers' schema definitions are bundled into every export" +
+ * "Per-binding includeData toggle controls data-register row-data
+ * inclusion"). Delegates to {@see DataRegisterExportBundler} (a
+ * dedicated collaborator — see design.md Decision 5 and this class's
+ * own constructor docblock for why), then pins every written file's
+ * mtime to the same deterministic `$zipTimestamp` every other file in
+ * the exported tree uses, preserving REQ-OBEX-008 byte-equivalence
+ * across re-exports.
+ *
+ * @param string $rootDir Scratch directory (exported tree root).
+ * @param array $dataRegisters Bindings + per-export includeData choice — see
+ * DataRegisterExportBundler::bundle() for the shape note.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
+ */
+ public function bundleDataRegisterSchemas(string $rootDir, array $dataRegisters): void
+ {
+ $this->dataRegisterBundler->bundle(rootDir: $rootDir, dataRegisters: $dataRegisters);
+ $this->pinDataRegisterFileTimestamps(rootDir: $rootDir);
+ }//end bundleDataRegisterSchemas()
+
+ /**
+ * Pin every file `bundleDataRegisterSchemas()` just wrote to the
+ * deterministic `$zipTimestamp` (REQ-OBEX-008) — mirrors how every
+ * other write in this class (`copyTemplate()`, `resolvePlaceholders()`)
+ * touches its own output. Kept here (not in the bundler) because the
+ * ZIP-determinism contract is this class's concern, not the bundler's.
+ *
+ * @param string $rootDir Scratch directory (exported tree root).
+ *
+ * @return void
+ *
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-4.2
+ */
+ private function pinDataRegisterFileTimestamps(string $rootDir): void
+ {
+ $targetDir = $rootDir.'/lib/Settings/data-registers';
+ if (is_dir($targetDir) === false) {
+ return;
+ }
+
+ $entries = scandir($targetDir);
+ if ($entries === false) {
+ return;
+ }
+
+ foreach ($entries as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+
+ touch($targetDir.'/'.$entry, $this->zipTimestamp);
+ }
+ }//end pinDataRegisterFileTimestamps()
+
/**
* Package a directory tree into a deterministic ZIP archive.
*
diff --git a/lib/Settings/register.d/30-export-job-data-registers.json b/lib/Settings/register.d/30-export-job-data-registers.json
new file mode 100644
index 000000000..1d68c4739
--- /dev/null
+++ b/lib/Settings/register.d/30-export-job-data-registers.json
@@ -0,0 +1,38 @@
+{
+ "_comment": "ADR-037 register fragment — data-registers-runtime (kind: code follower of data-registers-schema-declaration). Adds the optional exportJob.dataRegisters array property, sibling to includeSeedData. SettingsService::deepMergeConfig recurses into components.schemas.exportJob.properties and adds only the new dataRegisters key — exportJob.required and every other exportJob property (incl. includeSeedData) are untouched, and no other schema (incl. Application) is touched. Each entry carries the per-export choice of whether to bundle a bound data register's row data alongside its schema definitions: ExportService::bundleDataRegisterSchemas() always bundles a bound register's schema definitions; it additionally bundles that binding's row data only when includeData is true here. This is export-flow state persisted on the async ExportJob record, not Application configuration — mirrors includeSeedData's role exactly (ExportJobService::queue() already has the `(bool) ($payload['includeSeedData'] ?? false)` pattern this property's read reuses). See design.md Decision 5 and specs/openbuild-exporter/spec.md.",
+ "components": {
+ "schemas": {
+ "exportJob": {
+ "properties": {
+ "dataRegisters": {
+ "title": "Data Registers",
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "title": "Export Data Register Choice",
+ "required": [
+ "register"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "register": {
+ "title": "Register Slug",
+ "type": "string",
+ "description": "Slug of a register named in the source Application's `dataRegisters`, as declared at export-submission time."
+ },
+ "includeData": {
+ "title": "Include Row Data",
+ "type": "boolean",
+ "default": false,
+ "description": "When true, the export additionally bundles this register's current row data as a reference fixture alongside its schema definitions (which are always bundled). Defaults to false (schema definitions only)."
+ }
+ }
+ },
+ "description": "Per-export choice of which of the source Application's `dataRegisters` bindings to bundle, and whether to include each one's row data. Export-flow state persisted on the ExportJob record by ExportJobService::queue() — not Application configuration. Absent on every ExportJob created before this property existed; ExportJobService::queue() defaults it to []."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/openspec/changes/data-registers-runtime/.openspec.yaml b/openspec/changes/data-registers-runtime/.openspec.yaml
new file mode 100644
index 000000000..e089cfacb
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-05
diff --git a/openspec/changes/data-registers-runtime/design.md b/openspec/changes/data-registers-runtime/design.md
new file mode 100644
index 000000000..b12d07620
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/design.md
@@ -0,0 +1,432 @@
+## Context
+
+`data-registers-schema-declaration` (chain head, `kind: config`, merged on this
+branch) added an optional `dataRegisters` array property to the `Application`
+schema — each entry `{ register, label? }` names a shared, non-versioned
+OpenRegister register the app binds to alongside its own per-version register
+(`ApplicationVersion.register`, ADR-002). The head shipped the schema only
+(`lib/Settings/register.d/20-data-registers.json`); zero PHP, zero Vue, zero
+routes. This follower (`kind: code`, `depends_on:
+[data-registers-schema-declaration]`) wires the four consumers the head's own
+proposal named as out of scope for itself:
+
+1. **Pickers** — `src/composables/useRegisterPicker.js`, consumed by
+ `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`, and
+ `ApplicationDetailActions.vue`. Today `fetchRegisters()` lists every OR
+ register instance-wide and hoists the app's own per-version register to
+ the top; it has no notion of the Application's declared `dataRegisters`.
+2. **Promotion-skip regression coverage** — `lib/Service/VersionPromotionService.php`
+ already never touches `Application.dataRegisters` (verified by reading
+ `forwardSchemaSetToOR()`, `wipeTargetRegister()`, `copyRowsFromSource()` —
+ every method reads `$source['register']` / `$target['register']`, the
+ per-version field, exclusively). This spec adds the regression test that
+ locks the invariant in; it does not change the service.
+3. **Export** — `lib/Service/ExportService.php` bundles the per-version
+ register/manifest into the exported tree today (`generateAppZip()` →
+ `copyTemplate()` + `resolvePlaceholders()`); it has no notion of
+ `dataRegisters` at all.
+4. **Designer UI** — there is no UI path to populate `dataRegisters` on an
+ Application. `src/modals/AppSettingsModal.vue` is the existing owner-facing
+ settings surface (publish toggle, allow-user-overrides toggle), opened from
+ `ApplicationDetailActions.vue` and persisted via the `applicationContext`
+ mixin's `obPatchApp()` (a shallow-merge PUT to OR's
+ `/apps/openregister/api/objects/openbuild/application/{uuid}` — ADR-022,
+ no new backend route).
+
+**Codebase verification performed for this design** (all read in full before
+writing this document): `useRegisterPicker.js`; `IndexPageEditor.vue`,
+`DetailPageEditor.vue`, `LogsPageEditor.vue`, `ApplicationDetailActions.vue`;
+`src/views/PageDesigner.vue`; `src/builder.js`, `src/views/BuilderHost.vue`;
+`src/composables/useApplicationVersion.js`; `src/mixins/applicationContext.js`;
+`src/modals/AppSettingsModal.vue`, `src/dialogs/ExportDialog.vue`;
+`lib/Service/VersionPromotionService.php`, `lib/Service/ExportService.php`,
+`lib/Service/ExportJobService.php`, `lib/BackgroundJob/RunExportJob.php`,
+`lib/Controller/ExportsController.php`; the `exportJob` schema block in
+`lib/Settings/openbuild_register.json`; and the existing test files
+`tests/composables/useRegisterPicker.spec.js`,
+`tests/components/page-editor/IndexPageEditor.spec.js`,
+`tests/Unit/Service/VersionPromotionServiceTest.php`,
+`tests/Unit/Service/ExportServiceTest.php`.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Surface an Application's declared `dataRegisters` in the builder's
+ register/schema pickers, labelled per `binding.label ?? binding.register`,
+ via **one** logic change (the composable) rather than duplicating the merge
+ in every consumer.
+- Formally prove — spec Requirement + Scenario + PHPUnit test — that
+ `VersionPromotionService` never reads or writes `Application.dataRegisters`.
+- Let the exporter carry a bound data register's **schema** into the exported
+ app tree by default, and its **row data** only when the admin opts in
+ per binding.
+- Give an owner a way to add/remove `dataRegisters` bindings without hand-
+ editing the Application object via raw OR REST.
+- Keep every change additive and backward compatible: an Application with no
+ `dataRegisters` (every Application that predates the chain head) behaves
+ identically to today at every one of these four surfaces.
+
+**Non-Goals:**
+- No change to the `Application` schema itself — the head already shipped it.
+ This spec's only schema touch is one new property on the already
+ app-owned, already-imperative `exportJob` schema (see Decision 5).
+- No validation that a `dataRegisters[].register` slug resolves to a real,
+ reachable OR register at picker-render or export time — a dangling
+ reference resolves to "not found in the fetched list" (picker) or "no
+ schemas bundled" (export), exactly the existing failure mode for a deleted
+ `ApplicationVersion.register` (head design.md's own Non-Goals precedent).
+- No change to `src/builder.js` or `src/views/BuilderHost.vue` — see
+ Decision 3. Neither file has a register/schema picker or a
+ `dataSources`-loading routine today; there is nothing in either file for
+ this spec to extend.
+- No new RBAC mechanism. Access to a bound register's own objects continues
+ to be governed exclusively by that register's own schemas'
+ `authorization` blocks (head design.md's RBAC section, unchanged here).
+- No auto-import of a bundled data register's schema or row data into the
+ **exported** app's install process (no new ``) — see
+ Decision 5's non-ownership rationale.
+
+## Decisions
+
+### Decision 1: Picker merge lives entirely inside `useRegisterPicker.js`
+
+`useRegisterPicker(opts)` gains one new option, `opts.dataRegisters` (array of
+`{ register, label? }`, default `[]`). `fetchRegisters()` is extended, after
+its existing per-app-register hoist, to:
+
+1. Build a `Map` from `dataRegisters` (`label ??
+ register`).
+2. For every fetched register entry whose `slug`/`id` matches a key in that
+ map, set a `label` field on the entry to the resolved label (the raw
+ `title`/`slug` remains untouched — pickers that don't know about the new
+ field keep rendering exactly as before; consumers that want the friendlier
+ name read `entry.label || entry.title || entry.slug`).
+3. Re-sort so the order is: per-app register first (existing behaviour,
+ unchanged), then entries matching a `dataRegisters` binding (in the order
+ the Application declared them), then everything else in the order OR
+ returned it.
+4. When `opts.dataRegisters` is absent or `[]` (every existing call site,
+ until wired), steps 1-3 are no-ops and `fetchRegisters()` returns
+ byte-identical output to today — this is a regression-safe default, not a
+ breaking change to the composable's contract.
+
+**Wiring is mechanical, not logic-bearing**, at five call sites:
+- `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`: add a
+ `dataRegisters: { type: Array, default: () => [] }` prop; pass
+ `dataRegisters: props.dataRegisters` into the existing
+ `useRegisterPicker({ appSlug: props.appSlug })` call in `setup()`.
+- `PageDesigner.vue` (the parent that already passes `:app-slug="slug"` to
+ whichever sub-editor `subEditorFor(selectedPage.type)` resolves — see
+ Decision 2 for how it obtains the array): add `:data-registers="..."` next
+ to the existing `:app-slug="slug"` binding.
+- `ApplicationDetailActions.vue`: in `openSaveAsTemplate()`, extend
+ `useRegisterPicker({ appSlug: this.obApp.slug })` to also pass
+ `dataRegisters: this.obApp.dataRegisters || []` — `this.obApp` already
+ carries the field once the head's schema is live; no new fetch needed at
+ this call site.
+
+**Alternatives considered:**
+- *Duplicate the label/hoist logic in each of the three page-editor
+ components* — rejected: the task itself flags this as the anti-pattern to
+ avoid (6 edits instead of 1); it would also mean three independent
+ copies of the same sort/label algorithm to keep in sync on the next tweak.
+- *Merge inside `ApplicationDetailActions.vue` only, since it already holds
+ `this.obApp`, and have the three page editors read from a shared store
+ instead of the composable* — rejected: this repo has no Pinia store for
+ Application state on the page-designer route (ADR-004's "no custom stores"
+ rule plus the existing `useRegisterPicker` composable is already the
+ established single source of truth for register/schema option lists per
+ `page-designer-ui`'s own spec — REQ text "Register/schema backed editors
+ SHALL fetch their option lists"). Changing that contract is far larger than
+ this spec's scope.
+
+### Decision 2: `PageDesigner.vue` resolves the Application record with a small, dedicated fetch
+
+`PageDesigner.vue` today resolves `applicationVersion` via
+`useApplicationVersion(this.slug, versionSlug)` — that composable's public
+return shape is `{ applicationVersion, loading, error }`; it never exposes
+the **parent** Application record (it fetches the Application internally, in
+one branch only, purely to read `productionVersion`, and does not return it).
+Rather than widen `useApplicationVersion`'s contract — which is shared by
+"all four builder views" per its own header comment, only one of which
+(`PageDesigner`) needs `dataRegisters` — this spec adds a small, self-contained
+fetch in `PageDesigner.vue`: `GET
+/apps/openregister/api/objects/openbuild/application?slug=&_limit=1`
+(the exact call shape `useApplicationVersion.js` already uses internally),
+storing the result's `dataRegisters` (default `[]`) in a new
+`applicationDataRegisters` data field, invoked once in `created()` alongside
+the existing version resolution.
+
+**Alternatives considered:**
+- *Extend `useApplicationVersion()` to also return `application`* — rejected:
+ its `fetchBySlug()` branch (used whenever `?_version=` is present) never
+ fetches the Application today; adding it there too widens a
+ four-consumer-shared composable's contract for the benefit of exactly one
+ of those four consumers. A future spec that finds a second real need for
+ the Application record at that layer can revisit this trade-off with two
+ data points instead of one.
+- *Have `ApplicationDetailActions.vue` pass `dataRegisters` down via route
+ query or Vuex-style global state* — rejected: `PageDesigner.vue` is reached
+ directly by route (`/builder/{slug}/pages`), not always navigated to from
+ `ApplicationDetailActions.vue`; a route-independent fetch is the only
+ option that works regardless of entry point, and matches the existing
+ pattern (`PageDesigner` already independently resolves slug + version from
+ the route rather than expecting a parent to hand it state).
+
+### Decision 3: `src/builder.js` and `src/views/BuilderHost.vue` are explicitly NOT touched
+
+The task brief that scoped this spec named "the two builder-host dataSources
+loaders (`src/builder.js`, `src/views/BuilderHost.vue` `loadDataSources`)" as
+in-scope alongside the four picker consumers. Both files were read in full
+(current branch **and** `origin/development` HEAD — `git show
+origin/development:src/builder.js` / `:src/views/BuilderHost.vue`) as part of
+this design. Neither contains a `dataSources` prop, a `loadDataSources`
+function, or any register/schema-picker logic:
+
+- `src/builder.js` fetches the resolved manifest once
+ (`GET /api/applications/{slug}/manifest`) and hands it straight to
+ `h(CnAppRoot, { props: { manifest, registry, pageTypes, ... } })`.
+- `src/views/BuilderHost.vue` hands `bundled-manifest` +
+ `registry` + `options` (an `{ endpoint }` object) to a nested ``.
+
+Both hosts render an **already-resolved** manifest — the register + schema
+each page binds to was baked in at design time by the page editors (Decision
+1's surface); neither host re-opens a register picker at runtime. There is
+therefore no code at either call site for this spec to merge `dataRegisters`
+into. Inventing a new `dataSources`/`cnDataSources` cross-repo contract
+(threading a fresh prop through `CnAppRoot` from `@conduction/nextcloud-vue`)
+to give these two hosts a picker they don't otherwise have would be a
+materially larger, separate architectural change spanning another repo —
+exactly the scope creep ADR-032 warns against for a `kind: code` spec sized
+around "wire the schema the head declared." This is logged as a
+`DEFERRED_QUESTIONS` entry below rather than silently dropped.
+
+### Decision 4: Promotion-skip is a regression test, not a code change
+
+`VersionPromotionService::forwardSchemaSetToOR()`, `wipeTargetRegister()`, and
+`copyRowsFromSource()` each resolve their target exclusively via
+`$source['register']` / `$target['register']` — the per-version
+`ApplicationVersion.register` field — and never read `$source['dataRegisters']`
+or touch anything named `dataRegisters` (confirmed by reading the full
+current implementation). The invariant "promotion never copies a
+data-register row" therefore already holds with zero lines changed in this
+service, exactly as the head's design.md predicted. This spec's job is to:
+
+1. Add an ADDED Requirement + Scenario to `openspec/specs/version-promotion/`
+ stating the invariant formally (see `specs/version-promotion/spec.md` in
+ this change).
+2. Add a PHPUnit regression test to the existing
+ `tests/Unit/Service/VersionPromotionServiceTest.php` that constructs a
+ source ApplicationVersion whose parent Application carries a non-empty
+ `dataRegisters`, runs `promote()` with each of the three strategies, and
+ asserts the mocked `ObjectService`/`RegisterMapper` are never invoked with
+ the bound data register's slug — only with `source['register']` /
+ `target['register']`.
+
+No production code in `VersionPromotionService.php` changes. This keeps the
+regression test honest: it is provable to fail if a future change
+accidentally starts reading `Application.dataRegisters` inside the promotion
+flow.
+
+### Decision 5: Export bundles schema defs unconditionally, row data per-binding opt-in
+
+Per the head design.md's Open Questions resolution (Ruben, 2026-07-05):
+"Each `dataRegisters` binding gets an `includeData` choice in the export
+flow (default: schema-defs-only)." Concretely:
+
+- `ExportService::generateAppZip()` gains a step,
+ `bundleDataRegisterSchemas()`, called after `copyTemplate()` /
+ `resolvePlaceholders()`: for every entry in the source Application's
+ `dataRegisters`, resolve the named register (via `RegisterMapper`, already
+ a `VersionPromotionService` dependency — same injection pattern), read its
+ schema set, and write ONE reference file per binding into the exported tree
+ at `lib/Settings/data-registers/.schema.json` — the JSON
+ Schema definitions only, clearly namespaced away from the app's own
+ `_register.json` (it is not merged into the app's own
+ `components.schemas`, because the exported app does not own this register
+ any more than the source virtual app did).
+- **Schema defs are bundled for every binding, unconditionally** — there is
+ no "exclude this register from the export entirely" toggle; the head's
+ resolved decision language ("default: schema-defs-only") establishes
+ schema-defs as the floor, not an opt-in.
+- **Row data is opt-in per binding.** `exportJob` gains a new property,
+ `dataRegisters` (array of `{ register, includeData }`), mirroring the
+ existing `includeSeedData` boolean field's role (export-flow state
+ persisted on the async job record, not Application configuration — see the
+ head's design.md Open Questions: "the toggle is export-flow state, not
+ Application configuration"). `ExportDialog.vue` renders one
+ `NcCheckboxRadioSwitch` per binding the source Application declares
+ (labelled `binding.label ?? binding.register`), unchecked by default; on
+ submit, the payload's `dataRegisters` entries mirror the Application's
+ bindings 1:1, each carrying the resolved `includeData` flag.
+ `ExportJobService::queue()` persists the array onto the `ExportJob` record
+ (same pattern as `includeSeedData` today); `RunExportJob` reads it back via
+ `loadJob()` and forwards it to `generateAppZip()`. When `includeData` is
+ true for a binding, `bundleDataRegisterSchemas()` additionally writes
+ `lib/Settings/data-registers/.seed-data.json` — the
+ register's current rows in the same `{ "_comment", "objects": [...] }`
+ shape the head's own `seed-data.json` fixture uses.
+- **Neither file is wired into a `` or auto-import.** They are
+ reference material for whoever maintains the exported app next — exactly
+ as the running virtual app itself never auto-copies a bound register's
+ rows into its own namespace. Auto-importing here would silently re-create
+ the exact anti-pattern (a copy of canonical shared data forking on every
+ export) that motivated the head spec's non-ownership model in the first
+ place.
+
+**Alternatives considered:**
+- *Fold the bound register's schema straight into `_register.json`* —
+ rejected: that file already represents "schemas this app owns and the
+ exported app's own repair step imports on install." Merging a shared,
+ externally-fed register's schema into it would make the exported app
+ falsely appear to own/provision that register, silently reversing the
+ head's core non-ownership decision (Decision 1's "Why not reuse
+ `ApplicationVersion.register`'s ... pattern" argument applies identically
+ here).
+- *One export-wide "include all data-register data" toggle instead of
+ per-binding* — rejected: explicitly overridden by Ruben's 2026-07-05
+ decision recorded in the head's design.md; a municipality app binding both
+ `brp-personen` (sensitive, must stay schema-only) and a smaller reference
+ register illustrates why per-binding granularity matters.
+
+### Decision 6: Designer UI extends `AppSettingsModal.vue`, not a new modal
+
+`AppSettingsModal.vue` is already the owner-facing settings surface for
+Application-level toggles (`published`, `allowUserOverrides`), already
+modal-isolated per ADR-004, already wired through
+`ApplicationDetailActions.vue`'s `obPatchApp()` PUT. Adding a "Data
+registers" section (list of `{ register, label? }` rows with add/remove, no
+existence validation — matching the head's own save-time Non-Goal) is a
+natural extension of an existing, single-purpose settings surface rather than
+a new file. `ApplicationDetailActions.vue` binds the modal's
+`update:data-registers` event to `this.obPatchApp({ dataRegisters })` —
+identical shape to the existing `update:allow-overrides` →
+`setAllowOverrides()` wiring.
+
+**Alternatives considered:**
+- *A dedicated `DataRegistersModal.vue`* — rejected: `AppSettingsModal.vue`
+ is already exactly this kind of surface (simple property toggles/edits on
+ the Application object, one PUT on save-per-field); a second modal for one
+ more property section fragments the owner's settings experience across two
+ places for no isolation benefit (ADR-004's modal-isolation rule targets
+ inline markup inside a parent, not "one modal per property" granularity).
+
+## Declarative-vs-imperative decision (ADR-031)
+
+- **Pickers** (`useRegisterPicker.js` + its five call sites) render/populate
+ UI option lists — the same class the head's design.md already carves out
+ as never a declarative candidate ("There is no `x-openregister-*` extension
+ for 'render a dropdown'").
+- **Export bundling** (`ExportService::bundleDataRegisterSchemas()`) matches
+ ADR-031's "What apps SHOULD still write in PHP" bullet the head's design.md
+ already cited for `ExportService`: "Document/PDF/document-template
+ generation ... The schema engine has no opinion on rendered output." A
+ reference JSON file inside a ZIP is rendered output, identically classified
+ to the file this class already produces.
+- **Promotion-skip** needs no new code (Decision 4) — `VersionPromotionService`
+ is already an ADR-031 §Exceptions file per its own docblock ("every branch
+ in this file is classified imperative"). This spec adds a test, not a
+ behaviour.
+- **Designer UI** (`AppSettingsModal.vue` section + `ApplicationDetailActions.vue`
+ wiring) is UI + a plain OR REST PUT via the pre-existing `obPatchApp()`
+ helper — no new service class, no new route, no business logic beyond
+ "PUT this array back." This mirrors how `allowUserOverrides` (already on
+ the same modal) is wired.
+- **The `exportJob.dataRegisters` schema property itself is declarative** —
+ a schema-only patch to the already app-owned, already-imperative-in-purpose
+ `exportJob` schema, exactly like `includeSeedData` before it. No service
+ class is introduced by the property; `ExportJobService::queue()` already
+ has the exact `(bool) ($payload['includeSeedData'] ?? false)` pattern this
+ spec's `dataRegisters` field reuses.
+
+No exception justification is needed beyond what the head's design.md already
+established — this spec's imperative surfaces (pickers, export packaging) are
+the same two classes the head pre-cleared for its follower.
+
+## Seed Data
+
+**No new OpenRegister schema is introduced or modified on `Application`** —
+the head already shipped `dataRegisters`, and this spec adds no property to
+it. The head's own `openspec/changes/data-registers-schema-declaration/seed-data.json`
+(the `spectr` Application and the generic-municipality Application, both
+carrying populated `dataRegisters` arrays) already provides realistic
+fixtures for this spec's tests and manual QA — no new fixture is authored
+here; this spec's PHPUnit/vitest tests construct their own minimal in-memory
+`dataRegisters` arrays inline (standard practice for the existing
+`VersionPromotionServiceTest.php` / `ExportServiceTest.php` / composable
+specs, none of which read from a shared JSON fixture file today).
+
+The one schema this spec DOES touch — `exportJob` gains `dataRegisters`
+(array of `{ register, includeData }`) — is transient, per-request job
+state written by the export flow itself (`ExportJobService::queue()`), not
+admin-authored reference data an operator would hand-seed. The pre-existing
+sibling property `includeSeedData` on the same schema has no seed-data
+fixture anywhere in this repo for the same reason; this spec follows that
+precedent rather than stubbing an artificial "example ExportJob" fixture
+that no real workflow would create by hand.
+
+## Risks / Trade-offs
+
+- **[Risk]** A picker or export surface could be tempted to treat a
+ `dataRegisters` entry whose slug doesn't resolve in OR as an error rather
+ than a silent no-op. → **Mitigation**: explicitly out of scope (Non-Goals);
+ matches the existing, already-accepted failure mode for a deleted
+ `ApplicationVersion.register`.
+- **[Risk]** Bundling a data register's row data into an export ZIP
+ (`includeData: true`) could leak sensitive shared data (e.g. a
+ municipality's `brp-personen`) into a downloadable/GitHub-pushed artifact
+ if an owner opts in without understanding the register is shared, not
+ app-owned. → **Mitigation**: default is off; the per-binding label (from
+ the head's schema) is shown next to the toggle so the owner sees exactly
+ which shared register they are about to include row data for, not a bare
+ slug; RBAC on the referenced register's own schemas still gates who can
+ read that data in the first place (ADR-022) — this spec does not widen
+ access, only what an already-authorised exporter may bundle.
+- **[Risk]** Widening `useRegisterPicker.js`'s `fetchRegisters()` return
+ shape (adding a `label` field) could collide with a consumer that already
+ uses a property named `label` on register entries for something else. →
+ **Mitigation**: `fetchRegisters()`'s current return shape is OR's raw
+ register list (`{ id, slug, title, schemas, ... }` — no existing `label`
+ key per the current implementation and its own test fixtures); grepped
+ every current consumer's template/render code for `.label` reads on a
+ register entry — none exist today.
+- **[Trade-off]** The exported app's `lib/Settings/data-registers/*.json`
+ reference files are not consumed by any code in the exported app itself
+ (no repair step, no runtime read) — they exist purely as documentation for
+ a human maintaining the exported app. → Accepted: auto-consuming them would
+ require the exported app to either provision its own copy of a shared
+ register (reintroducing the exact per-app-copy problem `dataRegisters` was
+ designed to avoid) or take a runtime dependency back on the source
+ register's continued existence outside OpenBuild's own lifecycle — both
+ are bigger decisions than this spec's scope; the head's own design.md
+ leaves the same door open ("Open Questions" notes no cap or deeper
+ integration is addressed yet).
+
+## Migration Plan
+
+None required for `Application` — no schema change there. For `exportJob`,
+the new `dataRegisters` property is optional and additive (mirrors
+`includeSeedData`'s original rollout); every existing `ExportJob` object
+without it remains schema-valid, and `ExportJobService::queue()` defaults it
+to `[]` when the request payload omits it (identical fallback pattern to the
+existing `includeSeedData` read). No backfill of historical `ExportJob`
+records is needed — completed/failed jobs are not re-processed.
+
+Rollback is equally trivial: reverting the `exportJob` register.d fragment
+and the four code surfaces independently is safe in any order, since each is
+additive and none introduces a required field or a breaking change to an
+existing contract.
+
+## Open Questions
+
+- Should the exported app's `lib/Settings/data-registers/*.json` reference
+ files eventually be consumable by a future "re-attach to a shared
+ register" repair step for exported apps that want to keep receiving live
+ data post-export? Not addressed here — no consumer has asked for it yet;
+ flagged for a future spec if `spectr`'s own export path surfaces the need.
+- Should `dataRegisters[].includeData` also appear as a picker-visible hint
+ inside the builder itself (e.g. "this register's row data will be
+ exported") before the owner ever opens the export dialog? Not addressed
+ here — the export dialog is the only surface that currently needs to know
+ about `includeData`; deferred until real usage shows the builder-side hint
+ is needed.
diff --git a/openspec/changes/data-registers-runtime/proposal.md b/openspec/changes/data-registers-runtime/proposal.md
new file mode 100644
index 000000000..f26b4eb4d
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/proposal.md
@@ -0,0 +1,132 @@
+---
+kind: code
+depends_on: [data-registers-schema-declaration]
+---
+
+## Why
+
+`data-registers-schema-declaration` (chain head, merged on this branch) added the
+optional `dataRegisters` array property to the `Application` schema — the
+declarative surface that lets an admin name shared, non-versioned OpenRegister
+registers (e.g. `spectr`'s ~30-schema canonical dataset) the app binds to
+alongside its own per-version register. That spec shipped **zero** PHP/Vue code
+by design (per ADR-032, a `kind: config` head only declares schema). Today,
+declaring a `dataRegisters` binding on an `Application` object has no visible
+effect anywhere in OpenBuild: the builder pickers don't know it exists, the
+exporter doesn't bundle it, and there is no UI to add or remove a binding in
+the first place. `spectr`'s data-register work — the concrete consumer named in
+the head's proposal — stays blocked until this follower lands.
+
+This spec is that follower (`kind: code`, `depends_on:
+[data-registers-schema-declaration]`). It wires the four places in OpenBuild
+that need to become `dataRegisters`-aware:
+
+1. The builder's register/schema pickers surface the Application's declared
+ data registers, labelled, alongside its per-version register.
+2. A regression test formally locks in that version promotion never reads or
+ writes `Application.dataRegisters` (already true by construction per the
+ head's design.md — this spec adds the proof, not the guarantee).
+3. The exporter bundles bound data registers' schema definitions into the
+ exported app tree, with a per-binding opt-in to also bundle their row data.
+4. The Application settings surface gains a field to add/remove
+ `dataRegisters` bindings — today there is no UI path to populate the
+ property the head spec declared.
+
+## What Changes
+
+- **Pickers**: `useRegisterPicker.js` accepts the Application's declared
+ `dataRegisters` and labels/hoists matching entries in `fetchRegisters()`'s
+ result (per `binding.label ?? binding.register`). The four verified
+ consumers — `IndexPageEditor.vue`, `DetailPageEditor.vue`,
+ `LogsPageEditor.vue`, `ApplicationDetailActions.vue` — and their common
+ parent `PageDesigner.vue` (which resolves the Application record and passes
+ `appSlug` down today) thread the binding array through. This is a single
+ logic change in the composable; the four call sites + one parent only add
+ mechanical prop-passing (see design.md's Decision 1 for why this is not six
+ separate merges).
+- **Promotion-skip regression coverage**: `openspec/specs/version-promotion/`
+ gains an ADDED Requirement + Scenario asserting `VersionPromotionService`
+ never touches `Application.dataRegisters`, backed by a new PHPUnit
+ regression test in `VersionPromotionServiceTest.php`. No production code
+ changes — `forwardSchemaSetToOR()`, `wipeTargetRegister()`, and
+ `copyRowsFromSource()` already operate exclusively on
+ `ApplicationVersion.register` (verified by reading the current
+ implementation); this is a proof, not a fix.
+- **Export**: the exporter always bundles the **schema definitions** of every
+ register named in the source Application's `dataRegisters` into the
+ exported tree (reference-only — not auto-imported by the exported app's own
+ install process, preserving the "not owned by this app" contract). A new
+ **per-binding `includeData` toggle** in `ExportDialog.vue` (default off,
+ i.e. schema-defs-only) additionally bundles that binding's row data as a
+ reference fixture when explicitly opted in. The `exportJob` schema gains a
+ `dataRegisters` property (mirroring the existing `includeSeedData` field)
+ so the async `RunExportJob` background job can read the per-export choice.
+- **Designer UI**: `AppSettingsModal.vue` gains a "Data registers" section —
+ add/remove rows of `{ register slug, optional label }` — wired through
+ `ApplicationDetailActions.vue`'s existing `obPatchApp()` helper (a plain OR
+ REST PUT; no new backend route).
+- **NOT in scope**: `src/builder.js` and `src/views/BuilderHost.vue`. Both
+ were read in full — neither contains a register/schema picker or a
+ `dataSources`-loading routine to extend today (each simply hands an
+ already-resolved `manifest` / `bundled-manifest` to `CnAppRoot`). There is
+ no code at either call site for this spec to merge `dataRegisters` into;
+ see design.md's Decision 5 and this change's `DEFERRED_QUESTIONS`.
+- **NO schema change to `Application`** — the head already declared
+ `dataRegisters`; this spec only adds code that reads it (plus one small,
+ already-imperative `exportJob` schema property, exactly analogous to the
+ pre-existing `includeSeedData` field on that same schema).
+
+### Capabilities
+
+#### New Capabilities
+
+_(none — this spec extends three existing capabilities' code surfaces; it
+introduces no new capability domain)_
+
+#### Modified Capabilities
+
+- `version-promotion`: ADDED Requirement — `VersionPromotionService` never
+ reads or writes `Application.dataRegisters`; a regression test locks this
+ invariant in. No existing requirement's behavior changes.
+- `openbuild-exporter`: ADDED Requirement — the exporter bundles bound data
+ registers' schema definitions (always) and row data (opt-in per binding via
+ `includeData`) into the exported app tree.
+- `page-designer-ui`: ADDED Requirement — register/schema-backed sub-editors
+ surface the Application's declared `dataRegisters`, labelled, alongside the
+ per-version register.
+
+## Impact
+
+- **Changed files**:
+ - `src/composables/useRegisterPicker.js` — accept + apply `dataRegisters`.
+ - `src/components/page-editor/IndexPageEditor.vue`,
+ `DetailPageEditor.vue`, `LogsPageEditor.vue` — new `dataRegisters` prop,
+ threaded into `useRegisterPicker(...)`.
+ - `src/views/PageDesigner.vue` — resolve the Application's `dataRegisters`
+ and pass them to the active sub-editor.
+ - `src/components/ApplicationDetailActions.vue` — pass
+ `obApp.dataRegisters` into the `openSaveAsTemplate()` picker call and into
+ `ExportDialog`; wire the new settings-modal section to `obPatchApp()`.
+ - `src/modals/AppSettingsModal.vue` — add/remove `dataRegisters` bindings.
+ - `src/dialogs/ExportDialog.vue` — per-binding `includeData` toggle.
+ - `lib/Service/ExportService.php` — bundle bound data registers' schema
+ defs (+ optional row data) into the exported tree.
+ - `lib/Service/ExportJobService.php`, `lib/Controller/ExportsController.php`
+ — accept/persist the per-export `dataRegisters` choice.
+ - `lib/Settings/register.d/` — a new fragment adding `exportJob.dataRegisters`
+ (mirrors `includeSeedData`; does **not** touch `Application`).
+ - `lib/Service/VersionPromotionService.php` — read only (regression test
+ proves the existing code; no production-code edit expected).
+- **No breaking changes** — every new prop/property is optional and additive;
+ every existing Application/ExportJob object with no `dataRegisters` field
+ behaves exactly as it does today.
+- **OpenRegister** — no new register, no schema change to `Application`
+ (already shipped by the head); one additive property on the already
+ app-owned `exportJob` schema.
+- **Foundational ADRs honoured** — ADR-022 (designer UI change is a plain OR
+ REST PUT via the existing `obPatchApp()` helper — no new CRUD wrapper),
+ ADR-031 (declarative-vs-imperative classification in design.md — pickers +
+ export packaging are the sanctioned imperative surfaces the head's design.md
+ already carved out), ADR-032 (this is the `kind: code` follower closing the
+ 2-spec chain the head opened), ADR-037 (the `exportJob` fragment ships as
+ its own `register.d/` file, not a monolith edit).
diff --git a/openspec/changes/data-registers-runtime/specs/openbuild-exporter/spec.md b/openspec/changes/data-registers-runtime/specs/openbuild-exporter/spec.md
new file mode 100644
index 000000000..453404125
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/specs/openbuild-exporter/spec.md
@@ -0,0 +1,70 @@
+## ADDED Requirements
+
+### Requirement: Bound data registers' schema definitions are bundled into every export
+
+`ExportService::generateAppZip()` SHALL, for every `dataRegisters` binding the source Application declares, resolve the named register and write its schema definitions into the exported tree at `lib/Settings/data-registers/.schema.json` — one file per
+binding, containing JSON Schema definitions only. This file SHALL NOT be
+merged into the exported app's own `_register.json` and SHALL NOT be
+referenced by any `` in the exported `appinfo/info.xml` — it is
+reference documentation of a register the exported app does not own,
+consuming the same non-ownership contract `Application.dataRegisters`
+already establishes for the running virtual app. An Application with no
+`dataRegisters` SHALL produce an export tree with no
+`lib/Settings/data-registers/` directory at all — this requirement is fully
+additive and has no effect on an export that predates it.
+
+#### Scenario: Export bundles schema defs for every declared binding
+
+- **GIVEN** a published Application whose `dataRegisters` is
+ `[{ "register": "spectr", "label": "Spectr market intelligence data" }]`
+- **WHEN** the Application is exported (either target: `zip` or `github`)
+- **THEN** the exported tree contains
+ `lib/Settings/data-registers/spectr.schema.json` holding the `spectr`
+ register's current schema definitions
+- **AND** `lib/Settings/spectr_register.json` (an app-owned-looking filename)
+ is NOT created — the file lives only under the dedicated
+ `data-registers/` subdirectory
+
+#### Scenario: Export with no bindings produces no data-registers directory
+
+- **GIVEN** a published Application whose `dataRegisters` is absent or `[]`
+- **WHEN** the Application is exported
+- **THEN** the exported tree contains no `lib/Settings/data-registers/`
+ directory
+
+### Requirement: Per-binding includeData toggle controls data-register row-data inclusion
+
+The `exportJob` schema SHALL gain an optional `dataRegisters` array property
+(items shaped `{ register: string, includeData: boolean }`, default `[]`),
+populated by `ExportJobService::queue()` from the submit request body —
+mirroring the existing `includeSeedData` field's role as export-flow state
+persisted on the async job record, not Application configuration. For each
+binding whose `includeData` is `true`, `ExportService::generateAppZip()`
+SHALL additionally write
+`lib/Settings/data-registers/.seed-data.json` containing that
+register's current row data as a reference fixture, alongside (never instead
+of) that binding's schema-definitions file. A binding omitted from the
+export request's `dataRegisters`, or present with `includeData: false`,
+SHALL produce its schema-definitions file only — no row data SHALL be
+written for it under any circumstance where `includeData` is not explicitly
+`true`.
+
+#### Scenario: includeData true bundles row data alongside the schema
+
+- **GIVEN** a published Application bound to `spectr`, exported with request
+ body `dataRegisters: [{ "register": "spectr", "includeData": true }]`
+- **WHEN** the export completes
+- **THEN** the exported tree contains both
+ `lib/Settings/data-registers/spectr.schema.json` and
+ `lib/Settings/data-registers/spectr.seed-data.json`
+- **AND** neither file is referenced by any `` in the exported
+ `appinfo/info.xml`
+
+#### Scenario: includeData omitted defaults to schema-defs-only
+
+- **GIVEN** the same Application, exported with a request body that omits
+ `dataRegisters` entirely
+- **WHEN** the export completes
+- **THEN** the exported tree contains
+ `lib/Settings/data-registers/spectr.schema.json`
+- **AND** it does NOT contain `lib/Settings/data-registers/spectr.seed-data.json`
diff --git a/openspec/changes/data-registers-runtime/specs/page-designer-ui/spec.md b/openspec/changes/data-registers-runtime/specs/page-designer-ui/spec.md
new file mode 100644
index 000000000..a1e827f0c
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/specs/page-designer-ui/spec.md
@@ -0,0 +1,48 @@
+## ADDED Requirements
+
+### Requirement: Register/schema pickers surface the Application's declared dataRegisters
+
+`useRegisterPicker(opts)` SHALL accept an optional `opts.dataRegisters` array
+(shape `{ register, label? }`, default `[]`, matching the `Application`
+schema's `dataRegisters` property). `fetchRegisters()` SHALL label every
+returned register entry whose slug matches a `dataRegisters` binding with
+`binding.label ?? binding.register`, and SHALL order the result as: the
+per-app register first (existing behaviour, unchanged), then entries matching
+a `dataRegisters` binding in declaration order, then the remaining registers
+unchanged. `IndexPageEditor`, `DetailPageEditor`, and `LogsPageEditor` SHALL
+accept a `dataRegisters` prop and forward it into their `useRegisterPicker`
+call; `PageDesigner` SHALL resolve the active Application's `dataRegisters`
+and pass them to the mounted sub-editor. When `dataRegisters` is absent or
+empty, `fetchRegisters()` SHALL return output identical to its pre-existing
+behaviour — this requirement is additive and introduces no regression for an
+Application with no declared bindings.
+
+@e2e exclude component-contract spec — dataRegisters labelling/hoisting
+inside `fetchRegisters()` and the prop pass-through at each sub-editor are
+composable- and component-contract behaviour verified by Vitest unit tests
+(`useRegisterPicker.spec.js`, `IndexPageEditor.spec.js`); overall picker
+mounting and rendering inside the designer route is covered by the existing
+openbuild-page-designer Playwright tests
+
+#### Scenario: A bound data register is labelled in the picker
+
+- **GIVEN** an Application with
+ `dataRegisters: [{ "register": "spectr", "label": "Spectr market intelligence data" }]`
+- **WHEN** `IndexPageEditor` mounts and calls `fetchRegisters()`
+- **THEN** the `spectr` register entry in the returned list carries
+ `label: "Spectr market intelligence data"`
+
+#### Scenario: A bound data register without a label falls back to its slug
+
+- **GIVEN** an Application with `dataRegisters: [{ "register": "spectr" }]`
+ (no `label`)
+- **WHEN** a register/schema-backed sub-editor calls `fetchRegisters()`
+- **THEN** the `spectr` register entry's resolved label is `"spectr"`
+
+#### Scenario: An Application with no dataRegisters is unaffected
+
+- **GIVEN** an Application whose `dataRegisters` is absent
+- **WHEN** any of `IndexPageEditor`, `DetailPageEditor`, or `LogsPageEditor`
+ mounts and calls `fetchRegisters()`
+- **THEN** the returned list is unchanged from the pre-existing behaviour —
+ only the per-app register is hoisted, no entry carries a new `label` field
diff --git a/openspec/changes/data-registers-runtime/specs/version-promotion/spec.md b/openspec/changes/data-registers-runtime/specs/version-promotion/spec.md
new file mode 100644
index 000000000..b16a7d553
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/specs/version-promotion/spec.md
@@ -0,0 +1,54 @@
+## ADDED Requirements
+
+### Requirement: Promotion never reads or writes Application.dataRegisters
+
+`VersionPromotionService::promote()` and every private method it calls (`forwardSchemaSetToOR()`, `wipeTargetRegister()`, `copyRowsFromSource()`, `applyManifestAndSemver()`, `handlePromotionFailure()`) SHALL resolve their
+source and target register exclusively via `ApplicationVersion.register` (the
+per-version, app-owned register). None of these methods SHALL read, write, or
+otherwise reference the parent Application's `dataRegisters` property, under
+any of the three strategies (`start-with-source-data`,
+`migrate-existing-data`, `empty-start`). Promoting a version SHALL therefore
+neither copy, migrate, wipe, nor otherwise modify any row or schema in a
+register named in `Application.dataRegisters` — a shared data register bound
+to the app is invisible to the promotion flow in both directions.
+
+**ID:** REQ-OBVP-012
+
+#### Scenario: start-with-source-data leaves a bound data register untouched
+
+- **GIVEN** an Application whose `dataRegisters` includes
+ `{ "register": "spectr" }`, and a source ApplicationVersion whose
+ `promotesTo` target has 3 pre-existing rows in its own per-version register
+- **WHEN** an owner promotes with `strategy: "start-with-source-data"`
+- **THEN** the target's per-version register is wiped and repopulated from
+ the source's per-version register, exactly as REQ-OBVP-002 already
+ specifies
+- **AND** no read, write, lock, or delete operation is issued against the
+ `spectr` register at any point during the promotion
+
+#### Scenario: migrate-existing-data leaves a bound data register untouched
+
+- **GIVEN** the same Application as above, promoting with
+ `strategy: "migrate-existing-data"`
+- **WHEN** the promotion completes
+- **THEN** the target's per-version register schema set is aligned with the
+ source's, exactly as REQ-OBVP-003 already specifies
+- **AND** no operation of any kind touches the `spectr` register
+
+#### Scenario: empty-start leaves a bound data register untouched
+
+- **GIVEN** the same Application as above, promoting with
+ `strategy: "empty-start"`
+- **WHEN** the promotion completes
+- **THEN** the target's per-version register is wiped and left schema-only,
+ exactly as REQ-OBVP-004 already specifies
+- **AND** no operation of any kind touches the `spectr` register
+
+#### Scenario: A promotion failure does not archive or otherwise modify a bound data register
+
+- **GIVEN** an Application with a `dataRegisters` binding, whose promotion
+ fails mid-strategy (per REQ-OBVP-009)
+- **WHEN** `handlePromotionFailure()` flips the target ApplicationVersion's
+ `status` to `archived`
+- **THEN** only the target ApplicationVersion row is modified
+- **AND** the bound data register (and every object inside it) is unmodified
diff --git a/openspec/changes/data-registers-runtime/tasks.md b/openspec/changes/data-registers-runtime/tasks.md
new file mode 100644
index 000000000..e4a565a4f
--- /dev/null
+++ b/openspec/changes/data-registers-runtime/tasks.md
@@ -0,0 +1,52 @@
+## 1. Picker merge (single composable change)
+
+- [x] 1.1 `src/composables/useRegisterPicker.js`: accept `opts.dataRegisters` (array of `{register, label?}`, default `[]`); in `fetchRegisters()`, label matching entries (`binding.label ?? binding.register`) and hoist them after the per-app register, per design.md Decision 1 — when `dataRegisters` is absent/empty, output must stay byte-identical to today
+- [x] 1.2 Extend `tests/composables/useRegisterPicker.spec.js`: labelled match, slug-fallback when `label` absent, hoist ordering (per-app register, then matched bindings, then the rest), and a no-`dataRegisters`-passed regression case
+
+## 2. Wire the four verified consumers + PageDesigner
+
+- [x] 2.1 `IndexPageEditor.vue`, `DetailPageEditor.vue`, `LogsPageEditor.vue`: add a `dataRegisters: { type: Array, default: () => [] }` prop and pass it into the existing `useRegisterPicker({ appSlug: props.appSlug })` call in `setup()`
+- [x] 2.2 `src/views/PageDesigner.vue`: add an `applicationDataRegisters` data field populated by a small fetch (`GET /apps/openregister/api/objects/openbuild/application?slug=&_limit=1`, same call shape `useApplicationVersion.js` already uses) in `created()`; pass `:data-registers="applicationDataRegisters"` on the `` binding, next to the existing `:app-slug="slug"`
+- [x] 2.3 `ApplicationDetailActions.vue`: in `openSaveAsTemplate()`, extend `useRegisterPicker({ appSlug: this.obApp.slug })` to also pass `dataRegisters: this.obApp.dataRegisters || []`
+- [x] 2.4 Extend `tests/components/page-editor/IndexPageEditor.spec.js`: mounting with a `data-registers` prop passes it through to the mocked `useRegisterPicker` factory call
+
+## 3. Promotion-skip regression coverage
+
+- [x] 3.1 Confirm (already verified in design.md) `VersionPromotionService.php` needs no production-code change — no task, proof only
+- [x] 3.2 Add a PHPUnit regression test to `tests/Unit/Service/VersionPromotionServiceTest.php`: an Application/source carrying a `dataRegisters` binding promotes under all three strategies without any mock call referencing the bound register's slug — only `source['register']` / `target['register']` are touched
+
+## 4. Export: schema-defs + per-binding includeData toggle
+
+- [x] 4.1 Add a `register.d/` fragment declaring `exportJob.dataRegisters` (array of `{register, includeData}`, default `[]`) — mirrors `includeSeedData`; no touch to `Application`
+- [x] 4.2 `ExportService.php`: add `bundleDataRegisterSchemas()`, called from `generateAppZip()`, writing `lib/Settings/data-registers/.schema.json` for every bound register (schema defs only, never merged into `_register.json`) and `.seed-data.json` additionally when that binding's `includeData` is true
+- [x] 4.3 `ExportJobService::queue()` / `ExportsController::submit()`: accept and persist the request's `dataRegisters` array onto the `ExportJob` record (same pattern as `includeSeedData`); `RunExportJob` forwards it from `loadJob()` into `generateAppZip()`
+- [x] 4.4 `ExportDialog.vue`: render one `NcCheckboxRadioSwitch` per binding in the source Application's `dataRegisters` (label `binding.label ?? binding.register`), unchecked by default; submit payload mirrors the bindings 1:1 with the resolved `includeData` flags
+- [x] 4.5 `ApplicationDetailActions.vue`: pass `:data-registers="obApp.dataRegisters || []"` into ``
+- [x] 4.6 Add PHPUnit tests to `tests/Unit/Service/ExportServiceTest.php`: schema-defs file is always written for a bound register; seed-data file is written only when `includeData` is true; no `data-registers/` directory when `dataRegisters` is empty
+
+## 5. Designer UI: add/remove dataRegisters bindings
+
+- [x] 5.1 `AppSettingsModal.vue`: add a "Data registers" section — list of `{register, label?}` rows with add/remove controls (register slug `NcTextField`, optional label `NcTextField`), emitting `update:data-registers` with the full array on any change
+- [x] 5.2 `ApplicationDetailActions.vue`: wire `AppSettingsModal`'s `update:data-registers` to `this.obPatchApp({ dataRegisters })`, matching the existing `update:allow-overrides` → `setAllowOverrides()` pattern
+
+## 6. Spec-delta bookkeeping
+
+- [x] 6.1 Append this change to the `**OpenSpec changes**` list and set `**Status**: in-progress` on `openspec/specs/version-promotion/spec.md` (update its `status:` frontmatter key), `openspec/specs/openbuild-exporter/spec.md`, and `openspec/specs/page-designer-ui/spec.md` — verified already pre-seeded on all three (change-list entry + in-progress status present) when this task began; no edit needed
+
+## Quality reminders (run before requesting review — not tracked as tasks)
+
+- Run `openspec validate data-registers-runtime --strict` and resolve any structural errors.
+- Run `npm run test` (vitest) for the composable + component test changes.
+- Run the PHP test suite (`phpunit` / `composer test`, per this repo's existing scripts) for `VersionPromotionServiceTest.php` and `ExportServiceTest.php`.
+- Confirm every existing Application/ExportJob object with no `dataRegisters` field still round-trips unchanged through every touched surface (pickers, export, settings modal).
+- Confirm the exported tree for an Application with `dataRegisters` bindings contains no reference to those registers in `appinfo/info.xml`'s ``.
+
+## Acceptance Criteria
+
+- An Application's declared `dataRegisters` are labelled and hoisted (after the per-app register) in every register picker fed by `useRegisterPicker.js`, with zero behaviour change for an Application carrying no bindings.
+- A PHPUnit regression test proves `VersionPromotionService` never references a bound data register's slug under any of the three promotion strategies.
+- Exporting an Application with `dataRegisters` bundles each binding's schema definitions unconditionally, and its row data only when that binding's `includeData` was explicitly toggled on in the export dialog.
+- Neither bundled data-register file is wired into the exported app's own `` — they are reference-only.
+- An owner can add and remove `dataRegisters` bindings from the Application settings modal, persisted via the existing `obPatchApp()` OR REST PUT — no new backend route.
+- `src/builder.js` and `src/views/BuilderHost.vue` are unmodified by this change (see design.md Decision 3 and `DEFERRED_QUESTIONS`).
+- No change to the `Application` schema; the only schema touch is the additive `exportJob.dataRegisters` property.
diff --git a/openspec/specs/openbuild-exporter/spec.md b/openspec/specs/openbuild-exporter/spec.md
index 0adb225a3..46412b07e 100644
--- a/openspec/specs/openbuild-exporter/spec.md
+++ b/openspec/specs/openbuild-exporter/spec.md
@@ -15,6 +15,10 @@ schema bundle, no per-slug endpoint workaround, no nested mount — the exported
**is** the top-level app. Closes the loop on the hybrid model committed to in
`bootstrap-openbuild`.
+**OpenSpec changes**: [data-registers-runtime](../../changes/data-registers-runtime/)
+
+**Status**: in-progress
+
## Requirements
diff --git a/openspec/specs/page-designer-ui/spec.md b/openspec/specs/page-designer-ui/spec.md
index 278e5e656..6d07b883f 100644
--- a/openspec/specs/page-designer-ui/spec.md
+++ b/openspec/specs/page-designer-ui/spec.md
@@ -22,6 +22,10 @@ This capability is observed behaviour of the `PageDesigner`,
`page-editor/fields/*` builders. It is the frontend half of the
`openbuild-page-designer` backend capability.
+**OpenSpec changes**: [data-registers-runtime](../../changes/data-registers-runtime/)
+
+**Status**: in-progress
+
## Requirements
### Requirement: Controlled designer orchestrates pages, menu, undo/redo and save
diff --git a/openspec/specs/version-promotion/spec.md b/openspec/specs/version-promotion/spec.md
index 097497341..1211aec2d 100644
--- a/openspec/specs/version-promotion/spec.md
+++ b/openspec/specs/version-promotion/spec.md
@@ -1,5 +1,5 @@
---
-status: done
+status: in-progress
---
# version-promotion Specification
@@ -23,6 +23,8 @@ recovery. Default strategy is a pure function of chain position
(production-target → migrate; mid-chain → start-with-source-data; never
empty-start), implemented identically in PHP and JS.
+**OpenSpec changes**: [data-registers-runtime](../../changes/data-registers-runtime/)
+
## Requirements
### Requirement: Promotion endpoint accepts a strategy and targets `sourceVersion.promotesTo`
diff --git a/src/components/ApplicationDetailActions.vue b/src/components/ApplicationDetailActions.vue
index 8f0e4c733..2ad6e8211 100644
--- a/src/components/ApplicationDetailActions.vue
+++ b/src/components/ApplicationDetailActions.vue
@@ -113,16 +113,19 @@
+ @update:allow-overrides="setAllowOverrides"
+ @update:data-registers="setDataRegisters" />
} dataRegisters The full updated bindings array.
+ * @return {Promise}
+ */
+ async setDataRegisters(dataRegisters) {
+ if (this.obAppRole !== 'owner' || !this.obApp) {
+ return
+ }
+ this.error = ''
+ try {
+ await this.obPatchApp({ dataRegisters })
+ } catch (e) {
+ this.error = `${t('openbuild', 'Failed to save settings')}: ${e.message || e}`
+ }
+ },
/**
* Delete the app (Application + versions + per-version registers), then
* navigate back to the apps list. Owner-only (enforced server-side too).
@@ -505,7 +527,7 @@ export default {
this.saveTemplateManifest = this.obApp.manifest
|| (this.obApp.currentVersion && this.obApp.currentVersion.manifest)
|| {}
- const picker = useRegisterPicker({ appSlug: this.obApp.slug })
+ const picker = useRegisterPicker({ appSlug: this.obApp.slug, dataRegisters: this.obApp.dataRegisters || [] })
this.saveTemplateSchemas = await picker.fetchSchemas(picker.resolveAppRegister())
this.existingTemplates = await this.loadExistingTemplates()
this.saveTemplateOpen = true
diff --git a/src/components/page-editor/DetailPageEditor.vue b/src/components/page-editor/DetailPageEditor.vue
index 144c602b6..399b5fe29 100644
--- a/src/components/page-editor/DetailPageEditor.vue
+++ b/src/components/page-editor/DetailPageEditor.vue
@@ -139,6 +139,12 @@ export default {
type: String,
default: '',
},
+ // The Application's declared `dataRegisters` bindings, forwarded into
+ // useRegisterPicker so the register picker labels/hoists them.
+ dataRegisters: {
+ type: Array,
+ default: () => [],
+ },
pageType: {
type: String,
default: 'detail',
@@ -149,9 +155,10 @@ export default {
* Observed behaviour of `setup` (retrofit annotation).
*
* @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-2.1
*/
setup(props) {
- const picker = useRegisterPicker({ appSlug: props.appSlug })
+ const picker = useRegisterPicker({ appSlug: props.appSlug, dataRegisters: props.dataRegisters })
return { picker }
},
data() {
diff --git a/src/components/page-editor/IndexPageEditor.vue b/src/components/page-editor/IndexPageEditor.vue
index 6ae5dff81..c9869df6e 100644
--- a/src/components/page-editor/IndexPageEditor.vue
+++ b/src/components/page-editor/IndexPageEditor.vue
@@ -118,6 +118,12 @@ export default {
type: String,
default: '',
},
+ // The Application's declared `dataRegisters` bindings, forwarded into
+ // useRegisterPicker so the register picker labels/hoists them.
+ dataRegisters: {
+ type: Array,
+ default: () => [],
+ },
pageType: {
type: String,
default: 'index',
@@ -132,9 +138,10 @@ export default {
* Observed behaviour of `setup` (retrofit annotation).
*
* @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-2.1
*/
setup(props) {
- const picker = useRegisterPicker({ appSlug: props.appSlug })
+ const picker = useRegisterPicker({ appSlug: props.appSlug, dataRegisters: props.dataRegisters })
return { picker }
},
data() {
diff --git a/src/components/page-editor/LogsPageEditor.vue b/src/components/page-editor/LogsPageEditor.vue
index 5fa728c44..6da24d19d 100644
--- a/src/components/page-editor/LogsPageEditor.vue
+++ b/src/components/page-editor/LogsPageEditor.vue
@@ -127,6 +127,12 @@ export default {
type: String,
default: '',
},
+ // The Application's declared `dataRegisters` bindings, forwarded into
+ // useRegisterPicker so the register picker labels/hoists them.
+ dataRegisters: {
+ type: Array,
+ default: () => [],
+ },
parentRoute: {
type: String,
default: '',
@@ -137,9 +143,10 @@ export default {
* Observed behaviour of `setup` (retrofit annotation).
*
* @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-2.1
*/
setup(props) {
- const picker = useRegisterPicker({ appSlug: props.appSlug })
+ const picker = useRegisterPicker({ appSlug: props.appSlug, dataRegisters: props.dataRegisters })
return { picker }
},
data() {
diff --git a/src/composables/useRegisterPicker.js b/src/composables/useRegisterPicker.js
index ef1fa50b8..0ccf9f01a 100644
--- a/src/composables/useRegisterPicker.js
+++ b/src/composables/useRegisterPicker.js
@@ -37,12 +37,21 @@ const PICKER_HEADERS = () => ({
* @param {object} [opts] - Options.
* @param {string} [opts.appSlug] - Current Application slug. When set, the
* picker filters to the per-app register `openbuild-{slug}` first.
+ * @param {Array<{register: string, label?: string}>} [opts.dataRegisters] -
+ * The Application's declared shared data-register bindings
+ * (`Application.dataRegisters`, data-registers-schema-declaration). When
+ * set, `fetchRegisters()` labels matching entries with
+ * `binding.label ?? binding.register` and hoists them after the per-app
+ * register. Absent/empty is a no-op — `fetchRegisters()` then returns
+ * output byte-identical to the pre-existing (perApp-only) behaviour.
* @return {object} - { fetchRegisters, fetchSchemas, fetchSchemaProperties,
* resolveAppRegister }.
* @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-1
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-1.1
*/
export function useRegisterPicker(opts = {}) {
const appSlug = opts.appSlug || ''
+ const dataRegisters = Array.isArray(opts.dataRegisters) ? opts.dataRegisters : []
/**
* Resolve the per-app register slug for the current Application.
@@ -57,9 +66,14 @@ export function useRegisterPicker(opts = {}) {
/**
* Fetch the list of registers available to the page editor. When the
* current Application has a slug, the per-app register is hoisted to
- * the top so picker UX defaults to the right namespace.
+ * the top so picker UX defaults to the right namespace. When the
+ * Application declares `dataRegisters` bindings (design.md Decision 1),
+ * matching entries are labelled with `binding.label ?? binding.register`
+ * and hoisted immediately after the per-app register, in declaration
+ * order; every other entry keeps OR's original relative order.
*
* @return {Promise} - registers list.
+ * @spec openspec/changes/data-registers-runtime/tasks.md#task-1.1
*/
async function fetchRegisters() {
try {
@@ -73,17 +87,79 @@ export function useRegisterPicker(opts = {}) {
if (!Array.isArray(list)) {
return []
}
+
// Hoist the per-app register so it is the obvious default.
const perApp = resolveAppRegister()
- if (!perApp) {
- return list
+
+ // No dataRegisters bindings declared — regression-safe default:
+ // byte-identical to the pre-existing (perApp-only) behaviour.
+ if (dataRegisters.length === 0) {
+ if (!perApp) {
+ return list
+ }
+ const sorted = [...list].sort((a, b) => {
+ if ((a.slug || a.id) === perApp) return -1
+ if ((b.slug || b.id) === perApp) return 1
+ return 0
+ })
+ return sorted
+ }
+
+ // Map — label ?? register per design.md.
+ const labelByRegister = new Map()
+ dataRegisters.forEach((binding) => {
+ if (binding && binding.register) {
+ labelByRegister.set(binding.register, binding.label ?? binding.register)
+ }
+ })
+
+ // Declaration order of each binding, for the "matching entries,
+ // in the order the Application declared them" tier.
+ const declarationOrder = new Map()
+ dataRegisters.forEach((binding, index) => {
+ if (binding && binding.register && !declarationOrder.has(binding.register)) {
+ declarationOrder.set(binding.register, index)
+ }
+ })
+
+ const labelled = list.map((entry) => {
+ const key = entry && (entry.slug || entry.id)
+ if (key && labelByRegister.has(key)) {
+ return { ...entry, label: labelByRegister.get(key) }
+ }
+ return entry
+ })
+
+ // Tier 0: per-app register. Tier 1: dataRegisters bindings (in
+ // declaration order). Tier 2: everything else (OR's order).
+ function tierFor(entry) {
+ const key = entry && (entry.slug || entry.id)
+ if (perApp && key === perApp) {
+ return 0
+ }
+ if (key && declarationOrder.has(key)) {
+ return 1
+ }
+ return 2
}
- const sorted = [...list].sort((a, b) => {
- if ((a.slug || a.id) === perApp) return -1
- if ((b.slug || b.id) === perApp) return 1
- return 0
+
+ const indexed = labelled.map((entry, originalIndex) => ({ entry, originalIndex }))
+ indexed.sort((a, b) => {
+ const tierA = tierFor(a.entry)
+ const tierB = tierFor(b.entry)
+ if (tierA !== tierB) {
+ return tierA - tierB
+ }
+ if (tierA === 1) {
+ const keyA = a.entry.slug || a.entry.id
+ const keyB = b.entry.slug || b.entry.id
+ return declarationOrder.get(keyA) - declarationOrder.get(keyB)
+ }
+ // Tiers 0 (singleton) and 2 keep the original relative order.
+ return a.originalIndex - b.originalIndex
})
- return sorted
+
+ return indexed.map((i) => i.entry)
} catch {
return []
}
diff --git a/src/dialogs/ExportDialog.vue b/src/dialogs/ExportDialog.vue
index 3208b245d..28c3ee184 100644
--- a/src/dialogs/ExportDialog.vue
+++ b/src/dialogs/ExportDialog.vue
@@ -27,6 +27,22 @@
{{ t('openbuild', 'Include seed data') }}
+
+
+ {{ t('openbuild', 'Data registers') }}
+
+
+ {{ t('openbuild', 'This app is bound to shared data registers it does not own. Their schema definitions are always included as reference material. Row data is only bundled for a register when you switch it on below.') }}
+
+
+ {{ t('openbuild', 'Include row data for {label}', { label: choice.label || choice.register }) }}
+
+
+
[{ label: '0.1.0', value: '0.1.0' }],
},
+ // The source Application's declared `dataRegisters` bindings
+ // (data-registers-runtime design.md Decision 5). One toggle is
+ // rendered per binding, unchecked by default (schema-defs-only).
+ dataRegisters: {
+ type: Array,
+ default: () => [],
+ },
},
emits: ['close', 'queued'],
data() {
@@ -113,6 +136,14 @@ export default {
githubVisibility: { label: this.t('openbuild', 'Private'), value: 'private' },
githubPat: '',
},
+ // Per-binding includeData choice, unchecked by default. Built
+ // once from the dataRegisters prop — mirrors `form`'s own
+ // once-at-creation pattern above.
+ dataRegisterChoices: this.dataRegisters.map((binding) => ({
+ register: binding.register,
+ label: binding.label,
+ includeData: false,
+ })),
}
},
computed: {
@@ -185,6 +216,13 @@ export default {
target: this.form.target.value,
license: this.form.license.value,
includeSeedData: this.form.includeSeedData,
+ // Mirrors the source Application's dataRegisters bindings
+ // 1:1, each carrying the resolved includeData flag
+ // (data-registers-runtime design.md Decision 5).
+ dataRegisters: this.dataRegisterChoices.map((choice) => ({
+ register: choice.register,
+ includeData: choice.includeData,
+ })),
}
if (this.form.target.value === 'github') {
payload.githubOrg = this.form.githubOrg
@@ -221,6 +259,12 @@ export default {
margin: 0;
}
+.export-dialog__section-title {
+ margin: 8px 0 0;
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
.export-dialog__error {
color: var(--color-error);
margin: 0;
diff --git a/src/modals/AppSettingsModal.vue b/src/modals/AppSettingsModal.vue
index 0e725fd62..cf813ddc9 100644
--- a/src/modals/AppSettingsModal.vue
+++ b/src/modals/AppSettingsModal.vue
@@ -3,10 +3,11 @@
- SPDX-FileCopyrightText: 2026 Conduction B.V.
-
- AppSettingsModal — owner-facing app settings. Holds the publish/unpublish
- - toggle (whether the app is live in the Nextcloud app menu) and the
- - allow-user-overrides toggle. Emits intent; the parent
- - (ApplicationDetailActions) performs the API calls. Kept in its own file per
- - ADR-004 gate-modal-isolation.
+ - toggle (whether the app is live in the Nextcloud app menu), the
+ - allow-user-overrides toggle, and the Data registers section (add/remove
+ - `Application.dataRegisters` bindings — data-registers-runtime task 5.1).
+ - Emits intent; the parent (ApplicationDetailActions) performs the API
+ - calls. Kept in its own file per ADR-004 gate-modal-isolation.
-->
@@ -38,17 +39,52 @@
{{ t('openbuild', 'Let each user layer their own manifest changes on top of the shared app.') }}
+
+
+
+ {{ t('openbuild', 'Data registers') }}
+
+
+ {{ t('openbuild', 'Shared, non-versioned OpenRegister registers this app binds to alongside its own per-version register (e.g. a dataset fed by OpenConnector). Not owned by this app — promotion and export treat them as reference-only.') }}
+
+
+
+
+
+ {{ t('openbuild', 'Remove') }}
+
+
+
+ {{ t('openbuild', 'Add data register') }}
+
+
@@ -79,6 +211,12 @@ export default {
font-weight: 600;
}
+.app-settings__subtitle {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+}
+
.app-settings__row {
display: flex;
flex-direction: column;
@@ -90,4 +228,14 @@ export default {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
+
+.app-settings__hint--inline {
+ margin-left: 0;
+}
+
+.app-settings__data-register-row {
+ display: flex;
+ gap: 8px;
+ align-items: flex-end;
+}
diff --git a/src/views/PageDesigner.vue b/src/views/PageDesigner.vue
index f2931dac4..ff81318fb 100644
--- a/src/views/PageDesigner.vue
+++ b/src/views/PageDesigner.vue
@@ -61,6 +61,7 @@
:config="selectedPage.config || {}"
:page-type="selectedPage.type"
:app-slug="slug"
+ :data-registers="applicationDataRegisters"
:parent-route="selectedPage.route || ''"
@update:config="onConfigUpdate" />
@@ -104,6 +105,8 @@
+
+
diff --git a/src/views/SchemaDesigner.vue b/src/views/SchemaDesigner.vue
index 76c684194..8e3bc1497 100644
--- a/src/views/SchemaDesigner.vue
+++ b/src/views/SchemaDesigner.vue
@@ -22,13 +22,19 @@
-
+
+
+
+ {{ t('openbuild', 'Import data') }}
+
+
+
+
@@ -111,10 +117,20 @@
+
+
+
+
diff --git a/src/modals/LinkRepoDialog.vue b/src/modals/LinkRepoDialog.vue
new file mode 100644
index 000000000..be72fb4f7
--- /dev/null
+++ b/src/modals/LinkRepoDialog.vue
@@ -0,0 +1,162 @@
+
+
+
+
+
{{ t('openbuild', 'Link a GitHub repository') }}
+
+ {{ t('openbuild', 'Connect this app to a GitHub repository so you can publish and pull versions.') }}
+
+
+
+
+
+ {{ error }}
+
+
+
+ {{ t('openbuild', 'Cancel') }}
+
+
+ {{ submitting ? t('openbuild', 'Linking…') : t('openbuild', 'Link repository') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/modals/PublishConfirmDialog.vue b/src/modals/PublishConfirmDialog.vue
new file mode 100644
index 000000000..b2e95532c
--- /dev/null
+++ b/src/modals/PublishConfirmDialog.vue
@@ -0,0 +1,208 @@
+
+
+
+
+
{{ t('openbuild', 'Publish to GitHub') }}
+
+ {{ t('openbuild', 'Publishing adds a new commit to {repo} on branch {branch}. It never overwrites history.', { repo: repoLabel, branch: repo && repo.branch ? repo.branch : t('openbuild', 'the default branch') }) }}
+
+
+ {{ t('openbuild', 'Using credential: {name}', { name: credentialName || t('openbuild', 'none selected') }) }}
+
+
+
+ {{ error }}
+
+
+
+ {{ t('openbuild', 'Cancel') }}
+
+
+ {{ submitting ? t('openbuild', 'Publishing…') : t('openbuild', 'Publish') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/TemplateGallery.vue b/src/views/TemplateGallery.vue
index d4067a979..e6b34bbea 100644
--- a/src/views/TemplateGallery.vue
+++ b/src/views/TemplateGallery.vue
@@ -8,74 +8,181 @@
-
-
-
+
+
+ {{ t('openbuild', 'Local') }}
+
+
+ {{ t('openbuild', 'Registry') }}
+
+
+ {{ t('openbuild', 'GitHub') }}
+
-
-
- {{ t('openbuild', 'Loading templates…') }}
-
+
+
+
+
+
+
-
-
-
+
+
+ {{ t('openbuild', 'Loading templates…') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ tpl.title || tpl.slug }}
+
+
{{ t('openbuild', 'Organisation template') }}
+
{{ categoryLabel(tpl.category) }}
+
+ {{ tpl.useCase || '' }}
+
+
+ {{ tpl.description || '' }}
+
+
+
+
+
+ {{ t('openbuild', 'Edit') }}
+
+
+ {{ t('openbuild', 'Delete') }}
+
+
+ {{ t('openbuild', 'Use this template') }}
+
+
+
+ {{ t('openbuild', 'Install') }}
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
- {{ tpl.title || tpl.slug }}
-
-
{{ t('openbuild', 'Organisation template') }}
-
{{ categoryLabel(tpl.category) }}
-
- {{ tpl.useCase || '' }}
-
-
- {{ tpl.description || '' }}
-
-
-
-
- {{ t('openbuild', 'Edit') }}
-
-
- {{ t('openbuild', 'Delete') }}
-
-
- {{ t('openbuild', 'Use this template') }}
-
-
-
-
+
+ {{ githubRateLimited
+ ? t('openbuild', 'GitHub is rate-limiting anonymous browsing right now. Try again shortly.')
+ : t('openbuild', 'GitHub could not be reached right now. Try again shortly.') }}
+
+ {{ t('openbuild', 'Add a GitHub credential in your OpenRegister credentials settings to raise the rate limit and browse private repositories.') }}
+
+
+
+
+
+ {{ t('openbuild', 'Searching GitHub…') }}
+
+
+
+
+
+
+
+
+ @submit="onCloneSubmit"
+ @installed="onInstalled" />
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
-import { NcButton, NcDialog, NcEmptyContent, NcLoadingIcon, NcSelect, NcTextField } from '@nextcloud/vue'
+import { NcButton, NcDialog, NcEmptyContent, NcLoadingIcon, NcNoteCard, NcSelect, NcTextField } from '@nextcloud/vue'
import CloneTemplateDialog from '../modals/CloneTemplateDialog.vue'
import EditTemplateMetadataDialog from '../dialogs/EditTemplateMetadataDialog.vue'
@@ -123,6 +230,7 @@ export default {
NcDialog,
NcEmptyContent,
NcLoadingIcon,
+ NcNoteCard,
NcSelect,
NcTextField,
CloneTemplateDialog,
@@ -136,11 +244,33 @@ export default {
categoryFilter: null,
cloneOpen: false,
cloneTarget: null,
+ // Install routing for the shared CloneTemplateDialog:
+ // 'local' (clone), 'remote' (registry store), or 'github' (shop).
+ cloneMode: 'local',
+ cloneRemoteSlug: '',
+ cloneGithubRepo: null,
editOpen: false,
editTarget: null,
deleteOpen: false,
deleteTarget: null,
deleting: false,
+ // Source tabs (github-shop-catalogue): 'local' | 'registry' | 'github'.
+ source: 'local',
+ // Registry (remote store) source.
+ storeConfigured: false,
+ registryCards: [],
+ registryLoading: false,
+ // GitHub source.
+ githubQuery: '',
+ githubCards: [],
+ githubLoading: false,
+ githubSearched: false,
+ githubOutcome: '',
+ githubRateLimited: false,
+ githubBrokerAvailable: false,
+ githubCredentialId: null,
+ hasGithubCredential: false,
+ githubDebounce: null,
}
},
computed: {
@@ -161,26 +291,233 @@ export default {
* @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1
*/
filteredTemplates() {
+ return this.filterCards(this.templates)
+ },
+ /**
+ * Whether the active (non-GitHub) source is still loading.
+ *
+ * @return {boolean}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ activeLoading() {
+ return this.source === 'registry' ? this.registryLoading : this.loading
+ },
+ /**
+ * The template cards shown in the Local or Registry grid, filtered by the
+ * shared search + category filters. GitHub has its own grid.
+ *
+ * @return {Array}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ visibleCards() {
+ return this.source === 'registry' ? this.filterCards(this.registryCards) : this.filteredTemplates
+ },
+ /**
+ * Whether GitHub browsing is currently degraded (rate-limited or
+ * unreachable) — drives the non-blocking hint.
+ *
+ * @return {boolean}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ githubUnavailable() {
+ return this.githubRateLimited || (this.githubOutcome !== '' && this.githubOutcome !== 'ok')
+ },
+ },
+ mounted() {
+ this.fetchTemplates()
+ this.probeRegistry()
+ this.fetchGithubCredentials()
+ },
+ methods: {
+ /**
+ * Shared search + category filter used by the Local and Registry grids.
+ *
+ * @param {Array} list The cards to filter.
+ * @return {Array}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ filterCards(list) {
const needle = this.search.trim().toLowerCase()
const cat = this.categoryFilter?.id ?? this.categoryFilter ?? null
- return this.templates.filter((tpl) => {
+ return (Array.isArray(list) ? list : []).filter((tpl) => {
if (cat && tpl.category !== cat) {
return false
}
if (!needle) {
return true
}
- const haystack = [tpl.title, tpl.useCase, tpl.description, tpl.slug]
+ const haystack = [tpl.title, tpl.name, tpl.useCase, tpl.description, tpl.slug]
.map((s) => (s ? String(s).toLowerCase() : ''))
.join(' ')
return haystack.includes(needle)
})
},
- },
- mounted() {
- this.fetchTemplates()
- },
- methods: {
+ /**
+ * Switch the active source tab. Entering the GitHub tab for the first time
+ * runs the initial (empty-query) search so the topic-listed apps appear.
+ *
+ * @param {string} next The source id ('local' | 'registry' | 'github').
+ * @return {void}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ setSource(next) {
+ this.source = next
+ if (next === 'registry' && this.registryCards.length === 0) {
+ this.fetchRegistry()
+ }
+ if (next === 'github' && !this.githubSearched) {
+ this.searchGithub()
+ }
+ },
+ /**
+ * Probe the remote store search endpoint once to decide whether to offer
+ * the Registry tab. A `not_configured` outcome hides it (no registry set).
+ *
+ * @return {Promise}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ async probeRegistry() {
+ try {
+ const { data } = await axios.get(generateUrl('/apps/openbuild/api/store/templates'))
+ if (data && data.outcome && data.outcome !== 'not_configured') {
+ this.storeConfigured = true
+ this.registryCards = Array.isArray(data.cards) ? data.cards : []
+ }
+ } catch (e) {
+ // Store unreachable/misconfigured — simply omit the Registry tab.
+ this.storeConfigured = false
+ }
+ },
+ /**
+ * (Re)fetch the remote store template cards for the Registry tab.
+ *
+ * @return {Promise}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ async fetchRegistry() {
+ this.registryLoading = true
+ try {
+ const url = generateUrl('/apps/openbuild/api/store/templates')
+ const params = {}
+ const needle = this.search.trim()
+ if (needle) {
+ params.q = needle
+ }
+ const { data } = await axios.get(url, { params })
+ this.registryCards = Array.isArray(data?.cards) ? data.cards : []
+ } catch (e) {
+ this.registryCards = []
+ } finally {
+ this.registryLoading = false
+ }
+ },
+ /**
+ * Debounced handler for the GitHub search box.
+ *
+ * @param {string} value The new query.
+ * @return {void}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ onGithubQuery(value) {
+ this.githubQuery = value
+ if (this.githubDebounce) {
+ clearTimeout(this.githubDebounce)
+ }
+ this.githubDebounce = setTimeout(() => {
+ this.searchGithub()
+ }, 350)
+ },
+ /**
+ * Call the GitHub shop search endpoint and render the result cards.
+ * Passes the user's advisory github credential id (when present) so
+ * private repos + the raised rate limit apply; anonymous otherwise.
+ *
+ * @return {Promise}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ async searchGithub() {
+ this.githubLoading = true
+ try {
+ const url = generateUrl('/apps/openbuild/api/shop/github/search')
+ const params = { q: this.githubQuery.trim() }
+ if (this.githubCredentialId) {
+ params.credentialId = this.githubCredentialId
+ }
+ const { data } = await axios.get(url, { params })
+ this.githubCards = Array.isArray(data?.cards) ? data.cards : []
+ this.githubOutcome = data?.outcome || 'ok'
+ this.githubRateLimited = !!data?.rateLimited
+ this.githubBrokerAvailable = !!data?.brokerCredentialAvailable
+ } catch (e) {
+ this.githubCards = []
+ this.githubOutcome = 'github_unreachable'
+ this.githubRateLimited = false
+ } finally {
+ this.githubSearched = true
+ this.githubLoading = false
+ }
+ },
+ /**
+ * Feature-detect an allowed github credential via OpenRegister's
+ * credentials API (advisory only — the server-side broker is the
+ * authoritative gate). Populates the search credential id + the
+ * add-a-credential pointer.
+ *
+ * @return {Promise}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ async fetchGithubCredentials() {
+ try {
+ const { data } = await axios.get(generateUrl('/apps/openregister/api/credentials'))
+ const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : [])
+ const github = list.filter((c) => c && c.provider === 'github')
+ this.hasGithubCredential = github.length > 0
+ this.githubCredentialId = github.length ? (github[0].id || null) : null
+ } catch (e) {
+ this.hasGithubCredential = false
+ this.githubCredentialId = null
+ }
+ },
+ /**
+ * Open the clone dialog to install a Registry (remote store) template.
+ *
+ * @param {object} card The remote store card.
+ * @return {void}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ openRegistryInstall(card) {
+ this.cloneTarget = card
+ this.cloneMode = 'remote'
+ this.cloneRemoteSlug = card.slug || ''
+ this.cloneGithubRepo = null
+ this.cloneOpen = true
+ },
+ /**
+ * Open the clone dialog to install a GitHub app, seeded with the card.
+ *
+ * @param {object} card The GitHub result card.
+ * @return {void}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ openGithubInstall(card) {
+ this.cloneTarget = { title: card.name || card.slug || card.repo, slug: card.slug || card.repo, description: card.description }
+ this.cloneMode = 'github'
+ this.cloneRemoteSlug = ''
+ this.cloneGithubRepo = { owner: card.owner, repo: card.repo }
+ this.cloneOpen = true
+ },
+ /**
+ * Redirect to the new application after a Registry/GitHub install
+ * (the dialog owns the POST and emits `installed`).
+ *
+ * @param {object} created The created application payload.
+ * @return {void}
+ * @spec openspec/changes/github-shop-catalogue/specs/template-catalogue-ui/spec.md
+ */
+ onInstalled(created) {
+ this.cloneOpen = false
+ this.redirectAfterClone(created)
+ },
/**
* Observed behaviour of `fetchTemplates` (retrofit annotation).
*
@@ -234,6 +571,9 @@ export default {
*/
openClone(template) {
this.cloneTarget = template
+ this.cloneMode = 'local'
+ this.cloneRemoteSlug = ''
+ this.cloneGithubRepo = null
this.cloneOpen = true
},
/**
@@ -393,12 +733,53 @@ export default {
color: var(--color-text-maxcontrast);
}
+.template-gallery__tabs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ border-bottom: 1px solid var(--color-border);
+ padding-bottom: 8px;
+}
+
.template-gallery__filters {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
+.template-gallery__github-hint {
+ margin: 0;
+}
+
+.template-card__badge--warn {
+ background: var(--color-warning, #d99000);
+ color: var(--color-primary-element-text, #fff);
+}
+
+.template-card__github-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.template-card__chip {
+ font-size: 0.75rem;
+ padding: 2px 8px;
+ border-radius: 12px;
+ background: var(--color-background-dark);
+ color: var(--color-main-text);
+}
+
+.template-card__chip--muted {
+ color: var(--color-text-maxcontrast);
+}
+
+.template-card__disabled-hint {
+ font-size: 0.8rem;
+ color: var(--color-text-maxcontrast);
+}
+
.template-gallery__loading {
display: flex;
gap: 12px;
diff --git a/tests/modals/CloneTemplateDialogGithub.spec.js b/tests/modals/CloneTemplateDialogGithub.spec.js
new file mode 100644
index 000000000..7ce8cf67c
--- /dev/null
+++ b/tests/modals/CloneTemplateDialogGithub.spec.js
@@ -0,0 +1,133 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Vitest unit tests for the GitHub-shop install behaviour of
+ * `src/modals/CloneTemplateDialog.vue` (github-shop-catalogue).
+ *
+ * Covers template-catalogue-ui:
+ * - when :github=true with a githubRepo, a valid submit POSTs to the GitHub
+ * shop install endpoint and emits `installed` (+ `close`)
+ * - a strict-parse failure returned by the endpoint is surfaced in the dialog
+ * naming the offending file, creating nothing (no installed emission)
+ * - submission stays gated on a valid target (canSubmit)
+ */
+
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+// The global setup stub returns the bare key; give script-level t() real
+// {placeholder} interpolation so the "naming the offending file" assertion
+// can see the file name substituted into the error string.
+const realT = globalThis.t
+beforeAll(() => {
+ globalThis.t = (_app, key, vars) => (vars
+ ? String(key).replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? vars[k] : `{${k}}`))
+ : key)
+})
+afterAll(() => { globalThis.t = realT })
+
+const { axiosMock } = vi.hoisted(() => ({
+ axiosMock: { get: vi.fn(), post: vi.fn() },
+}))
+
+vi.mock('@nextcloud/router', () => ({
+ generateUrl: (path, params = {}) => path.replace(/\{(\w+)\}/g, (_, k) => (params[k] ?? `{${k}}`)),
+}))
+
+vi.mock('@nextcloud/axios', () => ({ default: axiosMock }))
+
+import CloneTemplateDialog from '../../src/modals/CloneTemplateDialog.vue'
+
+/**
+ * Mount the dialog open in GitHub-install mode.
+ *
+ * @param {object} props Extra props merged over the defaults.
+ * @return {Promise}
+ */
+async function mountDialog(props = {}) {
+ const wrapper = mount(CloneTemplateDialog, {
+ propsData: {
+ open: true,
+ template: { slug: 'petstore', title: 'Pet Store' },
+ github: true,
+ githubRepo: { owner: 'conduction', repo: 'petstore' },
+ ...props,
+ },
+ stubs: {
+ NcModal: { name: 'NcModal', template: '
' },
+ NcButton: { name: 'NcButton', props: ['disabled'], template: ' ' },
+ NcTextField: { name: 'NcTextField', props: ['value', 'label', 'placeholder'], template: ' ' },
+ },
+ })
+ await wrapper.vm.$nextTick()
+ return wrapper
+}
+
+describe('CloneTemplateDialog.vue — GitHub shop install', () => {
+ beforeEach(() => {
+ axiosMock.get.mockReset()
+ axiosMock.post.mockReset()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('POSTs to the GitHub install endpoint and emits installed + close on success', async () => {
+ const wrapper = await mountDialog()
+
+ wrapper.vm.localName = 'Pet Store'
+ wrapper.vm.localSlug = 'pet-store'
+ await wrapper.vm.$nextTick()
+ expect(wrapper.vm.canSubmit).toBe(true)
+
+ const created = { uuid: 'new-app', slug: 'pet-store', register: 'pet-store' }
+ axiosMock.post.mockResolvedValueOnce({ data: created })
+
+ await wrapper.vm.submit()
+
+ expect(axiosMock.post).toHaveBeenCalledTimes(1)
+ const [url, body] = axiosMock.post.mock.calls[0]
+ expect(url).toBe('/apps/openbuild/api/shop/github/install')
+ expect(body).toEqual({ owner: 'conduction', repo: 'petstore', name: 'Pet Store', slug: 'pet-store' })
+
+ expect(wrapper.emitted('installed')).toBeTruthy()
+ expect(wrapper.emitted('installed')[0][0]).toEqual(created)
+ expect(wrapper.emitted('close')).toBeTruthy()
+ // GitHub install never uses the local clone (submit) path.
+ expect(wrapper.emitted('submit')).toBeFalsy()
+ })
+
+ it('surfaces a strict-parse failure naming the offending file, creating nothing', async () => {
+ const wrapper = await mountDialog()
+
+ wrapper.vm.localName = 'Pet Store'
+ wrapper.vm.localSlug = 'pet-store'
+ await wrapper.vm.$nextTick()
+
+ axiosMock.post.mockRejectedValueOnce({
+ response: { data: { error: 'schema_invalid', file: 'schemas/pet.json' } },
+ })
+
+ await wrapper.vm.submit()
+
+ expect(wrapper.vm.error).toContain('schemas/pet.json')
+ expect(wrapper.vm.submitting).toBe(false)
+ expect(wrapper.emitted('installed')).toBeFalsy()
+ })
+
+ it('blocks submit on an invalid slug and never hits the endpoint', async () => {
+ const wrapper = await mountDialog()
+
+ wrapper.vm.localName = 'Pet Store'
+ wrapper.vm.localSlug = 'Not Valid'
+ await wrapper.vm.$nextTick()
+ expect(wrapper.vm.canSubmit).toBe(false)
+
+ await wrapper.vm.submit()
+
+ expect(axiosMock.post).not.toHaveBeenCalled()
+ expect(wrapper.vm.error).toBeTruthy()
+ })
+})
diff --git a/tests/modals/GitHubSyncModal.spec.js b/tests/modals/GitHubSyncModal.spec.js
new file mode 100644
index 000000000..1e5f4fc08
--- /dev/null
+++ b/tests/modals/GitHubSyncModal.spec.js
@@ -0,0 +1,178 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Vitest unit tests for `src/modals/GitHubSyncModal.vue` (github-app-sync).
+ *
+ * Covers application-detail-ui:
+ * - the GitHub section renders write controls (picker + link + publish + pull)
+ * and the status readout for owners
+ * - a non-owner sees the status readout but not the write controls
+ * - Publish is disabled with a hint when publishAvailable is false, and
+ * enabled once a credential is chosen and publish is available
+ * - Pull calls the pull endpoint and surfaces the new draft version; a
+ * strict-parse failure is surfaced as an error naming the offending file
+ */
+
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+// Give script-level t() real {placeholder} interpolation so the pull
+// parse-error assertion can see the offending file name in the message.
+const realT = globalThis.t
+beforeAll(() => {
+ globalThis.t = (_app, key, vars) => (vars
+ ? String(key).replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? vars[k] : `{${k}}`))
+ : key)
+})
+afterAll(() => { globalThis.t = realT })
+
+const { axiosMock } = vi.hoisted(() => ({
+ axiosMock: { get: vi.fn(), post: vi.fn() },
+}))
+
+vi.mock('@nextcloud/router', () => ({
+ generateUrl: (path, params = {}) => path.replace(/\{(\w+)\}/g, (_, k) => (params[k] ?? `{${k}}`)),
+}))
+vi.mock('@nextcloud/axios', () => ({ default: axiosMock }))
+vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn(), showSuccess: vi.fn() }))
+
+vi.mock('../../src/modals/LinkRepoDialog.vue', () => ({
+ default: { name: 'LinkRepoDialog', props: ['open', 'slug'], render() { return null } },
+}))
+vi.mock('../../src/modals/PublishConfirmDialog.vue', () => ({
+ default: { name: 'PublishConfirmDialog', props: ['open', 'slug', 'credentialId', 'credentialName', 'versions', 'repo'], render() { return null } },
+}))
+
+import GitHubSyncModal from '../../src/modals/GitHubSyncModal.vue'
+
+const linkedStatus = {
+ githubRepo: { owner: 'conduction', name: 'petstore' },
+ githubDefaultBranch: 'main',
+ lastPushedSha: 'abc1234567',
+ lastPulledSha: null,
+ brokerCredentialAvailable: true,
+ publishAvailable: true,
+}
+
+const STUBS = {
+ NcModal: { name: 'NcModal', props: ['name', 'size'], template: '
' },
+ NcButton: { name: 'NcButton', props: ['type', 'disabled'], template: ' ' },
+ NcSelect: { name: 'NcSelect', props: ['value', 'options', 'inputLabel'], template: ' ' },
+ NcLoadingIcon: true,
+ NcNoteCard: { name: 'NcNoteCard', props: ['type'], template: '
' },
+}
+
+/**
+ * Mount the modal and open it (fires the load watchers), dispatching the
+ * status / credentials / versions GETs by URL.
+ *
+ * @param {object} opts { isOwner, status, credentials, versions }
+ * @return {Promise}
+ */
+async function mountModal({ isOwner = true, status = linkedStatus, credentials = [{ id: 'cred-1', name: 'My GitHub', provider: 'github' }], versions = [{ slug: 'v1', name: 'v1', semver: '1.0.0' }] } = {}) {
+ axiosMock.get.mockImplementation((url) => {
+ const u = String(url)
+ if (u.includes('/github/status')) {
+ return Promise.resolve({ data: status })
+ }
+ if (u.includes('/credentials')) {
+ return Promise.resolve({ data: credentials })
+ }
+ if (u.includes('/versions')) {
+ return Promise.resolve({ data: versions })
+ }
+ return Promise.resolve({ data: {} })
+ })
+
+ const wrapper = mount(GitHubSyncModal, {
+ propsData: { open: false, slug: 'petstore', isOwner },
+ stubs: STUBS,
+ })
+ await wrapper.setProps({ open: true })
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await wrapper.vm.$nextTick()
+ return wrapper
+}
+
+describe('GitHubSyncModal.vue', () => {
+ beforeEach(() => {
+ axiosMock.get.mockReset()
+ axiosMock.post.mockReset()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('renders the status readout + write controls for an owner', async () => {
+ const wrapper = await mountModal({ isOwner: true })
+
+ expect(wrapper.vm.linked).toBe(true)
+ expect(wrapper.vm.repoLabel).toBe('conduction/petstore')
+ // Owner write controls present.
+ expect(wrapper.find('.github-sync__actions').exists()).toBe(true)
+ expect(wrapper.find('.github-sync__credential').exists()).toBe(true)
+ })
+
+ it('shows the status readout but no write controls for a non-owner', async () => {
+ const wrapper = await mountModal({ isOwner: false })
+
+ expect(wrapper.find('.github-sync__status').exists()).toBe(true)
+ expect(wrapper.find('.github-sync__actions').exists()).toBe(false)
+ expect(wrapper.find('.github-sync__credential').exists()).toBe(false)
+ })
+
+ it('disables Publish with a hint when publishAvailable is false', async () => {
+ const wrapper = await mountModal({
+ status: { ...linkedStatus, publishAvailable: false, brokerCredentialAvailable: false },
+ })
+
+ expect(wrapper.vm.publishAvailable).toBe(false)
+ expect(wrapper.vm.canPublish).toBe(false)
+ expect(wrapper.vm.publishHint.length).toBeGreaterThan(0)
+ })
+
+ it('enables Publish once a credential is chosen and publish is available', async () => {
+ const wrapper = await mountModal({ isOwner: true })
+
+ expect(wrapper.vm.canPublish).toBe(false) // no credential picked yet
+ wrapper.vm.selectedCredential = { id: 'cred-1', label: 'My GitHub' }
+ await wrapper.vm.$nextTick()
+ expect(wrapper.vm.canPublish).toBe(true)
+
+ wrapper.vm.openPublish()
+ await wrapper.vm.$nextTick()
+ expect(wrapper.vm.publishOpen).toBe(true)
+ })
+
+ it('Pull calls the pull endpoint and surfaces the new draft version', async () => {
+ const wrapper = await mountModal({ isOwner: true })
+
+ axiosMock.post.mockResolvedValueOnce({
+ data: { outcome: 'ok', versionUuid: 'ver-2', versionSlug: 'draft-2', status: 'draft', sourceRef: 'main' },
+ })
+
+ await wrapper.vm.doPull()
+
+ expect(axiosMock.post).toHaveBeenCalledTimes(1)
+ const [url, body] = axiosMock.post.mock.calls[0]
+ expect(url).toBe('/apps/openbuild/api/applications/petstore/github/pull')
+ expect(body.ref).toBe('main')
+ expect(wrapper.vm.pullResult).toBeTruthy()
+ expect(wrapper.vm.pullResult.versionSlug).toBe('draft-2')
+ })
+
+ it('surfaces a pull parse failure as an error naming the offending file', async () => {
+ const wrapper = await mountModal({ isOwner: true })
+
+ axiosMock.post.mockRejectedValueOnce({
+ response: { data: { error: 'manifest_invalid', file: 'manifest.json' } },
+ })
+
+ await wrapper.vm.doPull()
+
+ expect(wrapper.vm.error).toContain('manifest.json')
+ expect(wrapper.vm.pullResult).toBeNull()
+ })
+})
diff --git a/tests/stubs/openregister-stubs.php b/tests/stubs/openregister-stubs.php
index 7bfbaf8b2..6c2ae7a4d 100644
--- a/tests/stubs/openregister-stubs.php
+++ b/tests/stubs/openregister-stubs.php
@@ -293,11 +293,13 @@ public function getProperties(): array
class RegisterMapper
{
/**
- * @param array|null $_extend Eager-load relations (ignored).
+ * Signature mirrors the real OR RegisterMapper::find so callers
+ * passing `_rbac:` / `_multitenancy:` as named arguments resolve
+ * identically (the real mapper takes NO `_extend`/`published`).
*
* @return Register
*/
- public function find(string|int $id, ?array $_extend=[], ?bool $published=null, bool $_rbac=true, bool $_multitenancy=true): Register
+ public function find(string|int $id, bool $_rbac=true, bool $_multitenancy=true): Register
{
return new Register();
}//end find()
@@ -306,9 +308,13 @@ public function find(string|int $id, ?array $_extend=[], ?bool $published=null,
* Signature mirrors the real OR mapper so callers passing
* `_rbac:` / `_multitenancy:` as named arguments resolve identically.
*
+ * @param array|null $filters Filter map (ignored).
+ * @param array|null $searchConditions Search conditions (ignored).
+ * @param array|null $searchParams Search params (ignored).
+ *
* @return array
*/
- public function findAll(?int $limit=null, ?int $offset=null, array $filters=[], array $searchConditions=[], array $searchParams=[], bool $_rbac=true, bool $_multitenancy=true): array
+ public function findAll(?int $limit=null, ?int $offset=null, ?array $filters=[], ?array $searchConditions=[], ?array $searchParams=[], bool $_rbac=true, bool $_multitenancy=true): array
{
return [];
}//end findAll()
@@ -342,11 +348,15 @@ public function update(\OCP\AppFramework\Db\Entity $entity): \OCP\AppFramework\D
class SchemaMapper
{
/**
+ * Signature mirrors the real OR SchemaMapper::find (which takes
+ * `_extend` but NO `published` param), so callers passing
+ * `_multitenancy:` as a named argument resolve identically.
+ *
* @param array|null $_extend Eager-load relations (ignored).
*
* @return Schema
*/
- public function find(string|int $id, ?array $_extend=[], ?bool $published=null, bool $_rbac=true, bool $_multitenancy=true): Schema
+ public function find(string|int $id, ?array $_extend=[], bool $_rbac=true, bool $_multitenancy=true): Schema
{
return new Schema();
}//end find()
@@ -477,7 +487,7 @@ public function searchObjectsBySlug(string $registerSlug, string $schemaSlug, ar
*
* @return \OCA\OpenRegister\Db\ObjectEntity|null
*/
- public function find(int|string $id, ?array $_extend=[], bool $files=false, mixed $register=null, mixed $schema=null, bool $_rbac=true, bool $_multitenancy=true): ?\OCA\OpenRegister\Db\ObjectEntity
+ public function find(int|string $id, ?array $_extend=[], bool $files=false, \OCA\OpenRegister\Db\Register|string|int|null $register=null, \OCA\OpenRegister\Db\Schema|string|int|null $schema=null, bool $_rbac=true, bool $_multitenancy=true): ?\OCA\OpenRegister\Db\ObjectEntity
{
return null;
}//end find()
@@ -499,7 +509,7 @@ public function findAll(array $config=[], bool $_rbac=true, bool $_multitenancy=
*
* @return \OCA\OpenRegister\Db\ObjectEntity
*/
- public function saveObject(array|\OCA\OpenRegister\Db\ObjectEntity $object, ?array $extend=[], mixed $register=null, mixed $schema=null, ?string $uuid=null, bool $_rbac=true, bool $_multitenancy=true, bool $silent=false, ?array $uploadedFiles=null): \OCA\OpenRegister\Db\ObjectEntity
+ public function saveObject(array|\OCA\OpenRegister\Db\ObjectEntity $object, ?array $extend=[], \OCA\OpenRegister\Db\Register|string|int|null $register=null, \OCA\OpenRegister\Db\Schema|string|int|null $schema=null, ?string $uuid=null, bool $_rbac=true, bool $_multitenancy=true, bool $silent=false, ?array $uploadedFiles=null, ?\OCP\IUser $currentUser=null): \OCA\OpenRegister\Db\ObjectEntity
{
return new \OCA\OpenRegister\Db\ObjectEntity();
}//end saveObject()
@@ -507,7 +517,7 @@ public function saveObject(array|\OCA\OpenRegister\Db\ObjectEntity $object, ?arr
/**
* @return bool
*/
- public function deleteObject(string $uuid, bool $_rbac=true, bool $_multitenancy=true): bool
+ public function deleteObject(string $uuid, \OCA\OpenRegister\Db\Register|string|int|null $register=null, \OCA\OpenRegister\Db\Schema|string|int|null $schema=null, bool $_rbac=true, bool $_multitenancy=true, bool $_retentionSweep=false): bool
{
return true;
}//end deleteObject()
@@ -529,35 +539,27 @@ public function unlockObject(string|int $identifier): bool
}//end unlockObject()
/**
- * @return array|null
- */
- public function getLockInfo(string $identifier): ?array
- {
- return null;
- }//end getLockInfo()
-
- /**
- * Stub setter for the current register context. Real OR signature
- * is `setRegister(Register|string|int): static`.
+ * Stub setter for the current register context. Signature mirrors
+ * the real OR service `setRegister(Register|string|int): static`.
*
- * @param mixed $register Register reference.
+ * @param \OCA\OpenRegister\Db\Register|string|int $register Register reference.
*
* @return static
*/
- public function setRegister(mixed $register): static
+ public function setRegister(\OCA\OpenRegister\Db\Register|string|int $register): static
{
return $this;
}//end setRegister()
/**
- * Stub setter for the current schema context. Real OR signature
- * is `setSchema(Schema|string|int): static`.
+ * Stub setter for the current schema context. Signature mirrors
+ * the real OR service `setSchema(Schema|string|int): static`.
*
- * @param mixed $schema Schema reference.
+ * @param \OCA\OpenRegister\Db\Schema|string|int $schema Schema reference.
*
* @return static
*/
- public function setSchema(mixed $schema): static
+ public function setSchema(\OCA\OpenRegister\Db\Schema|string|int $schema): static
{
return $this;
}//end setSchema()
@@ -695,6 +697,41 @@ public function getFile(string $object, string $file): \OCP\Files\File
}//end if
}
+namespace OCA\OpenRegister\Service\Credential {
+
+ if (class_exists(CredentialBrokerService::class, autoload: false) === false) {
+ /**
+ * Stub CredentialBrokerService — the `request()` call surface OpenBuild's
+ * GitHubAppSyncService routes every outbound GitHub call through (resolved
+ * lazily via `Server::get()`). The signature mirrors the real OR broker
+ * (`request(string $credentialId, string $appId, string $method, string
+ * $path, array $headers=[], ?string $body=null, ?string $actingUserId=null):
+ * array`) so a caller passing the wrong argument shape fails identically
+ * against the stub and the real class.
+ */
+ class CredentialBrokerService
+ {
+ /**
+ * Broker a single outbound HTTP call for an allowed credential.
+ *
+ * @param string $credentialId The credential UUID.
+ * @param string $appId The calling app id.
+ * @param string $method The HTTP method.
+ * @param string $path The provider-relative path.
+ * @param array $headers Request headers.
+ * @param string|null $body Optional request body.
+ * @param string|null $actingUserId The acting user UID (owner guard).
+ *
+ * @return array The `{status, headers, body}` response shape.
+ */
+ public function request(string $credentialId, string $appId, string $method, string $path, array $headers=[], ?string $body=null, ?string $actingUserId=null): array
+ {
+ return [];
+ }//end request()
+ }//end class
+ }//end if
+}
+
namespace OCA\OpenRegister\Event {
if (class_exists(ObjectTransitionedEvent::class, autoload: false) === false) {
diff --git a/tests/views/TemplateGallery.spec.js b/tests/views/TemplateGallery.spec.js
index 704d0fb9a..6d08089ec 100644
--- a/tests/views/TemplateGallery.spec.js
+++ b/tests/views/TemplateGallery.spec.js
@@ -91,7 +91,22 @@ const seededTemplates = [
* @return {Promise}
*/
async function mountGallery(routerOverrides = {}) {
- axiosMock.get.mockResolvedValueOnce({ data: { results: seededTemplates } })
+ // mounted() fires three GETs: templates (fetchTemplates), the registry
+ // store probe (probeRegistry), and the github credential probe
+ // (fetchGithubCredentials). Dispatch by URL so all three resolve.
+ axiosMock.get.mockImplementation((url) => {
+ const u = String(url)
+ if (u.includes('application-template')) {
+ return Promise.resolve({ data: { results: seededTemplates } })
+ }
+ if (u.includes('store/templates')) {
+ return Promise.resolve({ data: { outcome: 'not_configured', cards: [] } })
+ }
+ if (u.includes('credentials')) {
+ return Promise.resolve({ data: [] })
+ }
+ return Promise.resolve({ data: {} })
+ })
const $router = {
resolve: vi.fn().mockReturnValue({ resolved: { matched: [{}], fullPath: '/applications/my-permits' } }),
@@ -120,6 +135,7 @@ async function mountGallery(routerOverrides = {}) {
},
NcLoadingIcon: true,
NcEmptyContent: { name: 'NcEmptyContent', props: ['name'], template: '{{ name }}
' },
+ NcNoteCard: { name: 'NcNoteCard', props: ['type'], template: '
' },
},
})
@@ -142,7 +158,8 @@ describe('TemplateGallery.vue', () => {
it('renders the four seeded templates after mount', async () => {
const { wrapper } = await mountGallery()
- expect(axiosMock.get).toHaveBeenCalledTimes(1)
+ expect(axiosMock.get).toHaveBeenCalled()
+ // fetchTemplates() is the first mount-time GET.
expect(axiosMock.get.mock.calls[0][0]).toContain('/apps/openregister/api/objects/openbuild/application-template')
const cards = wrapper.findAll('.template-card')
diff --git a/tests/views/TemplateGalleryGithub.spec.js b/tests/views/TemplateGalleryGithub.spec.js
new file mode 100644
index 000000000..091414818
--- /dev/null
+++ b/tests/views/TemplateGalleryGithub.spec.js
@@ -0,0 +1,176 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Vitest unit tests for the GitHub source tab additions in
+ * `src/views/TemplateGallery.vue` (github-shop-catalogue).
+ *
+ * Covers template-catalogue-ui:
+ * - the Local + GitHub source tabs render; Registry is hidden when the store
+ * is not configured
+ * - the GitHub tab issues the search request only when it is selected, and
+ * renders the returned cards
+ * - Install on an installable card opens CloneTemplateDialog seeded with the
+ * GitHub repo identity
+ * - a rate-limited search shows the degraded hint without breaking the Local
+ * grid, and points to a credential when none is present
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+const { axiosMock } = vi.hoisted(() => ({
+ axiosMock: { get: vi.fn(), post: vi.fn() },
+}))
+
+vi.mock('@nextcloud/router', () => ({ generateUrl: (p) => p }))
+vi.mock('@nextcloud/axios', () => ({ default: axiosMock }))
+
+vi.mock('../../src/modals/CloneTemplateDialog.vue', () => ({
+ default: {
+ name: 'CloneTemplateDialog',
+ props: ['open', 'template', 'remote', 'remoteSlug', 'github', 'githubRepo'],
+ render() { return null },
+ },
+}))
+vi.mock('../../src/dialogs/EditTemplateMetadataDialog.vue', () => ({
+ default: { name: 'EditTemplateMetadataDialog', props: ['open', 'template'], render() { return null } },
+}))
+
+import TemplateGallery from '../../src/views/TemplateGallery.vue'
+
+const seededTemplates = [
+ { uuid: 'tpl-1', slug: 'permit-tracker', title: 'Permit Tracker', category: 'government-services', isSeeded: true },
+]
+
+const githubCards = [
+ { owner: 'conduction', repo: 'petstore', slug: 'petstore', name: 'Pet Store', description: 'A pet store app', category: 'internal-operations', appType: 'virtual', version: '1.0.0', stars: 12, installable: true, unparseable: false, credentials: [] },
+ { owner: 'conduction', repo: 'broken', slug: 'broken', name: 'Broken', description: '', installable: false, unparseable: true, credentials: [] },
+]
+
+/**
+ * Mount the gallery with dispatched mount-time GETs. `githubResponse` shapes
+ * the response returned by the github search endpoint.
+ *
+ * @param {object} githubResponse The `/shop/github/search` response body.
+ * @param {object} credentialsResponse The `/credentials` response body.
+ * @return {Promise}
+ */
+async function mountGallery(githubResponse = { outcome: 'ok', cards: githubCards, rateLimited: false, brokerCredentialAvailable: false }, credentialsResponse = []) {
+ axiosMock.get.mockImplementation((url) => {
+ const u = String(url)
+ if (u.includes('application-template')) {
+ return Promise.resolve({ data: { results: seededTemplates } })
+ }
+ if (u.includes('store/templates')) {
+ return Promise.resolve({ data: { outcome: 'not_configured', cards: [] } })
+ }
+ if (u.includes('shop/github/search')) {
+ return Promise.resolve({ data: githubResponse })
+ }
+ if (u.includes('credentials')) {
+ return Promise.resolve({ data: credentialsResponse })
+ }
+ return Promise.resolve({ data: {} })
+ })
+
+ const wrapper = mount(TemplateGallery, {
+ mocks: { $router: { resolve: vi.fn(), push: vi.fn() } },
+ stubs: {
+ NcButton: { name: 'NcButton', props: ['type', 'disabled'], template: ' ' },
+ NcTextField: { name: 'NcTextField', props: ['value', 'label', 'placeholder'], template: ' ' },
+ NcSelect: { name: 'NcSelect', props: ['value', 'options'], template: ' ' },
+ NcLoadingIcon: true,
+ NcEmptyContent: { name: 'NcEmptyContent', props: ['name'], template: '{{ name }}
' },
+ NcNoteCard: { name: 'NcNoteCard', props: ['type'], template: '
' },
+ NcDialog: { name: 'NcDialog', props: ['open', 'name'], template: '
' },
+ },
+ })
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await wrapper.vm.$nextTick()
+ return wrapper
+}
+
+/**
+ * Count GETs issued to the github search endpoint.
+ *
+ * @return {number}
+ */
+function githubSearchCalls() {
+ return axiosMock.get.mock.calls.filter((c) => String(c[0]).includes('shop/github/search')).length
+}
+
+describe('TemplateGallery.vue — GitHub source tab', () => {
+ beforeEach(() => {
+ axiosMock.get.mockReset()
+ axiosMock.post.mockReset()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('renders the Local + GitHub tabs and hides Registry when the store is not configured', async () => {
+ const wrapper = await mountGallery()
+ const tabLabels = wrapper.findAll('.template-gallery__tabs .nc-button-stub').wrappers.map((b) => b.text())
+ expect(tabLabels).toContain('Local')
+ expect(tabLabels).toContain('GitHub')
+ expect(tabLabels).not.toContain('Registry')
+ expect(wrapper.vm.storeConfigured).toBe(false)
+ })
+
+ it('does not search GitHub until the tab is selected, then renders the cards', async () => {
+ const wrapper = await mountGallery()
+ expect(githubSearchCalls()).toBe(0)
+
+ wrapper.vm.setSource('github')
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await wrapper.vm.$nextTick()
+
+ expect(githubSearchCalls()).toBe(1)
+ expect(wrapper.vm.githubCards.length).toBe(2)
+ const cards = wrapper.findAll('.template-card')
+ expect(cards.length).toBe(2)
+ })
+
+ it('marks an unparseable repo as a non-installable card (no Install action)', async () => {
+ const wrapper = await mountGallery()
+ wrapper.vm.setSource('github')
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await wrapper.vm.$nextTick()
+
+ const badges = wrapper.findAll('.template-card__badge--warn')
+ expect(badges.length).toBe(1)
+ })
+
+ it('Install seeds CloneTemplateDialog with the GitHub repo identity', async () => {
+ const wrapper = await mountGallery()
+ wrapper.vm.setSource('github')
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await wrapper.vm.$nextTick()
+
+ wrapper.vm.openGithubInstall(githubCards[0])
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.vm.cloneOpen).toBe(true)
+ expect(wrapper.vm.cloneMode).toBe('github')
+ expect(wrapper.vm.cloneGithubRepo).toEqual({ owner: 'conduction', repo: 'petstore' })
+ expect(wrapper.vm.cloneTarget.slug).toBe('petstore')
+ })
+
+ it('shows a rate-limit hint (with a credential pointer) without breaking the Local grid', async () => {
+ const wrapper = await mountGallery({ outcome: 'github_rate_limited', cards: [], rateLimited: true, brokerCredentialAvailable: false }, [])
+
+ // Local grid still renders its template.
+ expect(wrapper.findAll('.template-card').length).toBe(1)
+
+ wrapper.vm.setSource('github')
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.vm.githubUnavailable).toBe(true)
+ expect(wrapper.vm.hasGithubCredential).toBe(false)
+ const hint = wrapper.find('.template-gallery__github-hint')
+ expect(hint.exists()).toBe(true)
+ })
+})
diff --git a/tests/vitest/TemplateGalleryManagement.spec.js b/tests/vitest/TemplateGalleryManagement.spec.js
index 5757e7ce4..a3d6673d0 100644
--- a/tests/vitest/TemplateGalleryManagement.spec.js
+++ b/tests/vitest/TemplateGalleryManagement.spec.js
@@ -44,6 +44,7 @@ const STUBS = {
NcSelect: { name: 'NcSelect', props: ['value', 'options'], template: ' ' },
NcLoadingIcon: true,
NcEmptyContent: { name: 'NcEmptyContent', props: ['name'], template: '
' },
+ NcNoteCard: { name: 'NcNoteCard', props: ['type'], template: '
' },
NcDialog: { name: 'NcDialog', props: ['open', 'name'], template: '
' },
}
@@ -51,7 +52,17 @@ const STUBS = {
* @return {Promise}
*/
async function mountGallery() {
- axiosMock.get.mockResolvedValueOnce({ data: { results: templates } })
+ // mounted() fires three GETs (templates + registry probe + credential probe).
+ axiosMock.get.mockImplementation((url) => {
+ const u = String(url)
+ if (u.includes('store/templates')) {
+ return Promise.resolve({ data: { outcome: 'not_configured', cards: [] } })
+ }
+ if (u.includes('credentials')) {
+ return Promise.resolve({ data: [] })
+ }
+ return Promise.resolve({ data: { results: templates } })
+ })
const wrapper = mount(TemplateGallery, {
mocks: { $router: { resolve: vi.fn(), push: vi.fn() } },
stubs: STUBS,
@@ -64,6 +75,8 @@ async function mountGallery() {
describe('TemplateGallery.vue — org-local management (REQ-SAT-005)', () => {
beforeEach(() => {
axiosMock.get.mockReset()
+ axiosMock.post.mockReset()
+ axiosMock.put.mockReset()
axiosMock.delete.mockReset()
})
@@ -113,8 +126,9 @@ describe('TemplateGallery.vue — org-local management (REQ-SAT-005)', () => {
expect(axiosMock.delete).toHaveBeenCalledTimes(1)
expect(axiosMock.delete.mock.calls[0][0]).toContain('/application-template/org-1')
- // Gallery re-fetched after delete.
- expect(axiosMock.get).toHaveBeenCalledTimes(2)
+ // Gallery re-fetched the template list after delete (mount fetch + refetch).
+ const templateGets = axiosMock.get.mock.calls.filter((c) => String(c[0]).includes('/application-template'))
+ expect(templateGets.length).toBe(2)
expect(wrapper.vm.deleteOpen).toBe(false)
})
})
From 0b6eddc3331afb992389c04c62422d68dfd30c21 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Thu, 9 Jul 2026 13:35:00 +0200
Subject: [PATCH 084/391] feat(editor): visual editor for manifest schedules[]
(apphost-scheduling)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes the loop on the apphost-scheduling capability: a citizen developer can
now add/edit/remove a scheduled task through OpenBuild's page designer instead
of hand-editing manifest JSON.
- SchedulesSection.vue — a controlled section (mirrors WorkflowAttachmentsSection),
mounted as a 4th section in PageDesignerHost alongside Workflows/Theme/Documents.
Empty state + list (id, human cadence/action/sync summary, enabled indicator),
add/edit/remove. Emits update:manifest with a cloned schedules[] — persistence
is free via the existing ApplicationVersion save; no new endpoint.
- ScheduleEditDialog.vue — standalone modal. Cadence presets
(Hourly/Daily/Weekly/Monthly -> interval; Custom cron -> validated 5-field cron;
Custom interval -> raw seconds), a labeled Action select ('Run a synchronization'
-> openconnector:synchronization), a synchronization picker (OR objects fetch)
with free-text-id fallback on load failure, an Enabled switch (default true),
and an auto-derived unique kebab id. Save gated on validity.
- services/manifestValidation/schedules.js — one-of interval|cron, 5-field cron,
allow-listed action, required arguments.synchronizationId, unique non-empty ids;
wired into useManifestValidator (additive-tolerant).
- i18n (en/nl), Vitest specs: SchedulesSection (9) + ScheduleEditDialog (13) +
schedulesValidation (23) = 45 new tests green.
UI-verified live: the section renders in the page designer, the dialog authors a
schedule (presets, sync free-text fallback, validation gating), and the entry
appears in the list. Depends on nextcloud-vue#132 (schedules[] schema).
Spec: openspec/changes/schedules-editor (validate --strict clean).
---
l10n/en.json | 72 ++-
l10n/nl.json | 34 +-
.../changes/schedules-editor/.openspec.yaml | 2 +
openspec/changes/schedules-editor/design.md | 222 +++++++++
openspec/changes/schedules-editor/proposal.md | 71 +++
.../openbuild-schedules-authoring/spec.md | 183 +++++++
openspec/changes/schedules-editor/tasks.md | 41 ++
src/components/SchedulesSection.vue | 262 ++++++++++
src/composables/useManifestValidator.js | 2 +
src/dialogs/ScheduleEditDialog.vue | 454 ++++++++++++++++++
src/services/manifestValidation/schedules.js | 147 ++++++
src/views/PageDesignerHost.vue | 10 +
tests/components/ScheduleEditDialog.spec.js | 210 ++++++++
tests/components/SchedulesSection.spec.js | 104 ++++
tests/services/schedulesValidation.spec.js | 144 ++++++
15 files changed, 1956 insertions(+), 2 deletions(-)
create mode 100644 openspec/changes/schedules-editor/.openspec.yaml
create mode 100644 openspec/changes/schedules-editor/design.md
create mode 100644 openspec/changes/schedules-editor/proposal.md
create mode 100644 openspec/changes/schedules-editor/specs/openbuild-schedules-authoring/spec.md
create mode 100644 openspec/changes/schedules-editor/tasks.md
create mode 100644 src/components/SchedulesSection.vue
create mode 100644 src/dialogs/ScheduleEditDialog.vue
create mode 100644 src/services/manifestValidation/schedules.js
create mode 100644 tests/components/ScheduleEditDialog.spec.js
create mode 100644 tests/components/SchedulesSection.spec.js
create mode 100644 tests/services/schedulesValidation.spec.js
diff --git a/l10n/en.json b/l10n/en.json
index 62d0382c5..92cb388fb 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -895,7 +895,77 @@
"Register slug": "Register slug",
"Remove data register": "Remove data register",
"Shared, non-versioned OpenRegister registers this app binds to alongside its own per-version register (e.g. a dataset fed by OpenConnector). Not owned by this app — promotion and export treat them as reference-only.": "Shared, non-versioned OpenRegister registers this app binds to alongside its own per-version register (e.g. a dataset fed by OpenConnector). Not owned by this app — promotion and export treat them as reference-only.",
- "This app is bound to shared data registers it does not own. Their schema definitions are always included as reference material. Row data is only bundled for a register when you switch it on below.": "This app is bound to shared data registers it does not own. Their schema definitions are always included as reference material. Row data is only bundled for a register when you switch it on below."
+ "This app is bound to shared data registers it does not own. Their schema definitions are always included as reference material. Row data is only bundled for a register when you switch it on below.": "This app is bound to shared data registers it does not own. Their schema definitions are always included as reference material. Row data is only bundled for a register when you switch it on below.",
+ "%n row could not be imported.": "%n row could not be imported.",
+ "%n rows could not be imported.": "%n rows could not be imported.",
+ "Action": "Action",
+ "Add rows to an existing schema": "Add rows to an existing schema",
+ "Add scheduled task": "Add scheduled task",
+ "Cadence": "Cadence",
+ "Choose a different file": "Choose a different file",
+ "Choose a file": "Choose a file",
+ "Choose a schema": "Choose a schema",
+ "Create a new schema from the file": "Create a new schema from the file",
+ "created": "created",
+ "Cron expression (5 fields)": "Cron expression (5 fields)",
+ "Cron: {expr}": "Cron: {expr}",
+ "CSV file": "CSV file",
+ "Custom (cron)": "Custom (cron)",
+ "Custom interval (seconds)": "Custom interval (seconds)",
+ "Daily": "Daily",
+ "Done": "Done",
+ "Download a matching template": "Download a matching template",
+ "Download error report": "Download error report",
+ "e.g. 0 3 * * 1": "e.g. 0 3 * * 1",
+ "e.g. 00000000-0000-0000-0000-000000000000": "e.g. 00000000-0000-0000-0000-000000000000",
+ "e.g. 43200": "e.g. 43200",
+ "e.g. Nightly BRP sync": "e.g. Nightly BRP sync",
+ "Edit scheduled task": "Edit scheduled task",
+ "Enter a valid 5-field cron expression.": "Enter a valid 5-field cron expression.",
+ "Every {seconds}s": "Every {seconds}s",
+ "Excel spreadsheet": "Excel spreadsheet",
+ "Field preview is not available for this file type; OpenRegister will parse it on import.": "Field preview is not available for this file type; OpenRegister will parse it on import.",
+ "File": "File",
+ "First rows": "First rows",
+ "Hourly": "Hourly",
+ "Identifier": "Identifier",
+ "Import": "Import",
+ "Import \"{file}\" as a new schema. OpenRegister infers the fields from the header row and writes the rows.": "Import \"{file}\" as a new schema. OpenRegister infers the fields from the header row and writes the rows.",
+ "Import \"{file}\" into the \"{schema}\" schema. OpenRegister parses the file and writes the rows.": "Import \"{file}\" into the \"{schema}\" schema. OpenRegister parses the file and writes the rows.",
+ "Import complete": "Import complete",
+ "Import data": "Import data",
+ "Import failed.": "Import failed.",
+ "Importing via OpenRegister…": "Importing via OpenRegister…",
+ "Interval (seconds)": "Interval (seconds)",
+ "JSON export": "JSON export",
+ "Large files import synchronously and may take a while. Consider splitting very large spreadsheets.": "Large files import synchronously and may take a while. Consider splitting very large spreadsheets.",
+ "Monthly": "Monthly",
+ "No action": "No action",
+ "No cadence": "No cadence",
+ "No scheduled tasks yet. Add one to run a synchronization on a schedule.": "No scheduled tasks yet. Add one to run a synchronization on a schedule.",
+ "No schemas in this version yet": "No schemas in this version yet",
+ "No synchronization": "No synchronization",
+ "OpenRegister reads the file's header row to infer the fields and creates the schema for you.": "OpenRegister reads the file's header row to infer the fields and creates the schema for you.",
+ "OpenRegister will infer these fields from the file and create the schema.": "OpenRegister will infer these fields from the file and create the schema.",
+ "Please complete the scheduled task before saving.": "Please complete the scheduled task before saving.",
+ "Preview": "Preview",
+ "Ready to import": "Ready to import",
+ "Remove this scheduled task?": "Remove this scheduled task?",
+ "Result": "Result",
+ "Rows will be mapped onto these schema fields by matching column headers.": "Rows will be mapped onto these schema fields by matching column headers.",
+ "Run a synchronization": "Run a synchronization",
+ "Scheduled tasks": "Scheduled tasks",
+ "Select xlsx, xls, csv or json": "Select xlsx, xls, csv or json",
+ "skipped": "skipped",
+ "Synchronization": "Synchronization",
+ "Synchronization id": "Synchronization id",
+ "The synchronization list could not be loaded. Enter a synchronization id manually.": "The synchronization list could not be loaded. Enter a synchronization id manually.",
+ "Undo import": "Undo import",
+ "Undoing…": "Undoing…",
+ "Unsupported file type": "Unsupported file type",
+ "updated": "updated",
+ "Weekly": "Weekly",
+ "Where should the data go?": "Where should the data go?"
},
"plurals": ""
}
diff --git a/l10n/nl.json b/l10n/nl.json
index d352071c9..6097eb5ff 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -753,7 +753,39 @@
"Selector resolved to no value in the latest sample": "Selector leverde geen waarde op in het laatste voorbeeld",
"Showing cached data — a refresh failed.": "Gecachte gegevens worden getoond — vernieuwen is mislukt.",
"Switching to OpenRegister discards the OpenConnector mapping. Continue?": "Overschakelen naar OpenRegister verwijdert de OpenConnector-koppeling. Doorgaan?",
- "This binding cannot be verified on this instance.": "Deze koppeling kan niet op deze instantie worden geverifieerd."
+ "This binding cannot be verified on this instance.": "Deze koppeling kan niet op deze instantie worden geverifieerd.",
+ "Scheduled tasks": "Geplande taken",
+ "Add scheduled task": "Geplande taak toevoegen",
+ "No scheduled tasks yet. Add one to run a synchronization on a schedule.": "Nog geen geplande taken. Voeg er een toe om een synchronisatie volgens een schema uit te voeren.",
+ "Disabled": "Uitgeschakeld",
+ "Cron: {expr}": "Cron: {expr}",
+ "Every {seconds}s": "Elke {seconds}s",
+ "No cadence": "Geen frequentie",
+ "Run a synchronization": "Een synchronisatie uitvoeren",
+ "No action": "Geen actie",
+ "No synchronization": "Geen synchronisatie",
+ "Edit scheduled task": "Geplande taak bewerken",
+ "e.g. Nightly BRP sync": "bijv. Nachtelijke BRP-synchronisatie",
+ "Identifier": "Identificatie",
+ "Cadence": "Frequentie",
+ "Cron expression (5 fields)": "Cron-expressie (5 velden)",
+ "e.g. 0 3 * * 1": "bijv. 0 3 * * 1",
+ "Enter a valid 5-field cron expression.": "Voer een geldige cron-expressie met 5 velden in.",
+ "Interval (seconds)": "Interval (seconden)",
+ "e.g. 43200": "bijv. 43200",
+ "Action": "Actie",
+ "Synchronization": "Synchronisatie",
+ "The synchronization list could not be loaded. Enter a synchronization id manually.": "De synchronisatielijst kon niet worden geladen. Voer handmatig een synchronisatie-id in.",
+ "Synchronization id": "Synchronisatie-id",
+ "e.g. 00000000-0000-0000-0000-000000000000": "bijv. 00000000-0000-0000-0000-000000000000",
+ "Please complete the scheduled task before saving.": "Vul de geplande taak volledig in voordat u opslaat.",
+ "Hourly": "Elk uur",
+ "Daily": "Dagelijks",
+ "Weekly": "Wekelijks",
+ "Monthly": "Maandelijks",
+ "Custom (cron)": "Aangepast (cron)",
+ "Custom interval (seconds)": "Aangepast interval (seconden)",
+ "Remove this scheduled task?": "Deze geplande taak verwijderen?"
},
"plurals": null
}
diff --git a/openspec/changes/schedules-editor/.openspec.yaml b/openspec/changes/schedules-editor/.openspec.yaml
new file mode 100644
index 000000000..074342d55
--- /dev/null
+++ b/openspec/changes/schedules-editor/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-09
diff --git a/openspec/changes/schedules-editor/design.md b/openspec/changes/schedules-editor/design.md
new file mode 100644
index 000000000..67d0a7640
--- /dev/null
+++ b/openspec/changes/schedules-editor/design.md
@@ -0,0 +1,222 @@
+## Context
+
+OpenBuild manifests carry a top-level `schedules[]` array — the
+apphost-scheduling capability. Each entry declares a scheduled task (a cadence
+plus an action) that a generic OpenRegister AppHost reconciler translates into
+an OpenConnector job. The reconciler and the `schedules[]` manifest schema are
+already implemented and live-verified. The schema itself ships from
+**nextcloud-vue** (PR #132, off `beta`), NOT this repo. What is missing is an
+authoring UI: today `schedules[]` is only hand-editable in raw manifest JSON.
+
+This change adds that UI and nothing else — it is a `kind: code` change scoped
+to the OpenBuild Vue frontend.
+
+### Ground-truth architecture (verified)
+
+- **Editor host** — `src/views/PageDesignerHost.vue` holds `this.manifest`
+ (data field); `onManifestUpdate()` (~line 410) assigns it; `save()`
+ (~lines 420–467) PUTs the whole ApplicationVersion manifest to
+ `PUT /apps/openregister/api/objects/openbuild/applicationVersion/{versionUuid}`
+ (fallback `/application/{uuid}`). The persisted payload is
+ `{ ...version, manifest }`, so **any top-level manifest key the editor does
+ not touch survives the round-trip**. A schedules editor therefore gets
+ persistence for free by mutating `manifest.schedules` and emitting
+ `update:manifest` — no new endpoint, no new store, no new save path.
+- **Section pattern to mirror** — `src/components/WorkflowAttachmentsSection.vue`
+ is a controlled component: `:manifest` prop in, `@update:manifest` out,
+ rendering a list with add/edit/detach and hosting a standalone dialog
+ (`src/dialogs/WorkflowAttachmentDialog.vue`) per the modal-isolation gate.
+ Its siblings mounted in `PageDesignerHost.vue` are `ThemeSection`,
+ `WorkflowAttachmentsSection`, `DocumentAttachmentsSection`. The new
+ `SchedulesSection.vue` mounts as the 4th such section.
+- **Synchronization list** — there is NO OpenConnector index route;
+ synchronizations are OR objects. Reuse the pattern in
+ `openconnector/src/modals/v2/JobFormFields.vue:341-368`:
+ `fetchSynchronizations()` → `GET /apps/openregister/api/objects/openconnector/synchronization?limit=500`,
+ mapped to `{ id, label: name || title || id }`. It must degrade gracefully
+ (free-text id fallback) when OpenConnector / OR is absent, exactly like
+ `src/components/ConnectorSourcePicker.vue`.
+- **Form primitives** — editors use plain `@nextcloud/vue` (`NcSelect` with
+ `:input-label`, `NcTextField`, `NcCheckboxRadioSwitch`, `NcButton`,
+ `NcModal`), NOT the runtime `Cn*` components. House style: see
+ `ConnectorSourcePicker.vue`, `AppSettingsModal.vue`, `ThemeSection.vue`.
+- **Validation service pattern** — `src/services/manifestValidation/*`
+ (`theme.js`, `workflowAttachments.js`, `documentAttachments.js`,
+ `connectorDataSource.js`) each export a `validateX(manifest)` used by
+ `src/composables/useManifestValidator.js`. Add `schedules.js` there and wire
+ it in.
+- **Tests** — Vitest (`npm run test`); component specs in `tests/components/`
+ (e.g. `WorkflowAttachmentsSection.spec.js`), service specs in
+ `tests/services/` (e.g. `workflowAttachmentsValidation.spec.js`).
+
+## Goals / Non-Goals
+
+**Goals**
+- A citizen developer can add, edit and remove a scheduled task through the UI
+ instead of hand-editing manifest JSON.
+- The written manifest entry is valid against the nextcloud-vue `schedules[]`
+ schema and stores EITHER `interval` OR `cron` (never both).
+- The section degrades gracefully when the synchronization list can't load.
+
+**Non-Goals**
+- No changes to the AppHost reconciler or OpenConnector job creation (already
+ live).
+- No new OpenRegister schema, register fragment, or seed data.
+- No new backend service, controller, or route.
+- No new save endpoint — persistence rides the existing ApplicationVersion PUT.
+- Not authoring the `schedules[]` JSON-schema (that lives in nextcloud-vue #132).
+
+## Decisions
+
+### Decision 1: Cadence UX — friendly presets + Custom cron
+
+A single `NcSelect` "Cadence" offers five presets. Non-custom presets write an
+`interval` (seconds); "Custom" reveals a raw 5-field `cron` `NcTextField` with
+live validation. The entry stores exactly one of `interval` | `cron`.
+
+| Preset | Writes |
+|---------|---------------------------|
+| Hourly | `interval: 3600` |
+| Daily | `interval: 86400` |
+| Weekly | `interval: 604800` |
+| Monthly | `interval: 2592000` (30d) |
+| Custom | `cron: "<5-field cron>"` |
+
+On edit, an existing entry is reverse-mapped: a known interval value selects
+its preset; any other `interval` or a `cron` value selects "Custom" (a
+non-preset interval is surfaced in an optional number+unit field so it
+round-trips). Switching a preset clears any previously-stored `cron`, and
+choosing "Custom" clears `interval` — so the one-of invariant holds at write
+time, not only at validate time.
+
+### Decision 2: Action = labeled select, one option now
+
+An "Action" `NcSelect` (`:input-label="Action"`) lists action types; today the
+only option is **"Run a synchronization"** → `action:
+"openconnector:synchronization"`. Below it, a **synchronization picker**
+(`NcSelect`) populates `arguments.synchronizationId`. The action is NOT
+hardcoded away: future action types add options and (optionally) their own
+argument sub-forms. For the sync action the written shape is:
+
+```json
+{
+ "id": "nightly-brp-sync",
+ "enabled": true,
+ "interval": 86400,
+ "action": "openconnector:synchronization",
+ "arguments": { "synchronizationId": "00000000-0000-0000-0000-000000000000" }
+}
+```
+
+### Decision 3: Synchronization picker degrades gracefully
+
+`fetchSynchronizations()` calls
+`GET /apps/openregister/api/objects/openconnector/synchronization?limit=500`
+and maps results to `{ id, label }`. On any failure (route 404, network error,
+OpenConnector/OR absent) the picker falls back to a plain `NcTextField` where
+the developer can type a raw `synchronizationId`. The already-stored id is
+preserved and shown either way — mirroring `ConnectorSourcePicker.vue`.
+
+### Decision 4: `enabled` and `id`
+
+- `enabled` — `NcCheckboxRadioSwitch type="switch"`, default `true`.
+- `id` — a stable slug, auto-derived from a human label (kebab-case) or typed
+ directly in an id `NcTextField`; unique within `manifest.schedules[]`. The
+ reconciler uses `id` as the OpenConnector job's stable key, so edits must
+ preserve it and adds must not collide.
+
+### Decision 5: Controlled component + free persistence (no new save path)
+
+`SchedulesSection.vue` never calls the API to save. It computes its list from
+`manifest.schedules`, and every mutation (add/edit/remove) emits an
+`update:manifest` with a shallow-cloned manifest whose `schedules` array is
+replaced. `PageDesignerHost` already owns the save; the section is pure
+presentation + local edit state. This is the same contract
+`WorkflowAttachmentsSection.vue` uses.
+
+### Declarative-vs-imperative decision (hydra ADR-031)
+
+There is **no declarative-backend behaviour in this change**. The
+apphost-scheduling reconciler (OR AppHost → OpenConnector jobs) already exists
+and is untouched. This change adds only an authoring UI that writes the
+already-defined declarative `schedules[]` manifest data. No
+`x-openregister-notifications` dialect, no imperative dispatch, no new
+service — ADR-031 is satisfied by construction (nothing declarative-backend is
+added or changed).
+
+### ADR-004 compliance
+
+The UI is Vue 2.7 + `@nextcloud/vue` primitives only (`NcSelect`,
+`NcTextField`, `NcCheckboxRadioSwitch`, `NcButton`, `NcModal`) — no runtime
+`Cn*` components in the editor. The dialog lives in its own file under
+`src/dialogs/` (modal-isolation gate). Every `NcSelect` carries an
+`:input-label` (nc-input-labels gate). No DOM data-attribute reads, no admin
+router exposure — none apply to a page-designer section.
+
+## Validation rules (`services/manifestValidation/schedules.js`)
+
+For each entry in `manifest.schedules`:
+- Exactly one of `interval` (positive integer seconds) or `cron` is present —
+ both-present or neither is an error.
+- When present, `cron` is a 5-field expression (minute hour day-of-month month
+ day-of-week); malformed field count or tokens is an error.
+- `action` is on the allow-list (currently `["openconnector:synchronization"]`).
+- For `action: "openconnector:synchronization"`,
+ `arguments.synchronizationId` is a non-empty string.
+- `id` is a non-empty slug and unique across `schedules[]`.
+
+Errors surface through `useManifestValidator` exactly like the sibling
+validators (side-panel list + the section's inline message).
+
+## Mixed-spec rationale / Dependencies (cross-repo)
+
+This change is `kind: code` (OpenBuild Vue). The `schedules[]` JSON-schema
+*definition* it authors against is a **nextcloud-vue delta already shipped in
+PR #132** (off `beta`) — a separate repo and spec, declared in `depends_on`.
+
+Because the canonical client validator
+(`validateManifest` from `@conduction/nextcloud-vue`, consumed by
+`useManifestValidator`, and the `check:manifest` gate) resolves against the
+installed nextcloud-vue build, an editor-authored manifest containing
+`schedules[]` must not be rejected before #132 is merged and released.
+**Guard/sequence:** treat `schedules[]` as an **additive** top-level key — the
+canonical validator must tolerate it (unknown-but-allowed), and the app-side
+`schedules.js` checks are the authoritative gate until #132 lands. Ship this UI
+against a nextcloud-vue build that includes #132, OR keep the section behind
+the same tolerance so a stale validator returns `valid` for `schedules[]`
+rather than failing closed.
+
+## No OR schema / no seed data
+
+This change adds **no** OpenRegister schema, no `register.d/` fragment, and no
+seed data. `schedules[]` is manifest JSON persisted inside the existing
+ApplicationVersion object via the existing PUT; it is not a separate OR object
+and needs no new schema surface here. (The schema that *validates* it is the
+nextcloud-vue delta, not an OR register.)
+
+## Risks / Trade-offs
+
+- **Stale validator false-negative** — if the deployed nextcloud-vue predates
+ #132 and its `validateManifest` fails closed on unknown keys, saving a
+ schedules manifest could be blocked. Mitigation: additive-tolerance guard
+ above; app-side `schedules.js` as the authoritative check.
+- **Interval reverse-mapping ambiguity** — a hand-authored `interval` that
+ isn't one of the four preset constants maps to "Custom"; handled by the
+ optional number+unit field so it round-trips without data loss.
+- **Sync id drift** — a stored `synchronizationId` whose synchronization was
+ deleted still shows (free-text / raw id) rather than silently clearing, so
+ edits never destroy a valid-looking reference the developer didn't intend to
+ drop.
+
+## Migration Plan
+
+None. Additive frontend only. Apps with no `schedules[]` render an empty
+section; apps with an existing `schedules[]` (hand-authored) load into the
+list unchanged.
+
+## Open Questions
+
+- Capability name `openbuild-schedules-authoring` vs `schedules-editor` — see
+ DEFERRED_QUESTIONS in the change summary.
+- Whether to expose the plain number+unit interval field for *every* preset or
+ only as the "non-preset interval" escape hatch (currently the latter).
diff --git a/openspec/changes/schedules-editor/proposal.md b/openspec/changes/schedules-editor/proposal.md
new file mode 100644
index 000000000..1961b9b01
--- /dev/null
+++ b/openspec/changes/schedules-editor/proposal.md
@@ -0,0 +1,71 @@
+---
+kind: code
+depends_on:
+ - nextcloud-vue#132 # manifest `schedules[]` JSON-schema definition (off `beta`)
+---
+
+## Why
+
+An OpenBuild manifest already carries a top-level `schedules[]` array — the
+**apphost-scheduling capability**. Each entry is a declarative scheduled task
+that a generic OpenRegister AppHost reconciler turns into a concrete
+OpenConnector job (run a synchronization on a cadence). The backend
+reconciler and the manifest schema for `schedules[]` are already implemented
+and live-verified; the schema itself ships from **nextcloud-vue** (added in
+PR #132, off `beta` — NOT this repo).
+
+Today the only way to add a scheduled task to an app is to hand-edit the
+manifest JSON. A citizen developer working in OpenBuild's app editor has no
+UI surface for it, so the apphost-scheduling loop is only half-closed: the
+runtime can execute schedules, but nobody can author them without dropping to
+raw JSON. This change closes the loop with an **authoring UI only** — no new
+backend behaviour, no new OR schema, no reconciler work.
+
+Per hydra ADR-004 the editor is plain Vue 2.7 + `@nextcloud/vue`, and per
+hydra ADR-031 there is deliberately **no declarative-backend behaviour** in
+this change: the reconciler already exists and is untouched; this is a pure
+authoring surface that mutates `manifest.schedules` in the page designer.
+
+## What Changes
+
+- **NEW** `SchedulesSection.vue` (`src/components/`) — a controlled component
+ (`:manifest` prop in, `@update:manifest` out) that renders the app's
+ `manifest.schedules[]` as a list with add / edit / remove, mirroring
+ `WorkflowAttachmentsSection.vue`. It mounts as a 4th section in
+ `PageDesignerHost.vue` alongside ThemeSection, WorkflowAttachmentsSection
+ and DocumentAttachmentsSection.
+- **NEW** `ScheduleEditDialog.vue` (`src/dialogs/`) — a standalone
+ `NcModal`-based dialog (per the hydra modal-isolation gate) that edits one
+ schedule entry: a friendly **cadence preset** dropdown (Hourly / Daily /
+ Weekly / Monthly / Custom → writes an `interval` in seconds, or a validated
+ 5-field `cron` for Custom), an **Action** select (one option today: "Run a
+ synchronization" → `action: "openconnector:synchronization"`), a
+ **synchronization picker** (`NcSelect`) that populates
+ `arguments.synchronizationId` and degrades to a free-text id field when the
+ list can't be loaded, an **Enabled** switch (default true), and a stable
+ **id** slug.
+- **NEW** `services/manifestValidation/schedules.js` — app-side strict checks
+ (exactly one of `interval` | `cron`; 5-field cron shape; allow-listed
+ action; unique entry ids; `arguments.synchronizationId` required for the
+ sync action), wired into `useManifestValidator` alongside the existing
+ workflow/theme/document/connector validators.
+- **NO new backend, route, OR schema, or seed data.** Persistence is free:
+ `PageDesignerHost.save()` already PUTs the whole ApplicationVersion manifest
+ (`{...version, manifest}`), so any top-level key the editor doesn't touch —
+ including `schedules[]` — survives. The section simply mutates
+ `manifest.schedules` and emits `update:manifest`.
+- **Cross-repo dependency / additive-validation guard.** The `schedules[]`
+ JSON-schema definition lives in nextcloud-vue (#132). The client-side
+ canonical validator (`validateManifest` from `@conduction/nextcloud-vue`,
+ and `check:manifest`) must **tolerate** a `schedules[]` array additively so
+ an editor-authored schedules manifest is not rejected before #132 merges —
+ see design.md "Mixed-spec rationale / Dependencies".
+
+### Capabilities
+
+- **ADDED** `openbuild-schedules-authoring` — the visual authoring surface for
+ the manifest `schedules[]` array in OpenBuild's app editor.
+
+No existing capability is modified. `openbuild-page-designer` gains a new
+section by composition (a mounted sibling component), not by changing its
+existing requirements.
diff --git a/openspec/changes/schedules-editor/specs/openbuild-schedules-authoring/spec.md b/openspec/changes/schedules-editor/specs/openbuild-schedules-authoring/spec.md
new file mode 100644
index 000000000..599b2c1f0
--- /dev/null
+++ b/openspec/changes/schedules-editor/specs/openbuild-schedules-authoring/spec.md
@@ -0,0 +1,183 @@
+## ADDED Requirements
+
+### Requirement: Schedules section renders the app's schedules as a list
+
+The system SHALL render a **Schedules** section in the OpenBuild page designer
+(`SchedulesSection.vue`, mounted in `PageDesignerHost.vue` beside the Theme,
+Workflow-attachments and Document-attachments sections) that lists every entry
+in the current `manifest.schedules[]` array. The section is a controlled
+component: it reads its data from the `:manifest` prop and emits all changes
+via `@update:manifest`; it never calls a save API of its own.
+
+**ID:** REQ-OBSA-001
+
+#### Scenario: Existing schedules render as a list
+
+- **WHEN** the page designer opens an app whose `manifest.schedules` contains
+ one or more entries
+- **THEN** the Schedules section lists each entry with its id, cadence summary,
+ action and enabled state
+- **AND** an app whose `manifest.schedules` is absent or empty renders an empty
+ Schedules section with an "add" affordance and no error
+
+#### Scenario: Add opens the edit dialog
+
+- **WHEN** the developer activates "Add schedule"
+- **THEN** the standalone `ScheduleEditDialog` (an `NcModal` in
+ `src/dialogs/`) opens with default values (enabled = true, no id yet, cadence
+ and action unset)
+
+### Requirement: Cadence preset writes interval or validated cron
+
+The edit dialog SHALL present a **Cadence** `NcSelect` with presets Hourly /
+Daily / Weekly / Monthly / Custom. Non-custom presets write an `interval` in
+seconds (Hourly = 3600, Daily = 86400, Weekly = 604800, Monthly = 2592000);
+"Custom" reveals a 5-field `cron` `NcTextField` with live validation. A saved
+entry SHALL carry exactly one of `interval` or `cron`, never both.
+
+**ID:** REQ-OBSA-002
+
+#### Scenario: Selecting a non-custom preset writes interval
+
+- **WHEN** the developer selects the "Daily" cadence preset and saves the
+ entry
+- **THEN** the entry written to `manifest.schedules` has `interval: 86400` and
+ no `cron` key
+
+#### Scenario: Custom writes a validated cron and clears interval
+
+- **WHEN** the developer selects "Custom" and enters a valid 5-field cron
+ (e.g. `0 2 * * *`) and saves
+- **THEN** the entry has `cron: "0 2 * * *"` and no `interval` key
+
+#### Scenario: Switching cadence enforces the one-of invariant
+
+- **WHEN** the developer changes an entry that had a `cron` back to the
+ "Weekly" preset
+- **THEN** the written entry has `interval: 604800` and the previous `cron`
+ key is removed — the manifest never carries both `interval` and `cron`
+
+### Requirement: Action select and synchronization picker write the action arguments
+
+The edit dialog SHALL present an **Action** `NcSelect` (`:input-label`) whose
+first and currently only option "Run a synchronization" maps to
+`action: "openconnector:synchronization"`, and — for that action — a
+**synchronization picker** `NcSelect` that writes the chosen id to
+`arguments.synchronizationId`. The action select SHALL remain extensible: new
+action types add options without removing the sync action.
+
+**ID:** REQ-OBSA-003
+
+#### Scenario: Choosing the sync action and a synchronization writes both fields
+
+- **WHEN** the developer selects "Run a synchronization" and picks a
+ synchronization from the picker and saves
+- **THEN** the entry has `action: "openconnector:synchronization"` and
+ `arguments.synchronizationId` set to the picked id
+ (e.g. `"00000000-0000-0000-0000-000000000000"`)
+
+#### Scenario: Synchronization picker degrades to free text when the list can't load
+
+- **WHEN** the synchronization list cannot be fetched (OpenConnector/OR absent,
+ route 404, or network error)
+- **THEN** the picker falls back to a plain text field for a raw
+ `synchronizationId`
+- **AND** any already-stored `arguments.synchronizationId` remains visible and
+ is preserved on save
+
+### Requirement: Enabled switch and stable id
+
+The edit dialog SHALL present an **Enabled** `NcCheckboxRadioSwitch`
+(`type="switch"`, default true) writing the entry's `enabled` boolean, and a
+stable **id** (auto-derived kebab-case slug from a human label, or typed in an
+id field) that is unique within `manifest.schedules[]`.
+
+**ID:** REQ-OBSA-004
+
+#### Scenario: Enabled defaults on and toggles off
+
+- **WHEN** a new entry is created and left untouched
+- **THEN** it is written with `enabled: true`
+- **AND** toggling the switch off writes `enabled: false`
+
+#### Scenario: id is a unique slug
+
+- **WHEN** the developer names a new schedule "Nightly BRP sync"
+- **THEN** the entry id is derived as a kebab-case slug (e.g. `nightly-brp-sync`)
+- **AND** attempting to save a second entry with an id already used in
+ `manifest.schedules[]` is blocked with a uniqueness message
+
+### Requirement: Edit updates in place and remove deletes the entry
+
+The Schedules section SHALL let the developer edit an existing entry (opening
+the dialog pre-filled and writing changes back to the same array position,
+preserving its `id`) and remove an entry (deleting it from
+`manifest.schedules[]`). Both emit `@update:manifest`.
+
+**ID:** REQ-OBSA-005
+
+#### Scenario: Edit updates the same entry in place
+
+- **WHEN** the developer opens an existing schedule, changes its cadence from
+ Daily to Weekly, and saves
+- **THEN** the same array entry is updated (`interval: 604800`) with its `id`
+ unchanged and no duplicate entry is added
+
+#### Scenario: Remove deletes the entry
+
+- **WHEN** the developer removes a schedule entry
+- **THEN** that entry is deleted from `manifest.schedules[]` and the section
+ emits the updated manifest
+
+### Requirement: Invalid entries are blocked with a message
+
+The system SHALL run `services/manifestValidation/schedules.js` (wired into
+`useManifestValidator`) and block an invalid entry with a clear message rather
+than writing it. An entry is invalid when it has both or neither of
+`interval`/`cron`, a malformed cron, an action not on the allow-list, a missing
+`arguments.synchronizationId` for the sync action, or a duplicate/empty id.
+
+**ID:** REQ-OBSA-006
+
+#### Scenario: Both interval and cron is rejected
+
+- **WHEN** an entry would carry both an `interval` and a `cron`
+- **THEN** validation reports the one-of violation and the entry cannot be
+ saved
+
+#### Scenario: Neither interval nor cron is rejected
+
+- **WHEN** an entry has neither `interval` nor `cron`
+- **THEN** validation reports that a cadence is required and the entry cannot
+ be saved
+
+#### Scenario: Malformed cron is rejected
+
+- **WHEN** the developer enters a cron that is not a well-formed 5-field
+ expression (e.g. `0 2 * *`)
+- **THEN** validation reports the cron shape error and the entry cannot be
+ saved
+
+#### Scenario: Missing synchronization for the sync action is rejected
+
+- **WHEN** the action is `openconnector:synchronization` and
+ `arguments.synchronizationId` is empty
+- **THEN** validation reports the missing synchronization and the entry cannot
+ be saved
+
+### Requirement: Edits persist via the existing ApplicationVersion save
+
+The system SHALL persist schedule edits through the page designer's existing
+save path — `PageDesignerHost.save()` PUTs the whole ApplicationVersion
+manifest, and `schedules[]` rides along as a top-level manifest key. This
+change SHALL NOT add a new endpoint, store, or save method for schedules.
+
+**ID:** REQ-OBSA-007
+
+#### Scenario: Schedules survive the manifest round-trip
+
+- **WHEN** the developer adds a schedule and triggers the page designer save
+- **THEN** the ApplicationVersion PUT payload's `manifest.schedules` contains
+ the new entry
+- **AND** every other top-level manifest key (pages, theme, workflows,
+ documents) is unchanged by the schedules edit
diff --git a/openspec/changes/schedules-editor/tasks.md b/openspec/changes/schedules-editor/tasks.md
new file mode 100644
index 000000000..e925383db
--- /dev/null
+++ b/openspec/changes/schedules-editor/tasks.md
@@ -0,0 +1,41 @@
+## 1. Authoring UI
+
+- [x] 1.1 Add `src/components/SchedulesSection.vue` — controlled component (`:manifest` in, `@update:manifest` out) listing `manifest.schedules[]` with add/edit/remove, mirroring `WorkflowAttachmentsSection.vue` (no save API of its own)
+- [x] 1.2 Add `src/dialogs/ScheduleEditDialog.vue` — standalone `NcModal` (modal-isolation gate) with cadence preset select (Hourly/Daily/Weekly/Monthly→`interval`; Custom→validated 5-field `cron`), reverse-mapping existing entries, enforcing the one-of `interval`|`cron` invariant at write time
+- [x] 1.3 In `ScheduleEditDialog.vue` add the Action `NcSelect` (`:input-label`, option "Run a synchronization"→`openconnector:synchronization`, kept extensible) plus the synchronization picker writing `arguments.synchronizationId`
+- [x] 1.4 Implement `fetchSynchronizations()` (`GET /apps/openregister/api/objects/openconnector/synchronization?limit=500`, map to `{id,label}`) with graceful free-text fallback when the list can't load — mirror `ConnectorSourcePicker.vue`
+- [x] 1.5 Add the Enabled `NcCheckboxRadioSwitch type="switch"` (default true) and the stable unique `id` slug (auto-derive kebab-case from a label, or typed id field)
+- [x] 1.6 Mount `SchedulesSection` as the 4th section in `src/views/PageDesignerHost.vue`, wired to the existing `manifest` data field and `onManifestUpdate()`
+
+## 2. Validation
+
+- [x] 2.1 Add `src/services/manifestValidation/schedules.js` — one-of `interval`|`cron`, 5-field cron shape, allow-listed action, unique non-empty ids, `arguments.synchronizationId` required for the sync action
+- [x] 2.2 Wire `validateSchedules` into `src/composables/useManifestValidator.js` alongside the existing workflow/theme/document/connector validators; keep `schedules[]` additive-tolerant so a pre-#132 canonical validator does not fail closed
+
+## 3. i18n & quality
+
+- [x] 3.1 Wrap all user-facing strings in `t('openbuild', ...)` with Dutch + English translations per hydra ADR-007 (English source keys)
+- [x] 3.2 Pass `eslint` and `stylelint` on the new/changed files
+
+## 4. Tests
+
+- [x] 4.1 Add `tests/components/SchedulesSection.spec.js` (Vitest) — list render, add/edit/remove emit `update:manifest`, empty-state, sync-picker free-text fallback
+- [x] 4.2 Add `tests/services/schedulesValidation.spec.js` (Vitest) — both/neither cadence, malformed cron, non-allow-listed action, missing sync id, duplicate id
+- [x] 4.3 `npm run test` green
+
+## Quality reminders (run before requesting review — not tracked as tasks)
+
+- Run `openspec validate schedules-editor --strict` and resolve any structural errors.
+- Confirm persistence rides `PageDesignerHost.save()` (ApplicationVersion PUT) — no new endpoint/store/save method is added for schedules.
+- Verify `schedules[]` is not stripped by the canonical `validateManifest` / `check:manifest` — the nextcloud-vue #132 schema is the cross-repo dependency; app-side `schedules.js` is authoritative until it lands.
+
+## Acceptance Criteria
+
+- The page designer shows a Schedules section listing `manifest.schedules[]`; empty when absent.
+- Add opens the standalone dialog; cadence presets write the correct `interval` (3600/86400/604800/2592000); Custom writes a validated 5-field `cron`; an entry carries exactly one of `interval`|`cron`.
+- The Action select writes `action: "openconnector:synchronization"` and the synchronization picker writes `arguments.synchronizationId`; the picker degrades to free text when the list can't load.
+- The Enabled switch writes `enabled` (default true); each `id` is a unique kebab-case slug.
+- Edit updates the entry in place preserving its `id`; remove deletes it; both emit `@update:manifest`.
+- Invalid entries (both/neither cadence, bad cron, non-allow-listed action, missing sync id, duplicate id) are blocked with a message.
+- Schedule edits persist via the existing ApplicationVersion PUT with every other top-level manifest key unchanged — no new backend, route, OR schema, or seed data.
+- Vitest component + service specs pass; eslint/stylelint clean.
diff --git a/src/components/SchedulesSection.vue b/src/components/SchedulesSection.vue
new file mode 100644
index 000000000..6a2526965
--- /dev/null
+++ b/src/components/SchedulesSection.vue
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+
+ {{ t('openbuild', 'No scheduled tasks yet. Add one to run a synchronization on a schedule.') }}
+
+
+
+
+ {{ schedule.id }}
+
+ {{ cadenceSummary(schedule) }} · {{ actionSummary(schedule) }} · {{ syncSummary(schedule) }}
+
+
+
+
+ {{ schedule.enabled === false ? t('openbuild', 'Disabled') : t('openbuild', 'Enabled') }}
+
+
+ {{ t('openbuild', 'Edit') }}
+
+
+ {{ t('openbuild', 'Remove') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/composables/useManifestValidator.js b/src/composables/useManifestValidator.js
index dd5d4fca0..831945c00 100644
--- a/src/composables/useManifestValidator.js
+++ b/src/composables/useManifestValidator.js
@@ -27,6 +27,7 @@ import { validateWorkflowAttachments } from '../services/manifestValidation/work
import { validateManifestConnectors } from '../services/manifestValidation/connectorDataSource.js'
import { validateTheme } from '../services/manifestValidation/theme.js'
import { validateDocumentAttachments } from '../services/manifestValidation/documentAttachments.js'
+import { validateSchedules } from '../services/manifestValidation/schedules.js'
const DEBOUNCE_MS = 300
@@ -66,6 +67,7 @@ export function useManifestValidator() {
.concat(validateManifestConnectors(manifest))
.concat(validateTheme(manifest))
.concat(validateDocumentAttachments(manifest))
+ .concat(validateSchedules(manifest))
errors.value = libErrors.concat(appErrors)
} catch (e) {
errors.value = [`validator threw: ${e && e.message ? e.message : e}`]
diff --git a/src/dialogs/ScheduleEditDialog.vue b/src/dialogs/ScheduleEditDialog.vue
new file mode 100644
index 000000000..1fe18dce4
--- /dev/null
+++ b/src/dialogs/ScheduleEditDialog.vue
@@ -0,0 +1,454 @@
+
+
+
+
+
+
+ {{ editing ? t('openbuild', 'Edit scheduled task') : t('openbuild', 'Add scheduled task') }}
+
+
+
+
+ {{ t('openbuild', 'Identifier') }}: {{ derivedId || '—' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('openbuild', 'The synchronization list could not be loaded. Enter a synchronization id manually.') }}
+
+
+
+
+
+
+ {{ t('openbuild', 'Enabled') }}
+
+
+
+ {{ t('openbuild', 'Please complete the scheduled task before saving.') }}
+
+
+
+
+ {{ t('openbuild', 'Cancel') }}
+
+
+ {{ t('openbuild', 'Save') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/services/manifestValidation/schedules.js b/src/services/manifestValidation/schedules.js
new file mode 100644
index 000000000..ee1b2acc3
--- /dev/null
+++ b/src/services/manifestValidation/schedules.js
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: EUPL-1.2
+/**
+ * schedules — app-side validation of the manifest top-level `schedules[]`
+ * array (apphost-scheduling authoring, REQ-OBSA-006). The `schedules[]`
+ * JSON-schema definition ships from nextcloud-vue (#132); the canonical
+ * `validateManifest` treats it as an additive top-level key
+ * (unknown-but-tolerated), and this module supplies the strict shape +
+ * cross-reference checks openbuild needs, surfaced through the
+ * `useManifestValidator` pipeline (the same mechanism the
+ * workflow/connector/theme/document siblings use).
+ *
+ * Each entry declares a scheduled task: a cadence (exactly one of `interval`
+ * seconds OR a 5-field `cron`), an allow-listed `action`, its `arguments`,
+ * an `enabled` flag and a stable unique `id`.
+ *
+ * Returned errors are `: ` strings so the existing
+ * path-prefix → inline-mark mechanism (REQ-OBPD-011) lights up the offending
+ * editor entry.
+ *
+ * @spec openspec/changes/schedules-editor/specs/openbuild-schedules-authoring/spec.md#req-obsa-006
+ */
+
+/** The allow-list of supported schedule actions in v1. */
+export const SCHEDULE_ACTIONS = Object.freeze(['openconnector:synchronization'])
+
+/** The only keys a schedule entry may carry. */
+const ALLOWED_KEYS = Object.freeze(['id', 'enabled', 'interval', 'cron', 'action', 'arguments'])
+
+/** kebab-case slug id (e.g. `nightly-brp-sync`). */
+const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
+
+/**
+ * A single cron field: a star, a number, a range `a-b`, a step (star or
+ * range followed by a slash and a divisor), or a comma-separated list of
+ * those.
+ */
+const CRON_FIELD_RE = /^(\*|\d+)(-\d+)?(\/\d+)?(,(\*|\d+)(-\d+)?(\/\d+)?)*$/
+
+/**
+ * Whether a string is a well-formed 5-field cron expression (minute hour
+ * day-of-month month day-of-week).
+ *
+ * @param {string} expr - the cron expression.
+ * @return {boolean}
+ */
+export function isValidCron(expr) {
+ if (typeof expr !== 'string') {
+ return false
+ }
+ const fields = expr.trim().split(/\s+/)
+ if (fields.length !== 5) {
+ return false
+ }
+ return fields.every((f) => CRON_FIELD_RE.test(f))
+}
+
+/**
+ * Validate one schedule entry (no cross-entry uniqueness — that is the
+ * array-level check). Used both by the pipeline validator below and by the
+ * dialog's live save-gating.
+ *
+ * @param {object} entry - the schedule entry.
+ * @param {number|string} [idx] - index for the JSON-Pointer prefix.
+ * @return {string[]} - list of `: ` error strings.
+ * @spec openspec/changes/schedules-editor/specs/openbuild-schedules-authoring/spec.md#req-obsa-006
+ */
+export function validateScheduleEntry(entry, idx = 0) {
+ const errors = []
+ const at = (code) => `/schedules/${idx}: ${code}`
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ errors.push(at('openbuild.schedule.error.invalid-shape'))
+ return errors
+ }
+ for (const key of Object.keys(entry)) {
+ if (!ALLOWED_KEYS.includes(key)) {
+ errors.push(`/schedules/${idx}/${key}: openbuild.schedule.error.unknown-key`)
+ }
+ }
+ // id
+ if (typeof entry.id !== 'string' || entry.id.trim() === '') {
+ errors.push(at('openbuild.schedule.error.id-required'))
+ } else if (!SLUG_RE.test(entry.id)) {
+ errors.push(at('openbuild.schedule.error.id-not-slug'))
+ }
+ // cadence: exactly one of interval | cron
+ const hasInterval = entry.interval !== undefined
+ const hasCron = entry.cron !== undefined
+ if (hasInterval && hasCron) {
+ errors.push(at('openbuild.schedule.error.cadence-both'))
+ } else if (!hasInterval && !hasCron) {
+ errors.push(at('openbuild.schedule.error.cadence-required'))
+ } else if (hasInterval) {
+ if (typeof entry.interval !== 'number' || !Number.isInteger(entry.interval) || entry.interval <= 0) {
+ errors.push(at('openbuild.schedule.error.interval-invalid'))
+ }
+ } else if (!isValidCron(entry.cron)) {
+ errors.push(at('openbuild.schedule.error.cron-invalid'))
+ }
+ // action
+ if (!SCHEDULE_ACTIONS.includes(entry.action)) {
+ errors.push(at('openbuild.schedule.error.action-unsupported'))
+ } else if (entry.action === 'openconnector:synchronization') {
+ const args = entry.arguments
+ const syncId = args && args.synchronizationId
+ if (typeof syncId !== 'string' || syncId.trim() === '') {
+ errors.push(at('openbuild.schedule.error.synchronization-required'))
+ }
+ }
+ // enabled (optional boolean)
+ if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') {
+ errors.push(at('openbuild.schedule.error.enabled-invalid'))
+ }
+ return errors
+}
+
+/**
+ * Validate the top-level `schedules[]` array of a manifest.
+ *
+ * @param {object} manifest - the in-flight manifest.
+ * @return {string[]} - list of `: ` error strings.
+ * @spec openspec/changes/schedules-editor/specs/openbuild-schedules-authoring/spec.md#req-obsa-006
+ */
+export function validateSchedules(manifest) {
+ const errors = []
+ const schedules = manifest && manifest.schedules
+ if (schedules === undefined) {
+ return errors
+ }
+ if (!Array.isArray(schedules)) {
+ errors.push('/schedules: openbuild.schedule.error.not-array')
+ return errors
+ }
+
+ const seenIds = new Map()
+ schedules.forEach((entry, idx) => {
+ errors.push(...validateScheduleEntry(entry, idx))
+ // cross-entry uniqueness (only when the id is a usable string)
+ if (entry && typeof entry.id === 'string' && entry.id.trim() !== '') {
+ if (seenIds.has(entry.id)) {
+ errors.push(`/schedules/${idx}: openbuild.schedule.error.duplicate-id`)
+ }
+ seenIds.set(entry.id, idx)
+ }
+ })
+
+ return errors
+}
diff --git a/src/views/PageDesignerHost.vue b/src/views/PageDesignerHost.vue
index 7b65274ca..13f2ed528 100644
--- a/src/views/PageDesignerHost.vue
+++ b/src/views/PageDesignerHost.vue
@@ -95,6 +95,14 @@
:schemas="appSchemas"
:docudesk-available="docudeskAvailable"
@update:manifest="onManifestUpdate" />
+
+
+
@@ -110,6 +118,7 @@ import PageDesigner from './PageDesigner.vue'
import WorkflowAttachmentsSection from '../components/WorkflowAttachmentsSection.vue'
import ThemeSection from '../components/ThemeSection.vue'
import DocumentAttachmentsSection from '../components/DocumentAttachmentsSection.vue'
+import SchedulesSection from '../components/SchedulesSection.vue'
const EMPTY_MANIFEST = { version: '1.0.0', menu: [], pages: [] }
@@ -124,6 +133,7 @@ export default {
WorkflowAttachmentsSection,
ThemeSection,
DocumentAttachmentsSection,
+ SchedulesSection,
},
data() {
diff --git a/tests/components/ScheduleEditDialog.spec.js b/tests/components/ScheduleEditDialog.spec.js
new file mode 100644
index 000000000..4d43cbc82
--- /dev/null
+++ b/tests/components/ScheduleEditDialog.spec.js
@@ -0,0 +1,210 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Vitest spec for ScheduleEditDialog.vue.
+ *
+ * Spec: schedules-editor / openbuild-schedules-authoring
+ * (REQ-OBSA-002, REQ-OBSA-003, REQ-OBSA-004, REQ-OBSA-006).
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+vi.mock('@nextcloud/router', () => ({ generateUrl: (p) => p }))
+vi.mock('@nextcloud/axios', () => ({ default: { get: vi.fn() } }))
+
+import axios from '@nextcloud/axios'
+import ScheduleEditDialog from '../../src/dialogs/ScheduleEditDialog.vue'
+
+const NcSelectStub = {
+ name: 'NcSelect',
+ props: ['value', 'options', 'loading', 'inputLabel', 'label', 'clearable'],
+ template: '
',
+}
+const NcTextFieldStub = {
+ name: 'NcTextField',
+ props: ['value', 'label', 'placeholder', 'type', 'error', 'helperText'],
+ template: ' ',
+}
+const NcCheckboxRadioSwitchStub = {
+ name: 'NcCheckboxRadioSwitch',
+ props: ['checked', 'type'],
+ template: ' ',
+}
+const NcButtonStub = {
+ name: 'NcButton',
+ props: ['type', 'disabled'],
+ template: ' ',
+}
+const NcModalStub = {
+ name: 'NcModal',
+ props: ['name'],
+ template: '
',
+}
+
+const stubs = {
+ NcModal: NcModalStub,
+ NcSelect: NcSelectStub,
+ NcTextField: NcTextFieldStub,
+ NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
+ NcButton: NcButtonStub,
+}
+
+const flush = () => new Promise((r) => setTimeout(r, 0))
+
+const factory = (propsData = {}) => mount(ScheduleEditDialog, {
+ propsData: { open: false, ...propsData },
+ stubs,
+})
+
+/** Open the dialog (fires the watcher → hydrate + fetch). */
+const openDialog = async (wrapper) => {
+ await wrapper.setProps({ open: true })
+ await flush()
+ await flush()
+}
+
+const cadence = (wrapper, id) => wrapper.vm.cadenceOptions.find((o) => o.id === id)
+
+describe('ScheduleEditDialog', () => {
+ beforeEach(() => {
+ axios.get.mockReset()
+ axios.get.mockResolvedValue({ data: { results: [] } })
+ })
+
+ it('a non-custom preset writes an interval and no cron', async () => {
+ const wrapper = factory()
+ await openDialog(wrapper)
+ wrapper.vm.label = 'Nightly BRP sync'
+ wrapper.vm.cadenceOption = cadence(wrapper, 'daily')
+ wrapper.vm.syncId = '00000000-0000-0000-0000-000000000000'
+ const entry = wrapper.vm.candidateEntry
+ expect(entry.interval).toBe(86400)
+ expect(entry.cron).toBeUndefined()
+ expect(entry.id).toBe('nightly-brp-sync')
+ })
+
+ it('custom cron writes a validated cron and no interval', async () => {
+ const wrapper = factory()
+ await openDialog(wrapper)
+ wrapper.vm.label = 'Weekly report'
+ wrapper.vm.cadenceOption = cadence(wrapper, 'custom-cron')
+ wrapper.vm.cron = '0 3 * * 1'
+ wrapper.vm.syncId = 'abc'
+ const entry = wrapper.vm.candidateEntry
+ expect(entry.cron).toBe('0 3 * * 1')
+ expect(entry.interval).toBeUndefined()
+ expect(wrapper.vm.valid).toBe(true)
+ })
+
+ it('a malformed cron blocks saving', async () => {
+ const wrapper = factory()
+ await openDialog(wrapper)
+ wrapper.vm.label = 'Bad'
+ wrapper.vm.cadenceOption = cadence(wrapper, 'custom-cron')
+ wrapper.vm.cron = '0 2 * *'
+ wrapper.vm.syncId = 'abc'
+ expect(wrapper.vm.valid).toBe(false)
+ })
+
+ it('the sync action writes action + arguments.synchronizationId', async () => {
+ const wrapper = factory()
+ await openDialog(wrapper)
+ wrapper.vm.label = 'Sync it'
+ wrapper.vm.cadenceOption = cadence(wrapper, 'hourly')
+ wrapper.vm.syncId = 'sync-123'
+ const entry = wrapper.vm.candidateEntry
+ expect(entry.action).toBe('openconnector:synchronization')
+ expect(entry.arguments.synchronizationId).toBe('sync-123')
+ })
+
+ it('enabled defaults on and toggles off', async () => {
+ const wrapper = factory()
+ await openDialog(wrapper)
+ expect(wrapper.vm.enabled).toBe(true)
+ wrapper.vm.enabled = false
+ wrapper.vm.label = 'Off one'
+ wrapper.vm.cadenceOption = cadence(wrapper, 'daily')
+ wrapper.vm.syncId = 'abc'
+ expect(wrapper.vm.candidateEntry.enabled).toBe(false)
+ })
+
+ it('save is gated on validity — invalid entries do not emit', async () => {
+ const wrapper = factory()
+ await openDialog(wrapper)
+ // no cadence, no sync id
+ wrapper.vm.label = 'Incomplete'
+ expect(wrapper.vm.valid).toBe(false)
+ wrapper.vm.onSave()
+ expect(wrapper.emitted().save).toBeFalsy()
+ })
+
+ it('a valid entry emits save and closes', async () => {
+ const wrapper = factory()
+ await openDialog(wrapper)
+ wrapper.vm.label = 'Nightly'
+ wrapper.vm.cadenceOption = cadence(wrapper, 'daily')
+ wrapper.vm.syncId = 'abc'
+ wrapper.vm.onSave()
+ const emitted = wrapper.emitted().save[0][0]
+ expect(emitted.id).toBe('nightly')
+ expect(emitted.interval).toBe(86400)
+ expect(wrapper.emitted()['update:open'].pop()).toEqual([false])
+ })
+
+ it('degrades to a free-text sync id field when the list cannot load', async () => {
+ axios.get.mockRejectedValueOnce(new Error('network'))
+ const wrapper = factory()
+ await openDialog(wrapper)
+ wrapper.vm.actionOption = wrapper.vm.actionOptions[0]
+ await wrapper.vm.$nextTick()
+ expect(wrapper.vm.syncFetchFailed).toBe(true)
+ expect(wrapper.vm.syncPickerAvailable).toBe(false)
+ expect(wrapper.find('.ob-schedule-edit__sync-manual').exists()).toBe(true)
+ })
+
+ it('populates the sync picker when the list loads', async () => {
+ axios.get.mockResolvedValueOnce({ data: { results: [{ id: 's1', name: 'BRP sync' }] } })
+ const wrapper = factory()
+ await openDialog(wrapper)
+ expect(wrapper.vm.syncPickerAvailable).toBe(true)
+ expect(wrapper.vm.syncOptions).toEqual([{ id: 's1', label: 'BRP sync' }])
+ })
+
+ it('enforces id uniqueness against other entries', async () => {
+ const wrapper = factory({ existingIds: ['nightly'] })
+ await openDialog(wrapper)
+ wrapper.vm.label = 'Nightly'
+ wrapper.vm.cadenceOption = cadence(wrapper, 'daily')
+ wrapper.vm.syncId = 'abc'
+ // slug auto-suffixes to nightly-2, which is unique → valid
+ expect(wrapper.vm.derivedId).toBe('nightly-2')
+ expect(wrapper.vm.valid).toBe(true)
+ })
+
+ it('reverse-maps an existing entry: preset interval selects its preset', async () => {
+ const entry = { id: 'weekly-one', enabled: true, interval: 604800, action: 'openconnector:synchronization', arguments: { synchronizationId: 'abc' } }
+ const wrapper = factory({ entry })
+ await openDialog(wrapper)
+ expect(wrapper.vm.cadenceOption.id).toBe('weekly')
+ expect(wrapper.vm.candidateEntry.id).toBe('weekly-one')
+ })
+
+ it('reverse-maps a non-preset interval to the custom-interval escape hatch', async () => {
+ const entry = { id: 'odd-one', enabled: true, interval: 43200, action: 'openconnector:synchronization', arguments: { synchronizationId: 'abc' } }
+ const wrapper = factory({ entry })
+ await openDialog(wrapper)
+ expect(wrapper.vm.cadenceOption.id).toBe('custom-interval')
+ expect(wrapper.vm.intervalSeconds).toBe('43200')
+ expect(wrapper.vm.candidateEntry.interval).toBe(43200)
+ })
+
+ it('reverse-maps a cron entry to custom-cron', async () => {
+ const entry = { id: 'cron-one', enabled: false, cron: '0 3 * * 1', action: 'openconnector:synchronization', arguments: { synchronizationId: 'abc' } }
+ const wrapper = factory({ entry })
+ await openDialog(wrapper)
+ expect(wrapper.vm.cadenceOption.id).toBe('custom-cron')
+ expect(wrapper.vm.cron).toBe('0 3 * * 1')
+ expect(wrapper.vm.enabled).toBe(false)
+ })
+})
diff --git a/tests/components/SchedulesSection.spec.js b/tests/components/SchedulesSection.spec.js
new file mode 100644
index 000000000..cab765957
--- /dev/null
+++ b/tests/components/SchedulesSection.spec.js
@@ -0,0 +1,104 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Vitest spec for SchedulesSection.vue.
+ *
+ * Spec: schedules-editor / openbuild-schedules-authoring
+ * (REQ-OBSA-001, REQ-OBSA-005, REQ-OBSA-007).
+ */
+import { describe, it, expect, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import SchedulesSection from '../../src/components/SchedulesSection.vue'
+
+const NcButtonStub = {
+ name: 'NcButton',
+ props: ['type', 'disabled'],
+ template: ' ',
+}
+
+const stubs = {
+ NcButton: NcButtonStub,
+ ScheduleEditDialog: { name: 'ScheduleEditDialog', template: '
' },
+}
+
+const entry = {
+ id: 'nightly-brp-sync',
+ enabled: true,
+ interval: 86400,
+ action: 'openconnector:synchronization',
+ arguments: { synchronizationId: '00000000-0000-0000-0000-000000000000' },
+}
+
+const factory = (manifest) => mount(SchedulesSection, {
+ propsData: { manifest },
+ stubs,
+})
+
+describe('SchedulesSection', () => {
+ it('renders the empty state with no schedules', () => {
+ const wrapper = factory({})
+ expect(wrapper.find('.ob-schedules-section__empty').exists()).toBe(true)
+ })
+
+ it('renders the empty state for an empty schedules array', () => {
+ const wrapper = factory({ schedules: [] })
+ expect(wrapper.find('.ob-schedules-section__empty').exists()).toBe(true)
+ })
+
+ it('lists existing schedules with id + cadence summary', () => {
+ const wrapper = factory({ schedules: [entry] })
+ expect(wrapper.findAll('.ob-schedules-section__item')).toHaveLength(1)
+ expect(wrapper.text()).toContain('nightly-brp-sync')
+ expect(wrapper.text()).toContain('Daily')
+ })
+
+ it('summarizes a cron cadence', () => {
+ const cronEntry = { ...entry, id: 'weekly', interval: undefined, cron: '0 3 * * 1' }
+ delete cronEntry.interval
+ const wrapper = factory({ schedules: [cronEntry] })
+ // the test t() stub does not interpolate, but the raw key contains "Cron"
+ expect(wrapper.text()).toContain('Cron')
+ })
+
+ it('adding a schedule emits an updated manifest with schedules[]', () => {
+ const wrapper = factory({})
+ wrapper.vm.openAdd()
+ wrapper.vm.onDialogSave(entry)
+ const emitted = wrapper.emitted()['update:manifest'][0][0]
+ expect(emitted.schedules).toHaveLength(1)
+ expect(emitted.schedules[0].id).toBe('nightly-brp-sync')
+ })
+
+ it('editing a schedule updates it in place preserving the id', () => {
+ const wrapper = factory({ schedules: [entry] })
+ wrapper.vm.openEdit(entry)
+ wrapper.vm.onDialogSave({ ...entry, interval: 604800 })
+ const emitted = wrapper.emitted()['update:manifest'][0][0]
+ expect(emitted.schedules).toHaveLength(1)
+ expect(emitted.schedules[0].id).toBe('nightly-brp-sync')
+ expect(emitted.schedules[0].interval).toBe(604800)
+ })
+
+ it('removing a schedule deletes it and drops the empty key', () => {
+ window.confirm = vi.fn(() => true)
+ const wrapper = factory({ schedules: [entry] })
+ wrapper.vm.remove(entry)
+ const emitted = wrapper.emitted()['update:manifest'][0][0]
+ expect(emitted.schedules).toBeUndefined()
+ })
+
+ it('excludes the edited entry id from the dialog uniqueness list', () => {
+ const wrapper = factory({ schedules: [entry] })
+ wrapper.vm.openEdit(entry)
+ expect(wrapper.vm.otherIds).not.toContain('nightly-brp-sync')
+ })
+
+ it('keeps other top-level manifest keys unchanged on save', () => {
+ const wrapper = factory({ pages: [{ id: 'p1' }], theme: { source: 'nldesign' } })
+ wrapper.vm.onDialogSave(entry)
+ const emitted = wrapper.emitted()['update:manifest'][0][0]
+ expect(emitted.pages).toEqual([{ id: 'p1' }])
+ expect(emitted.theme).toEqual({ source: 'nldesign' })
+ })
+})
diff --git a/tests/services/schedulesValidation.spec.js b/tests/services/schedulesValidation.spec.js
new file mode 100644
index 000000000..10e4938fd
--- /dev/null
+++ b/tests/services/schedulesValidation.spec.js
@@ -0,0 +1,144 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Vitest spec for app-side top-level schedules[] validation.
+ *
+ * Spec: schedules-editor / openbuild-schedules-authoring (REQ-OBSA-006).
+ */
+import { describe, it, expect } from 'vitest'
+import {
+ validateSchedules,
+ validateScheduleEntry,
+ isValidCron,
+ SCHEDULE_ACTIONS,
+} from '../../src/services/manifestValidation/schedules.js'
+
+const validEntry = {
+ id: 'nightly-brp-sync',
+ enabled: true,
+ interval: 86400,
+ action: 'openconnector:synchronization',
+ arguments: { synchronizationId: '00000000-0000-0000-0000-000000000000' },
+}
+
+const withSchedules = (schedules) => ({ schedules })
+
+describe('isValidCron', () => {
+ it('accepts a 5-field expression', () => {
+ expect(isValidCron('0 3 * * 1')).toBe(true)
+ expect(isValidCron('*/15 * * * *')).toBe(true)
+ expect(isValidCron('0 0 1-15 * 1,3,5')).toBe(true)
+ })
+ it('rejects the wrong field count', () => {
+ expect(isValidCron('0 2 * *')).toBe(false)
+ expect(isValidCron('0 2 * * * *')).toBe(false)
+ })
+ it('rejects malformed tokens', () => {
+ expect(isValidCron('0 2 * * abc')).toBe(false)
+ expect(isValidCron('')).toBe(false)
+ })
+})
+
+describe('validateSchedules', () => {
+ it('passes a valid schedules array', () => {
+ expect(validateSchedules(withSchedules([validEntry]))).toEqual([])
+ })
+
+ it('passes a valid cron entry', () => {
+ const cronEntry = { id: 'weekly-report', enabled: true, cron: '0 3 * * 1', action: 'openconnector:synchronization', arguments: { synchronizationId: 'abc' } }
+ expect(validateSchedules(withSchedules([cronEntry]))).toEqual([])
+ })
+
+ it('returns nothing when schedules is absent', () => {
+ expect(validateSchedules({})).toEqual([])
+ expect(validateSchedules({ pages: [] })).toEqual([])
+ })
+
+ it('rejects a non-array schedules key', () => {
+ const errs = validateSchedules(withSchedules({}))
+ expect(errs.some((e) => e.includes('not-array'))).toBe(true)
+ })
+
+ it('rejects both interval and cron (one-of)', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, cron: '0 3 * * 1' }]))
+ expect(errs.some((e) => e.includes('cadence-both'))).toBe(true)
+ })
+
+ it('rejects neither interval nor cron', () => {
+ const { interval, ...rest } = validEntry // eslint-disable-line no-unused-vars
+ const errs = validateSchedules(withSchedules([rest]))
+ expect(errs.some((e) => e.includes('cadence-required'))).toBe(true)
+ })
+
+ it('rejects a non-positive interval', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, interval: 0 }]))
+ expect(errs.some((e) => e.includes('interval-invalid'))).toBe(true)
+ })
+
+ it('rejects a non-integer interval', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, interval: 1.5 }]))
+ expect(errs.some((e) => e.includes('interval-invalid'))).toBe(true)
+ })
+
+ it('rejects a malformed cron', () => {
+ const { interval, ...rest } = validEntry // eslint-disable-line no-unused-vars
+ const errs = validateSchedules(withSchedules([{ ...rest, cron: '0 2 * *' }]))
+ expect(errs.some((e) => e.includes('cron-invalid'))).toBe(true)
+ })
+
+ it('rejects a non-allow-listed action', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, action: 'openconnector:job' }]))
+ expect(errs.some((e) => e.includes('action-unsupported'))).toBe(true)
+ })
+
+ it('rejects a missing synchronization id for the sync action', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, arguments: {} }]))
+ expect(errs.some((e) => e.includes('synchronization-required'))).toBe(true)
+ })
+
+ it('rejects an empty synchronization id', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, arguments: { synchronizationId: ' ' } }]))
+ expect(errs.some((e) => e.includes('synchronization-required'))).toBe(true)
+ })
+
+ it('rejects a missing id', () => {
+ const { id, ...rest } = validEntry // eslint-disable-line no-unused-vars
+ const errs = validateSchedules(withSchedules([rest]))
+ expect(errs.some((e) => e.includes('id-required'))).toBe(true)
+ })
+
+ it('rejects a non-slug id', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, id: 'Not A Slug' }]))
+ expect(errs.some((e) => e.includes('id-not-slug'))).toBe(true)
+ })
+
+ it('rejects duplicate ids', () => {
+ const errs = validateSchedules(withSchedules([validEntry, { ...validEntry }]))
+ expect(errs.some((e) => e.includes('duplicate-id'))).toBe(true)
+ })
+
+ it('rejects an unknown key', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, foo: 'bar' }]))
+ expect(errs.some((e) => e.includes('unknown-key'))).toBe(true)
+ })
+
+ it('rejects a non-boolean enabled', () => {
+ const errs = validateSchedules(withSchedules([{ ...validEntry, enabled: 'yes' }]))
+ expect(errs.some((e) => e.includes('enabled-invalid'))).toBe(true)
+ })
+
+ it('rejects an entry that is not an object', () => {
+ const errs = validateSchedules(withSchedules(['nope']))
+ expect(errs.some((e) => e.includes('invalid-shape'))).toBe(true)
+ })
+})
+
+describe('validateScheduleEntry', () => {
+ it('validates a single entry without uniqueness', () => {
+ expect(validateScheduleEntry(validEntry)).toEqual([])
+ })
+ it('only allows the synchronization action in v1', () => {
+ expect(SCHEDULE_ACTIONS).toEqual(['openconnector:synchronization'])
+ })
+})
From 4a19abf603b05927f005b4b74d7b3a148eb6258d Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Thu, 9 Jul 2026 14:11:59 +0200
Subject: [PATCH 085/391] docs(github): document the GitHub store +
publish/pull round-trip
New docs/github-store.md reference page (repo format, credential-broker security
model, endpoints), a docs/tutorials/user/09-publish-to-github.md walkthrough with
screenshots incl. the minimal fine-grained token permissions, and cross-links
from template-store.md and intro.md. Corrects the old 'publishing is out of
scope' note now that GitHub publish/pull ships.
---
docs/github-store.md | 112 ++++++++++++++++++
docs/intro.md | 4 +-
.../09-publish-to-github-00-permissions.jpg | Bin 0 -> 91233 bytes
.../user/09-publish-to-github-01.png | Bin 0 -> 103866 bytes
.../user/09-publish-to-github-02.png | Bin 0 -> 32847 bytes
.../user/09-publish-to-github-03.png | Bin 0 -> 109920 bytes
docs/template-store.md | 7 +-
docs/tutorials/user/09-publish-to-github.md | 57 +++++++++
8 files changed, 176 insertions(+), 4 deletions(-)
create mode 100644 docs/github-store.md
create mode 100644 docs/static/screenshots/tutorials/user/09-publish-to-github-00-permissions.jpg
create mode 100644 docs/static/screenshots/tutorials/user/09-publish-to-github-01.png
create mode 100644 docs/static/screenshots/tutorials/user/09-publish-to-github-02.png
create mode 100644 docs/static/screenshots/tutorials/user/09-publish-to-github-03.png
create mode 100644 docs/tutorials/user/09-publish-to-github.md
diff --git a/docs/github-store.md b/docs/github-store.md
new file mode 100644
index 000000000..a8c5520ea
--- /dev/null
+++ b/docs/github-store.md
@@ -0,0 +1,112 @@
+# GitHub store — publish and install apps
+
+The **GitHub store** lets you publish an OpenBuild app to a GitHub repository and
+discover and install apps that others have published — on top of the built-in
+templates and the [remote template store](./template-store.md).
+
+Unlike the remote template store (which is consume-only), the GitHub store is a
+full **round-trip**: build an app, publish it to GitHub, and install it again on
+any instance — the app lives in the repository, independent of the instance that
+built it.
+
+## What a published app looks like
+
+Publishing writes the app to a repository as plain, re-importable files, and tags
+the repo with the `openbuild-app` topic (the store's discovery contract):
+
+| File | Contents |
+|------|----------|
+| `openbuild-app.json` | App descriptor — slug, name, description, category, `appType`, version, icon refs, and the declared `credentials[]`. |
+| `manifest.json` | The `ApplicationVersion` manifest — every page, widget, menu entry, sidebar, and setting. |
+| `schemas/.json` | The companion schemas that make up the data model. |
+| `README.md` | Generated overview of the app. |
+
+`AppRepoSerializer` writes this layout deterministically (recursively key-sorted);
+`AppRepoParser` reads it back with strict, all-or-nothing validation, so a
+malformed repository fails loudly and installs nothing.
+
+## Credentials — the token never reaches OpenBuild
+
+Every GitHub call is routed through OpenRegister's **credential broker**. You
+store a GitHub personal access token once, in the **Credentials** pane of the
+app's user settings; it is kept in **Doriath**, the encrypted credential vault.
+OpenBuild never receives the token — it asks the broker to make each GitHub call
+(create repo, push commit, set topic, read contents), the token is injected
+server-side, host-locked to `api.github.com`, and only the result comes back.
+
+- Browsing the store is **anonymous** by default (public repos, no credential).
+- Passing a credential **upgrades** the call through the broker so you also see
+ your own private repositories and get a higher rate limit.
+- Publishing and pulling always go through the broker.
+
+Use a GitHub **fine-grained** token with exactly three repository permissions:
+**Administration — Read and write** (create the repository, set its topic),
+**Contents — Read and write** (push the app's files and commits), and
+**Metadata — Read-only** (required). Nothing else is needed; the broker's
+allow-rules deny issues, pull-requests, workflows, and webhooks regardless.
+
+The token owner controls access per credential (which apps may use it) and can
+revoke or rotate it in one place — nothing to clean up inside OpenBuild.
+
+## Publishing an app
+
+Open the app, choose **Actions → GitHub**, pick a `github` credential, and select
+**Publish**. OpenBuild:
+
+1. Serializes the chosen version to the repo layout.
+2. Creates the repository (via the broker) — **public by default** so it is
+ discoverable in the store's anonymous search; pass `visibility: "private"` to
+ keep it private.
+3. Sets the `openbuild-app` topic and commits the app in one clean commit via the
+ Git Data API (blob → tree → commit → ref).
+4. Records the resulting `commitSha` and repository on the app.
+
+Re-publishing advances the branch on a new commit — it never force-pushes or
+rewrites history.
+
+## Pulling changes back
+
+**Pull** fetches a repository ref back into a **new draft `ApplicationVersion`** —
+it never touches the production version. A change someone else pushed lands next
+to your production version for you to review and promote through the normal
+version-promotion flow.
+
+## Filling the store from GitHub
+
+Go to **Store → GitHub**. The store searches GitHub for the `openbuild-app` topic
+and renders each published app as an installable card built from its
+`openbuild-app.json`. Click **Install**, name the new app, and confirm — the
+repository is parsed and cloned into a fresh local app through the same seam as
+any template (`ApplicationsController::installFromTemplateArray`), so it is an
+ordinary editable virtual app, not a locked import.
+
+## Endpoints
+
+| Method + path | Purpose |
+|---|---|
+| `GET /index.php/apps/openbuild/api/shop/github/search?q=&credentialId=` | Search GitHub for `openbuild-app` repos. Anonymous by default; `credentialId` broker-upgrades to include private repos. Returns `{ outcome, cards, brokerCredentialAvailable, brokerUsed, rateLimited }`. |
+| `POST /index.php/apps/openbuild/api/shop/github/install` | Install an app from a repo. Body `{ owner, repo, ref?, name?, slug?, credentialId? }` → `201 { uuid, slug, register, companionSchemas }`. |
+| `GET /index.php/apps/openbuild/api/applications/{slug}/github/status` | Linked repo, default branch, last pushed/pulled sha, and feature-detection flags (`brokerCredentialAvailable`, `publishAvailable`). Viewer-readable. |
+| `POST /index.php/apps/openbuild/api/applications/{slug}/github/link` | Link an app to a repo. Body `{ owner, name, org? }`. Owner-only. |
+| `POST /index.php/apps/openbuild/api/applications/{slug}/github/push` | Publish. Body `{ credentialId, versionSlug?, repo?, visibility? }` → `{ outcome, repoUrl, commitSha, branch }`. Owner-only. |
+| `POST /index.php/apps/openbuild/api/applications/{slug}/github/pull` | Pull a ref into a new draft version. Body `{ ref, credentialId? }` → `{ outcome, versionUuid, versionSlug, commitSha, sourceRef, status: 'draft', register }`. Owner-only. |
+
+`outcome` values include `ok`, `not_linked`, `broker_unavailable`,
+`broker_denied`, `push_conflict`, `github_rate_limited`, `github_unreachable`.
+
+## Security
+
+- All GitHub reads are **fixed-host** to `api.github.com`; callers supply a path,
+ never a full URL, and the broker host-locks and allow-rule-checks every call.
+- The write operations (search, identity, repo-create, ref-update, topic-set)
+ require the widened `github` provider allow-rules shipped in OpenRegister
+ (catalogue v1.2.0). Issues, pull-requests, workflows, webhooks, and deletes
+ stay denied.
+- Publish/pull/link/status are **owner-gated** on the app's `permissions` model;
+ a Nextcloud admin who is not an owner is not auto-granted.
+- The credential secret is never returned to the app, the browser, a log line, or
+ an error message.
+
+See the OpenSpec changes `github-app-repo-format`, `github-shop-catalogue`, and
+`github-app-sync` (OpenBuild) and `github-provider-shop-rules` (OpenRegister) for
+the full specification.
diff --git a/docs/intro.md b/docs/intro.md
index 6597b3ce1..50c1daf2d 100644
--- a/docs/intro.md
+++ b/docs/intro.md
@@ -24,7 +24,9 @@ export it. No code, no second platform.
notifications are declared as schema metadata, not service code. Manifest
overrides are delta-only, so customisation survives upgrades.
- Version snapshots: snapshot the whole app and roll back a bad edit. Export
- the bundle as a ZIP, or push it straight to a new GitHub repository.
+ the bundle as a ZIP, or **publish the app to a GitHub repository** and install
+ it again from the store on any instance — the token stays in Doriath and never
+ reaches OpenBuild (see [GitHub store](./github-store.md)).
- RBAC: administrators control who can build, and per-record access is
enforced through OpenRegister.
diff --git a/docs/static/screenshots/tutorials/user/09-publish-to-github-00-permissions.jpg b/docs/static/screenshots/tutorials/user/09-publish-to-github-00-permissions.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..7fc8991a7c7f5946981d01d2b5fe6966291a9e6d
GIT binary patch
literal 91233
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