Skip to content

fix: review findings on #2631 (approval mirror forgery, trace id overwrite, export hardening) - #2635

Merged
WilcoLouwerse merged 6 commits into
developmentfrom
fix/woo589-review-2631
Oct 9, 2026
Merged

WilcoLouwerse merged 6 commits into
developmentfrom
fix/woo589-review-2631

Conversation

@WilcoLouwerse

Copy link
Copy Markdown
Contributor

Summary

Fixes the findings from @rjzondervan's review of the beta promotion #2154 (verdict), which covered #2631 (approvals in the shared task inbox, OpenTelemetry export, CTI call log). Merging this into development updates #2154, which follows development.

Finding Fix Commit
🔴 any logged-in user can expire or dead-letter a pending approval The listener only acts on the record's own mirror (task uuid === taskUuid). The timer branch requires a timer-recorded outcome (no completer or a flow-timer: actor) after the record's expiresAt. 8132104
🔴 an inbound traceparent sets the trace record's uuid The record always gets a fresh uuid. The caller's W3C trace id lives in a new otelTraceId field (schema 1.2.0), used by the export and by outbound traceparent headers, and kept across an approval suspension. efb0558
🟡 onReject: dead_letter and other rejecting outcomes Rejections are classified with OpenRegister's list (rejected, returned, declined, denied). A dead_letter with a real completer is a rerouted rejection and goes through reject() with both authorization layers. 8132104
🟡 a cancelled mirror leaves the approval pending forever The local sweep leaves a record to OpenRegister's timer only while its mirror is still open. Ended or missing mirrors are expired locally, unreadable ones wait for the next sweep. c19fd31
🟡 a BSN and URL credentials in url.full url.full keeps scheme, host, port and path, drops user:pass@, and masks identifier-like path segments as {id}. ab8672e
🟡 no backoff on collector outages Circuit breaker (five failures in a row pause sends and queuing for five minutes), retries after 5/10/20 minutes, collector timeout 10 s to 3 s. fa09f56
🟢 contact moment for any callId Not changed: the review marks it as a note until an agent mapping exists.

e140a3c is a behaviour-neutral refactor that keeps the new code within the phpmd thresholds (the breaker becomes its own OtelExportBreaker class).

REQ-OTEL-002, -003 and -004 in openspec/changes/observability-opentelemetry-export are reworded to the new behaviour, each with a scenario.

Test plan

  • New PHPUnit tests for each finding; each one fails on the code before its fix:
    • forged task;
    • user-recorded timer outcome;
    • every rejecting outcome and a routed rejection;
    • cancelled, terminated, missing and unreadable mirror in the sweep;
    • two requests with one traceparent;
    • continued trace on export and outbound;
    • approval resume;
    • BSN in path and URL credentials;
    • delayed retries and the breaker.
  • Full unit suite: 5308 tests. The only errors are 11 tests/Unit/AppInfo errors, which show up identically on a clean development checkout in this environment.
  • phpcs and phpmd (with baseline, both rule sets) clean on every changed lib/ file; schema-l10n ratchet 0 uncovered; check:l10n-js up to date.
  • psalm: crashes locally on a clean development checkout too (ConstantTypeResolver), so it is left to CI.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/integriq @ 0004904

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 ✅ ✅ 647/647
app:check-code ⏭️
info.xml ✅
REUSE ❌
lockfile sync ✅
PHPUnit ✅ 5307/5307 · 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-09 09:30 UTC

Download the full PDF report from the workflow artifacts.

@WilcoLouwerse
WilcoLouwerse merged commit 73e44a7 into development Oct 9, 2026
48 checks passed
@WilcoLouwerse
WilcoLouwerse deleted the fix/woo589-review-2631 branch October 9, 2026 09:40
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.

1 participant