Repository navigation
fix: second round of review fixes for the development → beta promotion #2643 - #2692
Conversation
…ite never undoes a send isTransient() counted any classified fout as a StUF fault, and a body that does not parse is classified as PARSE_ERROR, so a load balancer's HTML 503 page or a plain-text 502 ended the message as permanently failed on the first attempt. PARSE_ERROR is no StUF fault: the HTTP status decides. After the case system answered, a row save that threw reached the listener's catch, which refused a zaak that now existed; a retry by the caller would create a second one. The conclusion is now written through concludeRow(), which logs a failed save and keeps the outcome. A row that could not be marked retrying, or a retry job that could not be scheduled, left the message 'retrying' with no job behind it and the owning app waiting forever. Both now conclude the message as failed and announce it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reason out as one bounded line A zakLk01 W clears a value with an empty element carrying xsi:nil or StUF:noValue. The mapper dropped every empty value, so the owning app never saw the clear, kept the old value, and integriq answered Bv03. The mapper now names the cleared elements in the operation's 'clears' and the receiver refuses such a W with a Fo03 (StUF058) before the owning app is asked. Carrying clears to the owning app is a change of the operation contract and is left to the author. refuse() asks for a reason safe to return, but nothing enforced it; an app passing an exception message sent internal errors to the remote party. The reason now leaves as one line of at most 200 characters, and the full text is logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cting later one is logged Only an identical repeat was skipped, so a Fo03 arriving after a Bv03 for the same crossRefnummer (or the reverse) flipped the row and dispatched a second StufZdsCaseConcludedEvent with the opposite outcome. The first confirmation now stands; a later one with another code is acknowledged and logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sensitive Both roster maps in app config are keyed by a followed BSN or KvK number and were stored non-lazy and non-sensitive: Nextcloud loaded them on every request and occ config:list printed them. They are now written lazy and sensitive and read as lazy values, which also finds a value an older release stored eagerly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…analytics Since call_log became admin-only, the product analytics read ran with RBAC on, so a delegated integriq administrator, who passes #[AuthorizedAdminSetting], was shown zero traffic for every product. The rows are read with RBAC and multitenancy off, like the product lookup before it; only aggregates leave the controller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts as closed REQ-OCD-008 limits an investigation window to the instance maximum, but only BodyCaptureController::open() checked it. A bodyCaptureUntil written through the generic source API, a source update or an import opened a window of any length without a reason. isOpen() now also requires the end to lie within maxHours() of the call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…instance maximum Three tests opened a window ending in 2099 or 2999. With a window beyond the 72-hour maximum now counting as closed, two of them failed and the third no longer proved its point (no bodies even inside an open window). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quality Report — ConductionNL/integriq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 143/143 | |||
| npm | ✅ | ✅ 646/646 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| lockfile sync | ✅ | ||||
| PHPUnit | ✅ 5510/5510 · 1 skipped | ||||
| Newman | ✅ 130/130 | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development. |
||||
| Hydra gates | ✅ | ||||
| Hydra gates (axe) | ✅ |
Quality workflow — 2026-10-16 13:20 UTC
Download the full PDF report from the workflow artifacts.
The clear check flagged every element of the new object that carried noValue or xsi:nil, nested relations included. Senders that write each empty optional element that way, integriq's own translators among them, had every W refused. An element now counts as cleared only when it is a direct child of the new object and the old object holds a value for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…outcome The breaker's reset and recordFailure write app config and could still throw after the case system answered: the listener then refused a zaak that existed, or a failed attempt left the row retrying with no job. Both calls now go through breakerRecords(), which logs a failed write. A message concluded as RETRY_NOT_SCHEDULED keeps the last attempt's fout code in its details. Tests cover the breaker failures, an answered question whose row cannot be saved, and a cooldown that cannot be rescheduled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; scrub invalid UTF-8 The full refusal text went into a warning log line, where free text from the owning app can carry identifiers. The log now gets the same bounded line the case system receives. Invalid UTF-8 made preg_replace() return null, so the reason was lost; it is scrubbed first. A receive() test proves the Fo03 carries the bounded line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The requirement said each new confirmation dispatches the conclusion event; the receiver now announces only the first and logs a later one with another code. The requirement says so and names why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ute beyond is closed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; phpcs line length The breaker guard takes the breaker call as a callable instead of branching on a flag, and RETRY_NOT_SCHEDULED keeps the last attempt's fout code in details as is, so the class stays at the phpmd complexity threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One branch fewer keeps StufProviderReceiver under the phpmd class complexity limit once #2686's query checks land beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bject; refuse clears in a W without one The previous fix compared only direct children by their text, so a cleared relation (heeftAlsInitiator) or a cleared value inside one (inp.bsn) did not count and was answered Bv03. Every nil element of the new object is now compared with the old object at the same path of local names. A W that carries a single object has nothing to compare with, so its empty elements count as clears and it is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not stranded retry() called the breaker's allows() unguarded. Once a cooldown has passed it resets the breaker, an app-config write; when that threw, the retry job had already been consumed and the row stayed retrying with no job and no conclusion. All breaker calls now go through breakerCall(), which logs the failure and returns a fallback; allows() fails open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hronous confirmation The requirement claimed the owning app never receives two contradicting conclusions, but the synchronous answer at send concludes the row too and the first asynchronous confirmation still overrides it. The text now says so, and a scenario covers a contradicting later confirmation. The test points at this requirement instead of the retry one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wide characters Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s as a coupling Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The clear check ran an ancestor lookup and a path lookup in the old object for every empty element, so a W with thousands of them took seconds per request (5000 took about 6 s), from any source holding the token. Both objects are now walked once, collecting the paths that hold a value and those sent empty, and the clears are their intersection. Tests cover an old relation that held nothing and a W with 5000 empty elements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| * | ||
| * @spec openspec/changes/stuf-provider-writes/specs/stuf-provider-receiver/spec.md#requirement-a-refused-write-is-a-stuf-fault-with-a-refusing-status | ||
| */ | ||
| public function testARefusedWriteSendsTheRefusalOutAsOneLine(): void { |
There was a problem hiding this comment.
🟢 The log line of a refusal is not asserted
The receiver now logs the same bounded line it sends ('reason' => self::outwardReason(...)). This test asserts only the Fo03 body, so reverting the log context to the raw $refusal would stay green.
Fix: assert the logger's warning context. #2686 adds a $this->logger property to this test on the lines that build the receiver, so this lands after #2686 to avoid a conflict.
Verification: grep -n "createMock(LoggerInterface::class)" tests/Unit/Service/StufZkn/StufProviderReceiverTest.php → the receiver gets an anonymous mock.
| 'StUF-ZDS message {id} was concluded (sent: {sent}) but its row could not be saved: {error}', | ||
| ['id' => (string)($row['id'] ?? ''), 'sent' => json_encode($sent), 'error' => $exception->getMessage()] | ||
| ); | ||
| return $row; |
There was a problem hiding this comment.
🟢 A failed conclusion save leaves the envelope on a retrying row until #2686 sweeps it
When log->conclude() throws, the stored row stays retrying with its envelope, which holds personal data such as a BSN; conclude() is what erases it. On this branch alone nothing removes it later. #2686 adds a 24-hour expires to rows that keep an envelope, and LogCleanUpTask sweeps expired retrying rows, so the two PRs together close this.
Fix: none needed once #2686 is merged; until then this is a dependency, now named in the description.
Verification: git show origin/review/pr-2643-fixes:lib/Service/StufZkn/StufZdsMessageLog.php | grep -n expires → #2686 sets and clears it.
| $found = $message->xpath('./*[local-name()="object"]'); | ||
| $this->walk(element: $object, path: '', filled: $filled, empty: $empty); | ||
| if (is_array($found) === false || count($found) < 2) { | ||
| return array_keys($empty); |
There was a problem hiding this comment.
🟢 A W with a single object now refuses every empty element
With no old object to compare with, every noValue/nil element counts as a clear and the W gets a Fo03. That fails closed, but a sender that sends a W as one object and writes every empty optional element as nil will see every such update refused.
Fix: none in this PR. If a real counterparty sends that shape, the author decides whether to accept it (and drop the nils) or keep refusing.
Verification: testClearingARelationANestedValueOrWithoutAnOldObjectIsRefused, case no old object → 400, StUF058.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (Strict) — posted as a comment, because GitHub refuses a review verdict on one's own PR
No blockers left. The review (Strict) found one 🔴 and five 🟡; three pre-push checks then found three 🔴 and three 🟡 in the fixes. All of them are fixed on this branch:
- The W-clear check refused ordinary updates, and then missed relation and nested clears. Then it was quadratic in the number of empty elements.
- Circuit-breaker calls,
allows()included, could still change a message's outcome. - The spec contradicted first-confirmation-wins.
- The refusal text was logged unbounded.
- Several claims had no test.
Three notes remain, inline.
Connected issues
| Issue | State | What this PR does |
|---|---|---|
| #2684 | open | Unaffected: the stored WS-Security password is fixed in #2686. |
| #2685 | open | Unaffected: the retention sweep is fixed in #2686. |
🟢 Minor (3)
- The log line of a refusal is not asserted: this follows #2686, which adds the logger property to that test.
- A failed conclusion save leaves the envelope on a
retryingrow until #2686 sweeps it: a dependency on #2686, now named in the description. - A W with a single object now refuses every empty element: fails closed; the author decides if a real sender needs that shape accepted.
Heads-up, already there before this PR and outside its diff:
StUFXMLBuilder::buildFo03setsfaultstring->nodeValue, so an&in a refusal text leaves an empty faultstring.- A SOAP 1.1 fault without a StUF
<code>is retried on its HTTP status.
✅ Verified clean
analytics()readscall_logwith RBAC and multitenancy off behind#[AuthorizedAdminSetting], and only aggregates leave the controller.- Nextcloud 32 and 35 accept turning an existing key lazy and sensitive, and read an eager value with
lazy: true; checked inAppConfig::setTypedValue/getTypedValue. - The body-capture ceiling never closes a window
BodyCaptureController::open()created. - phpmd on the four StufZkn classes, on this branch and in the merged tree with #2686, stays under the class-complexity and coupling limits.
- The merge with #2686 is clean (
git merge-tree); 155 StufZkn and body-capture tests pass on the merged tree.
Persistence audit: not run (declined; the diff adds no grants, accounts, secrets or trust). CI: green on 80d820869; the fix commits' run was still in progress when this was posted. Not run: psalm (crashes locally), the full suite on the merged tree.
🤖 Generated with Claude Code
Quality Report — ConductionNL/integriq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 143/143 | |||
| npm | ✅ | ✅ 646/646 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| lockfile sync | ✅ | ||||
| PHPUnit | ✅ 5521/5521 · 1 skipped | ||||
| Newman | ✅ 130/130 | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development. |
||||
| Hydra gates | ✅ | ||||
| Hydra gates (axe) | ✅ |
Quality workflow — 2026-10-16 14:13 UTC
Download the full PDF report from the workflow artifacts.
Second round of fixes for the reviews of #2643 (Wilco's and Robert's), next to #2686. It is cut from
developmentand carries only what can land without #2686. It merges with #2686 in either order without a conflict (git merge-treeagainstreview/pr-2643-fixes), although both touchStufZdsOutbound,StufProviderReceiver,StufZdsConfirmationReceiverandBodyCapturePolicy.Fixed here
7fcd04fcePARSE_ERRORis not a StUF fault, so the HTTP status decides: an HTML 503 page or a plain-text 502 is retried.retryingwith no conclusion7fcd04fce,5bb7bb500,6ca75e4d7concludeRow()), also for a question. Every circuit-breaker call goes throughbreakerCall(), so its app-config reads and writes cannot change the outcome;allows()fails open. A row that cannot be markedretrying, or a retry job that cannot be scheduled (also after a cooldown), concludes the message as failed (RETRY_NOT_SCHEDULED, the last attempt's code indetails) and announces it. A failed conclusion save leaves the envelope on theretryingrow until #2686's expiry sweep removes it.3c83bcf43,4a9e42d87,55a227d1f,c72c979eaxsi:nil/StUF:noValue) counts as a clear when the old object holds a value at the same path, relations and nested values included. Such a W is refused with a Fo03 (StUF058) before the owning app is asked. Empty elements the old object did not hold pass, because senders, integriq's own translators among them, write every empty optional element that way. A W with a single object has nothing to compare with, so it is refused when it carries an empty element. Both objects are walked once, so 5000 empty elements cost milliseconds.3c83bcf43,2041a16a9,79c3aff46,912271de5refuse()documents as safe to return.e1eaaa421,9bebaf442,d604b2e47abe445c3b1b3687161fetchProductCallLogRows()reads with_rbac: false, _multitenancy: false; only aggregates leave the controller.bodyCaptureUntilwritten any other way thanBodyCaptureControlleris honoured however far away it is1fc33d25b,80d820869,4bde41249isOpen()also requires the end to lie withinmaxHours()of the call; a window ending exactly at the maximum is open. Three tests that opened a window ending in 2099 or 2999 now open one an hour ahead.ae6acc445,6b269d840and131a57740wrap lines phpcs flagged and keepStufZdsOutboundunder the phpmd class-complexity and coupling limits, on this branch and in the merged result with #2686.Not in this PR
zakLk01old/new pair rather than azakLk02has to be checked against the ZDS 1.2 XSD first, and fix(review): #2643 review findings, round 1 #2686 rewritesStufZdsMessageBuilder(the envelope without a Security header). This follows once fix(review): #2643 review findings, round 1 #2686 is merged, with a builder → mapper round-trip test.^6.4 || ^7.4), or add a CI job that installs--no-devand smoke-tests on NC32.unsubscribe(), so "callremove()on unsubscribe" needs an unsubscribe flow first. The read-modify-write race and moving the map into an OpenRegister object are design calls.sourceAppon a ZDS case command is a free-text label: needs a new source field (configuration.owningApp).StufZknRequestBodyexists only on fix(review): #2643 review findings, round 1 #2686).Checks run on this branch
bash hydra/scripts/run-hydra-gates.sh --scope-to-diff --base origin/development→ exit 0, COVERAGE 46 of 46 applicable gates ran (51 not applicable).vendor/bin/phpcs,phpstan analyseandphpmd(phpmd.xmland the hydra-gates unused-params rules) on every changedlib/file → clean.vendor/bin/phpunit -c phpunit-unit.xml(full suite): 5531 tests, 11 errors and 4 failures. The 11 errors are thetests/Unit/AppInfo/Application*Testcontainer errors fix(review): #2643 review findings, round 1 #2686 records. Two are integration tests (EndoflifeDateLiveSyncTest, which needs the live endpoint, andExecutionTraceIntegrationTest); they fail the same way withdevelopment'slib/. The other two were caused by the window maximum and are fixed in80d820869.phpmdonStufZdsOutbound,StufProviderReceiver,StufZdsConfirmationReceiverandStufProviderOperationMapperas they are in the merged tree with fix(review): #2643 review findings, round 1 #2686 → clean.git merge-treewas checked); eslint and vitest (no JS change).🤖 Generated with Claude Code