Skip to content

Commit e800d69

Browse files
authored
feat(openspec): softwarecatalog-adopt-or-abstractions — manifest + register-resolver (#212)
Phase 3 of the OR-abstraction audit (2026-05-03). Spec-only — no code changes. Drafts the per-app adoption openspec change so each app can run /opsx-apply against it when ready. References .claude/audit-2026-05-03/ research, Phase 2 OR/nc-vue/ hydra specs (#1420, #113, #218), and ADRs 022/024/025.
1 parent d29a3a9 commit e800d69

4 files changed

Lines changed: 767 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Design — softwarecatalog-adopt-or-abstractions
2+
3+
## Reuse analysis
4+
5+
| Capability | Reuse from | Why |
6+
|------------|-----------|-----|
7+
| App-manifest schema + loader | `@conduction/nextcloud-vue` (`src/schemas/app-manifest.schema.json`, `useAppManifest`) | Single source of truth per `hydra/openspec/changes/adopt-app-manifest/`. |
8+
| Index / detail page-type renderers | nc-vue `CnIndexPage`, `CnDetailPage` (already used) | Existing views already wrap these. Manifest declares which schema each consumes. |
9+
| Register / schema ID resolution | OR `RegisterResolverService` (`openregister/openspec/changes/register-resolver-service/`) | Eliminates 5 duplicated `getValueString(...register/schema...)` call shapes. |
10+
| Tenant context | `useTenantContext()` from nc-vue (`nextcloud-vue/openspec/changes/multi-tenancy-context/`) | Cache invalidation + write header stamping. |
11+
| i18n source-of-truth metadata | OR `sourceLanguage` (`openregister/openspec/changes/i18n-source-of-truth/`) | Read-only consumption — display badge. |
12+
| API language negotiation | OR `?_lang=` + `X-Translation-Target-Language` (`openregister/openspec/changes/i18n-api-language-negotiation/`) | Wire into single `orClient.js` composable. |
13+
| Pinia stores | SoftwareCatalog's existing entity-typed Pinia stores | Keep. Migrate fetch URL building into `orClient.js`. |
14+
| Sidebars / dialogs / modals | Existing `src/sidebars/`, `src/dialogs/`, `src/modals/` | Register via manifest's `slots` and `customComponents`. No reorganisation needed. |
15+
16+
### What we deliberately do NOT reuse
17+
18+
- **OR's lifecycle annotations** — applications and components do
19+
not have a status state machine that benefits from
20+
`x-openregister-lifecycle`. Procurement / publication workflow
21+
is out of scope here.
22+
- **OR's notification engine** — no notification triggers in
23+
SoftwareCatalog today. If product reasons emerge, follow-up
24+
change adopts `x-openregister-notifications`.
25+
- **VNG `Softwarecatalogus/` client repo** — read-only, separate.
26+
27+
### What's deferred
28+
29+
- **Concept Organisations** consumption — the
30+
`conceptOrganisatiesWidget.js` integrates with an external feed.
31+
Phase 1 declares the page in the manifest with
32+
`type: "custom"`; future change may model it as `type: "index"`
33+
if the feed exposes a register-shaped contract.
34+
35+
## Public API / migration shape
36+
37+
### `src/manifest.json` (new file)
38+
39+
```json
40+
{
41+
"$schema": "https://unpkg.com/@conduction/nextcloud-vue@latest/dist/schemas/app-manifest.schema.json",
42+
"version": "0.1.0",
43+
"dependencies": ["openregister"],
44+
"menu": [
45+
{ "id": "apps", "label": "softwarecatalog.menu.apps", "icon": "icon-apps", "route": "/apps", "section": "main", "order": 10 },
46+
{ "id": "components", "label": "softwarecatalog.menu.components", "icon": "icon-component", "route": "/components", "section": "main", "order": 20 },
47+
{ "id": "organisations", "label": "softwarecatalog.menu.organisations", "icon": "icon-organisation", "route": "/organisations", "section": "main", "order": 30 },
48+
{ "id": "catalogs", "label": "softwarecatalog.menu.catalogs", "icon": "icon-catalog", "route": "/catalogs", "section": "main", "order": 40 },
49+
{ "id": "concept-organisations", "label": "softwarecatalog.menu.conceptOrganisations", "icon": "icon-concept", "route": "/concept-organisations", "section": "main", "order": 50 },
50+
{ "id": "settings", "label": "softwarecatalog.menu.settings", "icon": "icon-settings", "route": "/settings", "section": "settings", "permission": "admin" }
51+
],
52+
"pages": [
53+
{
54+
"id": "apps-index",
55+
"route": "/apps",
56+
"type": "index",
57+
"title": "softwarecatalog.pages.apps",
58+
"config": {
59+
"register": "@resolve:apps_register",
60+
"schema": "@resolve:apps_schema",
61+
"columns": ["name", "organisation", "status", "version"]
62+
}
63+
},
64+
{
65+
"id": "apps-detail",
66+
"route": "/apps/:id",
67+
"type": "detail",
68+
"title": "softwarecatalog.pages.app",
69+
"config": {
70+
"register": "@resolve:apps_register",
71+
"schema": "@resolve:apps_schema"
72+
},
73+
"slots": { "sidebar": "AppSidebar" }
74+
}
75+
// ... components, organisations, catalogs (similar shape)
76+
, {
77+
"id": "concept-organisations-index",
78+
"route": "/concept-organisations",
79+
"type": "custom",
80+
"title": "softwarecatalog.pages.conceptOrganisations",
81+
"component": "ConceptOrganisationsPage"
82+
}
83+
, {
84+
"id": "settings",
85+
"route": "/settings",
86+
"type": "custom",
87+
"title": "softwarecatalog.pages.settings",
88+
"component": "SettingsPage"
89+
}
90+
]
91+
}
92+
```
93+
94+
Notes:
95+
- `@resolve:{key}` sentinel: same convention as LarpingApp adoption
96+
change. The renderer pre-processor runs `RegisterResolverService`
97+
(or its frontend equivalent) at render time.
98+
- `slots: { sidebar: "AppSidebar" }` registers
99+
`src/sidebars/AppSidebar.vue` for the apps detail page.
100+
101+
### `src/composables/orClient.js` (new file)
102+
103+
Identical contract to LarpingApp's `orClient.js`:
104+
105+
```js
106+
export function useOrClient () {
107+
const baseUrl = '/index.php/apps/openregister/api'
108+
109+
async function fetchObject ({ register, schema, uuid }) {
110+
const lang = OC.getLocale().split('_')[0]
111+
const url = `${baseUrl}/objects/${register}/${schema}/${uuid}?_lang=${lang}`
112+
return axios.get(url, { headers: buildHeaders() })
113+
}
114+
115+
async function patchObject ({ register, schema, uuid, body, targetLang }) {
116+
const url = `${baseUrl}/objects/${register}/${schema}/${uuid}`
117+
const headers = buildHeaders()
118+
if (targetLang) headers['X-Translation-Target-Language'] = targetLang
119+
return axios.patch(url, body, { headers })
120+
}
121+
122+
return { fetchObject, patchObject }
123+
}
124+
```
125+
126+
### Service migrations (Phase 2)
127+
128+
All five files follow the same pattern: inject
129+
`RegisterResolverService` and replace the resolver call.
130+
131+
`lib/Service/ModuleComplianceService.php`:
132+
133+
```php
134+
// before
135+
$register = $this->config->getValueString('softwarecatalog', 'modules_register', '');
136+
$schema = $this->config->getValueString('softwarecatalog', 'modules_schema', '');
137+
138+
// after
139+
$pair = $this->resolver->resolveForObjectType('modules');
140+
[$register, $schema] = [$pair->registerId, $pair->schemaId];
141+
```
142+
143+
Same shape for the other four classes with their respective
144+
object types: `gebruik`, `organisations`, `views`,
145+
`user-profile-organisation`.
146+
147+
### Migration risk surface
148+
149+
| Risk | Mitigation |
150+
|------|-----------|
151+
| `RegisterResolverService` not yet deployed | DI null-check fallback to legacy `getValueString` (Phase 2) plus deprecation log. |
152+
| 5-file resolver migration introduces typo / incorrect object-type string | Unit tests assert each service's `resolveForObjectType` argument matches the existing config-key naming convention. |
153+
| Manifest validation fails CI on first introduction | Tier 2 keeps router hand-wired; failed validation does not break runtime. |
154+
| Tenant switch on detail page may interrupt user mid-edit | Detail navigates back; pending edits are lost (existing UX). Document in spec; future change MAY add an unsaved-changes guard. |
155+
| External feeds (GEMMA, GitHub) used by sync services have their own auth context — confused with NC tenant context | Sync services run server-side; `useTenantContext()` is frontend-only. No conflict. |
156+
| `concept-organisations` page differs from other entity pages | `type: "custom"` covers the difference; widget keeps its existing implementation. |
157+
158+
## Open design questions
159+
160+
1. **Q1 — `@resolve:{key}` sentinel.** Same as LarpingApp Q1.
161+
Local pre-processor for now; upstream when ≥2 apps need it.
162+
163+
2. **Q2 — Multi-tenancy gating.** Same as LarpingApp Q2. Ship
164+
Phases 1-3 first; Phase 4 trails the nc-vue release.
165+
166+
3. **Q3 — Sidebar slot conventions.** Apps detail page registers
167+
`slot.sidebar = AppSidebar`. Should organisations / catalogs
168+
also expose sidebars by default, or keep them off until the user
169+
opts in? Recommend default-on for the entities that have sidebars
170+
today (apps, organisations); off for the others.
171+
172+
4. **Q4 — Concept-organisations page.** The widget pulls from an
173+
external feed (GEMMA / GitHub). It's modelled as `type: "custom"`
174+
in this change. Should we declare it as `type: "index"` with a
175+
pseudo-source URN (e.g.
176+
`softwarecatalog:concept-organisations`) so the page-type
177+
contract is uniform? Recommend: stay `custom` until nc-vue grows
178+
a "data source URN" concept.
179+
180+
5. **Q5 — User-profile event listener resolver injection.**
181+
`UserProfileUpdatedEventListener` reacts to a Nextcloud user
182+
profile event and writes a SoftwareCatalog organisation
183+
membership row. Should the listener inject the resolver in
184+
its constructor (DI-via-app-container) or fetch it lazily from
185+
the container? Recommend constructor injection for testability.
186+
187+
6. **Q6 — Sync service interval keys.** Each sync service has
188+
non-register `getValueString` keys for cron interval, retry
189+
policy, feature flags. Phase 2.4 keeps these on `IAppConfig`.
190+
Should we collect them under a `softwarecatalog/openspec/specs/
191+
sync-tunables/spec.md` so a future audit doesn't re-flag them as
192+
"hardcoded keys"? Recommend yes — small extra capability spec
193+
that documents the intentional separation.
194+
195+
7. **Q7 — Translation target on sync writes.** When sync services
196+
write to OR (e.g. updating an application's description from
197+
GitHub), should they stamp `X-Translation-Target-Language`?
198+
GitHub README content is typically English. Recommend: yes,
199+
stamp `en` so OR's i18n source-of-truth tracks where the
200+
content came from.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# SoftwareCatalog — adopt OR abstractions (manifest, register-resolver, multi-tenancy)
2+
3+
## Why
4+
5+
The 2026-05-03 OR-abstraction audit (`.claude/audit-2026-05-03/`)
6+
identified the same three adoption gaps in SoftwareCatalog as in
7+
LarpingApp:
8+
9+
1. **No architectural manifest** — SoftwareCatalog wires its router
10+
by hand and has no `src/manifest.json`. Per **ADR-024**
11+
(`hydra/openspec/architecture/`) and the migration order in
12+
`hydra/openspec/changes/adopt-app-manifest/`, SoftwareCatalog is
13+
in the second-wave cohort (small, schema-driven) — adopt after
14+
MyDash (the pilot).
15+
2. **`getValueString(...register/schema...)` consolidation** — five
16+
service classes (`ModuleComplianceService`, `GebruikSyncService`,
17+
`OrganizationSyncService`, `ViewService`, plus the
18+
`UserProfileUpdatedEventListener`) resolve register / schema
19+
IDs from `IAppConfig::getValueString` per-call. The new
20+
`RegisterResolverService` from
21+
`openregister/openspec/changes/register-resolver-service/`
22+
consolidates the pattern. SoftwareCatalog has more call sites
23+
than LarpingApp.
24+
3. **No multi-tenancy wiring** — SoftwareCatalog manages applications
25+
and organisations across municipalities. Frontend has no
26+
`useTenantContext()` wiring. When `multi-tenancy-context` ships
27+
in nc-vue, SoftwareCatalog adopts it for refetch and write
28+
header stamping.
29+
30+
> **Note**: This change concerns the **internal Conduction
31+
> SoftwareCatalog app** at `/softwarecatalog/` (lowercase), NOT the
32+
> VNG client repo at `Softwarecatalogus/` (capitalised). Per project
33+
> memory the VNG repo is read-only and MUST NOT be committed to.
34+
35+
## What Changes
36+
37+
### Manifest adoption (Tier 2 → Tier 3)
38+
39+
- Add `src/manifest.json` with:
40+
- top-level menu entries: Apps, Components, Organisations,
41+
Catalogs, Concept Organisations, Settings
42+
- per-entity `index` pages (`type: "index"`) and `detail` pages
43+
(`type: "detail"`)
44+
- sidebars (currently in `src/sidebars/`) registered via the
45+
`slots` map for relevant pages
46+
- dialogs (currently in `src/dialogs/`) registered via
47+
`customComponents` for any `type: "custom"` pages
48+
- Set `dependencies: ["openregister"]` (SoftwareCatalog's ADR-001
49+
already requires OR per its config.yaml).
50+
- Tier 2 first; Tier 3 (manifest-driven nav) tracked as follow-up.
51+
52+
### `RegisterResolverService` consumption
53+
54+
- Replace `IAppConfig::getValueString` calls that resolve
55+
register/schema pairs in:
56+
- `lib/Service/ModuleComplianceService.php`
57+
- `lib/Service/GebruikSyncService.php`
58+
- `lib/Service/OrganizationSyncService.php`
59+
- `lib/Service/ViewService.php`
60+
- `lib/EventListener/UserProfileUpdatedEventListener.php`
61+
- DI `RegisterResolverService` into each constructor.
62+
- Keep `getValueString` calls for non-register keys (sync
63+
intervals, feature flags, admin tunables) on `IAppConfig`
64+
directly.
65+
66+
### Multi-tenancy wiring
67+
68+
- Adopt `useTenantContext()` in:
69+
- `src/views/` apps / components / organisations index views
70+
- and corresponding detail views
71+
- Refetch on tenant switch.
72+
- Stamp `X-OpenRegister-Organisation` on writes.
73+
74+
### i18n wiring
75+
76+
- Pass `?_lang=` on OR fetches.
77+
- Pass `X-Translation-Target-Language` on writes when editing
78+
non-default-language content.
79+
- Display "(translated from {lang})" badge on lists where the
80+
served language differs from `sourceLanguage`.
81+
82+
## Problem
83+
84+
SoftwareCatalog already complies with ADR-001 (data in OR) and
85+
ADR-012 (nc-vue components only). The remaining adoption gap is
86+
purely operational:
87+
88+
- **Hand-wired routes** — adding a new entity type means editing
89+
three places (`router/index.js`, `navigation/...`,
90+
`views/{type}/...`).
91+
- **Five duplicated resolver call shapes** — each service that
92+
needs an OR object resolves register and schema IDs identically.
93+
A typo in one call returns silent empty results.
94+
- **No tenant switch reactivity** — the frontend cannot tell when
95+
the active organisation changes. Lists show stale data on
96+
switch.
97+
- **No language negotiation** — translatable application
98+
descriptions silently overwrite source language on edit.
99+
100+
The cohort solution exists; SoftwareCatalog adopts it.
101+
102+
## Proposed Solution
103+
104+
A single `softwarecatalog-adopt-or-abstractions` change with five
105+
phases (see `tasks.md`):
106+
107+
1. Manifest at Tier 2.
108+
2. `RegisterResolverService` consumption (5 files).
109+
3. i18n wiring (`?_lang=`, `X-Translation-Target-Language`,
110+
`sourceLanguage` display).
111+
4. Multi-tenancy wiring (gated on nc-vue release).
112+
5. Manifest Tier 3 graduation (follow-up tracking).
113+
114+
Each phase is independently shippable.
115+
116+
## Out of Scope
117+
118+
- The VNG `Softwarecatalogus/` client repo. Read-only per project
119+
memory.
120+
- Sync engine refactors. The three sync services
121+
(`Gebruik`, `Organization`, `Module`) keep their integration
122+
with external GEMMA / GitHub feeds; this change only consolidates
123+
their register/schema resolution.
124+
- Custom-icon / image-upload paths. Untouched.
125+
- Newman / API test suite reorganisation. Tracked in a separate
126+
swc-test concern.
127+
128+
## See also
129+
130+
- `openregister/openspec/changes/register-resolver-service/` — the
131+
service this change consumes.
132+
- `openregister/openspec/changes/pluggable-integration-registry/`
133+
(ADR-019) — future GEMMA / GitHub sync sources may register as
134+
integration providers.
135+
- `openregister/openspec/changes/i18n-source-of-truth/` (ADR-025).
136+
- `openregister/openspec/changes/i18n-api-language-negotiation/`
137+
(ADR-025).
138+
- `nextcloud-vue/openspec/changes/multi-tenancy-context/`.
139+
- `hydra/openspec/changes/adopt-app-manifest/` — fleet-wide
140+
manifest convention (ADR-024).
141+
- ADR-001 — All data in OR.
142+
- ADR-012 — nc-vue components only.
143+
- ADR-022 — Apps consume OR abstractions.
144+
- ADR-024 — App manifest fleet-wide adoption.
145+
- ADR-025 — i18n source-of-truth + API language negotiation.
146+
- `.claude/audit-2026-05-03/` — source audit.

0 commit comments

Comments
 (0)