Skip to content

Road to General Availability - #426

Open
LennyObez wants to merge 1757 commits into
mainfrom
chore/quality-and-performance
Open

Road to General Availability#426
LennyObez wants to merge 1757 commits into
mainfrom
chore/quality-and-performance

Conversation

@LennyObez

@LennyObez LennyObez commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Five months of work on chore/quality-and-performance, brought to a state where 1.0.0 is the next decision rather than the next problem.

What this branch does

It reunites its own history with main. The two had no common ancestor: two rewrites left the branch an orphan, git merge-base answered nothing, and no pull request could produce a diff worth reading. A -s ours merge joins them while keeping this branch's tree byte for byte. Nothing published was rewritten, so every commit keeps its identity and its signature.

It makes the framework work on MySQL. CREATE INDEX IF NOT EXISTS is a syntax error there rather than a clause it ignores, so every migration writing it by hand could not run. 331 such statements existed. 298 are gone, src/ has none left, and the three worst were not migrations at all: the cache driver, the cache lock and the failed-job repository created their indexes on first use, so the failure landed on whoever touched the feature.

It gives the dialect the questions callers were answering themselves. compileTableExists(), compileColumnExists(), compilePrimaryKeyExists(), compileCollapseDuplicates(), partial-index predicates and index column ordering, plus TableIntrospector and IndexColumn. Recorded as ADR-0039.

It closes a replay hole in two-factor authentication. The guard was keyed on (identity, purpose, timeStep), so a code captured on a login afterwards bought a setup or a step-up. ADR-0038 and its migration.

What adversarial review found

Every change here was audited against the code rather than against its own description, by a reviewer set to disprove it rather than confirm it. That posture earned its place five times:

  • WiringContractGraphTest contradicted its own fix. It booted a kernel and then queried the static table instead of the registry it had just produced. It would have failed CI.
  • PostgreSQL dropped an index by bare name while the existence guard resolved it through its table. Where two schemas on the search path carry the same index name, the guard confirms one object and the drop removes another.
  • Encryptor did not carry the #[SensitiveParameter] its own interface demands of implementers.
  • MariaDB reported supportsIndexIfNotExists() === true while compiling a statement without the clause, because it inherits a MySQL override that hardcoded false.
  • Migration index lists written once per engine had drifted apart, silently: predicates present on two engines and absent on the third, orderings written for PostgreSQL alone though SQLite supports them too.

CI

ci.yml is rewritten and the branch adds provenance.yml, workflow-lint.yml and benchmark-regression.yml. Since no workflow uses pull_request_target, these run from the merge ref and are the definitions that will execute here.

Read before merging

This deletes 70 files main holds. Each was checked for its capability rather than its filename: Qodana was retired by decision, the documentation was reorganised, and the oauth2, social-sso and webauthn extensions were folded into extensions/auth under ADR-0032. The three with no namesake — Stampede, OAuth2ServiceProvider, DashboardRequest — all survive elsewhere. It is the intent of five months of work, but it is a deletion on the default branch.

Not everything is verified. The container suite has not been re-run since the fixes that target it: var/ arrived in the image at mode 0555 and 2810 directories were unwritable by their owner, which produced 24 failures reading as kernel regressions. The fix is verified in place, the suite run is not. Two migrations are also left unconverted, their tests asserting a per-engine statement count that is exactly the defect being removed.

LennyObez added 30 commits July 21, 2026 08:58
…iring-contract gate (M0)

The super-audit's dominant failure mode (Theme B) is a security control that is
built, tested, marked #[Api], and documented as active — but never wired, so it
silently does nothing in production. The audit names the highest-value fix as
M0: assert every security control has a live composition-root binding through
the real boot path, and fail closed when it does not.

The wiring-contract mechanism (DescribesWiring + WiringContractInspector, added
in rc.11) already boots a real kernel and checks the contract graph, but
SecurityWiring described no contract, so its controls were outside the gate.

SecurityWiring now implements DescribesWiring and declares the security controls
it binds unconditionally on every boot — session (interface, manager, handler,
middleware), CSRF (manager, interface, middleware), the HMAC service, the flash
bag, and the security-headers middleware. Master-key-gated bindings (crypto,
tokenization, audit chain) are deliberately excluded: they are conditional on
PULSAR_MASTER_KEY, not always-on.

A new contract-graph test boots a real kernel and asserts every control
SecurityWiring declares actually resolves from the booted container. A control
that loses its binding — the exact "built-but-never-wired" regression — now
fails the build instead of shipping inert. The pattern extends to the remaining
security wirings the same way.
resolveStrictKeys() parked config['config'] in a mixed-typed variable and
the extension-enabled filter iterated a mixed-valued array — both tripped
psalm's MixedAssignment on the authoritative level-1 run. Narrow the
array-access expressions in place (isset + is_array) and build the enabled
allow-list with array_values(array_filter(..., 'is_string')) so no mixed
value is ever bound. Behaviour is unchanged; static analysis is clean again.
Cross-tenant IDOR (super-audit C11/C12): with a TenantScope bound, the ORM
enforced tenant boundaries on INSERT enrichment only. Reads (find/findBy/
findOneBy/count/exists) and writes (UPDATE/DELETE) were unscoped, so a
caller holding another tenant's row id could read or mutate it.

Read side: GenericRepository::query() — the single chokepoint for every
read — now applies TenantScopeApplier, injected through EntityManager and
bound in OrmServiceProvider only when a TenantScopeInterface is present, so
single-tenant apps are unaffected.

Write side: AuditingPersister constrains every write builder (versioned
UPDATE, plain UPDATE, soft-delete UPDATE, hard DELETE) with a
WHERE tenant_column = :active_tenant predicate, reusing
TenantInsertEnricher::activeTenantColumn(). A tenant-scoped write that
matches no row is surfaced as a TenantIsolationException (or, on the
versioned path, the existing OptimisticLockException) rather than a silent
no-op the caller mistakes for success.

Tests: real-SQLite read isolation (find/findBy/count/exists cannot reach a
foreign tenant's row; switching the active tenant switches visible rows; an
unscoped control sees every row) and write isolation (each write path
carries the predicate; a zero-match cross-tenant write raises
TenantIsolationException).
Extension host compromise (super-audit RC-4): ExtensionBootstrap already
scoped each extension's container and router through capability-gated
proxies, but nothing in the production boot path attached a CapabilityPolicy
— the property stayed null, the proxies were bypassed, and every extension
(trusted or not) received the raw container and router. A malicious or
compromised third-party extension could replace core security services or
register arbitrary routes with full host privileges.

Kernel::boot() now calls ExtensionSandbox::harden() for both the injected and
the auto-discovered bootstrap, before the register phase, attaching
CapabilityPolicy::defaults() plus the host trust allow-list from
config/extensions.php. Deny-by-default: an extension absent from the list is
capped at Community (TrustedExtensionsConfig takes min(requested, allowed)
with Community the default), so a manifest cannot self-elevate to Core —
trust is granted by the host, never claimed by the extension. Core-tier
extensions bypass the proxy entirely, so first-party extensions run exactly
as before. Fail-closed: a missing or malformed config still engages the
sandbox with an empty trust list rather than leaving the policy null.

config/extensions.php now lists every bundled first-party extension at core
(previously only 10 of 27 were listed; the rest would have dropped to the
Community cap and failed to boot once the sandbox engaged). The four bundled
extensions that register services but omitted trust_tier (devices, feedback,
releases, subscriptions) now declare core. A drift test keeps the list
complete against extensions/ so the sandbox posture cannot silently regress.

Tests: ExtensionSandbox unit tests (attaches policy, fail-closed on missing/
malformed config, no self-elevation); an engagement test proving — with the
real shipped config — that an unlisted extension requesting core is capped at
Community and denied a crypto-key service while a listed core extension keeps
full access; the drift test; and updated lifecycle/studio integration tests
that express extension trust as a real deployment would.
Super-audit C18: guardAgainstSsrf() validated and pinned only the initial
host, but doSend() set follow_location=1, so PHP's http stream wrapper
transparently followed 3xx Location targets — re-resolving DNS and connecting
with no SSRF re-validation. A public attacker-controlled endpoint returning
302 Location: http://169.254.169.254/… (cloud metadata / IAM credentials) or
an internal host was fetched, defeating the entire DNS-pinning design.

The stream wrapper no longer follows redirects (follow_location=0). Redirects
are followed explicitly in sendFollowingRedirects(), which re-runs the full
guard — scheme check, private/reserved-IP validation, and DNS pinning — on the
initial URL and on every hop's resolved Location before connecting, and caps
hops at maxRedirects. 301/302/303 downgrade a body-bearing method to GET;
307/308 preserve method and body.

Also closes the non-HTTP scheme vector: guardAgainstSsrf() now rejects any
scheme but http/https before the host checks, so a redirect (or caller) of
file:///etc/passwd, gopher://, dict://, or php://filter — which parse with no
host and previously slipped straight through — is blocked.

Redirect target resolution (absolute / protocol-relative / absolute-path /
relative) is factored into a testable RedirectResolver. Tests: scheme-block
coverage for file/gopher/dict/php, and RedirectResolver resolution across all
Location forms including an attacker redirect to the metadata endpoint.
…APIs

