Skip to content

Release: merge development into beta - #2154

Merged
WilcoLouwerse merged 540 commits into
betafrom
development
Oct 9, 2026
Merged

WilcoLouwerse merged 540 commits into
betafrom
development

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

dependabot Bot and others added 23 commits October 2, 2026 09:20
Bumps [webpack](https://github.com/webpack/webpack) from 5.110.3 to 5.111.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](webpack/webpack@v5.110.3...v5.111.1)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.111.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [nextcloud/ocp](https://github.com/nextcloud-deps/ocp) from 35.0.0 to 35.0.1.
- [Commits](nextcloud-deps/ocp@v35.0.0...v35.0.1)

---
updated-dependencies:
- dependency-name: nextcloud/ocp
  dependency-version: 35.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) from 30.5.0 to 30.5.2.
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.2/packages/jest)

---
updated-dependencies:
- dependency-name: jest
  dependency-version: 30.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [marked](https://github.com/markedjs/marked) from 18.0.11 to 18.0.14.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](markedjs/marked@v18.0.11...v18.0.14)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 18.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.13 to 2.2.16.
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fix(objecten): accept the objecttype URL a standard consumer sends as type
…ycle instead of deleting it

sourceConfig.disappearanceValues names the values a keeping disappearance
policy writes onto the target; the three course marketplace sets archive the
Course and retire the Lesson and the placement. Behind the existing
completeness guards, so an incomplete fetch retires nothing.

Assisted-by: Claude Code
… needs nothing here (#2455)

Docs: the addresses a broker must know after the React portal's retirement (portaliq#1072); integriq's documented portaliq return address now includes /index.php/apps/portaliq. Integriq never receives post_logout_redirect_uri; only an organisation's own OIDC broker does (per-deployment config). integriq check:strict: lint/phpcs/phpmd/psalm/phpstan pass; test:all 11 errors in tests/Unit/AppInfo/Application*Test are inherited (this PR changes only a docblock line). Admin merge authorised by Ruben 2026-10-02.
…and Dutch refusals

Design D5 is decided (DECISIONS row 50): a schema bound to a ZGW store holds the
store's own ZGW shape. The six set files drop the translator key no code read;
the mappings stay bare pass-throughs. The installer's operator refusals go
through IL10N with Dutch and English strings, and docs/features/zgw-sets.md is
the install guide per set.

Assisted-by: Claude Code
…tate

docs(lti): tick what the platform launch change has built, name what is open
…tire

feat(connectors): bring a selection of the Go1, LinkedIn Learning and Udemy Business catalogues into learniq
hitl-on-shared-tasks follow-ups 2.1-2.4, change archived:
- SharedApprovalTaskListener resolves the approval_request when its mirrored
  OpenRegister task is completed (approved or rejected, both authorization
  layers re-checked, fail closed) or closed by the shared sweep;
- ApprovalDecisionService carries the resume paths the controller composed,
  so both decision surfaces resume the run the same way;
- the mirror is offered to the approver group so OpenRegister notifies it;
  Integriq's own notification is the fallback;
- the mirror's title and description are translated (en, nl);
- the local sweep leaves rows whose expiry the shared sweep owns.
Row auto-approval-tasks -> built (2026-10-09-hitl-on-shared-tasks).
observability-opentelemetry-export Tasks 1-5 (row integriq:obs-otel; Woo 13.29 part 1):
- each finished, sampled trace is queued on persist and sent by OtelExportJob
  as OTLP/HTTP JSON (one root span, one child per step, no step content);
  a refused send is retried three times, then dropped with one log line;
- an inbound W3C traceparent continues the caller's trace; outbound calls
  carry one naming the call step's span;
- step and trace times in microseconds (execution_trace schema 1.1.0);
- admin settings: on/off, collector, service name, sampling share, collector
  login as a credential name; https unless marked internal.
Also: psalm knows OpenRegister's TaskTerminalEvent (hitl listener).
Open: Newman inbound header and the Playwright admin test (written, nightly).
…export setting refusals; prettier and eslint on the new admin section and e2e test; schema strings in en and nl
Comment thread lib/Controller/EventsController.php

@rjzondervan rjzondervan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES (Strict), re-review of the 9 commits since 0dbb6af

Both earlier points are fixed. A numeric iss now goes through the weak-secret guard with the same (string) cast OpenRegister uses, a non-scalar is refused, and the tests would fail if either were reverted. HS384 and HS512 now have the same refused/accepted boundary tests as HS256. Threads resolved.

One new 🟡: the subscriptions paging fix leaves limit/offset unclamped. The rest of the delta is clean: the contracts and forward fixes are correct and their tests fail on the old code, and the E2E changes add assertions rather than remove them. CI is green on dbf95d5.

feat: decide approvals in the shared task inbox, send traces to OpenTelemetry
Comment thread lib/EventListener/SharedApprovalTaskListener.php Outdated
Comment thread lib/Service/EndpointService.php Outdated
Comment thread lib/EventListener/SharedApprovalTaskListener.php Outdated
Comment thread lib/Service/ApprovalService.php
Comment thread lib/Observability/Otel/SpanMapper.php Outdated
Comment thread lib/BackgroundJob/OtelExportJob.php Outdated

$this->exporter->export(payload: $this->mapper->map(trace: $trace, serviceName: $this->settings->serviceName()));
} catch (Throwable $e) {
if ($attempt < self::MAX_RETRIES) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern — A collector outage ties up cron: about 40 s per failed trace, with no backoff or circuit breaker

Each send may block for TIMEOUT_SECONDS = 10 (OtlpTraceExporter), and a failure re-queues immediately, up to MAX_RETRIES = 3. failed traces always pass sampling (OtelSettings::isSampled). A partner outage produces many failed traces, and a collector outage at the same time turns that into N × 40 s of serial cron work, starving other apps' background jobs.

Fix: a circuit breaker (after K consecutive failures, skip sends until a cooldown passes), delayed retries instead of an immediate re-add, a lower timeout, and a cap on the queue depth.

Verification: this file at d9c62df lines 54 and 110-111 → MAX_RETRIES = 3, re-added at once with jobList->add(…, 'attempt' => $attempt + 1). Not load-tested.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #2635 (fa09f56): after five failed sends in a row, sends and queuing pause for five minutes; retries wait 5, 10 and 20 minutes; the collector timeout is 3 s instead of 10 s.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Partly addressed in fa09f56 (#2635): the breaker and the shorter timeout are in, but the retry doesn't really wait (#2154 (comment)), and a pause drops traces without counting them and uses up retries (#2154 (comment)). Leaving this thread open until both are fixed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 The remaining half of this (the retry that doesn't wait and the pause that burns attempts) is addressed in #2638; see the f7 and f10 threads. The thread stays open until it is merged.

// moment when the agent's panel posts it twice.
$call = null;
if ((string) ($input['callId'] ?? '') !== '') {
$call = $this->callEvents->findEnded(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Minor — A contact moment can be recorded for any callId

findEnded() filters on callId, kind and sourceId only, so any kiss.push holder can attach a contact moment to a colleague's call. The first one recorded wins, and later pushes get the existing kissId back. REQ-007 does not require binding the call to the agent, and the caller's number is not exposed, so this is a note. Consider checking agentId against the user's agent mapping once one exists.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is in #2635, as a note until an agent mapping exists.

@rjzondervan rjzondervan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES (Strict), re-review of dbf95d5 → d9c62df

The limit/offset clamp is deferred to #2634 as agreed. This round covers the merge of #2631 (approvals in the shared task inbox, OpenTelemetry export, CTI call log), which landed on development without a review. This is its first one.

Two blockers:

Concerns: 🟡 onReject: dead_letter and other rejecting outcomes, 🟡 a cancelled mirror leaves the approval pending forever, 🟡 a BSN and URL credentials in url.full, 🟡 no backoff on collector outages; plus a 🟢 note on contact moments.

Fine as written: the approve/reject path keeps every check the old controller had, the OpenRegister test stubs match OpenRegister beta, the mirror carries no approval data, and retention uses OpenRegister's archival task. CI is green on d9c62df. Until the two 🔴 are fixed on development (or #2631 is reverted there), this should not go to beta.

WilcoLouwerse and others added 6 commits October 9, 2026 11:00
Any signed-in user can create an OpenRegister task with Integriq's appId
and an approvalRequestId in its metadata, then complete it as skipped,
failed or dead_letter to expire or dead-letter that approval. The
listener now requires the task uuid to equal the record's taskUuid, and
takes the timer branch only for an outcome the timer recorded (no
completer, or a flow-timer: actor) after the record's expiresAt.

Every outcome OpenRegister counts as a rejection (returned, declined,
denied as well as rejected) now rejects the record, and so does a
rejection OpenRegister rerouted to dead_letter for a mirror with
onReject: dead_letter, behind the same two authorization layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local sweep skipped every mirrored record with a shared onTimeout,
leaving its expiry to OpenRegister's timer. A mirror the requester or an
admin cancelled, or one terminated with its run, never reaches that
timer, so the record stayed pending and, once expired, could be neither
decided nor swept. The sweep now leaves a record to the timer only while
its mirror is still open; an ended or missing mirror is expired locally,
and an unreadable one is left for the next sweep.

The OpenRegister test stubs gain TaskService::get() and the Task states,
copied from the real classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… record's id

On the public endpoint route the caller's W3C trace id became the
execution_trace uuid, and persisting is an upsert by that uuid, so anyone
who knew a trace id (every outbound partner receives one) could overwrite
that execution's trace across tenants, and two executions under one
upstream trace collapsed into one record.

The record now always gets a fresh uuid. The caller's trace id is kept
apart in a new otelTraceId field (execution_trace schema 1.2.0), which
the OTLP export and outbound traceparent headers use, and which survives
an approval suspension together with the parent span id. REQ-OTEL-004 is
reworded to match, with a scenario for two requests under one
traceparent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… url.full

Only the query string and fragment were cut from url.full, so a BSN in a
path such as /ingeschrevenpersonen/999993653 and user:pass@ credentials
reached the external collector, against REQ-OTEL-003. url.full now keeps
scheme, host, port and path only, drops the userinfo, and replaces every
path segment holding four or more consecutive digits, or a long token
with a digit (a uuid, a hash), with {id}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each send could block cron for 10 s and a failure was re-queued at once,
up to three times, and failed traces are always sampled, so a partner
outage during a collector outage meant N x 40 s of serial cron work.

- a circuit breaker: after five failed sends in a row, sends pause for
  five minutes and no new trace is queued meanwhile;
- retries wait 5, 10 and 20 minutes instead of re-running at once;
- the collector timeout drops from 10 s to 3 s.

REQ-OTEL-002 now states the delay and the pause, with a scenario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hpmd limits

The export circuit breaker moves out of OtelSettings into its own
OtelExportBreaker, so OtelSettings stays under ten public methods, and
OtelExportJob::run() is split into argument, send and retry helpers. The
shared approval listener's mirror check, timer expiry and outcome
mapping become helpers, and it reads the expiry with strtotime() instead
of DateTime, which brings its complexity and coupling back under the
thresholds. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: review findings on #2631 (approval mirror forgery, trace id overwrite, export hardening)
Comment thread lib/BackgroundJob/OtelExportJob.php Outdated
$now = $this->time->getTime();
if ($argument['notBefore'] > $now) {
// A delayed retry that is not due yet waits for a later cron run.
$this->jobList->add(self::class, $argument);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern: a retry that is not yet due is put back as an immediately runnable job, so the worker keeps picking it up

QueuedJob::start() removes the row by id before run(). run() then calls jobList->add(self::class, $argument), which inserts a new row with last_checked = time() (JobList::add, $firstCheck defaults to now). JobList::getNext() selects any row with last_checked <= now, so the job can run again at once. Under occ background-job:worker (only usleep(50000) between jobs, no executed-ids guard) a waiting retry is picked up about 20 times a second, for 5, 10 or 20 minutes. Each pass deletes a row, inserts one and records a job run, for every trace waiting on a retry during a collector outage. That is the cron load the original finding was about. Nextcloud ≥ 28 has IJobList::scheduleAfter($job, $runAfter, $argument), which sets last_checked to the run time, and Integriq requires NC ≥ 32.

Fix: in retryOrDrop(), schedule the retry with $this->jobList->scheduleAfter(self::class, $notBefore, $argument), and remove the not-due branch in run(), or keep it only as a guard that also uses scheduleAfter. Change the test to assert scheduleAfter with the delay instead of add.

Done when: no code path re-adds an OtelExportJob with last_checked = now before its notBefore, and a test asserts the delayed retry goes through scheduleAfter with the expected run time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Proposed fix in #2638, commit 2e9c221: retries, the not-due guard and the paused path now go through IJobList::scheduleAfter() at their run time, and a test asserts add() is never used for a retry. The thread stays open until it is merged.

Comment thread lib/Observability/Otel/SpanMapper.php Outdated
return false;
}

return preg_match('/\d{4,}/', $segment) === 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern: the identifier mask lets a formatted BSN, an e-mail address and letter-only tokens through to the collector

looksLikeIdentifier() masks a segment only if it has a run of 4+ digits, or is 20+ characters of a token alphabet that includes a digit. I ran the extracted method on the host:

  • /ingeschrevenpersonen/999.993.653 and /999-993-653 are exported unchanged. Both are common ways to write a BSN, and the spec scenario "a BSN in the URL path never reaches the collector" (specs/execution-trace/spec.md:73) says the payload must not contain it.
  • /klanten/j.devries@gemeente.nl is exported unchanged (personal data).
  • /services/abcdefghijklmnopqrstuvwxyz (a path token without digits, e.g. a webhook secret) is exported unchanged.
  • A host label such as 999993653.example.org is kept (less likely, noted for completeness).

Fix: count digits after removing ., - and spaces (e.g. preg_match('/\d{4,}/', preg_replace('/[\s.\-]/', '', $segment))), mask any segment containing @, and mask long tokens whether or not they contain a digit. Better still, export a route template or only server.address + method. Add these cases to testABsnInThePathAndUrlCredentialsNeverReachTheCollector.

Done when: a dotted BSN, a dashed BSN, an e-mail segment and a long letter-only token are all masked, and the test asserts each one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Proposed fix in #2638, commits 4212edf + 992f266: the segment is URL-decoded and stripped of every non-alphanumeric before the 4+-digit test, and @ segments and opaque tokens are masked. The test covers dotted, dashed, _, ,, +, NBSP-encoded, %2F and double-encoded BSNs, plus an e-mail address, and checks that ingeschrevenpersonen stays. The thread stays open until it is merged.


try {
if ($this->settings->isEnabled() === false
|| $this->breaker?->isOpen(now: ($this->time?->getTime() ?? time())) === true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern: while the breaker is open, new traces are discarded without a log line or counter, and waiting traces use up their retries without a send

  1. TraceExportQueue::queue() returns false while breaker->isOpen(). Nothing is logged or counted, and this includes failed traces, which the sampler otherwise always keeps. Each 5-minute pause therefore leaves a gap in the collector that an operator cannot see or measure.
  2. OtelExportJob::send() throws "Sends are paused" while the breaker is open (line 159-161), and run() sends that to retryOrDrop(), which uses up one of the three attempts. A trace waiting during the pause loses attempts without being sent. After about 35 minutes of outage every trace is dropped, and the drop log says "after 4 failed sends" even when fewer sends were made. Concurrent cron workers also read and write otel_breaker_failures in IAppConfig without locking, so counts can be lost (low impact).

The spec now permits "no new trace is queued meanwhile", so (1) is by design, but the original concern asked for a bounded queue, not unaccounted loss.

Fix: while paused, reschedule a waiting job to open_until without using up an attempt. Count traces skipped at queue time (e.g. an otel_dropped_total app-config counter shown in the admin settings, or one warning per pause window). Make the drop log report the real number of sends.

Done when: a paused send does not consume a retry attempt, every trace skipped or dropped during a pause is counted or logged, and a test covers both.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Proposed fix in #2638, commits ec06475 + 58ae385 + 1374f45: a paused send keeps its attempt and is rescheduled to the end of the pause. Skipped traces are counted with a distributed-cache inc(), with no app-config write per trace, and one warning per pause carries skippedTotal. Showing the counter to operators is left to the author (see the PR body). The thread stays open until it is merged.

* @return string|null `approval.approve`, `approval.reject` or null.
*/
private function decision(string $outcome, bool $byTimer): ?string {
if ($outcome === 'approved') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Minor: an outcome like Rejected or Approved closes the mirror but decides nothing

OpenRegister classifies rejections with in_array(strtolower(trim($outcome)), REJECTING_OUTCOMES, true) (TaskState.php:174), but stores the outcome exactly as sent (completeInternal sets $recordedOutcome = $outcome). The listener compares exactly (=== 'approved', strict in_array). An API client that completes with Rejected or denied therefore closes the mirror as a rejection (comment enforced) while the record stays pending until the local sweep expires it. The same happens for Approved. The UI probably sends lowercase values, so impact is low.

Fix: normalise with strtolower(trim(...)) before decision(), as OpenRegister does, and add a mixed-case case to testEveryRejectingOutcomeAndARoutedRejectionReject.

Done when: the listener classifies outcomes with the same normalisation OpenRegister uses, and a test covers a mixed-case outcome.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Proposed fix in #2638, commit dc1e7d8: the outcome is normalised with strtolower(trim()) before it is classified, and Rejected, Denied and Approved are tested. The thread stays open until it is merged.

@rjzondervan rjzondervan left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES (Strict), re-review of d9c62df → 73e44a7 (fix PR #2635)

#2635 fixes both 🔴s and three of the four 🟡s from round 5, but its own fixes add 3 new 🟡s and 1 🟢, and the OTel backoff thread is only partly addressed. 6 threads are open, and the beta ruleset needs every thread resolved and 1 approval before merge; #2634 (the clamp) is still a release-blocker for beta → main.

🔧 Fix PR: #2638 — 4 of 4 findings, one commit each (plus 3 pre-push: commits)

Merge it into development, then re-request the review.

Round Found Status
1 (b0f9d27) 🔴 3: l10n strings, retour contract tests, gate-23 fixed
2 (539970e) 🔴 1 credential-validation regression, 🟡 1 release ordering fixed (#2618, OR setup check)
3 (0dbb6af) 🟡 numeric iss skips guard, 🟢 test boundaries fixed
4 (dbf95d5) 🟡 unclamped subscriptions limit/offset deferred to #2634 (release-blocker before main)
5 (d9c62df) 🔴 2, 🟡 4, 🟢 1 in #2631 (approvals via task inbox, OTel export) 5 fixed in #2635, 1 partial, 🟢 left as-is
6 (73e44a7) — this review 🟡 3, 🟢 1 in #2635's fixes open
Finding Was Now Where
🔴 Forged task expires an approval 🔴 round 5 resolved in 8132104 thread
🔴 Inbound traceparent sets the record uuid 🔴 round 5 resolved in efb0558 thread
🟡 dead_letter and other rejecting outcomes 🟡 round 5 resolved in 8132104 thread
🟡 Cancelled mirror leaves approval pending 🟡 round 5 resolved in c19fd31 thread
🟡 BSN and credentials in url.full 🟡 round 5 resolved in ab8672e (gaps: f8) thread
🟡 OTel export has no backoff 🟡 round 5 still open, partly fixed in fa09f56 (see f7, f10) thread
🟢 Contact moment for any callId 🟢 round 5 still open, left as-is by agreement thread
🟡 f7: a retry that isn't due yet runs again straight away new new — 🟡 comment
🟡 f8: url.full masking misses a formatted BSN and e-mail new new — 🟡 comment
🟡 f10: a paused breaker loses traces and burns retries new new — 🟡 comment
🟢 f9: outcome match is case-sensitive new new — 🟢 comment
Details of this round

Scope. The delta since round 5 is exactly #2635 (6 commits plus merge 73e44a7, 25 files). #2635 was merged with no review, so this round is a full Strict analysis of it, not the fix-PR fast path.

Verified clean. The forged-task test uses forged-1 against taskUuid task-1, and fails on the old code. OpenRegister sets a flow-timer: completer only on the timer's skip, so a user cannot produce one. or_tasks_uuid is a unique index, so no second task can share the mirror's uuid. TaskService::get() is a plain findByUuid, so "missing" and "unreadable" are told apart correctly. The inbound traceparent is still validated (version 00, exact hex, all-zero ids refused). Records without the new fields fall back to the local sweep. The new en/nl strings match their use.

How the new findings were checked. f7: core JobList::add() sets last_checked to now, getNext() takes last_checked <= now, and QueuedJob::start() removes the row before run(); IJobList::scheduleAfter() is @since 28.0.0, and integriq needs NC 32 or later. f8: I extracted SpanMapper::safeUrl() and ran it on the host; 999.993.653, 999-993-653, j.devries@gemeente.nl and a 26-letter token come out unchanged. f10: TraceExportQueue::queue() returns false while the breaker is open, with no log, and send() throws so that retryOrDrop() uses up an attempt.

Connected issues. #2631 (merged): the source of round 5's findings, unaffected. #2635 (merged): the fix PR reviewed here. #2634 (open): the clamp, labelled release-blocker before beta → main, unaffected by this delta.

CI: all checks green on 73e44a7, including quality / Hydra Gates, PHPUnit (8 runs) and Newman. Gates: taken from that CI run, so no local run.

Before the next round. This round's findings were mostly fix-induced (fix-induced 3 + missed-sibling 1 ≥ new 0), so next round's fixes go through the class sweep and the pre-push check first. Checks to run before asking for a re-review:

  • f7: a test asserts scheduleAfter(OtelExportJob::class, <notBefore>, …), and grep -n 'jobList->add' lib/BackgroundJob/OtelExportJob.php finds no not-due re-add.
  • f8: safeUrl() masks 999.993.653, 999-993-653, a.b@c.nl and a 20+ letter token, each asserted in testABsnInThePathAndUrlCredentialsNeverReachTheCollector.
  • f10: a send while the breaker is open does not increase attempt, and dropped traces are counted or logged.
  • f9: completing with Rejected rejects the record.

🤖 Reviewed with Claude Code

rjzondervan and others added 7 commits October 9, 2026 16:18
… immediately runnable job

#2154 (comment)

OtelExportJob now schedules a retry with IJobList::scheduleAfter(), so
the job row's last_checked is the retry's run time (five, ten, twenty
minutes out) and the worker does not pick it up before then. The
not-due guard in run() also uses scheduleAfter() with the argument's
notBefore instead of add(), so no path re-adds the job with
last_checked = now.

The tests assert scheduleAfter() with the expected run times
(1300/1600/2200, and 1001 for the not-due guard) and that add() is never
used for a retry.

Sweep: the other IJobList::add() calls in lib/ (TraceExportQueue,
EventService, DsoIngestService, JobService) queue new work to run now;
none re-adds a delayed job, so no other site changed.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-mail address and letter-only tokens through

#2154 (comment)

SpanMapper::looksLikeIdentifier() now masks a URL path segment when:
- it contains `@` (an e-mail address);
- it has a run of 4+ digits after dots, dashes and whitespace are
  removed (999.993.653, 999-993-653, "999 993 653");
- it is a token-alphabet string of 32+ characters, or of 20+ characters
  that holds a digit (the old rule) or mixes upper and lower case.

A lowercase route word under 32 characters stays, so the spec scenario's
`/ingeschrevenpersonen/{id}` holds. Trade-off: a lowercase route word of
32+ characters, or a camel-case one of 20+, is masked as well.

New test testFormattedIdentifiersAndOpaqueTokensAreMaskedRouteWordsStay
asserts url.full for a dotted, dashed and spaced BSN, an e-mail segment,
a 32-letter token, a 20-letter mixed-case token, and that
`ingeschrevenpersonen` and `zaakinformatieobjecten` survive. Each rule
was mutated out in turn and the test failed each time.

Sweep: SpanMapper::safeUrl() is the only URL/attribute redaction in
lib/Observability; nothing else changed. The host-label case in the
finding (999993653.example.org) is not addressed: the host is kept.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ded silently and retries are used up

#2154 (comment)

(a) OtelExportJob::run() now checks the breaker before sending: while
sends are paused it reschedules the same attempt with
IJobList::scheduleAfter() at the breaker's open-until time, so a paused
send no longer reaches retryOrDrop() and no attempt is used up without
a send. send() no longer throws "Sends are paused".

(b) TraceExportQueue::queue() checks sampling before the breaker, so
only a trace that would have been exported is counted as skipped.
OtelExportBreaker::recordSkipped() increments the otel_skipped_total
app setting and returns true for the first skip in each pause window
(tracked by otel_skipped_warned_until = open-until); the queue logs one
warning for that one, not one per trace. New breaker method openUntil()
backs isOpen() and both callers.

(c) The drop log line reports ($attempt + 1) failed sends instead of the
constant MAX_RETRIES + 1; with (a) every attempt is a real send try.

Tests: testACollectorOutageOpensTheBreaker asserts a paused send is
scheduled at the pause end with its attempt unchanged (0 and 2);
testTracesSkippedDuringAPauseAreCountedAndLoggedOncePerPause asserts the
counter (2, then 3), one warning per pause, and a new warning for a new
pause. Reverting OtelExportJob.php or TraceExportQueue.php to the
previous commit fails the respective test. The drop-log assertion
("after 4 failed sends") also passes on the old code, since the normal
path drops at attempt 3.

Not changed: the unlocked read-modify-write of otel_breaker_failures
across concurrent cron workers (the finding rates it low impact).

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…but decides nothing

#2154 (comment)

SharedApprovalTaskListener::resolve() now normalises the task outcome
with strtolower(trim()) before any classification, as OpenRegister's
TaskState does for REJECTING_OUTCOMES. This covers decision() (approve /
reject) and the timer-outcome check, so `Rejected`, ` Denied ` and
` Approved ` now decide the record instead of leaving it pending.

Tests: testEveryRejectingOutcomeAndARoutedRejectionReject adds
`Rejected` and ` Denied ` (7 rejections); testAnApprovalInTheShared
InboxResumesTheRun adds ` Approved ` (2 approvals). Both fail with the
previous listener.

Sweep: the listener is the only place in lib/ that reads an
OpenRegister task outcome; ApprovalService/ApprovalTaskMirror only write
outcomes, and the other `outcome` comparisons (ContractCommitNode etc.)
are Integriq's own values. Nothing else changed.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…coding

#2154 (comment)

looksLikeIdentifier() now URL-decodes the segment three more times
(nested rawurldecode; a no-op once nothing is left to decode, and no
added class complexity, which the helper-method version pushed over the
phpmd limit of 50), then tests for `@` and, after removing every
character that is not an ASCII letter or digit (byte-wise, so a UTF-8
NBSP goes too), for a run of 4+ digits.

Newly masked: 999_993_653, 999,993,653, 999+993+653,
999%C2%A0993%C2%A0653 (NBSP), 999%2F993%2F653, 999%252E993%252E653 and
j.devries%2540gemeente.nl (double-encoded e-mail). A reflection script
calling safeUrl() on the previous SpanMapper returned the first six
unmasked; with the decode removed, the double-encoded e-mail case fails
the test. `ingeschrevenpersonen` and `zaakinformatieobjecten` stay.

The test docblock now says lowercase route words under 32 characters
stay (32+ are masked as opaque tokens).

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…buted cache, not app config

#2154 (comment)

OtelExportBreaker::recordSkipped() wrote otel_skipped_total to IAppConfig
(an unlocked read-modify-write and a database UPDATE) for every sampled
trace skipped while paused, on the traced request path (REQ-OTEL-002).
It now counts with IMemcache::inc('otel_skipped_total') on
ICacheFactory::createDistributed('integriq'). If there is no
distributed IMemcache, or creating or incrementing it throws, the count
is 0 and nothing reaches the traced work. The only app-config write
left is otel_skipped_warned_until, once per pause window. recordSkipped()
returns the count so far for the first skip of a pause and null after
that. TraceExportQueue logs that one warning with `skippedSoFar` in
the context.

Tests: testTracesSkippedDuringAPauseAreCountedAndLoggedOncePerPause
asserts 3 then 4 cache increments, that the only app-config write is
otel_skipped_warned_until, and warnings carrying counts [1, 4];
testASkippedTraceWithoutADistributedCacheStillLogsOnce covers a plain
ICache and a throwing factory. On the previous breaker and queue both
tests fail; replacing the inc() call fails the first, removing the
catch fails the second.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2154 (comment)

The pause warning's log field `skippedSoFar` was the cache counter's
lifetime total, which never resets per pause. The field is renamed to
`skippedTotal`. The docblocks of OtelExportBreaker::recordSkipped() and
countSkipped() and of TraceExportQueue::skipWhilePaused() now say it is
the running total since the cache was last cleared, per node when the
distributed cache falls back to APCu. The tests read the renamed field.
Behaviour is otherwise unchanged.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@WilcoLouwerse
WilcoLouwerse merged commit c235e06 into beta Oct 9, 2026
46 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.

5 participants