- **Classification retention can no longer purge legal-hold content, and the configured role→clearance mapping is now honoured (audit #18/#19, WP13).** Two `field`/classification defects: (1) **`PurgeJob` could delete legal-hold entities.** Its class doc claimed a `hold-*` label is "never a `purge` target by construction" — but nothing enforced it, so a misconfigured purge policy whose `applies_to` glob matched `hold-*` (e.g. `hold-*` or `*`) deleted legal-hold content. A hard guard now skips any `hold-*`-labelled entity regardless of policy match, so the invariant cannot be configured away. (2) **`classification.role_clearance` was silently ignored.** `FieldServiceProvider` bound `new RoleBasedClearanceChecker()` with **no argument**, so the host's role→clearance override never reached the checker and clearance was permanently the stock default. The binding now passes the configured mapping (falling back to `DEFAULT_ROLE_CLEARANCE` when absent). Acceptance: `PurgeJobTest::never_purges_legal_hold_labelled_entities_even_when_a_policy_matches` + `FieldServiceProviderClearanceConfigTest::honours_the_classification_role_clearance_override` (both verified to fail against the pre-fix code).
- **The Layer-1 `user` package no longer reaches up into the Layer-6 `ssr` package — upward layer violation / install-closure (audit #46, WP12b).** `UserServiceProvider` (Layer 1) built `AuthMailer` by statically calling `Waaseyaa\SSR\SsrServiceProvider::getTwigEnvironment()` (Layer 6) — an upward dependency that would fatal on any `user`-without-`ssr` install (and is invisible to the `use`-based layer gate because it was an inline FQN). `AuthMailer`'s `$twig` is now nullable and the provider resolves `Twig\Environment` from the container instead; `SsrServiceProvider` registers `Twig\Environment` as a singleton, and cross-provider resolution (`ProviderRegistryKernelServices`) supplies it when SSR is installed. With no Twig renderer available, `AuthMailer::isConfigured()` returns `false` and the send methods no-op rather than fataling. Acceptance: `AuthMailerTest::is_unconfigured_and_sends_nothing_without_a_twig_renderer` + `UserServiceProviderLayerTest::does_not_reach_into_the_ssr_layer` (both verified to fail against the pre-fix code).
- **Off-monorepo single-package installs no longer hit fatal missing dependencies or a fataling DI binding (install-closure, audit #7/#20/#21/#44, WP12a).** Three mechanical install-closure defects: (1) **Undeclared composer requires** — `foundation`, `field`, `api`, and `structured-import` `use`d sibling `waaseyaa/*` packages they did not declare in `require` (e.g. `foundation`→`cache`/`database-legacy`, `field`→`cache`/`scheduler`, `api`→`audit`/`cache`/`database-legacy`/`field`/`mail`/`media`/`oidc`/`relationship`, `structured-import`→`entity`), so a consumer installing one package off the monorepo got a fatal missing class. All same-or-lower-layer imports are now declared, enforced going forward by a new `RequireParityTest` (the layer gate only caught *upward* edges, never undeclared declarable ones). (2) **`structured-import` DI fatal** — `StructuredImporterInterface` was bound to the class *name* (a string), which the resolver instantiates with `new $concrete()` (zero args), raising an `ArgumentCountError` on resolution because `GfmTableImporter` needs three constructor dependencies; it is now a closure binding that supplies them. (3) **`attachment` declared zero `#[Field]`s** — so the framework's schema-sync built a table without `parent_entity_type`/`parent_entity_id`/`is_active`, the exact columns `AttachmentRepository::setActive()` reads and updates; those three are now declared as `#[Field]`s so schema-sync materializes them. Acceptance: `RequireParityTest`, `StructuredImportServiceProviderTest::resolves_the_structured_importer_without_a_fatal`, `AttachmentFieldsTest::declares_the_parent_linkage_and_active_columns_for_schema_sync` (all verified to fail against pre-fix code). (The `user`→`ssr` upward layer violation and the `attachment` hand-schema/`#[Field]` single-authority consolidation are tracked as separate follow-ups.)
- **GitHub OAuth login fails loudly on an upstream error instead of minting a degenerate identity (audit, WP11).** `GitHubOAuthProvider::getUserProfile()` called `GET /user` + `GET /user/emails` and read `->json()` **without checking `isSuccess()`** (unlike `exchangeCode()`). On a `401/403/429/5xx`, the error body has no `id`/`login`, so the method coerced it into an `OAuthUserProfile` with an **empty `providerId`/`name`** — a wrong/empty identity a consumer could map to the wrong account. It now throws a `RuntimeException` (surfacing GitHub's `message`) when the user request is not successful, and rejects a success body missing an `id`. The `/user/emails` call stays **best-effort** — a token without the `user:email` scope (or a transient error there) now yields no verified email rather than a failed login, and an error body is no longer read as email data. Acceptance: `GitHubOAuthProviderTest::{testGetUserProfileThrowsOnErrorResponseInsteadOfDegenerateIdentity, testGetUserProfileToleratesFailedEmailsLookup}` (the error-path verified to produce a degenerate profile against the pre-fix code).
- **Scheduler now works on MySQL/Postgres and its overlap lock is durable cross-host regardless of the queue driver (audit, WP10).** Two defects: (a) `ScheduleStateRepository::recordRun()` used SQLite-only `INSERT OR REPLACE`, which is a **syntax error on MySQL/Postgres** — so every scheduler tick threw on those engines. It now performs a portable upsert through the platform-agnostic query builders (transaction-wrapped `UPDATE`, then `INSERT` only when no row was affected; the per-task overlap lock guarantees a single writer). (b) `SchedulerServiceProvider` selected the overlap `LockInterface` from `config.queue.driver`, falling back to the **per-process `InMemoryLock`** whenever the queue driver was not `database` (the default is `sync`) — so the README's promised cross-host mutual exclusion was false in the default config. The lock is now chosen by **database availability**, not the unrelated queue driver: a durable `DatabaseLock` whenever a `DatabaseInterface` is bound, `InMemoryLock` only on a database-less install. `ScheduleStateRepository` is likewise bound on database availability (resolving to the admin dashboard's existing `resolveOptional()` null-degrade when absent). Acceptance: `ScheduleStateRepositoryTest::recordRun_does_not_emit_sqlite_only_insert_or_replace` + `SchedulerServiceProviderTest::{uses_durable_database_lock_when_a_database_is_available_even_with_non_database_queue, falls_back_to_in_memory_lock_only_without_a_database}` (the upsert and lock cases both verified to fail against the pre-fix code). Portable-upsert semantics verified end-to-end on SQLite (insert + update + no-duplicate); the fix removes all engine-specific SQL so it is portable by construction — a live Postgres run was not available in this environment.
- **Async/queued notifications now render every channel instead of silently dropping mail (and degrading database) — async delivery defect (audit, WP09).** `SendNotificationHandler` rebuilt the queued notification as an anonymous class implementing only `via()` + `toArray()`, so `MailChannel` (which early-returns when `!method_exists($notification, 'toMail')`) sent **nothing**, and `DatabaseChannel` silently fell back from `toDatabase()` to the generic `toArray()`. Queued notifications therefore rendered differently from synchronous ones. `SendNotificationJob` now carries the **real `NotificationInterface` instance** (it is `serialize()`d onto the queue like any payload) and the handler delivers it directly, so `toMail()`/`toDatabase()` and any other channel renderer run exactly as on the synchronous path. **BC:** `SendNotificationJob`'s constructor drops `notificationClass`/`notificationData` in favour of a single `NotificationInterface $notification` (the only producer is `NotificationDispatcher::sendAsync()`; no consumers read the old fields). Acceptance: `SendNotificationHandlerTest::{async_delivery_renders_channel_specific_methods_not_just_to_array, survives_a_queue_serialize_round_trip}` (both verified to drop `toMail()` against the pre-fix anonymous-class reconstruction).
- **Engagement `view` access now cascades from the parent content and honours comment moderation — fail-open content exposure (audit #14/#15, WP08).** `EngagementAccessPolicy::access()` returned `allowed` for `view` **unconditionally**, so reactions/comments/follows on a **draft/unpublished** parent were publicly viewable, and an **unmoderated comment** (`status = false`) was publicly viewable — directly contradicting the package README's promised "parent-cascade visibility". `view` now (a) **cascades** from the parent: it loads `target_type`/`target_id` and grants only when the caller may `view` the parent (failing **closed** when the parent is missing, unresolvable, denied, or the policy's `EntityTypeManagerInterface`/`EntityAccessHandler` were not wired — the kernel's two-phase policy discovery injects both), and (b) hides an **unpublished comment** from everyone but its owner (admins with `administer content` still bypass). README corrected to document the real access model. Acceptance: `EngagementAccessPolicyTest::{view_is_denied_when_parent_is_not_viewable, view_of_unpublished_comment_is_denied_to_non_owner, view_fails_closed_without_parent_resolution_dependencies}` (all verified to leak against the pre-fix unconditional-allow code) + the allowed-path cases.
- **Listings no longer cache one user's access-filtered rows under a key shared by everyone — cross-user data leak (audit #28, WP07).** `ListingResolver` folded `user.roles` into the cache contexts only when `$accessOps` was non-default, but the **default `view` path also runs the per-row access gate** (unless a policy opts into the FR-032 `SUPPORTS_LISTING_FAST_PATH`), producing an account-dependent result that was then stored under a cache key with **no user context**. A second user resolving the same listing got a cache hit on the first user's filtered rows — exposing role- or owner-restricted entities. `ListingResolver::computeCacheContexts()` now binds **both `user.id` and `user.roles`** into the cache key whenever the per-row gate runs (`!canUseAccessFastPath()`), so the cache is keyed per acting account; fast-path (genuinely user-independent) listings stay shareable. `ListingDefinition::effectiveContexts()` is unchanged — it remains a pure function of the definition, and the gate-aware binding lives in the resolver where the fast-path decision is known. Acceptance: `CacheIntegrationTest::defaultViewCacheKeyIsPerUserWhenAccessGateRuns` (verified to serve account 1's cached rows to account 2 against the pre-fix code).
- **Relationship browsing no longer leaks related labels/paths for unpublished entities — fail-open content exposure (audit #36, WP06).** `RelationshipTraversalService::isEntityPublic()` defaulted to `true` when no `VisibilityFilterInterface` was wired, and **both** live consumers — the discovery API (`DiscoveryApiHandler::createDiscoveryService()`) and SSR node pages (`SsrPageHandler::buildRelationshipRenderContext()`) — built the service with **no filter**. So `browse()` in `published` mode emitted the `related_entity_label`/`related_entity_path` of related entities that are themselves unpublished (e.g. a draft node) to anonymous callers. The null default is now **fail-closed** (`?? false`): an unwired filter withholds every related label/path rather than assuming public. Both live consumers now pass `WorkflowVisibilityFilter`, so authorized published-content navigation is unchanged while drafts stay hidden; `ssr` now declares its (previously transitive) `waaseyaa/workflows` dependency. Acceptance: `RelationshipTraversalServiceTest::testBrowsePublishedFailsClosedWithoutVisibilityFilter` (verified to leak the draft node against the pre-fix code). Residual: the ai-agent `RelationshipTraverseTool` returns raw edge values with no per-edge view check (separate audit item, tracked separately).
- **Full-text search no longer returns documents the caller may not view — fail-open content exposure (audit #40, WP05).** `Fts5SearchProvider::search()` returned every row matching the FTS5 query with **zero access enforcement**. Because the index is populated automatically on entity save (`SearchIndexSubscriber` on `POST_SAVE`, regardless of policy), an unpublished/forbidden entity (e.g. a draft node) was searchable by any caller — reachable anonymously via the Twig `search()` function (`SearchTwigExtension`) and any API wiring. The provider now filters every candidate hit through a `SearchAccessChecker`; the default `EntitySearchAccessChecker` loads each **entity-backed** hit (`entity_type` names a registered entity type) and enforces `view` for the acting account (`AccountContextInterface` → `EntityAccessHandler`), failing **closed** (no acting context, unparseable id, unloadable entity, or non-allowed policy result all deny). **Non-entity** documents (crawled markdown / specs indexed as `document`) carry no entity policy and pass through, so public document search is unaffected. The checker is wired by `SearchServiceProvider`, so the live read path enforces access with no template/controller change. Known limitation (noted in code): `totalHits`/`totalPages` are counted in SQL before the PHP access filter, so they are an upper bound — forbidden documents never appear in `hits`, but a page may be short and the count is not a security boundary. Acceptance: `SearchAccessTest::forbidden_entity_documents_are_excluded_from_results` (verified to leak the forbidden node against the pre-fix code) + `::without_a_checker_the_index_leaks_every_match`.
- **JSON-LD `<script>` blocks can no longer be broken out of by hostile entity data — stored XSS (audit WP04).** `EntitySchemaOrgMapper::toScriptTag()` and `SeoTwigExtension::jsonLdScript()` encoded JSON-LD with `JSON_UNESCAPED_SLASHES` but **without** `JSON_HEX_TAG`, then wrapped it in `<script type="application/ld+json">…</script>`. Untrusted entity data reaches `toScriptTag()` on the live SSR path (`SsrPageHandler` renders `$entity->label()` into the JSON-LD `name`), so a label/description containing `</script><img src=x onerror=…>` closed the element and injected markup that executes in the victim's browser. Both encoders now add `JSON_HEX_TAG|JSON_HEX_AMP`, hex-escaping `<`/`>`/`&` so the payload stays inside the JSON string (and round-trips back to the original text once parsed). Public contracts unchanged. Acceptance: `EntitySchemaOrgMapperTest::script_tag_does_not_allow_closing_tag_breakout` + `SeoTwigExtensionTest::seo_json_ld_script_escapes_closing_tag_breakout` (both verified to fail against the pre-fix code).
- **GraphQL mutations can no longer run over HTTP GET — CSRF (audit #23, WP03).** `/graphql` was registered `GET,POST` + `allowAll()` + `csrfExempt()`, and `GraphQlEndpoint` executed the GET `query` param including mutations, so `GET /graphql?query=mutation{...}` ran state-changing operations carrying the victim's session cookie (GET is a simple cross-site request, no preflight). Per the GraphQL-over-HTTP spec, GET is now query-only: `GraphQlEndpoint::handle()` returns `405` for a mutation selected over GET before it executes (`selectsMutation()` honours `operationName`). POST mutations and GET queries are unchanged. Acceptance: `GraphQlEndpointTest::{getMutationIsRejectedAndNotExecuted, postMutationIsStillAllowed, getQueryIsStillAllowed}`.