Skip to content

gates: close gate-54 and gate-25, take gate-26 to PASS — and the three defects the contract test found - #465

Merged
rubenvdlinde merged 11 commits into
developmentfrom
gates/dd-g54-g25-g26-0811
Aug 11, 2026
Merged

gates: close gate-54 and gate-25, take gate-26 to PASS — and the three defects the contract test found#465
rubenvdlinde merged 11 commits into
developmentfrom
gates/dd-g54-g25-g26-0811

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes gate-54 and gate-25; takes gate-26 to PASS; moves gate-19 by one. Every number below is full-tree scope (empty-tree base 4b825dc…), which is the scope the failing workflow_dispatch run used, and the base is named because these gates report the same word over an empty scope as over a clean one.

gate start end scope
gate-54 relation-dialect 2 0 — PASS 25 register files
gate-25 contract-coverage 4 0 — PASS (110 endpoints) full tree
gate-26 visual-coverage 5 (CI) / 4 (after #458) 0 — PASS full tree
gate-19 e2e-coverage 984 983 full tree

🔴 The Hydra Gates cell on this PR is not evidence for any of them. gate-19 and gate-26 are diff-scoped and this branch's diff will scope them differently. The evidence is the controls below, which are scope-independent.

The controls

Committed plants (a staged-only plant can manufacture a false "the gate is blind"):

  • gate-54: PASS → FAIL — 1 naming Decision.zzPlantProbeRelationDialect → PASS. Restore verified byte-identical.
  • gate-25: PASS → FAIL — 1 naming settings#zzPlantProbeContract → PASS. Restore verified byte-identical.

Both plants are in this branch's history and revert to nothing; git diff origin/development..HEAD contains no plant line.

Newman, on a live instance — 35 assertions. Reverting only the two service files: 6 fail, and exactly the 6 that describe the three defects; the other 29 (auth postures, enum normalisation, response shapes, round-trip) stay green.

Playwright mutation matrix — each test fails when and only when its own target breaks:

mutation decision motion agenda-item
manifest di-deck id + agenda-item description copy FAIL pass FAIL
MotionIntegrations registry entry removed pass FAIL pass

Mutating modeLabels.js's assoc label fails the mode-switch test and none of the other three in its file.

⚠️ The first attempt at that matrix came back green, and it was the locator, not the test: getByRole's name option matches a substring by default, so MUTANT-di-deck still satisfied { name: 'di-deck' }. Fixed with exact: true. A null control result reads exactly like a test that cannot fail.

What writing the tests found

gate-25 said four endpoints had never been called by an automated test. Writing that test found three defects, all answering a healthy 200/201 with an empty or wrong body:

behaviour before after
vote twice as the same citizen on one advisory proposal HTTP 201, second CitizenVote row written, both carrying the same "idempotency" slug HTTP 409
votesFor after two votes 0 1
POST /api/participation/budgets/{id}/publish proposals: [], participationCount: 0, allocatedAmount: 0 1 / 1 / 2000

One cause: a findAll() filter keyed on a schema slug. OpenRegister keys _relations by the property path it walked, so _relations.budget-proposal matches nothing. lib/Service/ObjectRelationFilter.php documents this exact failure and ships filterFor() as the remedy — 7 call sites used it, 23 did not. The 20 outside this change are filed as #463 with the discriminator (a kebab-case key is a slug and is dead; camelCase may be a real property) and are not asserted to be broken, because I measured two key shapes, not nine.

gate-26 flagged four page components. Rendering all four in a browser showed two cannot render at all: /decisions/:id/integrations and /agenda-items/:id/integrations are manifest pages of type: "detail", which are built from config.widgets + config.layout and never resolve component. Both .vue roots never appeared; the positive control in the same run is /motions/:id/integrations, type: "custom", which rendered its component and passed first time. This refutes the earlier conclusion that all three *Integrations.vue are live — being named as a manifest component is not evidence when the page type never consults it. Deleted, with their registrations and the two inert keys; a new test asserts each page's type/component pairing so the state cannot silently return.

src/views/settings/UserSettings.vue goes on separate evidence: nothing imports it, no manifest page names it, and the string only it renders (Decidesk settings) is in none of the built bundles while a string only its live twin renders is in decidesk-main.js. It duplicated that twin's four sections exactly, so no field is orphaned.

Honesty notes

  • gate-16 is not in the table because it cannot be, and that is a defect (.github#361). check_spec_coverage.py hardcodes the base to origin/development, so a full-scope run on development diffs a branch against itself: origin/developmentcount=0 (PASS), origin/beta5, origin/main and --mode report91. decidesk's honest gate-16 is 91. Not addressed here.
  • gate-19 moved by one, and only one. Twelve dangling anchors remain, filed as 12 dangling @e2e anchors credit nothing and report nothing #461 rather than repointed — two of them sit on a test whose body asserts app-root and two nav links, so aiming them at the KPI scenarios would be .github#343 by hand. The one repoint carried its own control: gate-19 984 → 983, the figure computed before the run.
  • No exclusion was added anywhere in this branch. decidesk's exclusion profile, measured with the correct key (exclude_reason, positive control asserted first): 220 of 1345 scenarios excluded across 116 distinct reasons, 0 bare; split 31.4 % whole-spec / 10.5 % requirement-inherited / 58.2 % scenario-level. The largest single marker blankets 22 voting scenarios on a reason this repo's own CI refutes — filed as One whole-spec @e2e exclude blankets 22 voting scenarios on a reason this repo's own CI refutes #462, not removed here, because removing it raises the gate-19 count and is a sequencing decision.
  • gate-26 passes, but one of its two covered pages is covered by tests that skip themselves (gate-26's one 'covered' page is covered by tests that skip themselves; ~14 skip-guarded @e2e tests still open #464). I also triggered .github#358 while writing this: MotionIntegrations appeared under tests/e2e/ only in my own comments, so the gate was green on prose. Fixed with an executable table, and verified by re-running is_covered() against a comment-stripped corpus — both pages stay covered.
  • Decidesk is clean of the procest#784/#794 class, checked rather than assumed: no data?.results || data || [] anywhere, and all 33 frontend /apps/decidesk/... URLs resolve to declared routes. Controls run first — a known-absent route returns HTTP 200 text/html, 38 KB, a known-present one 200 application/json, 91 bytes.

Filed rather than forced: decidesk#461, #462, #463, #464, and .github#363 (gate-25's auth lookback is a fixed 20 lines, so it reads the previous method's attribute — and prose).

…target

ConsultationRequest.achterbanraadpleging carried format:uuid and a relation
description with no $ref, justified as keeping a 'cross-capability dependency
soft' because constituency-consultation 'lands in any order'. That reason is
refuted by the file it is written in: fragment 47 already hard-$refs schemas
owned by fragments 42, 46 and 55, and the register.d set carries 61 such
cross-fragment $refs in total. MemberConsultation (slug member-consultation,
fragment 48) is the named target and resolves in the same register set, so the
relation is expressible. Optionality is now carried by nullable, where it
belongs, instead of by withholding the target.

Voordracht.voordragendePartij.referentie is genuinely polymorphic — persoon
resolves against Person, fractie/orgaan against GovernanceBody — so it declares
x-relation-schema-field: type. That is a claim the gate verifies (the named
sibling must exist on the same object), not a waiver.
…d nothing

gate-25 reported four public endpoints no automated test had ever called.
Writing the contract test for them found three defects, every one of which
answered a healthy 200/201 with an empty or wrong body:

  1. A citizen could cast unlimited advisory votes on one proposal. The dedup
     query filtered on '_relations.budget-proposal'. OpenRegister keys the
     _relations JSONB by the property path it walked ('relations.0.id'), never
     by the related schema's slug, so it returned zero rows, the guard was
     skipped, and a second CitizenVote was written carrying the SAME
     deterministic 'idempotency' slug as the first.
  2. tallyAdvisoryProposal() wrote votesFor=0/votesAgainst=0 back over real
     votes, from the same filter.
  3. publishBudgetResults() published proposals:[], participationCount:0 and
     allocatedAmount:0 for every round, because fetchValidatedProposals()
     filtered on '_relations.participatory-budget' rather than the flat
     participatoryBudget property PortalContributionProvider actually writes.

lib/Service/ObjectRelationFilter.php already documents this exact failure and
ships filterFor() as the remedy; seven call sites use it and twenty-three still
hand-write the slug. The two structured-relations queries here now use
filterFor(); the proposal query uses the plain property, with relatesToBudget()
still re-checking both write shapes.

Measured on a live instance, before -> after: duplicate vote 201 -> 409;
votesFor after two votes 0 -> 1; published summary 0/0/[] -> 1/2000/[1].
…f them

A green local run was a green over an unopened scope — the exact shape the
fleet keeps paying for. Glob the directory so the local runner and CI's
`for collection in *.postman_collection.json` measure the same set, and fail
loudly rather than silently when the glob is empty.
…renders

gate-26 flagged four page components with no visual proof. Rendering all four
in a browser showed two of them cannot render at all.

/decisions/:id/integrations and /agenda-items/:id/integrations are manifest
pages of type:"detail" carrying a full declarative body (config.widgets +
config.layout). The renderer builds that body and never resolves `component`;
only type:"custom" consults src/registry.js. Both routes rendered their
manifest widget groups and the manifest description, and neither .vue's root
data-testid ever appeared. The positive control is in the same run:
/motions/:id/integrations is type:"custom" and DID render
MotionIntegrations.vue's root and copy.

So DecisionIntegrations.vue and AgendaItemIntegrations.vue were imported,
registered, bundled and unreachable — the same state as the MeetingIntegrations
.vue deleted in #458, except these two also carried the `component` key, which
is precisely the evidence a reader uses to call a file live. Deleted, with
their registrations and the two inert `component` keys. The declarative pages
lose nothing: config.sidebar.useRegistry:true was the only behaviour the .vue
files added, and the manifest already declares it.

src/views/settings/UserSettings.vue goes too, on separate evidence: nothing
imports it, no manifest page names it, and the string only it renders
('Decidesk settings') appears in none of the built bundles while a string only
its live twin UserSettingsPage.vue renders does appear in decidesk-main.js. It
duplicated that twin's four preference sections exactly, so no field is
orphaned by removing it.

Coverage is three executed browser tests in tests/e2e/workflows/ — the
directory CI's playwright.config.ts actually runs, unlike tests/e2e/visual/,
which its only chromium project ignores. Each asserts a handle its own surface
alone emits AND the absence of both siblings', because all three routes share
one renderer and a resolution fall-through is a failure this app has shipped.
gate-19 984 -> 983, the exact figure computed before the run.

settings-page.spec.ts anchored the REQUIREMENT slug
requirement-req-adm-mode-001-organisatie-modus-tenant-setting. gate-19 indexes
SCENARIO slugs, so it resolved to nothing — and a dangling anchor is reported
by no one, which is why it survived while reading like proof. It now names
admin-selects-a-tenant-mode, and the body was extended to cover that scenario's
third THEN clause rather than only its first two: the mode had to be shown
reaching a consumer, not just round-tripping through the form that wrote it.
Reading the relabelled nav in the SPA proves persistence AND the relabel in one
navigation, so the test got stronger and one page load cheaper. Cleanup moved
to the API because a third SPA boot spent the whole 20 s budget in teardown —
the fix for that is removing a navigation, never widening the timeout.

Twelve dangling anchors remain and are filed (#461) rather than repointed:
two sit on a test whose body asserts app-root and two nav links, so aiming them
at the KPI scenarios would be .github#343 by hand.

integration-surfaces.spec.ts drops the two anchors I had written for slugs that
sounded right and resolved to nothing, and states why no anchor replaces them:
these three routes have no Scenario, and writing one here would be authoring the
spec this suite is checked against.

Also tightens that spec's role locators with exact: true. That is not tidiness.
getByRole's name option matches a SUBSTRING by default, so renaming a widget id
to MUTANT-di-deck still satisfied { name: 'di-deck' } and the mutation control
came back green — a null result that reads exactly like a test which cannot
fail. With exact: true the matrix discriminates: mutating the decision widget id
and the agenda-item copy fails those two tests and not the motion one; removing
the MotionIntegrations registration fails the motion one and not the other two.
…prose

I hit the defect I was working around. gate-26 matches component names as plain
substrings over the RAW text of everything under tests/e2e, comments included
(.github#358). `MotionIntegrations` appeared in this file only in two
explanatory comments — the tests address that page by its data-testid,
`motion-integrations`, a different string — so the gate was reporting the page
as covered on the strength of my prose while a real, executed,
mutation-verified test sat right below it.

Caught only by re-running is_covered() against a comment-stripped corpus, which
is the check worth keeping: the gate going green says nothing about which text
made it green.

The fix is a MANIFEST_PAGES table in executable code plus an assertion that
uses it, and the assertion is worth having on its own account — it is the
regression guard for the bug that removed two .vue files here. A page of
type "detail" never resolves `component`; flipping the motion page to
"detail", or re-adding a component key to either of the other two, silently
recreates a named-but-unrenderable component, and no other test would notice
because all three routes render something either way.

Verified: both discovered page components stay covered with comments stripped.
…s to prevent shipped

The coverage ratchet failed this branch — correctly, and for a reason worth
stating: collapsing two multi-line filter arrays into single expressions removed
exactly 3 statements, all of them covered, and removing covered statements from
a codebase below 100% necessarily lowers the ratio (merge base 9222/15214 =
60.6152%, head 9219/15211 = 60.6075%). Nothing became untested; the arithmetic
moved. The honest response is to cover more, not to bypass the gate.

So this covers the class whose contract the original bug violated, which had no
test at all. The fixtures are not invented — they are the @self.relations
payloads dumped from a live OpenRegister instance, which is the only place the
storage shape is actually decided:

  citizen-vote     {proposalId, relations.0.id, relations.0.schema}
  budget-proposal  {participatoryBudget}

Neither map contains the related schema's SLUG. testAFilterKeyIsNeverASchemaSlug
pins exactly that, and asserts no hyphen survives the prefix, because a slug is
kebab-cased in this register and that makes the wrong shape cheap to detect.

Control: setting RELATION_FILTER_FIELD back to '_relations.budget-proposal' —
the original defect — fails 3 of the 8 tests, the guard among them, naming the
key. Restored: 8 tests, 10 assertions, OK. matching() is covered in both
directions, including a row that must be REJECTED, since a filter that cannot
reject is not a filter.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidesk @ 8929e04

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
composer ✅ 100/100
npm ✅ 548/548
PHPUnit
Newman
Playwright
Hydra gates

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

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Not merging: E2E Tests (Playwright) has not reported. 25 checks pass, 3 skip, that one is still pending after a long wall-clock wait (stuck at step 20/36, Run Playwright tests). A job that has not reported is not a pass, so this stays open for a human or the coordinator to land once it does.

Everything else that exercises this change is green on a fresh CI instance, not just my rig:

  • Integration Tests (Newman) — all 14 collections green; the new decidesk-participation-publication collection ran 35 assertions, 0 failed. That is the real corroboration for the gate-25 work, because it proves the wire contract on a clean install.
  • PHPUnit (PHP 8.3 and 8.4) — both green. The 8.3 cell was red on the first push, correctly: my refactor deleted 3 fully-covered statements, and removing covered statements from a codebase below 100% necessarily lowers the ratio (merge base 9222/15214 = 60.6152%, head 9219/15211 = 60.6075%). Nothing became untested — the arithmetic moved. Fixed by covering more, not by bypassing: ObjectRelationFilter, the class whose contract the original bug violated, had no test at all.

The Playwright evidence that does exist is local and scope-independent — the mutation matrix in the PR body, where each of the three integrations tests fails when and only when its own target breaks, plus the modeLabels.js mutation failing the mode-switch test and nothing else. Those controls do not depend on which instance runs them; the CI cell would be corroboration, not the proof.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment — please disregard the "may be stuck" note. The E2E job is healthy.

I wrote that Playwright "also failed to report on the previous push, so two data points suggest it may be stuck rather than slow." Both data points were wrong, and I had not measured either before saying so:

  1. The previous run's E2E job was cancelled, not unreported — superseded by my own second push (run 31518138243, job 93868932640, conclusion=cancelled; the run itself is completed cancelled). A cancelled job is no verdict; I read it as a non-report.
  2. This run's job is not stuck. There is exactly one job record (93870772290), created 17:41:57Z, started 17:42:06Z. Against GitHub's own Date header at 18:07:39Z that is 25m34s elapsed — unremarkable for a ~115-test suite on an app that awaits initializeStores() before every mount.

I also said the job id had changed, implying re-dispatch. It has not — I had compared it against a job id from a different run and against the Newman job's id.

What produced the false picture: I was printing elapsed time by looping over every in_progress job and reading whichever matched, so I was sometimes reading Newman's age and sometimes Playwright's. The number moved up and down between polls and I read that as restarts rather than as my instrument reading different rows.

So there is nothing to diagnose here and no reason to cancel or re-run this job. Everything else on the PR stands unchanged; I am simply waiting for it, and the merge decision is still gated on it reporting normally.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidesk @ 6431141

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
composer ✅ 100/100
npm ✅ 548/548
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-11 18:11 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Resolved: E2E Tests (Playwright) has now reported, and it was never stuck. Retracting my earlier "stuck at step 20/36" reading.

The job ran 27m49s (Playwright step 25m32s), which is squarely inside this job's own observed band on decidesk — 25m31s, 27m40s, 28m02s, 29m26s, 29m36s across its five previous completed cells. The earlier comment was posted 4m56s into a step whose floor is 25m31s, i.e. about 17% of the way through. And the "previous push also failed to report" data point was my own doing: that run's E2E job (93868932640) was cancelled at 17:39:12, 3m44s in, by the push of 69272bc at 17:38:51 — the shared workflow carries concurrency: quality-${{ github.ref }} with cancel-in-progress: true. A cancelled job is no verdict, not a failure to report. There is exactly one E2E job record on this run, so it was never re-dispatched either.

Merging on the same basis as #458: the red is pre-existing and strictly not worsened. Merge base is 461952a5, which is both origin/development's tip and the exact commit of the development run I compared against, with zero commits on development missing from this branch — so this is a same-tree comparison, not an approximation:

development 461952a5 this branch 69272bc6
failed 7 7 — byte-identical set, same file:line, same titles
passed 108 112
skipped 58 58

Nothing was weakened to get there: no test.skip, test.fixme, .only, mode: 'serial' or testIgnore added, no existing timeout changed (the diff removes no line containing timeout), and no @e2e exclude added anywhere. The only MUTANT string in the net diff is a comment explaining why exact: true is load-bearing; both committed gate plants are reverted byte-identical.

Hydra Gates also reported green on this run (44s) — it had been cancelled alongside E2E on the previous push. The one remaining red, Quality Report, is a pure aggregator of the E2E cell and is not independent debt.

@rubenvdlinde
rubenvdlinde merged commit 443a77e into development Aug 11, 2026
29 of 31 checks passed
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

E2E reported: red, and the red is provably not mine. Formal set-diff against development (461952a, job 93802820899) rather than an eyeball:

development this branch
failed 7 7
passed 108 112
skipped 58 58
  • New failures I caused: zero. comm -13 over the two sorted failure sets is empty — the 7 are identical down to file and line number.
  • Skipped is unchanged at 58, so nothing was silenced to get here.
  • +4 passed is exactly the four new tests in integration-surfaces.spec.ts.

The 7 pre-existing failures, unchanged from development:

integration-registry.spec.ts:342          OCS / JS registry drift        (nextcloud-vue#630)
board-evaluation-workflow.spec.ts:84      \
board-evaluation-workflow.spec.ts:133      >  board self-evaluation      (#460)
board-evaluation-workflow.spec.ts:168     /
crud-persistence.spec.ts:218              Meeting CRUD
crud-persistence.spec.ts:334              Decision CRUD
meeting-transcription-workflow.spec.ts:220

And my own tests are positively confirmed to have run, not merely absent from the failure list — 58 tests were skipped in this run, so absence would have proved nothing:

✓ 121 settings-page.spec.ts:40   admin section mounts                                  (5.4s)
✓ 122 settings-page.spec.ts:74   organisation mode saves, reaches the SPA, relabels nav (12.6s)
✓ 123 settings-page.spec.ts:132  register mapping actions                              (5.4s)
✓ 124 settings-page.spec.ts:141  no 5xx / no console error                             (5.4s)
✓ 152 integration-surfaces.spec.ts:156  manifest type/component pairing guard          (6ms)
✓ 153 integration-surfaces.spec.ts:174  decision integrations surface                  (7.6s)
✓ 154 integration-surfaces.spec.ts:200  motion integrations surface                    (7.6s)
✓ 155 integration-surfaces.spec.ts:229  agenda-item integrations surface               (7.8s)

Merging on the same standard #458 used — pre-existing red, not increased, nothing skipped or weakened to achieve it.

@rubenvdlinde
rubenvdlinde deleted the gates/dd-g54-g25-g26-0811 branch August 11, 2026 18:16
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