Super-audit C3 + C4: the commerce order API and the media DELETE endpoint were
registered with no middleware and no tenant scoping. GET /api/v1/orders/{id}
returned customer PII (email, billing/shipping address, notes) for any id, and
DELETE /api/v1/media/{id} permanently destroyed any asset by id — both
unauthenticated and across tenants. Order/asset ids were the only secret.

These routes now carry CmsApiKeyMiddleware, and the controllers enforce
authorization fail-closed:
  - showOrder / listOrders / media delete require a valid cms_api_key
    (401 otherwise);
  - the resource is returned or deleted only when its tenantId equals the
    authenticated key's tenantId — a mismatch (or absent order/asset) is a 404,
    never 403, so ids cannot be probed for existence across tenants;
  - listOrders scopes its query to the key's tenant (previously it read a
    tenant_id request attribute that nothing ever set, so it returned every
    tenant's orders);
  - the media deletion audit records the acting api-key id instead of a generic
    "Deleted via REST API" actor.

Tests cover the matching-tenant success path plus the 401 (no key) and 404
(foreign tenant) denials for both endpoints.

Note: CmsApiKeyMiddleware was built but never attached to any route, so the
wider CMS API remains unauthenticated (a separate wiring gap); this commit
closes the two exploitable CRITICAL endpoints.
Super-audit C2: GoogleCalendarSync::getAccessToken() built the RS256 assertion
by joining base64_encode(header).base64_encode(claims), signing that, and
appending base64_encode(signature). RFC 7515/7519 require base64url — the
URL-safe alphabet with padding stripped. Standard base64 emits '+', '/', and
'=', which Google's token endpoint rejects as invalid_grant, so every
createEvent/updateEvent/deleteEvent call failed. Because the signature is
computed over the encoded "header.claims", the encoding must be applied before
signing, not merely re-applied after.

All three segments now go through a base64UrlEncode() helper
(rtrim(strtr(base64_encode($b),'+/','-_'),'=')). A test generates a real RSA
key, captures the emitted assertion, asserts each segment is base64url, and
cryptographically verifies the RS256 signature over the base64url header.claims.

Also fixes two pre-existing booking-calendar test failures surfaced alongside:
GoogleCalendarConfig::fromArray now coerces non-string calendar_id /
service_account_key_path to '' (it passed them straight into string
constructor params, throwing a TypeError), and the not-readable test asserts the
actual "missing or not readable" message a nonexistent path produces.
Adversarial review of the C18 redirect fix confirmed the redirect re-validation
holds, but surfaced three residual bypasses in the DNS-pinning layer the guard
relies on:

1. Pinning targeted the wrong host. pinHostToIp() replaced the first occurrence
   of the host substring in the URL, so a URL whose userinfo repeats the host
   (http://h@h/) had its USERINFO rewritten and the authority left unpinned —
   file_get_contents then re-resolved it, reopening the DNS-rebinding TOCTOU the
   pin exists to prevent. Pinning now rebuilds the URL from parsed components
   (PinnedUrl) so only the authority host is replaced; userinfo/port/path/query/
   fragment are preserved.

2. Fail-open on DNS failure. An unresolved host was allowed through unpinned, so
   a rebinding attacker could return SERVFAIL at validation and a private A at
   connect time. The guard now fails closed (HttpClientException::unresolvableHost).

3. NAT64 / IPv4-embedding IPv6 not blocked. The reserved-range filter misses the
   NAT64 prefix 64:ff9b::/96, so 64:ff9b::7f00:1 (== 127.0.0.1) or the metadata
   address passed. isPrivateIp() now extracts any embedded IPv4 (IPv4-mapped,
   NAT64, 6to4) and validates it too.

The pinning logic is factored into a unit-tested PinnedUrl. Tests cover the
userinfo-repeat regression, IPv4-embedding IPv6 (NAT64/6to4/mapped) blocking,
and fail-closed on an unresolvable host. Also drops four redundant (string)
casts psalm flagged in the redirect helper once its cache was cleared.
…ified identity

Super-audit C9: GrpcExtensionAdapter passed $call->getPeer() to the request
handler as $peerIdentity whenever it was non-empty. On the grpc PECL extension
getPeer() returns the transport endpoint (e.g. "ipv4:203.0.113.7:54321"), not
the client certificate SAN, and it is populated for every call regardless of
whether mTLS was negotiated. AuthInterceptor then accepted that address as an
authenticated identity, so any tokenless request authenticated as its own
source address, defeating the UNAUTHENTICATED gate.

The adapter now passes null: PECL exposes no client certificate to derive a
verified identity from, so there is nothing trustworthy to supply, and the auth
pipeline correctly requires a bearer token. A test drives dispatchEvent with a
call that exposes a transport peer address and asserts the handler receives
null. (Behaviour change: PECL deployments relying on mTLS-only auth must now
present a token.)
Super-audit C10: AttributeColumnEncryptor::blindIndex() passed
$config->blindIndexContext as the BLAKE2b key to sodium_crypto_generichash().
That value is a domain-separation LABEL — it defaults to the public literal
"orm__bidx" and ships that way in config/orm.php — not secret key material. The
searchable blind index over encrypted PII (SSN, MRN, account numbers) was
therefore an UNKEYED hash: an attacker with a database dump could brute-force or
confirm the plaintext of any indexed column offline with a well-known key.

blindIndex() now derives a secret key from the master key via a dedicated
SubKeyId::OrmBlindIndex (13), distinct from the encryption key (Orm=5), and uses
that as the BLAKE2b key. The public label is folded into the 8-byte KDF context
for domain separation; the derived key is zeroed after use so the readonly
instance holds no key bytes. encrypt()/decrypt() are unchanged.

Known-answer tests prove two deployments with different master keys produce
different blind indexes for the same plaintext, and that a different context
derives a different key.

BREAKING (data): the blind-index derivation changes, so previously stored
*_bidx values no longer match. Any deployment using #[BlindIndex] must recompute
its blind indexes after upgrade (decrypt the source column, recompute, rewrite)
or equality lookups return zero rows.
Super-audit C17: S3StorageAdapter::uploadPart() could not read the ETag S3
returns for each part — CloudHttpResponse captured only the status code and
body, never response headers — so it returned a synthetic string
("<key>-part-<n>"). completeMultipartUpload() then submitted those bogus ETags,
S3 rejected them with 400 InvalidPart, and every put() over the 5 MB multipart
threshold failed (aborted and rethrew). Large-object uploads were entirely
broken.

CloudHttpResponse now carries response headers (lowercased, with a
case-insensitive header() accessor), captured in CloudHttpClient via
CURLOPT_HEADERFUNCTION. uploadPart() reads the real ETag from the response
header and fails closed if it is absent rather than fabricating one;
completeMultipartUpload() XML-escapes each ETag (ENT_NOQUOTES|ENT_XML1 keeps
S3's literal quotes) when building the request body.

A CloudHttpClientInterface seam lets the adapter be driven against controlled
responses: tests walk the full multipart sequence asserting the completion body
carries the real per-part ETag (never a "-part-" placeholder) and that a part
with no ETag header fails the upload closed. Also drops a now-unmatched
phpstan-ignore on curl_setopt_array.
…HMAC

Super-audit C14: PayPalWebhookHandler::verifySignature() computed
hash_hmac('sha256', transmissionId|time|webhookId|crc32(body), webhookId) and
compared it to PAYPAL-TRANSMISSION-SIG. That is not PayPal's scheme and it is
forgeable: the webhookId is a PUBLIC identifier (shown in the dashboard,
returned by the API), so any party that knew it could compute a passing
signature and POST BILLING.SUBSCRIPTION.* / PAYMENT.SALE.COMPLETED events to
flip subscription state or fake payments.

PayPal signs webhooks with RSA-SHA256. The handler now verifies the base64
PAYPAL-TRANSMISSION-SIG with openssl_verify against the PUBLIC KEY of the
certificate served at PAYPAL-CERT-URL, over the message
transmissionId|transmissionTime|webhookId|crc32(rawBody). The certificate is
resolved through a new injectable PayPalCertificateProvider that allow-lists the
(attacker-supplied) cert URL to paypal.com over https before any fetch, fetches
over hostname-validated TLS, extracts the RSA public key, and caches it —
returning null (reject) on any failure.

Tests generate a real RSA key: a genuinely signed webhook verifies, while the
old HMAC forgery, a tampered body (crc mismatch), a signature from a different
key, and a null certificate (disallowed URL / failed fetch) are all rejected.
The provider rejects non-https and non-paypal cert URLs before fetching.
Locale negotiation lets the same URL produce different responses from
Accept-Language — the bare-root courtesy redirect (302 → /fr for a French
browser vs a 200 default page for others), or a negotiated unprefixed body.
A shared cache keyed on the URL alone could then serve one visitor's variant
(or redirect) to another, or bounce a crawler off the x-default root.

LocalePrefixMiddleware now Varies precisely on Accept-Language:
  - the courtesy redirect AND the non-redirected default-locale root it would
    otherwise have redirected both carry it — whether a visitor got a redirect
    depended on the header, so the fallthrough 200 must vary too (this was the
    gap: it previously varied only when negotiate_unprefixed_locale was set);
  - Cookie is appended only when the locale cookie is enabled (the cookie-aware
    negotiator reads it), instead of being hardcoded — no needless fragmentation;
  - deterministic locale-prefixed URLs (/fr/about) are NOT given the header;
  - any existing Vary (e.g. Vary: Cookie) is merged, never overwritten.

Adds Response::varyOn(...$fieldNames) and the underlying VaryHeader::merge for
app code doing custom negotiation, so a controller need not remember the raw
header per call site: field-names are de-duplicated case-insensitively, Vary: *
wins, and existing tokens are preserved. The i18n docs gain a "Caching
negotiated responses (Vary)" section covering framework behaviour, the opt-in
API, and the sibling Vary: Cookie / CDN cache-key guidance.
…ts + subscriptions)

Super-audit C13/C15/C16: Apple App Store JWS payloads were trusted without real
verification. The payments JwsVerifier extracted the public key from x5c[0] —
the leaf certificate the message itself supplies — and openssl_verify'd against
it, so an attacker who self-signed a leaf and put it in x5c passed the check
(and, because it never converted the raw JOSE R||S signature to DER, it also
rejected genuine Apple notifications). The subscriptions WebhookController's
decodeAppleJws() only base64-decoded the claims — no signature check at all —
yet called processWebhook(..., signatureVerified: true), so any unauthenticated
caller could POST a crafted signedPayload (e.g. REVOKE/REFUND with an arbitrary
originalTransactionId) and flip any user's subscription state.

Adds a shared, correct verifier in framework core (extensions cannot cross-
import, so the primitive lives in src/Security/Jws): X5cChainJwsVerifier does
full ES256 x5c validation — enforces alg=ES256 (no alg-confusion), checks every
certificate's validity window, verifies each chain link cryptographically, and
anchors the top presented certificate to a locally PINNED root (byte-identical
fingerprint or directly signed by it) so a self-signed or swapped root is
rejected. The compact JWS raw R||S signature is converted to ASN.1 DER before
openssl_verify. AppleJwsVerifierFactory pins it to the bundled, fingerprint-
verified Apple Root CA G3 (resources/security/apple/AppleRootCA-G3.pem, SHA-256
63:34:3A:...:91:79) and fails closed if the anchor is missing.

payments JwsVerifier now delegates to the shared verifier (callers unchanged);
subscriptions WebhookController::appleSns verifies BOTH the outer notification
JWS and the inner transaction JWS, returning 400 on failure and only reaching
signatureVerified:true once both verify. Core tests build a real EC chain and
prove a genuine chain verifies while a self-signed forgery, alg-confusion, a
tampered payload, and an out-of-window certificate are all rejected.
…ently dropped

Super-audit C1. Analytics recorded consent under one identifier and checked it
under another, so with requireConsent enabled — the GDPR default — every tracked
hit was silently dropped. ConsentController::grant and AnalyticsConsentMiddleware
keyed consent on HMAC(ip|userAgent|'consent'), but TrackingService looked it up
under the raw, un-hashed IP; and the two hashing sites read the client IP
differently from the tracker (bare REMOTE_ADDR vs the X-Forwarded-For-aware
walk), so behind a proxy even the two hashed sites disagreed. Three copies of
the subject derivation, three different results.

Introduces VisitorConsentIdentity as the single source of truth for the consent
subject: it owns both the trusted-proxy-aware client-IP resolution and the keyed
hash, and the grant endpoint, the banner middleware and the tracker all derive
their subject through it — so they agree by construction. TrackingService
defaults it from its own key manager and config when not injected, so a caller
that omits it still derives the identical subject rather than silently diverging
(fail-safe, not fail-silent-wrong).

Tests: a VisitorConsentIdentity unit test (deterministic, a keyed hash never the
raw IP, X-Forwarded-For honoured only from a configured trusted proxy) and a
TrackingService regression proving a hit is recorded only when consent was
stored under the shared subject — pinning the exact invariant that was broken.
…g packages

Super-audit C21. The VEX generator decided a vulnerable package's code was "not
in the execute path" by checking whether the composer VENDOR segment (lowercased
$vendorParts[0]) appeared as a top-level namespace key in a src-only import scan.
That mapping is wrong twice over: the composer vendor is not a PHP namespace
(nikic/php-parser registers PhpParser\, and the vendor key "nikic" never matches
a `use PhpParser\...`), and transitive dependencies — the bulk of real CVEs —
never appear in a first-party src import scan at all. Every such package was
stamped not_affected / vulnerable_code_not_in_execute_path, so genuinely
vulnerable dependencies were silently cleared in the VEX document.

