Skip to content

fix(adr-066): ship the decidesk-decisions leaf's server-side face (closes gate-24) - #520

Merged
rubenvdlinde merged 5 commits into
developmentfrom
fix/adr-066-decisions-leaf-face
Aug 17, 2026
Merged

fix(adr-066): ship the decidesk-decisions leaf's server-side face (closes gate-24)#520
rubenvdlinde merged 5 commits into
developmentfrom
fix/adr-066-decisions-leaf-face

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What

Ships the server-side half of decidesk's decidesk-decisions integration leaf, and pins it with tests.

This closes [gate-24] integration-parity, decidesk's last failing gate — but it is not why it was done. The finding was real: the leaf was registered on the client only, which under ADR-066 is half a contract.

Why ADR-066 asks for this

ADR-066 decision 1 makes the JS registerIntegration() path the render-surface half of the leaf contract, "bound to the server descriptor by shared id". Its Consequences → Cross-app / governance section names the job the other half does:

registered descriptors surface through OpenRegister's OCS capabilities so admin UI and manifest apps can enumerate leaves without loading any app's JS bundle.

Decision 4 then requires the two halves to correspond under one id, and decision 7 requires the server descriptor's renderMode to equal the JS registration's.

decidesk had one JS registration (decidesk-decisions, renderMode: 'mount', a complete mount/unmount pair) and zero server faces — new LeafDescriptor(, IntegrationProvider and RegisterLeafProvidersEvent all measured empty in lib/, and openregister ships no face for that id either. That is an orphan registration.

Cutting the other way, stated plainly: nothing reads the capability today. The only references to openregister.integrations.leaves in the whole fleet checkout are the writer (openregister/lib/Capabilities/IntegrationsCapability.php:114) and LeafRegistry::describeForCapabilities(). Rendering is driven entirely client-side, so the leaf renders today and this PR changes no rendering. What it adds is server-side discoverability — a genuine ADR-066 Decision 1/4 gap with no runtime symptom yet.

The shape

Modelled on hermiq's lib/Listener/RegisterAgentLeafListener.php, the fleet's reference for exactly this situation — same collect-event, same guarded-throw contract, adapted to decidesk's leaf:

  • kinds: ['render-surface'] and only that kind. Asserted, not left unstated.
  • Null IntegrationProvider. The tab and widget read (and append) decisions through OpenRegister's own object API in the browser (ADR-022), so decidesk holds no app-local store behind this leaf.
  • renderMode: mount, matching the JS half's DOM hand-off (openregister#2127).
  • No verb. Raising a decision from another app stays the ADR-041 DecisionRequestedEvent path (REQ-DCDH-002), never the leaf seam — ADR-066 decision 2.

Two things worth a reviewer's eye

1. The subscription is in a NEW registrar. PlatformIntegrationRegistrar sat at a PHPMD CouplingBetweenObjects of 12 against a threshold of 13; the leaf's two class references took it to 14 and turned a correct registration into a red phpmd job. Extraction is the move this codebase already makes at that boundary — it is why the four existing registrars were split out of Application in the first place, and those files' own docblocks say so. Application goes 11 → 12.

2. surfaces is now declared on BOTH halves. The JS half declared no surfaces key at all. A half that declares a value by omission gives a cross-layer check nothing to compare — which is exactly how hermiq's two halves drifted apart while both compilers stayed quiet. All four VALID_SURFACES members are declared because the leaf really does render on all four (componentForSurface() roots the widget on the three dashboard/detail surfaces and the tab everywhere else). The key is inert on the client@conduction/nextcloud-vue 2.3.0's useIntegrationRegistry never reads it; the registry routes tab-vs-widget through the surface mount prop — so this changes no rendering either.

gate-24, before and after

Both sides measured with the same gate package 742f370e152b296acdca3289230a0119d8bb23b8 (= ConductionNL/.github@main), the same node_modules, the same vendor, the same runner invocation. The base is a sibling worktree at a9de4096, not a remembered number.

base a9de4096 head 7e3f2293
run-hydra-gates.sh exit 1 0
gate-24 FAIL — 1 parity violation(s) PASS
coverage 62 of 62 applicable ran 62 of 62 applicable ran
verdict 1 GATE(S) FAILED ALL 62 APPLICABLE GATES GREEN — and all 62 of them ran

The base line reproduces CI run 95288055932 exactly ([gate-24] integration-parity: FAIL — 1 parity violation(s), 1 GATE(S) FAILED). The violation:

✗ [R2 id-correlation] registerIntegration id "decidesk-decisions"
  (src/integrations/registerDecisionsLeaf.js) has NO matching server-side face in lib/**
  — orphan registration: it mounts on window.OCA.OpenRegister.integrations but is
  invisible to the openregister.integrations.leaves capability.

Re-run with a delta base (--base a9de4096, which activates the diff-scoped gates CI also runs): ALL 64 APPLICABLE GATES GREEN — and all 64 of them ran, exit 0 — including [gate-16] spec-coverage: PASS, [gate-46] spec-anchor-existence: PASS and [gate-47] security-change-has-tests: PASS.

The gate was not weakened, exempted or retargeted, and no parity script was shaped around decidesk's configuration. scripts/check-integration-parity.js is byte-identical to developmentgit diff touches it not at all.

The tests, and their red control

Nine tests. Every one was shown able to fail before it was shown to pass.

Removing lib/Listener/RegisterDecisionsLeafListener.php entirely (tests kept):

✘ Both halves declare the same leaf                        ✘ Registers decisions leaf
✘ Both halves declare the same surface set                 ✘ The descriptor is enumerable through the capability row
✘ The mount mode render pair is complete                   ✘ Every surface is in the open register vocabulary
✘ Ignores non matching event                               ✘ The listener is subscribed to the leaf collect event
Tests: 8, Errors: 7, Failures: 1        →  restored: OK (9 tests, 57 assertions)

🔴 That control changed the test. On the first run, seven tests errored and "the listener is subscribed to the collect event" still PASSED over a listener class that did not exist. ::class is resolved by the compiler to a plain string and registerEventListener() only stores strings, so a subscription to a missing class is indistinguishable from a working one right up until OpenRegister dispatches the event. The test now asserts the named class exists and implements IEventListener — and it is red in the control above.

Three further controls, each reverted:

mutation gate-24 the test
PHP ICONScaleBalance FAIL (R4 metadata) ✘ 2 tests
IntegrationLeafRegistrar call removed from Application::register() pass composition root runs the leaf registrar
JS requiredAppdecidesk-typo exit 0, "all rules pass" both halves declare the same leaf
JS surfaces key removed exit 0, R4 count silently 4 → 3 both halves declare the same surface set

The last two rows are the point of DecisionsLeafParityTest. gate-24's static reader cannot resolve Application::APP_ID or $this->l10n->t(self::LABEL_SOURCE), and it treats an unresolvable value as "not compared, never a failure" — so requiredApp and label are unguarded by the gate. Measured: with requiredApp mutated, gate-24 exits 0 and prints "all rules pass". The test does not.

(Root cause, reported not fixed — see below: the checker's /class\s+(\w+)/ regex matches the word "class" inside Application.php's docblock sentence "Main application class for the Decidesk…", so it files APP_ID under for::APP_ID.)

A registrar with no caller registers as much as no registrar at all, so testTheCompositionRootRunsTheLeafRegistrar reads Application::register() through reflection — with a positive control on the same reader (new PlatformIntegrationRegistrar() must be found), so a failure means "not wired", never "read nothing".

Everything else, measured

Run in a php:8.3-cli container (the host has no PHP 8.3):

check result
phpunit -c phpunit.xml (full suite) Tests: 982, Assertions: 3766, Errors: 87, Failures: 1, Skipped: 33
same suite, my listener + tests removed Tests: 973, Errors: 87, Failures: 1, Skipped: 33
phpcs lib/ 0 errors, 109 warnings (all pre-existing; my 2 files add 0 errors and only the app-wide SPDX-comment warning)
phpmd (both rulesets) exit 0
phpstan [OK] No errors
psalm No errors found!
eslint src/integrations/registerDecisionsLeaf.js clean
prettier --check clean
npm run test:l10n OK — every used translation key is present

⚠️ The 87 errors + 1 failure are LOCAL-ONLY and identical on the base, which is why the second row is there rather than a claim. They are the documented OCA\OpenRegister\Service\FileService mocks: CI checks out openregister@development as an additional app and binds the real class. My change contributes exactly +9 tests / +57 assertions and moves neither number.

Two things reported, deliberately NOT fixed here

1. gate-24's checker has a class-name blind spot. collectServerFaces() builds its Class::CONST table from the FIRST /class\s+(\w+)/ match in each PHP file. In lib/AppInfo/Application.php that match is the word "class" in the header docblock — verified: it returns "for" at offset 58, from "Main application class for the Decidesk Nextcloud app." So Application::APP_ID is never resolvable and R4 silently skips requiredApp on this repo. That sentence appears in most Conduction app bootstraps, so this is fleet-wide. Not patched here on purpose: scripts/check-integration-parity.js is the generation the board proposes to vendor into hydra-gates as-is (md5 8516176…), and forking it in one app would make the seed a moving target. It belongs in ConductionNL/.github.

2. LeafDescriptor::toArray() drops referenceType and requiresPermission. LeafRegistry::describeForCapabilities() publishes toArray() plus a usable flag, and toArray() omits both fields. So the AD-18 referenceType marker — the thing that tells a consumer which schema property renders this leaf's single-entity surface — is declared on the descriptor and never reaches the capability. An openregister question, not decidesk's; the stub mirrors the omission rather than helpfully filling it in, so no test here asserts a payload OpenRegister does not emit.

Not in this PR

decidesk's OpenRegister authorization blocks. That is a separate, real security finding with a behaviour change of its own and it gets its own PR.

decidesk registered its `decidesk-decisions` leaf on the CLIENT only. Under
ADR-066 decision 1 the JS `registerIntegration()` path is the render-surface
HALF of the leaf contract, bound to a server descriptor by shared id, and the
ADR's Consequences name the job the other half does: registered descriptors
surface through OpenRegister's OCS capabilities so an admin UI or manifest app
can enumerate leaves without loading any app's JS bundle.

Without that half the leaf renders but is invisible to every server-side
consumer — an orphan registration under ADR-066 decision 4 (gate-24 R2).

Adds RegisterDecisionsLeafListener, modelled on hermiq's
RegisterAgentLeafListener (the fleet's reference shape for the same situation):
one `render-surface` kind, a null IntegrationProvider (the leaf reads and
appends through OpenRegister's own object API from the browser, ADR-022, so
decidesk holds no app-local store behind it), `renderMode: mount` matching the
JS half's mount/unmount DOM hand-off, and every metadata field equal to the JS
half's declaration.

The subscription lives in a new IntegrationLeafRegistrar rather than on
PlatformIntegrationRegistrar: that class was at a PHPMD CouplingBetweenObjects
of 12 against a threshold of 13, and the leaf's two class references would have
taken it to 14. Extraction is the move this codebase already makes at that
boundary. Registered unconditionally from register() — `::class` is a
compile-time string and registerEventListener() stores strings, so nothing
autoloads an OpenRegister class, and a class_exists() guard there would resolve
differently purely by app load order.
The JS half declared no `surfaces` key at all. ADR-066 decision 4 requires the
two halves to correspond, and a half that declares a value by OMISSION gives a
cross-layer check nothing to compare — which is exactly how hermiq's two halves
drifted apart unnoticed while both compilers stayed quiet.

All four members of LeafDescriptor::VALID_SURFACES are declared because the leaf
really does render on all four: componentForSurface() roots CnDecisionsWidget on
detail-page / app-dashboard / user-dashboard and CnDecisionsTab everywhere else.

The key is inert on the client today — the registry routes tab-vs-widget through
the `surface` mount prop and never reads this list (checked against
@conduction/nextcloud-vue 2.3.0's useIntegrationRegistry) — so this changes no
rendering. It is a declaration, and it is what the parity assertions read.
…pear

Nine tests across two files, each shown able to fail before it was shown to pass.

RegisterDecisionsLeafListenerTest asserts the leaf is discoverable server-side:
exactly one contributed leaf, the render-surface kind and ONLY that kind, the
mount render mode, a null provider, and the exact capability row
LeafRegistry::describeForCapabilities() publishes.

Two of its tests exist because of what the red control showed. With the listener
class DELETED, seven of eight tests errored and
'the listener is subscribed to the collect event' still PASSED — `::class` is a
compile-time string and registerEventListener() only stores strings, so a
subscription to a missing class is indistinguishable from a working one until
the event is dispatched. It now asserts the named class exists and implements
IEventListener. And because a registrar nobody calls registers as much as no
registrar at all, a second test reads Application::register() through reflection
to prove the composition root reaches it — with a positive control on the same
reader, so a failure means 'not wired', never 'read nothing'.

DecisionsLeafParityTest compares the two DECLARATIONS directly, reading the JS
source, because there is no runtime in this process where both exist. It covers
two fields gate-24's static reader silently skips on this repo: `requiredApp`
(written Application::APP_ID) and `label` (written $this->l10n->t(...)) are both
unresolvable to it, and it treats an unresolvable value as 'not compared, never
a failure'. Measured: with the JS `requiredApp` mutated to 'decidesk-typo',
gate-24 exits 0 and reports every rule passing while this test fails.

The three OpenRegister stubs mirror the real classes' FULL public surface,
checked against openregister development when written. They need no require_once
branch — their paths under tests/Stubs/ mirror their namespaces, so the PSR-4
root the bootstrap already registers resolves them, and adding one would
recreate the dead-guard shape #399 removed.
The behaviour this PR adds is shipped and observable, so it gets a written
requirement rather than a `@spec exclude`. It records what each half declares,
why the server half exists (capability enumeration without loading the bundle),
that the leaf declares render-surface ONLY and contributes a null provider, and
that raising a decision from another app stays the ADR-041
DecisionRequestedEvent path rather than the leaf seam.

Carries an `@e2e exclude` with its reason: one of the two declarations is a PHP
LeafDescriptor no browser ever sees, so there is no rendered state in which
Playwright could observe the server half being absent — which is precisely why
the halves were allowed to drift.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidesk @ 3bac6e9

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
composer ✅ 104/104
npm ✅ 532/532
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-17 08:15 UTC

Download the full PDF report from the workflow artifacts.

Picks up #522's composer cooldown in .github/dependabot.yml. The gate package
moved from 742f370e to 0b189e30 mid-review and added gate-93
composer-cooldown-config; this branch's green was measured on 742f370e, before
that gate existed, and would fail it purely by predating the fix development
already carries.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidesk @ 2b1abe5

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
composer ✅ 104/104
npm ✅ 532/532
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-17 11:42 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 340ded3 into development Aug 17, 2026
79 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants