Skip to content

Release: merge development into beta for the morning checkup build - #809

Merged
rubenvdlinde merged 66 commits into
betafrom
sync/dev-to-beta-20260821
Aug 21, 2026
Merged

Release: merge development into beta for the morning checkup build#809
rubenvdlinde merged 66 commits into
betafrom
sync/dev-to-beta-20260821

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Carries the evaluation-response read-scope fix (#781) into the beta build being reviewed this morning.

Why now: #781 landed on development after last night's fleet sync, so the beta environment would otherwise be reviewed without it. Requested explicitly rather than assumed — a mid-review beta change is normally worth avoiding.

What it carries. Before #781, the raw anonymous board self-evaluation answers were readable by any authenticated account through OpenRegister's generic object API. That does not identify anyone — the schema holds no member relation and its responseToken is a server-secret HMAC — but it defeats the anonymity threshold BoardEvaluation applies to its aggregate, which suppresses scores when too few members responded precisely so a small board cannot be read individually.

The schema now closes read and keeps create open so members can still submit (a block shadows the register baseline entirely, so a block without create would stop submission — the same shape as the read-only block that only Newman ever caught). BoardEvaluationScoreService reads the set with _rbac: false, because a chair does not own the other members' responses and the caller is already authorised by BoardEvaluationAccessGuard::requireChairOrSecretary().

No conflicts. Verified in the merged tree rather than assumed: evaluation-response at version 0.2.0 with {"create":["authenticated"]}, the scorer carrying _rbac: false, and the test pinning that argument present.

On development this merged with 33 jobs green — Newman, Playwright, Hydra Gates and the Coverage guard included.

github-actions Bot and others added 30 commits August 20, 2026 13:06
Supersedes #526, which bumped phpstan/phpstan alone and could not pass: 60
errors, of which 38 were migration behaviour rather than defects. Those 38 are
fixed once for the fleet in ConductionNL/.github#517, released as
conduction/hydra-gates v1.8.2 (treatPhpDocTypesAsCertain: false, plus an
ignore for PHPMD's @SuppressWarnings tag which 2.x parses as its own).

This bumps hydra-gates to ^1.8.2 alongside phpstan ^2.0 — the lockfile pinned
v1.8.1, so the shared config was not in effect here — and clears the remaining
22, which are genuine:

  4  throws.unusedType         @throws RuntimeException on four getObjectService()
                               methods that ADR-083 turned into plain property
                               reads. Their own comment already says 'a property
                               read throws nothing, so the old catch was
                               unreachable'; the tag was left behind.

  11 alreadyNarrowedType       is_object($saved) / method_exists($x, 'getObject'|
                               'jsonSerialize') on values from OpenRegister.
                               saveObject() declares ': ObjectEntityInterface' —
                               a REAL return type, not a docblock — and that
                               interface declares getObject(): array and extends
                               JsonSerializable. PHP raises a TypeError before
                               any of these guards could run, so each fallback
                               branch was unreachable. Removed the probes and
                               kept the branch that always executed.

  3  notIdentical.alwaysTrue   '!== null' after isset() (already false for null),
                               and after a '?? ""' chain that cannot yield null.

  2  parameterByRef.type       copyItemDocuments() declared array<int, string>
                               for the same $skipped it passes straight into
                               writeFileDefensively(), which declares
                               array<string>. Aligned to the consumer.

  1  argument.type             str_replace() handed mixed values from decoded
                               object payloads where it declares array<string>.
                               Cast — an array replacement would silently become
                               the literal 'Array'.

  1  function.impossibleType   class_exists() on OPENCONNECTOR_CANDIDATES. That
                               probe IS the optional-integration feature;
                               OpenConnector is a separate app whose classes
                               exist only at runtime. Ignored in phpstan.neon,
                               scoped to the rule AND the file, so a genuinely
                               impossible class_exists() elsewhere still fails.

Verified locally: phpstan 2.2.8 exit 0 (control: a deliberately broken file
still reports 'Found 1 error', so it is analysing). phpcs exit 0. php -l clean.
PHPUnit 1150 tests, 4546 assertions, OK.
…60820130433

chore(release): 1.0.1-unstable.20260820130433
…60820202311

chore(release): 1.0.1-unstable.20260820202311
…60820204542

chore(release): 1.0.1-unstable.20260820204542
…60820211107

chore(release): 1.0.1-unstable.20260820211107
`CnObjectDataWidget.editable` defaults to TRUE, so every property named in a
data widget's `include` list becomes a text box the user can type into. That
put lifecycle state and audit stamps — `status`, `lifecycle`, `submittedAt`,
`approvedBy`, `openedAt`, `closedAt`, `publishedAt`, `enactedAt` — in front of
users as editable fields.

These are written by the backend when a transition lands (`TransitionEngine`
stamps them through `saveObject()`), so an input for them is a control that
can only ever fail or confuse: the guarded path is the lifecycle buttons, and
`LifecycleValidationListener` rejects anything that is not a legal transition.

Locked with per-field `overrides.<field>.editable: false` rather than
`editable: false` on the widget: these panels mix process state with fields
the user legitimately edits, and a blanket lock would make those read-only
too.

NOT fixed here: widgets that declare no `include` at all render EVERY schema
property, and enumerating their fields in the manifest would drift the moment
the schema changes. 52 such widgets fleet-wide expose 124 process fields.
Closing those needs a server-side "system-owned" marker, which OpenRegister
does not have — `readOnly:true` has no bypass for backend callers and would
break the transition that writes the field. Filed as
ConductionNL/openregister#2644.

Inserted textually, one compact line per widget, so the diff is the change and
nothing else: a full JSON re-serialisation reflowed hand-compacted lines and
turned this into thousands of lines of churn. A verifier re-parses both files
and asserts the only structural difference is the added overrides, and that no
non-process field was locked.
…60820213054

chore(release): 1.0.1-unstable.20260820213054
…60820215610

chore(release): 1.0.1-unstable.20260820215610
…l copy

The PHPUnit leg failed on "Guard coverage baseline", and the guard itself
printed the reason:

  scripts/coverage-guard.php predates --deletion-neutral, so deleting
  well-tested dead code will still read as a coverage drop. Copy the
  canonical version from ConductionNL/.github at
  quality-config/coverage-guard.php to pick it up.

That is exactly what this PR does to the code:

  base 1782/2324 statements (76.68%)
  head 1763/2305 statements (76.49%)   FAIL: dropped by 0.19%

Nineteen statements were deleted and all nineteen were covered. Nothing
became less tested; the denominator shrank and took the numerator with it,
so the ratio dipped. Reading the percentage alone would call this a
regression — the counts are what say otherwise.

Replaces the app's 434-line copy with the canonical 837-line one, which
reports `deletion-neutral` among its capabilities. No local edits: it is a
verbatim copy of ConductionNL/.github@main quality-config/coverage-guard.php,
which the shared workflow expects apps to keep byte-identical.

Every one of the fleet's PHP apps still ships the stale copy, so this will
resurface elsewhere; fixing it here rather than suppressing the ratchet
keeps the guard doing its actual job on real coverage losses.
…60820222118

chore(release): 1.0.1-unstable.20260820222118
…60820223950

chore(release): 1.0.1-unstable.20260820223950
fix(manifest): process and audit fields are no longer editable inputs
…60820230259

chore(release): 1.0.1-unstable.20260820230259
The coverage ratchet failed this PR, and after refreshing the guard to the
canonical deletion-neutral copy it still failed with the SAME numbers:

  Deletion-neutral: attributed by method name
    (head 13 file(s), base 13 file(s), 144 head method(s), 144 base method(s))
  Surviving code, head:   76.49%  (1763/2305 statements)
  Surviving code, base:   76.68%  (1782/2324 statements)

144 methods on both sides — so no method was deleted, and deletion-neutrality
had nothing to excuse. The 19 statements went from INSIDE methods that still
exist, and all 19 were covered. The guard is arithmetically right: removing
covered lines from a 76%-covered body lowers the ratio. My earlier assumption
that refreshing the guard would clear this was wrong.

The honest way back to parity is coverage, not suppression.

VotingBehaviourService was the obvious candidate: 129 statements, ZERO covered,
while being what answers 'how does this board member actually vote' — a
participation statistic shown to a governance body. It needed tests on its own
merits.

Five tests pin the three-hop traversal and the arithmetic:
  - an empty body yields a zeroed but WELL-FORMED report (callers render every
    key; a missing one is a broken page, not a zero)
  - an OPEN round is excluded from the denominator — nobody is absent from a
    vote that has not closed
  - ballots tally per bucket, an unknown value lands in none, and an isProxy
    ballot counts as GIVEN not received (inverting that misreports who
    delegated to whom)
  - participation rate is a percentage of closed rounds
  - a motion with no resolvable id is skipped rather than fatal

Note on style: tests are not in phpcs's scope here (phpcs.xml declares only
<file>lib</file>), and the existing suite uses positional arguments in test
code — ActionItemAnalyticsServiceTest reports 21 of the same
RequireNamedParameters findings. This file matches that convention rather than
inventing a different one.
…60820233551

chore(release): 1.0.1-unstable.20260820233551
…60820235806

chore(release): 1.0.1-unstable.20260820235806
…60821001641

chore(release): 1.0.1-unstable.20260821001641
rubenvdlinde and others added 23 commits August 21, 2026 04:20
…60821021105

chore(release): 1.0.1-unstable.20260821021105
…60821022430

chore(release): 1.0.1-unstable.20260821022430
…60821024534

chore(release): 1.0.1-unstable.20260821024534
…60821025343

chore(release): 1.0.1-unstable.20260821025343
…60821030551

chore(release): 1.0.1-unstable.20260821030551
…60821031952

chore(release): 1.0.1-unstable.20260821031952
…60821033312

chore(release): 1.0.1-unstable.20260821033312
…60821035120

chore(release): 1.0.1-unstable.20260821035120
…60821040203

chore(release): 1.0.1-unstable.20260821040203
…60821041243

chore(release): 1.0.1-unstable.20260821041243
…stance (#781)

* fix(security): close the raw board-evaluation answers to the whole instance

`evaluation-response` declared no authorization block, so it fell back to
the decidesk register baseline and every raw board self-evaluation answer
was readable by ANY authenticated account through OpenRegister's generic
object API.

This does NOT de-anonymise anyone — the schema carries no member relation,
and `responseToken` is an HMAC(participantId + evaluationId) under a
server-side secret. What it defeats is the anonymity THRESHOLD deliberately
built one level up: `BoardEvaluation` suppresses `dimensionScores` and
`themes` (`suppressed = true`) when too few members responded, precisely so
a small board cannot be read individually — while the underlying answers
stayed open to everyone. You could not learn WHO said it; you could read
WHAT was said in a cycle whose summary was withheld for exactly that
reason.

The block declares `create` and omits everything else:

  * `read` closed — the exposure above. Owner and admin only.
  * `create` OPEN to `authenticated`, and this is load-bearing: a block
    SHADOWS the register baseline entirely, so a block without `create`
    would stop members submitting at all. That is the same shape as the
    read-only block that closed create/update once before, which 1073 unit
    tests and 60 gates all passed and only the Newman collection caught.
  * `update` closed still permits a resubmission, because the author OWNS
    their response — the upsert is keyed on the opaque `responseToken`
    slug, so it never needs to read anyone else's row.
  * `delete` closed outright.

`BoardEvaluationScoreService::closeCycle()` now reads the response set with
`_rbac: false`. That is REQUIRED, not an optimisation: a chair does not own
the other members' responses, so under caller RBAC the query would return
nothing and the cycle would close with a vacuous score summary on a healthy
200 — which the comment directly above that call records having already
happened once, for a different reason. The caller is authorised before the
service is reached: `BoardEvaluationController::close()` runs
`BoardEvaluationAccessGuard::requireChairOrSecretary()`, and it is the only
caller.

`BoardEvaluation` itself is deliberately left alone. Its `read:
authenticated` was flagged as over-broad, but its aggregate is suppressed
below threshold and both participant-id arrays are documented as carrying
no link to response content. The schema holding the sensitive material was
the one with no block at all.

Schema version bumped 0.1.0 -> 0.2.0 — an annotation-only change never
deploys. Sentinel 26 -> 27, with the `create` exception pinned to
`['authenticated']` and nothing wider.

1150 tests / 0 failures, verified with a COPIED vendor: with vendor
symlinked, `DecisionLifecycleVocabularyTest::testClassesUnderTestResolveToThisRepository`
correctly failed because the autoloader was serving the other checkout's
classes and the suite was not testing this code at all.

* test(security): pin the `_rbac: false` the scorer depends on

The coverage ratchet failed this PR honestly: base 75/131 -> head 75/132
statements on the touched files. The `_rbac: false` argument added one
statement and nothing covered it.

Covering it properly rather than adjusting the baseline, because the
argument is exactly the kind that should be pinned: it is one token, and
deleting it leaves a suite that still passes and a feature that silently
reports zeros. `evaluation-response` now closes `read` to everyone but the
response author, so under caller RBAC a chair sees NOTHING and the cycle
closes with a vacuous score summary on a healthy 200 — the same silent
failure the relation-filter comment above that call records having already
happened once, for a different reason.

The test CAPTURES the argument the service actually passed rather than
pinning it with `->with()`, so the assertion describes the value and not
merely that a call happened.

Control run, because a guard test that cannot fail is not evidence: with
`_rbac: false` removed the test fails with "Failed asserting that true is
false"; restored, it passes. Full suite 1151 tests / 0 failures, phpcs
clean.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…60821042457

chore(release): 1.0.1-unstable.20260821042457
Carries the evaluation-response read-scope fix (#781) into the beta build
being reviewed this morning, at Ruben's request.

Before #781 the raw anonymous board self-evaluation answers were readable by
any authenticated account through OpenRegister's generic object API, which
defeated the anonymity threshold BoardEvaluation applies to its aggregate.
The schema now closes `read` and keeps `create` open so members can still
submit, and BoardEvaluationScoreService reads the set with `_rbac: false`
because the caller is already authorised by
BoardEvaluationAccessGuard::requireChairOrSecretary().

No conflicts. Verified in the merged tree rather than assumed:
evaluation-response is at version 0.2.0 with `{"create":["authenticated"]}`,
the scorer carries `_rbac: false`, and the test pinning that argument is
present.
@rubenvdlinde
rubenvdlinde merged commit e23ba35 into beta Aug 21, 2026
28 of 29 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidesk @ 93d4f8b

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

Quality workflow — 2026-08-21 05:29 UTC

Download the full PDF report from the workflow artifacts.

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