Reworks reachability on two principles. First, map each package to the PSR-4/PSR-0
namespace prefixes it actually registers, read from composer.lock (direct AND
transitive), never from the vendor segment. Second — and this is the load-bearing
change — a source-only import scan can PROVE reachability (an explicit `use` of
the namespace) but can never prove NON-reachability, because transitive and
dynamic use leave no import. So the generator now emits `affected` when an import
is found and `under_investigation` otherwise; it never asserts `not_affected`
from the heuristic. Unmappable packages (transitive, or classmap/files autoload)
also fall to `under_investigation` rather than a false clearance.

Tests updated to the sound contract, plus a filesystem regression that writes a
composer.lock mapping nikic/php-parser -> PhpParser\ and a source `use
PhpParser\Parser`, proving the lock-based mapping marks it reachable where the
old vendor-segment heuristic falsely cleared it.
… + MX)

The anti-spam pipeline scored the message body and hard-gated on the honeypot,
time-trap and managed challenge, but nothing inspected the sender's e-mail
DOMAIN. A bot that runs no JS (so it sends no challenge token, leaving the
captcha off its path), respects the time-trap and skips the honeypot therefore
reached only the content scorers, which never block. A disposable-mailbox
enquiry got through on exactly that path. A domain-level check closes it on
every path, JS or not, and belongs in the framework rather than in N per-project
stopgaps.

Adds EmailDomainCheck, an AntiSpamCheckInterface composing two independent
signals, each of which hard-gates, scores, or is off:
  - Disposable/throwaway domain: the sender domain or a registrable parent
    (inbox.mailinator.com -> mailinator.com) is on a bundled, maintainable list
    (resources/security/anti-spam/disposable-email-domains.txt) that a project
    EXTENDS — never replaces — via a config path and/or inline array.
  - Deliverability: the domain publishes no MX and no A/AAAA fallback (RFC 5321
    §5.1 implicit MX). SystemMxDeliverabilityResolver caches positive/negative
    results in the tagged cache and FAILS OPEN when the resolver is unreachable
    (a liveness probe on example.com tells "domain has no records" apart from
    "resolver down"), so a DNS outage never blocks everyone.

Independent modes let a "zero lost lead" deployment keep both on 'score' while a
strict one uses 'hard'. AntiSpamContext gains a typed email() accessor (explicit
$email, else the conventional 'email' form field); the check never throws —
address format stays the caller's Email rule, so an unparseable address is
simply OK. If MX caching is requested with no tagged cache bound, the wiring
emits the same loud "…is inert" warning as the other cache-backed checks.

Config in config/anti-spam.php (email_domain_check_enabled, disposable_block,
disposable_list, mx_check_enabled, mx_block, mx_fail_open, mx_cache_ttl); wired
into the pipeline right after the honeypot; documented in docs/anti-spam.md.
Once shipped, a site retires its project-side EmailDomainGate for this.
…ed offline

Super-audit C8. The PSD2 Strong Customer Authentication code was
substr(hash('sha256', challengeId | amount | currency | payeeId), 0, len) —
every input is public to the party being authenticated (the challengeId is
returned in the challenge, the amount/currency/payee are the transaction the
user is asked to authorize), and there was no secret. Any client that knows the
transaction it is initiating could compute a valid "authentication code"
offline, so PSD2 Art. 97 SCA was entirely bypassable.

The code is now a keyed MAC: HMAC-SHA-256 over the transaction details plus a
server-generated per-challenge nonce, under a per-deployment secret derived from
the master key (SubKeyId::Psd2ScaDynamicLinking, ADR-0006 — never a constant,
mirroring the C10 blind-index fix). The nonce is high-entropy, stored with the
challenge and NEVER returned to the client (ScaChallenge::toArray omits it), and
verifyChallenge recomputes the code from the stored nonce. Without the secret
and the nonce the code cannot be reproduced, and any change to amount, currency,
or payee still invalidates it (dynamic linking preserved). The service fails
closed if no real key is available (< 32 bytes), and the provider throws rather
than wire an SCA service with no master key to derive the secret from.

Documents in the class that the code is a possession factor an integrator must
deliver out of band, never echo back to the initiating client. Tests prove the
old offline-computable public hash is now rejected, the nonce is never
serialised, and a too-short secret fails closed.
…tion

Super-audit C6. The FHIR REST controller touched PHI with ZERO authorization:
FhirServiceProvider bound SmartScopeEnforcer and the extension advertised "SMART
on FHIR scope enforcement", but a full read of the request path showed the
enforcer was never called — read/search/create/update/delete/batch went straight
to the repository with no scope check, no token inspection, and no forbidden
branch. Any unauthenticated caller could read or mutate any patient's records.

Every PHI interaction now passes a fail-closed SMART scope gate BEFORE the
repository is touched: the controller reads the granted SMART scopes from the
`smart_scopes` request attribute (a space-delimited scope string the deployment's
OAuth2/SMART resource-server populates from the validated access token), maps the
interaction to a resource type and read/write permission, and calls
SmartScopeEnforcer. No scopes present -> 401 OperationOutcome; scopes insufficient
-> 403; only then does the repository run. Batch/transaction bundles require an
authenticated context up front and enforce each entry's scope independently, so a
denied entry becomes a 403 response entry and its operation never executes. The
CapabilityStatement (/fhir/metadata) stays public, per the FHIR spec.

