Repository navigation
Release: merge development into beta - #2154
Conversation
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.
… translator Assisted-by: Claude Code
Assisted-by: Claude Code
…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
Assisted-by: Claude Code
Assisted-by: Claude Code
…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
…erged CTI call log
rjzondervan
left a comment
There was a problem hiding this comment.
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
|
|
||
| $this->exporter->export(payload: $this->mapper->map(trace: $trace, serviceName: $this->settings->serviceName())); | ||
| } catch (Throwable $e) { | ||
| if ($attempt < self::MAX_RETRIES) { |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
There was a problem hiding this comment.
🔧 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( |
There was a problem hiding this comment.
🟢 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.
There was a problem hiding this comment.
Left as is in #2635, as a note until an agent mapping exists.
rjzondervan
left a comment
There was a problem hiding this comment.
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:
- 🔴 any logged-in user can expire or dead-letter a pending approval through a forged or mis-completed OpenRegister task
- 🔴 an inbound
traceparentsets the trace record's uuid, so an anonymous caller can overwrite another execution's trace
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.
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)
| $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); |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
🔧 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.
| return false; | ||
| } | ||
|
|
||
| return preg_match('/\d{4,}/', $segment) === 1 |
There was a problem hiding this comment.
🟡 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.653and/999-993-653are 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.nlis 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.orgis 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.
There was a problem hiding this comment.
🔧 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 |
There was a problem hiding this comment.
🟡 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
TraceExportQueue::queue()returns false whilebreaker->isOpen(). Nothing is logged or counted, and this includesfailedtraces, which the sampler otherwise always keeps. Each 5-minute pause therefore leaves a gap in the collector that an operator cannot see or measure.OtelExportJob::send()throws "Sends are paused" while the breaker is open (line 159-161), andrun()sends that toretryOrDrop(), 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 writeotel_breaker_failuresinIAppConfigwithout 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.
There was a problem hiding this comment.
🔧 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') { |
There was a problem hiding this comment.
🟢 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.
There was a problem hiding this comment.
🔧 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.
There was a problem hiding this comment.
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
betaruleset 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>, …), andgrep -n 'jobList->add' lib/BackgroundJob/OtelExportJob.phpfinds no not-due re-add. - f8:
safeUrl()masks999.993.653,999-993-653,a.b@c.nland a 20+ letter token, each asserted intestABsnInThePathAndUrlCredentialsNeverReachTheCollector. - f10: a send while the breaker is open does not increase
attempt, and dropped traces are counted or logged. - f9: completing with
Rejectedrejects the record.
🤖 Reviewed with Claude Code
… 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>
fix(review): #2154 review findings, round 6
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.