Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b9baf2e
test: exercise the three revived services through their real construc…
rubenvdlinde Aug 27, 2026
b8a6b91
test(aggregation): remove the coverage metadata that was discarding c…
rubenvdlinde Aug 27, 2026
d100f8e
feat(aggregation): conditional metrics, and refuse unknown filter ope…
rubenvdlinde Aug 27, 2026
eb307fe
fix(flow): enforce the assignee that AwaitSignalNode already recorded…
rubenvdlinde Aug 27, 2026
da5df5f
test(e2e): make the delegation suites establish their own preconditio…
rubenvdlinde Aug 27, 2026
77b4bf4
fix(repair): register ImportFlowRegister and RenameDutchColumns (#2923)
rubenvdlinde Aug 27, 2026
21b85a0
fix(federation): an installed flow must belong to the installer, not …
rubenvdlinde Aug 27, 2026
909a8e7
feat(aggregation): group by a field that lives on the joined schema (…
rubenvdlinde Aug 27, 2026
f2f65ae
feat(graphql): let a bucket carry what the engine actually returned (…
rubenvdlinde Aug 27, 2026
fd08208
fix(aggregation): read `metrics` on the cross-schema path, and refuse…
rubenvdlinde Aug 27, 2026
ce4061f
fix(scope): clear the pending schema ref on the read path too (#2918)
rubenvdlinde Aug 27, 2026
fa926df
feat(schema): validate cron as a string format (#2928)
rubenvdlinde Aug 27, 2026
249c03d
fix(tmlo): all three endpoints answered 500 on every request (#2921)
rubenvdlinde Aug 27, 2026
0534346
perf(ci): one Code Quality run per commit, not two (#2938)
rubenvdlinde Aug 27, 2026
6f484a8
feat(aggregation): derived metrics — arithmetic over the metrics besi…
rubenvdlinde Aug 27, 2026
12608bb
feat(registers): show which app-declared registers landed, and re-imp…
rubenvdlinde Aug 27, 2026
a45db01
fix(phpcs): MetricExpressionEvaluator reddens phpcs on every branch (…
rubenvdlinde Aug 27, 2026
db7fcb5
feat(demo-data): ship demo data for every schema openregister supplie…
rubenvdlinde Aug 27, 2026
819d9e0
feat(nav): the flow list is an ordinary index, and the editor is `flo…
rubenvdlinde Aug 27, 2026
2673058
fix(quality): drop the else in parseExpression (#2952)
rubenvdlinde Aug 28, 2026
56541bf
chore(deps): hydra-gates 1.10, so the E2E skip-discipline gate can ru…
rubenvdlinde Aug 28, 2026
d4a4273
fix(deps): nextcloud-vue 2.21.0, which restores the flow create butto…
rubenvdlinde Aug 28, 2026
5ad939c
chore(deps): bump @conduction/nextcloud-vue to ^2.21.0 (#2955)
rubenvdlinde Aug 28, 2026
25dc396
fix(ci): a push to development must reach a verdict here too (#2960)
rubenvdlinde Aug 28, 2026
1f444fb
fix(scope): a foreign pending schema ref must not fail setRegister() …
rubenvdlinde Aug 28, 2026
f6bf327
fix(e2e): make the flows spec report WHY the button is absent (#2962)
rubenvdlinde Aug 28, 2026
a49d101
fix(descriptors): the demo-data import seeded nothing and reported su…
rubenvdlinde Aug 28, 2026
6ba34d6
fix(schemas): the extended-by map cast schema OBJECTS to the string "…
rubenvdlinde Aug 28, 2026
3879faf
chore(release): 1.1.7-unstable.20260828091020 (#2968)
github-actions[bot] Aug 28, 2026
311f1cf
fix(phpstan): drop the unreachable is_scalar branch in ObjectService …
rubenvdlinde Aug 28, 2026
215e823
merge: development into beta
Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 49 additions & 21 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,47 @@ name: Code Quality

on:
push:
# An ALLOW-LIST of prefixes is a gate with a hole in it, and the hole is
# silent: a branch matching nothing gets no CI, and its last visible status
# is whatever it inherited. Two live examples, both on 2026-08-14:
# `perf/**` was uncovered in openconnector (a merge with conflict markers and
# 84 red tests sailed through), and `feat/**` is uncovered HERE — note the
# list says `feature/**`, so every branch anyone named `feat/...` has been
# running without checks.
# DEFAULT BRANCHES ONLY. `pull_request` below carries every other branch.
#
# Prefixes added rather than `**` because this workflow is expensive. The
# fast structural checks DO run on `**` — see merge-hygiene.yml.
# This was an allow-list of branch prefixes, and that was a gate with a
# SILENT hole: a branch matching nothing got no CI at all, and its last
# visible status was whatever it inherited — indistinguishable, on every
# dashboard, from a branch that passed. Two live examples, both found
# 2026-08-14: `perf/**` was uncovered in openconnector, where a merge
# carrying unresolved conflict markers and 84 failing tests was pushed and
# nothing ran; and `feat/**` was uncovered in openregister, because the
# list said `feature/**`.
#
# ⚠️ Adding prefixes is not the durable fix; the next invented one is
# uncovered again. The durable fix is branch protection requiring a PR into
# development, which the pull_request trigger below already gates properly.
# The comment that stood here said adding prefixes was not the durable fix,
# and that the durable fix was to let the pull_request trigger gate it.
# THIS IS THAT CHANGE.
#
# What forced it now: a push to a branch with an open PR ran the SAME 34
# jobs TWICE on the same commit. `concurrency` cannot dedupe them — the
# group is suffixed by event name deliberately (.github#540: a
# default-branch push carries jobs a PR run does not, and a dispatch must
# not be cancellable by a standing release PR), so the two events sit in
# different lanes BY DESIGN and both run to completion. Measured fleet-wide
# 2026-08-25..27, 659 of 2,106 Code Quality runs were that duplicate — 31%
# of the fleet's most expensive workflow, re-deciding a commit another run
# was already deciding. The account ceiling is 60 concurrent jobs (Team
# plan); the fleet was measured at 53 running with 1,528 jobs queued behind
# them, the oldest run 7 hours old and not yet started.
#
# NO BRANCH LOSES ITS FLOOR. merge-hygiene.yml runs on `'**'` — every
# branch anyone pushes, no prefix list to forget — and it is the check
# `development` actually requires. That is the smoke alarm; this workflow
# is the fire brigade and belongs on the PR. Of 668 feature-branch push
# runs in that window, only NINE were on a branch with no PR run beside
# them.
#
# The default branches STAY: their push runs are not duplicates, they are
# the only carrier of Coverage Baseline Check, SBOM and Features Extract,
# none of which run on a pull_request event.
branches:
- main
- beta
- development
- feature/**
- feat/**
- bugfix/**
- hotfix/**
- perf/**
- refactor/**
- chore/**
- fix/**
pull_request:
# `synchronize` — a push to an open PR — is what was missing. Without it the
# quality suite runs ONCE, when the PR is opened, and every commit after
Expand Down Expand Up @@ -128,7 +143,20 @@ concurrency:
# A branch name is not a unique lane when two event types can each produce a
# run for it, so the event is now always part of the key.
group: quality-${{ github.head_ref || github.ref_name }}${{ github.event_name != 'pull_request' && format('-{0}', github.event_name) || '' }}
cancel-in-progress: true

# PUSH RUNS ARE NOT CANCELLED — and this has to be said HERE, not only in the
# shared workflow. .github#597 set `cancel-in-progress` on quality.yml itself,
# but a caller's own concurrency cancels the whole run before the called
# workflow's setting can apply, so that fix reached only the apps that declare
# no concurrency of their own. Measured 2026-08-28 over push runs on
# `development` since #597: 0 of 11 cancelled where the caller was silent, 7 of
# 13 (54%) cancelled where the caller still said `true`.
#
# An integration branch needs a verdict per commit: the run being cancelled is
# the only thing that would have said whether what just landed is sound, and
# its replacement is cancelled too. `pull_request` keeps cancelling, where
# superseding really is correct.
cancel-in-progress: ${{ github.event_name != 'push' }}

# Permission CEILING for the called quality pipeline. GitHub statically
# validates the called workflow's declared job permissions against this
Expand Down
50 changes: 50 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ Vrij en open source onder de EUPL-licentie.
<step>OCA\OpenRegister\Repair\RemoveRetiredCronJobs</step>
<step>OCA\OpenRegister\Repair\RegisterRiskLevelMetadata</step>
<step>OCA\OpenRegister\Repair\LogDanglingLinkedTypes</step>
<!-- The flows register must EXIST before any step reads flows.
ImportFlowRegister was written, and named here zero times, so
the `flows` register was never created and every flow step
below it silently operated on nothing.

Surfaced by `occ openregister:descriptors:list`, which reported
`flows` ABSENT on an instance where `occ upgrade` had just
reported complete success — 8 of 15 declared registers missing.
Before that command existed the only evidence was two unrelated
e2e suites dying on `registers slug=flows`. -->
<step>OCA\OpenRegister\Repair\ImportFlowRegister</step>
<!-- Rewrite before the index is derived: the index is built FROM node
types, so the types should already be the current ones. -->
<step>OCA\OpenRegister\Repair\MigrateRenamedFlowNodeTypes</step>
Expand All @@ -192,15 +203,40 @@ Vrij en open source onder de EUPL-licentie.
<step>OCA\OpenRegister\Repair\ImportCredentialBrokerRegister</step>
<step>OCA\OpenRegister\Repair\ImportDsarRegisters</step>
<step>OCA\OpenRegister\Repair\ImportEdepotTransferRegister</step>
<!-- `merge-operation` is where MergeService writes the audit row for
every merge, and where reverseMerge reads the preMergeSnapshot
back from. Its descriptor shipped with no Repair step, so the
register never existed: a merge history that is not recorded,
and a reversal that has nothing to read. -->
<step>OCA\OpenRegister\Repair\ImportMergeOperationRegister</step>
<!-- ONE STORE PER FLOW. Flows lived in two places — this table and
objects in the `flows` register — and every subsystem reads the
table. Measured with controls: the same definition fires and
bundles from the table and does neither from the register.
Copies the register-authored ones across, disabled, leaving the
register rows in place so the step stays reversible. -->
<step>OCA\OpenRegister\Repair\MigrateRegisterFlowsToTable</step>
<step>OCA\OpenRegister\Repair\ImportTrustConfigurationRegister</step>
<step>OCA\OpenRegister\Repair\SeedVocabularyRegister</step>
<step>OCA\OpenRegister\Repair\RegisterOpenRegisterWithDoriath</step>
<step>OCA\OpenRegister\Repair\SeedZgwZakenMigrationPack</step>
<!-- Post-migration ONLY: a fresh install has no Dutch columns to
move, so listing this under <install> would be a guaranteed
no-op. MagicMapper ADDS a column for a renamed property and
never renames, so without this step the data stays in the old
column while every read looks at the new one and finds null —
no error, no data loss, invisible to the suite. -->
<step>OCA\OpenRegister\Repair\RenameDutchColumns</step>
</post-migration>
<install>
<step>OCA\OpenRegister\Repair\ReconcileDeclaredBackgroundJobs</step>
<step>OCA\OpenRegister\Repair\RegisterRiskLevelMetadata</step>
<step>OCA\OpenRegister\Repair\LogDanglingLinkedTypes</step>
<!-- The flows register must EXIST before any step reads flows.
ImportFlowRegister was written, and named here zero times, so
the `flows` register was never created and every flow step
below it silently operated on nothing. -->
<step>OCA\OpenRegister\Repair\ImportFlowRegister</step>
<!-- Rewrite before the index is derived: the index is built FROM node
types, so the types should already be the current ones. -->
<step>OCA\OpenRegister\Repair\MigrateRenamedFlowNodeTypes</step>
Expand All @@ -212,6 +248,19 @@ Vrij en open source onder de EUPL-licentie.
<step>OCA\OpenRegister\Repair\ImportCredentialBrokerRegister</step>
<step>OCA\OpenRegister\Repair\ImportDsarRegisters</step>
<step>OCA\OpenRegister\Repair\ImportEdepotTransferRegister</step>
<!-- `merge-operation` is where MergeService writes the audit row for
every merge, and where reverseMerge reads the preMergeSnapshot
back from. Its descriptor shipped with no Repair step, so the
register never existed: a merge history that is not recorded,
and a reversal that has nothing to read. -->
<step>OCA\OpenRegister\Repair\ImportMergeOperationRegister</step>
<!-- ONE STORE PER FLOW. Flows lived in two places — this table and
objects in the `flows` register — and every subsystem reads the
table. Measured with controls: the same definition fires and
bundles from the table and does neither from the register.
Copies the register-authored ones across, disabled, leaving the
register rows in place so the step stays reversible. -->
<step>OCA\OpenRegister\Repair\MigrateRegisterFlowsToTable</step>
<step>OCA\OpenRegister\Repair\ImportTrustConfigurationRegister</step>
<step>OCA\OpenRegister\Repair\SeedVocabularyRegister</step>
<step>OCA\OpenRegister\Repair\RegisterOpenRegisterWithDoriath</step>
Expand Down Expand Up @@ -240,6 +289,7 @@ Vrij en open source onder de EUPL-licentie.
shared rows still need splitting. Reports only unless applied. -->
<command>OCA\OpenRegister\Command\DedupCollidedSchemasCommand</command>
<command>OCA\OpenRegister\Command\BackfillTranslationSourceLanguageCommand</command>
<command>OCA\OpenRegister\Command\DescriptorListCommand</command>
<!-- field-level-object-encryption: encrypt existing plaintext values of a newly-flagged property. -->
<command>OCA\OpenRegister\Command\EncryptFieldCommand</command>
<command>OCA\OpenRegister\Command\DedupeRegistersCommand</command>
Expand Down
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@
['name' => 'endpoints#allLogs', 'url' => '/api/endpoints/logs', 'verb' => 'GET'],

// Settings - Legacy endpoints (kept for compatibility).
// Register descriptors — which app-declared registers landed, and a
// forced re-import for the ones that did not. Admin-only, enforced in
// the controller.
['name' => 'registerDescriptor#index', 'url' => '/api/register-descriptors', 'verb' => 'GET'],
['name' => 'registerDescriptor#import', 'url' => '/api/register-descriptors/{appId}/{slug}/import', 'verb' => 'POST'],
['name' => 'settings#index', 'url' => '/api/settings', 'verb' => 'GET'],
['name' => 'settings#update', 'url' => '/api/settings', 'verb' => 'PUT'],
['name' => 'settings#rebase', 'url' => '/api/settings/rebase', 'verb' => 'POST'],
Expand Down
12 changes: 6 additions & 6 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 107 additions & 0 deletions docs/Technical/register-descriptors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Register descriptors

An app declares its registers in a descriptor — an OpenAPI document under
`lib/Settings/` carrying `components.registers`. OpenRegister imports it through
`ConfigurationService::importFromApp()`, and per
[ADR-005](../../openspec/architecture/adr-005-register-import-via-repair-steps.md)
the import is delivered by an idempotent Repair step.

That decision settled **who** seeds a register. It did not give anyone a way to
re-run the seeding, or to see whether it worked. This page is about the gap and
the panel that closes it.

## Why a register can be missing on a healthy-looking instance

Two properties combine badly.

**Repair steps only run on install and `occ upgrade`.** As soon as an app's
`installed_version` matches its `info.xml`, `occ upgrade` reports *"No upgrade
required"* and skips the repair steps entirely. There is no supported way to run
one again short of `occ maintenance:repair`, which fires **every** app's steps,
or an app disable/enable cycle.

**A failed import is only logged.** From `ImportFlowRegister`'s own docblock:

> Never throws — a failure logs a warning and leaves the instance otherwise
> healthy.

That is a defensible trade at boot, where the alternative is an app that will
not install. Its cost is an instance that looks fine and is missing a register,
with the only evidence in a log nobody is reading.

The consequence is not hypothetical. On a dev instance an `occ upgrade` that
reported complete success left **8 of 15 declared registers absent**, including
`flows`. Two e2e suites died in `beforeAll` on the missing register, and
establishing why took an account listing, a register dump, and a read of the
Repair step's source.

## The panel

**Admin settings → Register descriptors** lists every register any installed app
declares, in one of three states:

| state | meaning | what it costs you |
|---|---|---|
| `current` | present at the version the app ships | nothing |
| `behind` | present, but older than the shipped descriptor | code runs against an older contract |
| `absent` | the app declares it and it does not exist | the code paths that need it are dead |

`absent` and `behind` are deliberately **not** collapsed into one "needs
attention": they call for different actions and carry different risk.

Each row offers an import. The outcome is shown to the administrator who
triggered it — a button that reports nothing is indistinguishable from one that
did nothing, which is the failure being repaired.

## The import is always forced

The re-import passes `force: true`, and that is not a convenience.
`ImportHandler` short-circuits on:

```php
$force === false && version_compare($data['version'], $existingVersion, '<=')
```

The situation an administrator presses this button in is exactly one where the
version counter is not to be trusted — the register is absent, or the write
failed, while the counter says current. An unforced re-import would report
success and do nothing in every case that motivates the action.

## Is a forced re-import safe for customised schemas?

**Yes.** A schema that extends one shipped by a descriptor refers to its base
rather than copying it — `Schema::getAllOf()` returns *"Array of schema IDs,
UUIDs, or slugs"*. Re-importing the base updates the base row; the extension
keeps its own properties and resolves against the new base. It is impervious to
the base moving.

That is a property of the implementation rather than a law, so it is pinned by a
test (`register-descriptors.spec.ts`) rather than assumed: an extension
materialised as a copy at import time would silently revert somebody's
customisation, and it would do so through the button offered as a repair.

## Without a browser

The condition this diagnoses is most likely on an instance mid-setup or
mid-repair, where the admin UI may itself depend on the broken thing:

```bash
occ openregister:descriptors:list # the full inventory
occ openregister:descriptors:list --problems-only # absent and behind only
occ openregister:descriptors:list --app=openregister --import=flows
```

The command formats the same service method the panel reads, so the two surfaces
cannot drift into disagreeing about one instance.

## For app authors

If your app ships a register and it never appears:

1. Run `occ openregister:descriptors:list` and find your app's row. If it is not
listed at all, your descriptor is not being recognised — it must be a JSON
document under `lib/Settings/` with a non-empty `components.registers`.
Recognition is by **shape**, not filename.
2. If it is listed as `absent`, the descriptor is valid and the import never
landed. Import it from the panel or the command, then check your Repair step:
per ADR-005 Rule 1, shipping the JSON alone does nothing at runtime.
Loading
Loading