This also fixes the controller's request wiring: the handlers now take the PSR-7
request and read route params from attributes and the body from the parsed body,
where the previous by-name/array-param binding mismatched the route (e.g. the
{type} param never reached $resourceType and the request body was never read).
Tests prove a missing scope 401s and an insufficient scope 403s without ever
calling the repository, a read-only scope cannot write, and a batch entry
touching an unscoped resource type is refused.
Super-audit C20. The device-identity WebAuthn attestation verifier performed
zero cryptographic verification. verifyAttestationIntegrity claimed "KeyRing-based
HMAC" but computed no HMAC — it only checked the key existed and authData was
>= 37 bytes. verifyPackedAttestation and verifyFidoU2fAttestation checked only
that the 'sig'/'alg'/'x5c' keys were present and non-empty, then returned
verified(0.9) without ever validating the signature or certificate. Since the
challenge and origin are values the server itself sends the client, an attacker
could submit {fmt:'packed', authData: 37+ arbitrary bytes, a clientDataJSON
echoing the challenge/origin, attStmt with any sig/alg/x5c} and be trusted as a
hardware-attested device at 0.9 confidence.

The verifier now does real verification. It computes clientDataHash =
SHA-256(clientDataJSON) and verifies the attestation signature over
authenticatorData ‖ clientDataHash: for packed with an x5c chain, against the
leaf attestation certificate's public key (COSE alg → OpenSSL); for packed
self-attestation, against the credential public key parsed out of the
authenticator data; for fido-u2f, by rebuilding the U2F signature base
(0x00 ‖ rpIdHash ‖ clientDataHash ‖ credentialId ‖ 0x04‖x‖y) and verifying it
against the x5c leaf. A statement that does not verify is rejected. Parsing the
credential COSE key needs CBOR, so a minimal CBOR decoder is ported into core
(core cannot import the auth extension's copy). The fake KeyRing HMAC dependency
is removed. 'none' attestation carries no statement and stays low-confidence,
per spec.

Trust is limited to "the authenticator holds this private key" (Basic/Self): the
leaf chain is not yet validated to a FIDO Metadata Service root, documented in
the class, so callers needing certified hardware must add an AAGUID allowlist.
Tests build real EC attestations and prove a genuine packed-basic and a genuine
self-attestation verify while a forged signature, a challenge mismatch, and an
origin mismatch are all rejected.
…ing fields

Super-audit C7. DefaultCertificateValidator::validate() called only
openssl_x509_parse() — which merely DECODES a certificate — and then derived
every trust decision from attacker-suppliable string fields: certificate type
via str_contains(subject,'QWAC'), qualified status and PSD2 roles via
str_contains on extension text, and the NCA authorization number via a loose
regex. No signature check, no chain building to a trusted eIDAS QTSP root, no
revocation. A self-signed certificate carrying the right strings was therefore
accepted as a qualified PSD2 certificate, and the configured trusted_issuers /
check_revocation settings were never consulted at all.

Closes the exploitable core: validate() now fails CLOSED unless the certificate
cryptographically chains to a configured eIDAS trust list. A new
CertificateConfig::trustedCaBundlePath points at a PEM bundle of trusted QTSP CA
certificates; before ANY field is read, assertTrustedChain() refuses outright
when no trust list is configured (trustAnchorsUnavailable) and verifies the
chain with openssl_x509_checkpurpose against the bundle, rejecting anything that
does not anchor to it (certificateChainUntrusted). A self-signed forgery is now
refused before its self-declared type/roles/NCA/authorization number are ever
trusted, and both failure paths are audit-logged.

Scope, stated honestly: this establishes chain trust and validity, which is what
made the finding exploitable. Full eIDAS conformance — OCSP/CRL revocation
checking and ASN.1 parsing of the ETSI TS 119 495 QcStatements for precise PSD2
roles/NCA data instead of the string hints — remains to be layered on and is
documented in the validator; until then a deployment must treat the derived
roles as advisory and pair them with its own allowlist. Tests prove the
validator fails closed with no trust list, rejects a certificate that does not
chain to the configured list, and accepts one that does.
…P_ENV)

Two fail-open defects on the path every Pulsar site exercises, independent of
which extensions are enabled.

Authorization: AuthorizationMiddleware only ran its permission checks inside
`if ($matchedRoute !== null)`. When the `_route` request attribute was absent,
the entire block was skipped and the request fell through to the handler — an
authenticated user passed with NO authorization check. It now fails closed:
without route context the required permissions are unknowable, so the request is
denied (403) and audit-logged, even when the gate would have allowed.

Config: AppConfig::fromArray mapped an unrecognized APP_ENV (a typo, or an
unexpected value) to EnvironmentMode::Local, which turns debug on and discloses
full stack traces and source. A single env typo in production was an information
leak. Unrecognized values now fail secure to Production (debug off); an unset
APP_ENV still defaults to local for development.

.env.example documents the accepted APP_ENV values and the fail-secure rule so
the value is not fat-fingered in the first place. Tests: authorization denies
without route context (even when the gate allows), and an unrecognized APP_ENV
resolves to Production with debug off.
The runtime SecurityWiring reads PULSAR_MASTER_KEY through the Environment
(which parses .env), but Kernel::preBindFrameworkCache — the early boot-cache
optimisation — read it with a bare getenv(), which never sees a value that lives
only in .env. A developer who followed .env.example and set the key in .env got
it honoured at runtime but silently missed at early boot: an inconsistent,
confusing half-state.

preBindFrameworkCache now resolves PULSAR_MASTER_KEY, PULSAR_MASTER_KEY_PREVIOUS
and CACHE_ENCRYPT through a small earlyEnv() helper that prefers the Environment
(honouring .env) once config has loaded and falls back to the process
environment when it has not — the cache pre-bind can legitimately run before
config load, so the fallback preserves the existing behaviour there. Net effect:
a .env-set master key is honoured wherever the Environment is available, and
nothing regresses where it is not (the cache simply degrades to off, as before).

.env.example now documents PULSAR_MASTER_KEY precisely: it is resolved through
the Environment (so .env works), production should prefer a real environment
variable or secret manager over a file, and `pulsar serve` exports it to the
process environment so every boot phase sees it in local dev. Adds the
PULSAR_MASTER_KEY_PREVIOUS (rotation) and CACHE_ENCRYPT keys with explanations.
Makes the .env surface state-of-art and typo-safe, following the fail-secure
AppConfig/master-key fixes.

Validation: a new EnvironmentValidationCheck (registered in deploy:check) reads
the RAW APP_ENV and APP_DEBUG from the Environment — before AppConfig's
fail-secure mapping hides a typo — and errors loudly on any unrecognized value,
listing the accepted set (APP_ENV: local|staging|production; APP_DEBUG:
true/false and 1/0, yes/no, on/off). So a fat-fingered APP_ENV=prod that silently
resolved to production is now caught at deploy time.

Consistency: PULSAR_DIAGNOSTICS_TOKEN was read with a bare getenv() in
DiagnosticsWiring and MetricsWiring, so a value set only in .env was ignored —
the same trap just fixed for the master key. Both now resolve through the
Environment, so documenting the key in .env.example is honest.

Documentation: .env.example now covers the previously-undocumented, wired keys —
APP_DOMAIN, SESSION_DOMAIN, the opt-in per-subsystem key overrides
(PULSAR_ENCRYPTION_KEY / PULSAR_AUDIT_KEY, cross-referencing docs/key-rotation.md),
PULSAR_DIAGNOSTICS_TOKEN, PULSAR_VERIFY_ARTIFACTS (noted as a process/CI
build-integrity gate), and the CAPTCHA keys — each with accepted values, effect,
and default. Verified PULSAR_AUDIT_KEY / PULSAR_ENCRYPTION_KEY are a genuinely
wired opt-in override feature (CompositeKeyProvider), not dead keys.
…ing-matching

Super-audit C7, eIDAS follow-up (1/3). The certificate validator derived PSD2
roles, NCA name/id and qualified status by str_contains() over the certificate
text: `str_contains($value, 'PSP_AI')`, `str_contains($subject, 'QWAC')`,
`str_contains($oid, '1.3.6.1.5.5.7.1.3')`. That is unsound — a role token like
PSP_AI can appear anywhere in a field, and the authoritative data lives in a
structured DER extension. Combined with the now-mandatory chain verification,
the roles a caller acts on must come from the signed extension, decoded properly.

Adds a strict, definite-length ASN.1 DER decoder (DerDecoder/DerNode — reusable
for the OCSP/CRL steps to follow) and Psd2QcStatementsParser, which walks
certificate -> tbsCertificate -> [3] extensions -> the qcStatements extension
(OID 1.3.6.1.5.5.7.1.3) -> the PSD2 QcStatement (OID 0.4.0.19495.2) ->
PSD2QcType { rolesOfPSP, nCAName, nCAId } per ETSI TS 119 495, mapping the PSP
role OIDs (0.4.0.19495.1.1..1.4) to PSP_AS/PSP_PI/PSP_AI/PSP_IC and reading the
eIDAS QcCompliance statement (OID 0.4.0.1862.1.1) for qualified status. Depth-
bounded and fail-safe: a malformed or absent extension yields no PSD2 attributes,
never a fatal.

DefaultCertificateValidator now sources roles, NCA name/id and isQualified from
the parser and the four string-matching helpers are removed. Tests build valid
PSD2 qcStatements DER by hand and prove roles/NCA/qualified are decoded, an
unknown role OID falls back to its name, and a plain certificate (no extension)
yields null. OCSP/CRL revocation are the remaining eIDAS steps (2/3, 3/3).
…n (C7)

The PSD2 certificate validator needs to build OCSP requests and hash cert
sub-structures (issuer Name, subjectPublicKeyInfo) with no dependency on the
openssl CLI. PHP exposes no OCSP/CRL primitives, so this adds a minimal but
correct definite-length DER codec:

- DerEncoder: SEQUENCE, OCTET STRING, INTEGER (verbatim value bytes), OBJECT
  IDENTIFIER (base-128 arcs), NULL, AlgorithmIdentifier and [n] EXPLICIT
  context tags — exactly the grammar RFC 6960 OCSP requests require.
- DerNode now captures the full raw TLV bytes of every node, so a decoded
  sub-structure (e.g. the issuer Name) can be re-hashed byte-for-byte.
- DerDecoder records each node's raw span during the single decode pass.

Round-trips are covered by DerCodecTest (encode/decode, raw capture, EXPLICIT
context tags, a known OID vector, and long-form lengths).

This is the ASN.1 groundwork for the OCSP and CRL revocation checks that
complete C7's move from string-matching to real eIDAS certificate validation.
A clean, cache-free psalm run surfaced seven level-1 errors that earlier gate
runs had reported as green — the exit code was being read from a trailing shell
command and psalm's incremental cache was serving stale-clean results, so real
MixedAssignment/UnusedForeachValue findings in already-pushed commits went
unnoticed.

Fix them properly (no suppressions):

- CborDecoder::decodeItem now declares its real union return type
  (int|string|bool|array|null) instead of mixed, so array/map decoding no longer
  assigns an untyped value.
- VexGenerator iterates autoload prefixes with array_keys() — the foreach value
  was unused.
- SystemMxDeliverabilityResolver annotates the cache read as the mixed it is
  before narrowing it against the 'yes'/'no' sentinels.
- FhirController annotates the query-parameter loop value as mixed before the
  is_string() narrowing.

Behaviour is unchanged; verified by the existing ZeroTrust, SupplyChain,
AntiSpam and FHIR suites (1488 tests green). Gate runs now use --no-cache with
the real psalm exit code.
…ed OCSP (C7)

A PSD2 certificate can chain to a trusted CA yet have been revoked since
issuance. The validator now confirms revocation on every validation, via a
self-contained OCSP client (RFC 6960 / RFC 8954) with no dependency on the
openssl command line.

- OcspRevocationChecker builds a DER OCSP request for the leaf, POSTs it to the
  responder from the leaf's Authority Information Access extension (through the
  framework's SSRF-protected HTTP client), and trusts the BasicOCSPResponse only
  after verifying its signature against the issuing CA or a delegated responder
  whose certificate is signed by that CA and carries the id-kp-OCSPSigning EKU
  (RFC 6960 4.2.2.2). The echoed nonce must match; the matching SingleResponse
  must be within its thisUpdate/nextUpdate window.
- CertificateFields decodes the X.509 fields OCSP needs (issuer Name, serial,
  issuer public-key hash, OCSP responder URL, EKU) from DER rather than trusting
  openssl_x509_parse's string rendering.
- IssuerResolver finds the issuing CA in the trust bundle, verifying its key
  actually signed the leaf; self-signed anchors resolve to null (not checked).
- DefaultCertificateValidator rejects a confirmed revocation always, and an
  inconclusive result unless CertificateConfig::revocationSoftFail is set — it
  never fabricates a "good" verdict, and audits every outcome.
- Psd2ServiceProvider wires the checker with the framework HTTP client, falling
  back to a concrete SSRF-protected client so the check actually runs.

RevocationStatus/RevocationCheckerInterface model the outcome and strategy so a
CRL fallback can be layered on later. Covered by 11 OCSP tests (direct and
delegated signing, tampered-signature, untrusted-key, nonce-mismatch, staleness,
missing-EKU, no-AIA, unreachable) plus validator revocation-policy tests.

Docs: docs/psd2.md. Config: certificate.revocation_soft_fail.
The security-posture advisory log is already gated behind logAtBoot (off in
production) so it cannot flood the log on every FPM boot. The literal-header
shadow warning — raised when a literal Strict-Transport-Security or
Permissions-Policy in `headers` overrides its structured sub-config — is the
same kind of boot-time config invariant but was logged unconditionally, so it
repeated on every production request under PHP-FPM (boot==request).

Gate it behind the same SecurityPostureConfig::logAtBoot flag: the override is
surfaced during development (logAtBoot on outside production) and stays silent on
each production request, matching the posture advisory's flood control. The
literal remains authoritative and is still emitted on the wire — no change to
header emission or request handling.

Regression test asserts the shadow warning is not logged when APP_ENV=production.
…idence

The native time-trap check already existed (signed no-JS render stamp, renderer,
@timetrap/@shield directives, wiring) but blocked too aggressively: a missing,
malformed, tampered, wrong-form, future-dated, or STALE stamp all failed the
check. Under the pipeline's short-circuit mode that hard-rejects legitimate
submissions — a slow human with a stale tab, or any client whose stamp did not
round-trip, loses their message.

Align it with the "zero lost lead" policy: the check now fails on ONE case only
— a validly-signed stamp, bound to this form, submitted faster than
time_trap_min_seconds (a bot posting on load), within the −5s clock-skew
tolerance. Every other outcome passes; the honeypot, managed challenge and rate
limiter cover those, and no-master-key already disables the check at wiring time.

Because staleness must never block, the time_trap_max_seconds knob contradicted
the policy and is removed (breaking: drop the key from config/anti-spam.php). The
signed-stamp crypto, renderer and directives are unchanged.

Tests updated to assert fail-open on missing/tampered/wrong-form/future/stale and
a block only on the too-fast case. Snapshot regenerated (AntiSpamConfig field
removed). docs/anti-spam.md documents the fail-open design.
The previous commit turned three out-of-scope attributes into CoversNothing.
In one file another, valid CoversClass attribute remained, and the script
rewrote the import before checking for that — so the file used CoversClass with
no import, and carried CoversNothing alongside it, which contradicts it.

ImportAnalysisResult is a real class inside the coverage scope, so the attribute
naming the test helper should simply have been dropped there rather than
converted. PHPStan caught it in one line; my own audit script reported hundreds
of false positives on the same question and was wrong.
…tation

Your objection was right, and the check written to answer it found what the
objection predicted.

A #[CoversNothing] contract test proves an interface is implementable. It proves
nothing about the classes that ship, and it makes the interface look tested
while they may not be — the suite is green, the coverage report attributes
nothing, and no one is told which shipped class was never exercised.

ContractTestCoverageTest states the missing rule: an interface exercised only by
contract must have at least one concrete implementation carrying its own
#[CoversClass]. Of 29 such interfaces, 27 were already clean. Two were not:

- TicketService, exercised by 17 tests that declared no coverage target. The
  coverage existed and was never claimed; the attribute says so now.
- DbTicketRepository — 334 lines, 12 public methods, no test at all. Shipped
  and never run by anything.

The repository has one now, leading with its optimistic locking: save() carries
the expected version in its WHERE and increments it in its SET, so a second
writer holding a stale version must be refused rather than silently overwrite
the first. Two agents editing one ticket is the ordinary case. That behaviour
was correct and unproven; it is proven now.

7 tests over an in-memory SQLite, following the DbContentRepositoryTest harness.
The invariant check passes, PHPStan max is clean.
Conditions came back 0.00% (0/0) — zero conditionals in a 149792-statement
codebase, which is a missing metric, not a low one. I removed --path-coverage
on the reasoning that Clover has no path element and nothing consumed it. The
first half was right; the second was wrong. Xdebug emits branch data only under
path coverage, and the threshold gate reads `conditionals`. Measured both ways
on one suite: 602 conditionals with the flag, 0 without.

Paratest cannot replace it either — it has no --path-coverage option at all. So
the original sequential run was not caution about merging, as its comment led me
to read; it was the only way to produce the metric. Back to phpunit, with the
ceiling raised to 90 minutes since 45 cut it at 55.

Sharding the suite across jobs and merging the reports is how this gets
parallelised. Process-level parallelism cannot, and that is worth stating where
the next person will look.
Three defects that shared one cause: the booking suites were never wired
into phpunit.xml, so nothing ever executed this code.

BookingConfig::fromArray cast the five booleans defensively and passed the
five integers and nine strings through raw. Config reaches it from YAML and
environment variables, and an environment variable is always a string, so
deposit_percent="50" raised a TypeError instead of meaning 50. Numeric
strings now coerce; a value that is not a number at all falls back to the
documented default rather than to 0, which would silently disable a deposit
or a cancellation window.

TimeSlotManager iterated Result::$rows as arrays. They are Row objects, so
every read was a fatal error -- the code had never run against the real API.
It was invisible because each loop carried a /** @var array{...} */
annotation asserting the opposite: PHPStan and Psalm reported nothing
because they had been told a lie. The false annotations are gone and the
typed Row accessors do the work, and block() now uses Result::first()
instead of copying every row to take one.

ReminderService took its two senders as concrete final classes, so an
application could not put its own transactional-email or SMS provider in
their place -- the substitution the extension model promises -- and the
class could not be doubled in a test. Both senders now implement
ReminderSenderInterface and the service depends on the contract.
booking, devices, feedback, messaging, observability-export, releases,
subscriptions and tickets each shipped a tests/Unit directory that was
absent from the Unit testsuite. 939 tests existed and none of them ran, so
the suites reported nothing while the code drifted underneath them.

Wiring them in surfaced 33 problems. Each was judged on its own: three were
product defects, fixed in the preceding commit; the rest were tests left
behind by changes to the code they cover.

- BookingService and ReleaseController::edit lost a constructor and a
  route-handler parameter respectively; the calls now match the signatures.
- TimeSlotManagerTest stubbed Result::getIterator, a method Result does not
  have, on a readonly value object that should never have been doubled. It
  builds a real Result::fromArrays now -- which is what exposed the Row
  handling defect.
- ReminderSchedulerJobTest asserted that getSchedule() throws, because
  Schedule::everyMinutes() did not exist when it was written. It exists, so
  the test asserts the schedule instead of asserting the gap.
- The feedback and releases route counts were each one GET behind. Both were
  checked for double registration first; every path is distinct.
- Five #[NoDiscard] returns are discarded inside expectException blocks,
  where the call exists only to throw. Cast to (void), one at a time.
- ReminderServiceTest built an SMS double for the one case that runs without
  a sender at all. It is created on demand, and the case that leaves email
  disabled now asserts that email is not sent.
PDO indexes driver options by integer attribute constant and drops string
keys without a diagnostic. Three parts of the framework had grown on top of
that fact and formed a closed loop:

RuntimeVerifier told the operator to set database.options.ssl_mode to
"require" or "verify-full". Nothing else documented the key --
config/database.php ships 'options' => [] -- so the verifier's own
remediation text was the specification operators followed.

ComplianceVerificationWiring read that key back out of the same array and,
finding "require" or stronger, reported PCI-DSS encryption in transit as
satisfied.

PdoConnection::fromConfig carried /** @var array<int, mixed> */ over a
value declared array<string, mixed>, which is why PHPStan at max and Psalm
at level 1 said nothing about the string keys reaching PDO.

So the framework instructed a setting, read it back, and certified itself
compliant on the strength of it, while the option never reached the
database and the connection came up in plaintext. Verified on PHP 8.5.9;
compliance strict mode booted successfully against it. The trigger is the
spelling, not the engine: MySQL is sound only through the integer
Pdo\Mysql::ATTR_SSL_* attributes, which survive because they are integers.

- Driver::buildDsn carries sslmode into the PostgreSQL DSN, where libpq
  reads it, and refuses it for drivers whose DSN has no such parameter
  rather than accepting a setting that cannot take effect.
- Values are allow-listed against libpq's six. "requre" fails where the
  operator can read the message instead of degrading to plaintext.
- fromConfig partitions the options: integer keys to PDO, sslmode to the
  DSN, and any other string key raises rather than vanishing.
- connectionUsesTls counts sslmode only for PostgreSQL, and the remediation
  text gives the per-engine instruction that works.

ConnectionConfig::$options is now declared array<array-key, mixed>, which
is what it always held. Declaring string keys is what made fromConfig
restate the type instead of narrowing it.

Records the decision in ADR-0040, and drops the two substitutability
baseline entries the preceding commit paid off.
The substitutability baseline indexes findings by kind|file|line|target, so
editing a file above a recorded line makes its entry stop matching and the
finding reappear as new. The preceding commit added three lines to
RuntimeVerifier's remediation text and about seventy to PdoConnection, which
moved six recorded lines:

  RuntimeVerifier   128, 154, 175, 204  ->  131, 157, 178, 207
  PdoConnection     105, 119            ->  175, 189

Re-keyed by hand. --generate-baseline would have produced the same six lines
and silently blessed anything else that had appeared since, which is the one
thing the baseline exists to prevent. The count is unchanged at 1231: six
entries replaced by six, none added, none dropped.
The coverage step was cancelled at the 90-minute job timeout having measured
nothing. Reading the rate out of that cancelled run — 0.8728 s/test across
5546 tests — projects the full 43576 to 633.9 minutes, or 10.6 hours. That is
past GitHub's own six-hour hard ceiling for a hosted job, so no value of
timeout-minutes could have accommodated it, and moving it to a schedule would
have failed the same way with less to show for it. Mutation testing declares
needs: php-tests, so Infection has been gated out of every run on this branch
as a consequence.

A comment in this file asserted that the conditions metric required the
--path-coverage command-line flag and that the pass therefore had to be
sequential. The first half is wrong: pathCoverage="true" on the <coverage>
element does the same job, and it applies to any runner reading the
configuration. That unverified assertion is what produced the sequential shape
and the 10.6-hour wall.

The split comes from PHPUnit's own discovery. tools/ci/split-tests.php reads
--list-tests-xml and partitions the test IDs round-robin, so a directory added
to phpunit.xml enters the shards by itself — a splitter fed by its own list of
paths would silently stop covering it, which is exactly how eight extension
suites went unexecuted.

The merge cannot inflate a percentage, and that is arithmetic rather than
hope. Every shard instruments the identical <source> set and reports uncovered
files as well as covered ones, so each report already carries the whole
denominator and the merge only unions numerators. Verified end to end
locally: two shards merged to conditionals=3541 against statements=149920,
the same denominator a single run produces. A lost shard lowers the figure and
trips the gate; it cannot raise it. merge-coverage.php refuses an incomplete
set outright rather than merging what happens to have arrived.

php-tests keeps the driver contract, the uninstrumented paratest run that is
the only isolation guard, and the yield gate, and drops to a 30-minute ceiling.

Also corrects the pin comment on actions/download-artifact: the SHA
018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 is v6.0.0, not the v7.0.0 the comment
claimed. A pin whose label misstates the version gives up most of what pinning
is for.
Infection had never executed. It declares needs: php-tests, and php-tests was
cancelled at its timeout on every run of this branch, so the mutation gate
reported nothing while appearing to exist. Unblocking php-tests would have sent
it straight into the same wall: full scope is 2587 files in src/, each mutant
re-running the tests that cover it, inside a pull request a reviewer is waiting
on.

A surviving mutant means different things in different places. In a line the
change touched it is a test gap being introduced right now, and refusing the
change is the correct response. In untouched code it is existing debt, and
blocking an unrelated pull request on it prevents nothing from getting worse.
So the two are separated by scope and audience, not by standard:

  ci.yml            mutates the changed lines, MSI 80, 60 minutes
  mutation-nightly  mutates the whole of src/, MSI 80, on a schedule

The bar is identical in both. What differs is which lines are measured and who
is waiting for the answer.

--only-covering-test-cases applies to both, and costs no signal: a test that
does not cover the mutated line cannot kill the mutant, so running it proves
nothing and only spends time.

Two details that would each have made the gate silently useless:

  fetch-depth: 0 on the checkout — --git-diff-base resolves against a branch
  that has to exist locally, and checkout clones a single commit by default.
  Without it the diff resolves against nothing, Infection mutates nothing, and
  the job passes having measured nothing.

  --ignore-msi-with-no-mutations — a change touching only workflows or
  documentation produces no mutable line, and an MSI of 0/0 has to read as
  nothing to measure rather than as a failure.

The nightly ceiling is 330 minutes, under GitHub's 360-minute hard cap. Both
ceilings are estimates: no run has ever produced a duration to size them from.
The TLS fix added an optional trailing $sslMode to Driver::buildDsn() and three
named constructors to InvalidDsnComponentException, both on the #[Api] surface.
PublicApiSnapshotTest caught the omission: the snapshot had been regenerated
before that change rather than after it.

The diff is additive throughout — a trailing optional parameter and three new
static factories — which is what the RC phase asks for. No signature narrowed,
nothing removed.
…rable resolution

An override is a promise: whatever pulls this package in, it resolves to at
least this version. Two of them were promising less than the advisory requires.

  js-yaml   >=4.2.0  admitted 4.2.0, GHSA-5p4m-2wfm-xmqj needs 4.3.1
  postcss   >=8.5.18 admitted 8.5.18, CVE-2026-69153 needs 8.5.23

Neither was exploitable today — postcss resolved to 8.5.23 and js-yaml is not in
the tree at all. The defect is that nothing held them there: a lockfile
regeneration could have dropped either back onto a vulnerable version with no
alert to say so, because Dependabot reads the resolved tree and not the floor
that permits it.

postcss goes to the current stable 8.5.26 rather than the bare patched version,
and the lockfile follows. js-yaml stops at 4.3.1 deliberately: 5.x is a major
line, and forcing it through an override that is dormant today would break any
future consumer written against the 4.x API for no security gain. A 5.x
resolution still satisfies >=4.3.1.

The other 34 open advisories need no action here. They are reported against the
default branch, which carries league/commonmark 2.8.0 where this branch carries
2.9.0 — every commonmark advisory is fixed at 2.9.0 or earlier. Merging resolves
them.
…to the measurement

Two defects from the first real run of the sharded coverage, both mine.

Declaring pathCoverage="true" in tools/php/phpunit.xml made every consumer of
that file require Xdebug. Branch and path data is Xdebug-only, so PHPUnit
refuses to run at all under PCOV and reports "No tests executed" — and Infection
runs the suite under PCOV for speed. The mutation job died in 27 seconds on its
first execution ever. The attribute was there to serve a paratest design that
was abandoned; the shards run plain phpunit, which takes the flag on the command
line, so the Xdebug requirement now lives with the one job that needs it.
Verified both ways locally: the flag yields conditionals=453 on the same subset
the attribute did, and PCOV runs green again.

Every one of the eight shards was killed at exactly 60 minutes. The measurement
that sized them says 633.9 sequential minutes, which is 79 minutes across eight
— a figure printed in the same table the 60-minute ceiling was chosen from.
Sixteen shards is about 40 minutes of tests each, against a 90-minute ceiling.

The merge grows to memory_limit=8G for sixteen inputs and lists them by brace
expansion. merge-coverage.php still refuses an incomplete set, so an expansion
that produced the wrong count fails loudly rather than merging a subset.
TenancyWiring constructed TenantResolutionMiddleware, bound it into the
container, and stopped there. Nothing else in the tree references it: three
occurrences total, all in that file, and no pipe() call anywhere in it. Every
other wiring in WiringList pipes what it builds — Tracing, ThreatDetection,
Security, RequestContext, Profiler, Metrics, I18n, Edge, Auth. This one did not.

So TenantContext was empty on every request. TenantAwareConnectionManager,
TenantAwareIdempotencyStore, TenantJobMiddleware, ModelBindingMiddleware and the
payments handlers all scope by a context nothing populated, in a framework whose
case for banking and healthcare is tenant isolation. CmsPageCacheMiddleware:447
even documents reading "the tenancy attribute set by TenantResolutionMiddleware"
— an attribute that was never set.

The test suite said this was fine. It asserted
$container->has(TenantResolutionMiddleware::class): proof the object was built,
which is not proof it runs. Five green assertions reported tenancy configured.

Two tests replace that. Both were confirmed to fail with the pipe removed and
pass with it restored, rather than assumed to guard anything.

Ordering is safe and checked rather than hoped: src/Auth does not reference
TenantContext, so piping at TenancyWiring's position in WiringList, after
AuthWiring, leaves no earlier middleware reading a context it needs. Every real
consumer runs during request handling or in middleware piped later.
The first sharded coverage run failed nine of sixteen shards. None of it was
infrastructure; all three causes were invisible to every uninstrumented run,
which is why the suite had been green.

RendersAdminViewTest carried #[CoversClass] on RendersAdminView, which is a
trait. PHPUnit only calls that an invalid coverage target while coverage is
being collected, and with failOnWarning="true" the warning failed whichever
shard held one of the class's methods — seven of them, because round-robin
splits a class across shards. Now #[CoversTrait]. A scan of all 5208 coverage
attributes in the tree found no other mismatch.

RateLimitedSampler read hrtime() directly, and its tests exhausted the token
bucket and asserted the next call was refused, trusting that no refill happened
in between. At 100 tokens per second a token returns in 10 ms, so under a
profiler the two consecutive calls had already refilled what the test emptied.
The class takes an optional trailing clock — additive, production passes nothing
— and both duplicate copies of the test drive time instead of sleeping. The
usleep(60_000) is gone with it: the tests are now deterministic rather than
merely slower than the race.

The benchmark and verification-matrix groups are excluded from the shards only.
A 200 ms budget measured at 745 ms under Xdebug path coverage is not measuring
the checkout, it is measuring the profiler. Both groups still assert in
php-tests, which runs uninstrumented, and the code they exercise is still
covered by the other tests that reach it.
… to use

PciDssMapping registers Req 3.4 with ControlStatus::Implemented and names
DatabaseTokenStore as the store "for production persistence". That class was
instantiated nowhere, and could not be: it took a raw PDO, PdoConnection keeps
its own private, and nothing in the framework hands one out.

Meanwhile SecurityWiring resolved TokenStoreInterface eagerly and fell back to
InMemoryTokenStore. Nothing else binds that interface anywhere in the tree, and
this wiring runs eighth in WiringList against DatabaseWiring's seventeenth, so
the fallback was never a fallback — it was the only branch reachable. Every
deployment held PAN tokens in process memory, lost them on restart, and was told
by its own compliance catalogue that the requirement was met.

A tokenization vault that forgets does not degrade gracefully. The tokens
outlive the mappings, resolve to nothing, and the PANs they replaced are gone.

- DatabaseTokenStore takes ConnectionInterface. This breaks a signature carrying
  #[Api(since: '1.0.0')], deliberately: the alternative was preserving a
  constructor no caller can satisfy, which is the appearance of compatibility
  rather than compatibility. It also means the vault inherits the connection's
  TLS settings instead of a PDO assembled elsewhere under other rules.
- TokenStoreInterface is bound with singleton() rather than resolved inline, so
  the database exists by the time the answer is needed. Eager resolution at
  wiring time is what made the ordering fatal.
- InMemoryTokenStore remains the answer when no connection is configured, and
  only then.

Two tests guard it, both confirmed to fail with the fix reverted: one asserts a
DatabaseTokenStore once a connection is bound after the wiring runs, the other
that memory is used when there is no connection at all.

ADR-0041 records the API break. The snapshot diff is one line.
Eleven methods across three files assert latency budgets. Under Xdebug path
coverage a checkout budgeted at 200 ms measures 745 ms and a search budgeted at
100 ms measures 337 ms. Those numbers are not regressions; they are the
instrumentation. Asserting on them fails correct code, which is worse than not
asserting: it teaches everyone to disbelieve the gate.

The first attempt filtered the two groups out of the coverage shards from the
workflow file. That was a workaround in the wrong place — it only covered the
one job that happened to name them, and it would drift the moment a wall-clock
test landed outside those groups. The precondition belongs with the assertion,
so RequiresUninstrumentedRuntime skips when a coverage driver is recording,
wherever the test runs. Verified both directions: 11 assertions with no driver,
3 abstentions under coverage.

tests/Support/ was gitignored, in the same rule as .worklog/ and docs/internal/
under a comment about working notes not belonging in a public history. It does
not hold notes; it holds test infrastructure, and ignoring it means a shared
base class added there is silently absent from CI while every test extending it
dies on an unresolvable class — a failure that cannot reproduce locally, where
the file is right there. This trait would have vanished on push.

FilesystemTestCase.php had been sitting untracked for exactly that reason: 177
lines with symlink-escape refusal, temp-directory containment checks and a
bounded retry for the SQLite sidecars Windows holds open. It is unused, and the
ignore rule is why — no committed test could extend a file CI never receives.
… carry

Sixteen shards each carry four service containers, so a run started 64 of them
and one MySQL failed to come up — a failure with nothing to do with the code
under test. Eight shards halve that at a per-shard cost the measurement already
gives: 633.9 sequential minutes is about 80 minutes eight ways. The ceiling is
150, sized from the observed 41-64 minute spread at sixteen rather than from its
average, because the spread is what kills a job.

The --exclude-group filter is gone. The wall-clock tests now declare their own
precondition through RequiresUninstrumentedRuntime, which travels with the
assertion instead of living in this file, so it cannot drift from it and it
covers tests outside the two groups that flag happened to name.

One job remains impossible and this is the measurement that says so: 0.873
s/test over 43576 tests is 633.9 minutes, and GitHub caps a hosted job at 360.
The runner is also about four times slower per test than the workstation this
was compared against — 0.873 against 0.206 — so the gap is hardware, not the
suite. Xdebug is the only PHP driver that emits branch data, and paratest
produces no coverage output at all here across five separate attempts, so
inter-job sharding is the only mechanism left that demonstrably works.
…orization

Both extensions registered their /admin/* routes through the bare router sugar,
which attaches no middleware. Enabling either extension published them to
anonymous callers.

What that exposed, concretely:

  GET  /admin/releases/beta-signups   every beta subscriber's email address
  POST /admin/releases                sets the download URL that the public
                                      /api/v1/version endpoint hands to clients
  POST /admin/feedback/{id}/github-issue
                                      sends the server's stored GitHub token to
                                      the repository named in the request

The last one is not a disclosure, it is a credential being spent by whoever
asks.

This exact regression already has a test in extensions/admin —
AdminRouteSecurityTest, written because "every admin middleware was bound in the
container but never attached to a route, leaving the panel serving
unauthenticated CRUD and schema DDL". It was fixed there and never carried
across to these two.

Each admin route now goes through Route with middleware: ['auth'] and an
explicit permission, following the resource.action convention the seeded roles
already use. The API routes keep the sugar: they are public by design.
AuthorizationMiddleware default-denies a route that declares no permissions, so
a route added here without an attribute fails closed.

Two new test classes assert both halves on every /admin/ route — the middleware
and the permission — because the alias alone would fail closed for everyone,
which is a broken feature rather than a guarded one. Confirmed to fail with the
guard removed from a single route.
…s twice

acceptsTokenExpiringExactlyAtSkewBoundary built a token whose expiry sat exactly
on the clock-skew boundary, then handed it to a verifier that called time()
again. The test does not measure elapsed time — it assumes none elapses. Signing
an RS256 token takes well over a second under code coverage, so the token was
already past the boundary before it was verified, and a coverage shard failed on
correct code.

JwksIdTokenVerifier now takes an optional clock. It is #[Internal], so this is
not an API change, and production passes nothing and gets time() as before. The
test pins the clock, which makes it stronger rather than weaker: it can now
assert the exact boundary, which is what its name always claimed, instead of
asserting a boundary plus however long the runner took.

This is the same shape as the RateLimitedSampler fix — a unit test whose result
depended on wall-clock progress it did not control.
The releases and feedback extension tests exist twice — once under
extensions/<name>/tests/ and once under tests/Unit/Extension/<Name>/ — and the
two trees have diverged: 356 Cms tests against 205, 25 Analytics against 87, 21
OpenTelemetry against 49. Guarding the admin routes updated one copy and left
the other asserting the old shape, which is how a change here silently applies
to half the suite.

Both mirrored tests indexed a per-verb call list on a RouterInterface mock:
$getCalls[2], $postCalls[1], and so on. That ties every assertion to which
registration method a route happens to use, so moving the admin routes onto
Route — the only way to carry the auth middleware and permission they now need —
broke assertions about paths that had not changed at all.

They now boot into a real Router and assert name => path from namedRoutes, plus
the total route count. Same facts, no coupling to the registration style, and
the next security change to these routes will not break them.

ci: back to sixteen coverage shards

Eight was wrong and the run proved it: seven shards hit the 150-minute ceiling
and the one that finished took 134 minutes for 5567 tests. The cost per test is
not constant — coverage data accumulates in the collecting process and every
later test merges into a larger structure:

  16 shards   ~2780 tests   ~50 min    1.08 s/test
   8 shards    5567 tests    130 min   1.41 s/test

Splitting further is superlinearly cheaper, not merely linearly, so sixteen is
the right size and the 64 service containers it costs are the cheaper side of
the trade. The measurement is in the comment so the next person does not repeat
the reduction.
Injecting the clock added thirteen lines of docblock above the JwksFetcher
dependency, moving it from line 43 to 56. The baseline keys on
kind|file|line|target, so the recorded finding stopped matching and reappeared
as new, failing PHP Quality and skipping every job behind it.

Re-keyed by hand. Count unchanged at 1231: one entry moved, none added, none
blessed.

Twice today this has been a process failure rather than a code one — the gate
runs as `composer class-shape` and I pushed having run only cs:fix, phpstan,
psalm and boundary:check. Five of six gates is not the gate.

It is also a weakness in the key itself. A finding that has not moved should not
stop matching because an unrelated line was added above it, and the pressure
that creates is to regenerate the baseline, which is how a ratchet rots.
Dropping the line from the key would collapse 1231 entries into 1087 distinct
ones, with 75 collision groups, all q1 — a file depending on the same target at
two sites. Keying on kind|file|target with a count would survive drift and keep
the ratchet: more than N accepted findings for that pair is new. Not changed
here, because it alters the semantics of a security gate and that decision
deserves its own change rather than being taken while unblocking CI.
@LennyObez
LennyObez force-pushed the chore/quality-and-performance branch 2 times, most recently from eb5f67d to d83d935 Compare August 19, 2026 18:24
The section was declared, listed in SecurityConfig::KNOWN_KEYS so the loader
accepted it, and read by nothing. Its only consumer was CmsSecurityIntegration,
which built `new WafConfig(enabled: true, paranoiaLevel: 1)` in code, so
`waf.enabled = false` did not disable the firewall, `paranoia_level = 4` did not
raise it, and `bypass_ips` was ignored outright. An application not running the
CMS extension had no firewall at all while believing it had configured one.

WafWiring reads the section raw, the arrangement the SecurityConfig docblock
documents for waf, tokenization, key_overrides and threat_detection, builds the
engine with the OWASP core rule set and pipes the middleware. It runs before
ThreatDetectionWiring: a request the firewall refuses should not be scored,
logged and challenged first.

The CMS now prefers the container-bound middleware and keeps its own only as a
fallback for a host that wires no firewall.
Sixteen bundled extensions ship a config/<name>.php, and eleven service
providers read a `config.<name>` container entry to build their config object.
Nothing bound that entry, and nothing could have: the container rejects a
factory returning anything but an object, so an array binding throws on
resolution. All eleven fell back to hard-coded defaults and the files they ship
went unread, while the extension graph compiler hashed those same files for
cache invalidation. The convention was enforced everywhere except at the point
where it mattered.

The tests did not catch it because they mock ContainerInterface, where the
object-only rule does not apply. A production boot would have thrown.

ExtensionConfigRegistry replaces the string key with a typed service, published
between discovery and the register phase so a provider sees it. The host's own
config/<name>.php overrides the extension's rather than merging into it, because
a deep merge leaves the effective value of a key readable from neither file. A
file that does not return an array raises instead of yielding an empty one,
which would recreate the defect this closes.
JobRegistryInterface has carried #[Api(since: '1.0.0')] with no implementation
and no binding. health-status and analytics both guard on
has(JobRegistryInterface::class) before scheduling, so both skipped in silence,
including the visitor-salt purge that is analytics' GDPR retention control.

The two contracts differ on purpose: the interface registers a class name and a
schedule, the concrete JobRegistry stores job instances. ContainerResolvedJob
carries one to the other and defers construction to execute(), because
registration runs on every request while a scheduled job only ever runs from
schedule:run. The schedule given at the call site wins over any the class
declares for itself.
…rifted

Twenty-five entries moved line: Kernel.php by one, CmsSecurityIntegration.php by
eleven. Two are new. ContainerJobRegistry depends on JobRegistry, which is the
class it exists to adapt, and ContainerResolvedJob returns JobResult because
JobInterface::execute() imposes it, a site the baseline already accepts at ten
other implementations. One left: ExtensionManifest now reads as a value object.

The baseline is keyed by line number, so any edit above a recorded site re-arms
the gate for it. Re-keying by hand is what that costs today.
FiberSchedulerTimerTest asserted a wall-clock budget that describes the coverage
driver rather than the scheduler once instrumentation is on. Only that
assertion stands down now; the structural ones keep running, so the code path
stays covered rather than the whole case being skipped.

FanOutCoverageTest set a 1 ms deadline on the assumption that resuming a single
fiber costs less than that. Under coverage it does not, and a healthy fiber was
reported as timed out on correct code. 250 ms reaches the same branch without
betting on the speed of one resume.
Both jobs kept dying and both were read as slow. Neither was. php-code-coverage
appends the id of every test to every line it covers, unconditionally and with
no setting to turn it off, so memory grows with the test count: 1.1 MB per test,
measured from a run killed by a signal at 13,457 of 42,579 tests on a 16 GB
runner. The whole suite needs over 40 GB. Infection reports that kill as exit
code -1 under the words "tests must be in a passing state", which reads as a
failing test and is what sent the first several investigations the wrong way.

Coverage stays one job with no matrix, and recycles its process every ~4,450
tests. partition-tests.php cuts PHPUnit's own listing, so a directory added to
phpunit.xml enters the parts by itself; merge-clover.php merges at line level so
a line covered by two parts counts once, which summing the parts' metrics would
get wrong in the flattering direction. Both carry tests, because a slip in
either moves the enforced figure without moving a line of covered code.

Mutation testing is scoped to src/Auth, src/Security and src/Audit with the
3,625 tests that cover them, through a PHPUnit configuration of its own.
MutationConfigTest compares that copy against the shared one on every setting
they share, so it cannot loosen in silence. Covered MSI is enforced at 90;
plain MSI is not, because it counts mutants no test reaches and the scope is
narrowed on purpose.

Conditions are now reported as not measured rather than as 0%: PCOV has no
notion of branches, and Xdebug needs 10.6 hours here, past GitHub's six-hour
ceiling for a job. docs/testing.md carried a 70% threshold that has been 80 for
a release and a memory_limit of 512M for a run that needs eighty times that.
ADR-0042 records what all of this costs.
QueueWiring builds a Worker with eleven collaborators and binds it. queue:work
could not use that instance, because its options come from command-line flags
while the bound worker carries the ones from config/queue.php. So it built its
own from a driver and a logger and dropped the other eight.

What that costs is not cosmetic. No dead-letter queue, so a job that exhausts
its retries is gone rather than parked. No retry policy, no metrics, no events,
no request context to correlate its logs with. And no execution pipeline, which
is where duplicate suppression, rate limiting, effect classification and payload
decryption live — so a worker started from the command line handed ciphertext to
a handler expecting a job. Every deployment that runs its queue from the CLI,
which is every deployment following docs/deployment.md, ran that worker.

WorkerFactory holds the assembly and builds a worker for whatever options the
caller has. QueueWiring makes its bound Worker through the same factory, so the
two cannot drift. The command takes WorkerFactoryInterface rather than the
concrete class: it keeps the seam a consumer can reach, and it leaves the command
with no driver to build a worker from, which is what makes the old shortcut
impossible rather than merely discouraged.

QueueWorkCommand's constructor changes. It carries no #[Api] attribute and
bin/pulsar is its only caller in the tree.

The seven new class-shape entries are the factory's own. Five repeat dependency
sites the baseline already accepts on Worker — the assembly moved, the coupling
did not — and two are the product type: a factory for a Worker returns a Worker.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Contains breaking API changes ci CI/CD and tooling oversize-pr-acknowledged ADR-0031 para 1: oversize PR acknowledged and approved php Pull requests that update php code release Release milestone PR security Security hardening, crypto, audit testing Test coverage and quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants