diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f15c1..78993da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,24 +6,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] -### Added - -- `ForbidRawExceptionMessageInResponseRule` — new rule (war-room enforcement queue #140) that flags a raw `Throwable::getMessage()` — or a `Throwable` expression itself — flowing into a **client-facing response sink**. A raw exception message is internal detail (stack-trace fragments, SQL, file paths, driver errors); when it reaches an API response it is an information-disclosure leak (ISO 27001 A.5.33 / general defence-in-depth for the ISO 27001 / AVG / NEN 7510 consumer territories). The remediation is always the same: **log** the raw message server-side (`Log::`, `report()`) and hand the client a stable, app-authored message. The rule is the durable Level-2 backstop for the raw-exception-message leak family (the confirmed point-fix sites — ublgenie's 8 MCP tools + codebook `DeleteChapterTool`, each returning `Response::error('...' . $e->getMessage())` — and each consumer's release-pin adoption are separate, separately-tracked steps). **Sink model:** a sink is a `FQCN::method` signature, matched in BOTH call forms — a `StaticCall` whose resolved class equals the FQCN (`Response::error(...)`) and a `MethodCall` whose receiver type is a subtype of the FQCN (an injected persist-sink service). The built-in default sink is `Laravel\Mcp\Response::error` (the confirmed dominant shape, always armed); a consumer adds its own PERSIST sinks (an invoice-log setter, a `MarkInvoiceFailed` Action) via the new optional `rawExceptionMessageSinks` PHPStan parameter (`listOf(string())`, default `[]` — so the rule is safe to adopt with only the MCP shape armed). **Argument detection:** a matched sink call is flagged when any argument is, directly OR via string concatenation (`'context: ' . $e->getMessage()`), a `->getMessage()` call on an expression whose type is a subtype of `\Throwable`, or a `\Throwable` expression passed directly. **Type-aware discrimination is load-bearing:** `$validator->getMessage()` on a non-`Throwable` receiver does NOT fire — only a message pulled off an actual exception is a leak. **Mandatory false-positive exclusions (the remediation pattern, never the violation):** `Log::` / `logger()->` / PSR `LoggerInterface` log-level calls (`info` / `warning` / `error` / `critical` / `debug` / `log` / `notice` / `alert` / `emergency`) and `report()` — server-side logging of the raw message is exactly where it is *supposed* to go. Because a sink is keyed on `FQCN::method` a logger is never a sink under the default config; the exclusion additionally short-circuits BEFORE sink matching, so a consumer that adds a broad sink can never turn a logger into a false positive (pinned by tests that configure a logger method AS a sink and assert it still stays silent). **Exemptions (narrowest first):** the `safeMessageExceptionClasses` parameter (`listOf(string())`, default `[]`) lists exception FQCNs whose message discipline is proven app-authored — arch-test-pinned in the consuming territory, the codebook `DependentModelRelationException` shape — so a prove-safe class costs ONE config line, not a per-call-site annotation (type-aware, subtypes inherit; covers the **message only** — the Throwable itself still fires, `__toString` carries class/file/trace regardless of message discipline); a `// @leak-safe: ` comment on the sink call line (or in the contiguous comment block directly above it) suppresses a proven-safe call site the class list cannot express (the codebook `SendCodyReportAction` shape); the standard PHPStan inline-ignore mechanism on the identifier is the alternative. `$e?->getMessage()` (a `NullsafeMethodCall` — a distinct AST node) is matched like its unconditional sibling. Identifier: `forbidRawExceptionMessageInResponse.rawMessageInResponse`. **Deliberate misses (v1 scope):** `getTraceAsString()` / `__toString()` and other Throwable accessors (a future minor can widen the accessor set), a Throwable laundered through a helper/formatter call whose return type is no longer `Throwable`, and plain local-variable extraction (`$msg = $e->getMessage(); Response::error($msg);` — the type at the sink is `string`, provenance gone; closing it needs data-flow tracking) (ADR-0021 posture — false negatives acceptable, false positives are not). Doctrine: war-room §Architectural Principles — Explicit over implicit (#1); information-disclosure hardening. **Versioning: candidate MAJOR** (surfaces new errors in already-clean consumer code wherever a raw exception message reaches a response sink — the confirmed ublgenie/codebook MCP-tool sites). Per the pre-1.0 caret convention `^0.7` excludes the next minor, so tagging auto-adopts nobody — each consumer remediates and goes green on its own bump PR (a suppress-only / baseline-absorb posture). **NOT tagged** (release is ally-gated). Seed: war-room enforcement queue #140. +## [0.8.0] — 2026-08-11 -### Changed +**Release-as-a-whole: candidate MAJOR** — `EnforceActionResultDtoRule` (war-room enforcement queue #136), `ForbidInlineArrayJsonResponseInControllersRule` (queue #137) and `ForbidRawExceptionMessageInResponseRule` (queue #140) all surface new errors in already-clean consumer code, as does the `ForbidEloquentMutationInControllersRule` receiver-scope fix (see their bullets). Per the pre-1.0 caret convention `^0.7` excludes this minor, so tagging auto-adopts nobody — each consumer adopts on its own pin-bump PR. Seeds: kendo PR #1653 (queue #136 + #137), war-room queue #140 (ublgenie/codebook MCP-tool leak sites), tc-api PR #133 (baseline-unmatched finding). _(Section originally dated 2026-07-13 covering only the queue #136/#137 pair; the tag was cut 2026-08-11 at `30c5145` and this section was folded to match the shipped payload.)_ -- `ForbidEloquentMutationInControllersRule` — registration moved off `Class_` onto `CallLike` (per-node), closing a receiver-type blind spot. The rule previously registered on `Class_` and manually walked every method body, resolving each receiver via `$scope->getType($node->var)` against the **class-entry** scope — a scope carrying no flow-derived knowledge of method-local variables, so any receiver born inside the method body (`$m = new Model; $m->save();`; `$m = Model::query()->…->firstOrFail(); $m->delete();`; a `Builder` held in a local var) resolved to `mixed` and **silently never fired** — only receivers typed from a method signature (typed parameters) matched. Empirically proven on tc-api PR #133 (PHPStan-baseline entries for exactly these shapes came back "unmatched" — the rule never emitted them). Now registers on `CallLike` and branches `MethodCall` / `StaticCall` (mirrors `EnforceCurrentUserAttributeRule` / `LogRule`), so PHPStan supplies a method-level flow scope and local-variable receivers resolve to their real Model / Builder types. The namespace gate (`controllerNamespacePrefixes`, unchanged), the `checkInstanceCall` / `checkStaticCall` type-matching, the blocklist, the error identifier `forbidEloquentMutationInControllers.eloquentMutationInController`, and the message format are all preserved byte-for-byte; the containing-controller FQCN for the message now comes from `$scope->getClassReflection()?->getName()` at the call site (the manual `Class_`-scope `resolveClassFqcn` + `walkNodes` helpers are deleted). The static-call path (`Model::destroy($id)`) is unaffected — it never depended on flow scope. Every pre-existing fixture still fires at the same line; new positive fixtures cover the three local-receiver shapes and a new compliant fixture pins that a local var of a NON-Model class stays clean (the type gate still discriminates under flow scope). Nullsafe `$m?->delete()` calls are covered without a dedicated branch: PHPStan's `NodeScopeResolver` emits a synthetic non-null-narrowed `MethodCall` node (attribute `virtualNullsafeMethodCall`) for every `?->` call, so the plain `MethodCall` branch already fires once — an explicit `NullsafeMethodCall` branch double-reports (the real node plus its synthetic twin). `TypeCombinator::removeNull()` is applied to the receiver before the type gate for the DISTINCT plain-call-on-nullable shape (a plain `->delete()` on a `?Post` from `->first()`: `Post|null` is only a `maybe()` Model supertype, never `yes()`, so without the strip it never fires; a nullable Model receiver carries the same audit-bypass risk). Per-node registration additionally reaches trait bodies analysed through a using class: a mutation in a trait declared under a controllers namespace (e.g. `App\Http\Controllers\Concerns\*`) now fires, message naming the using class — intended coverage the old `Class_` walk structurally never had (a trait file has no `Class_` node). These are pinned by new fixtures (`ViolationNullsafeDelete` — single-fire, no double-report; `ViolationPlainNullableDelete` — the `removeNull` path; `ViolationInTraitFile`). **Versioning: candidate MAJOR per ADR-0021 §Versioning** — surfaces new errors in previously-clean consumer code (known: tc-api `EducationController::store` / `destroy`; the nullsafe + trait paths are additional new-error surface under the same candidacy); per the pre-1.0 caret convention `^0.7` excludes the next minor, so tagging auto-adopts nobody — each consumer runs a pre-cascade audit and remediates on its own pin-bump PR. Seed: tc-api PR #133 baseline-unmatched finding. -- `EnforceResourceDataValidatorOptInRule` + `EnforceFormRequestToDtoRule` + `EnforceAuditModelProtectionsRule` — migrated the internal inheritance gate off the deprecated `ClassReflection::isSubclassOf(string)` API onto `isSubclassOfClass(ClassReflection)` (war-room enforcement queue #112). `isSubclassOf(string)` is `@deprecated Use isSubclassOfClass instead.` in PHPStan 2.2+ and **removed in PHPStan 3.x** — a latent break for every consumer's static analysis the day this package (the fleet's canonical static-analysis backbone) targets PHPStan 3. Each of the three rules now injects `PHPStan\Reflection\ReflectionProvider` (constructor DI, autowired by the PHPStan/Nette container — no `extension.neon` argument wiring needed, proven by each rule's container-resolution test) and resolves its configured/known base FQCN via `reflectionProvider->hasClass()/getClass()` before calling `isSubclassOfClass()`. **Behaviour is byte-for-byte preserved:** the migration inlines the deprecated method's own body (`if (!hasClass($fqcn)) return false; return isSubclassOfClass(getClass($fqcn));`), so the load-bearing unknown-base-class no-op — a tree lacking the configured base class (or `Illuminate\Database\Eloquent\Model` for the audit rule) silently does not fire, the "consumers analysing non-Laravel trees are unaffected" guarantee — is reproduced exactly. Pinned by a new base-class-absent no-op test per rule (a configured base FQCN absent from the analysed tree ⇒ zero errors); all existing positive tests stay green. **Versioning: PATCH per ADR-0021 §Versioning** (internal API migration / future-proofing — adds no errors, removes none, no consumer-visible behaviour change). Seed: war-room enforcement queue #112. +### Added -## [0.8.0] — 2026-07-13 +- `ForbidInlineArrayJsonResponseInControllersRule` — new rule (war-room enforcement queue #137) that flags constructing the base `Illuminate\Http\JsonResponse` — or its `response()->json(...)` factory twin — with an **array** payload inside a class whose namespace `str_starts_with` any configured `controllerNamespacePrefixes` prefix (default `['App\Http\Controllers']`, shared with the other three controller-scoped rules). A response shape assembled from an inline array (or an array-typed variable) has no type-level contract: the frontend and every future reader re-derive the shape by reading the controller body. Response shapes belong to a Resource / ResourceData or a dedicated `JsonResponse` subclass. **Type-aware** (a blanket ban on `new JsonResponse(...)` would criminalize the compliant DTO / Resource / message-object payloads): fires only when the first argument's resolved type `isArray()->yes()`, catching both inline literals (`new JsonResponse(['enabled' => …])`) and array-typed variables (`new JsonResponse($result)` where `$result` is array-typed — the same violation laundered through a variable). Two AST shapes inspected, mirroring the sibling: (1) `New_` of EXACTLY `Illuminate\Http\JsonResponse` (`$scope->resolveName()` FQCN equality, **NOT** `isSuperTypeOf` — subclasses like `NoContentResponse` are the compliant fix and matching by supertype would criminalize it); (2) `MethodCall` named `json` whose receiver is the `response()` helper `FuncCall` (AST-shape match — the helper's `ResponseFactory` return type is unloaded in stub-only environments; the `json` factory always builds a base `JsonResponse`, so it is in scope for the same reason). Passes on: no arguments; a `null` first argument (`new JsonResponse(null, 204)` — not an array type; steering `null/204` toward `NoContentResponse` is a different rule's job); Resource / DTO / `JsonSerializable` / `Arrayable` / mixed / unknown payloads (uncertain types stay silent — false negatives are acceptable, false positives are not, ADR-0021 posture); and any JsonResponse **subclass** with any payload. Identifier: `forbidInlineArrayJsonResponseInControllers.arrayPayload`. **Sibling / inverse of `ForbidResourceWrappedInJsonResponseRule`** — both police the same `JsonResponse` × payload boundary from opposite directions and share the two-AST-shape match + the `controllerNamespacePrefixes` gate: the sibling fires when the payload IS a `JsonResource` (a resource is already a `Responsable`, do not double-wrap); this rule fires when the payload is a bare array (a struct that should be a Resource / DTO / dedicated response). The two are disjoint by construction — a `JsonResource`-typed payload is not an array type and vice versa — so a given call site fires at most one of them. **Deliberate miss:** `JsonResponse::fromJsonString(...)` (a static factory over a raw JSON string, not an array payload) is left uncovered. Doctrine: ADR-0009 (Unified ResourceData Pattern). Seed: kendo PR #1653 (KD-0220 central-user 2FA) — `TwoFactorController::status()` returned `new JsonResponse(['enabled' => …, 'has_recovery_codes' => …])` ("Voor consistentie opzich mooier om in deze controller ook Resources terug te sturen"), and `enable()` laundered the same violation through a variable. **Known-consumer impact:** kendo grep sizing 2026-07-06: 33 inline `new JsonResponse([` across 18 controllers, plus 1 `response()->json([...])` (`ProjectIssueController:246`) and an unbounded number of array-typed-variable sites the type-aware check additionally catches (33 is not the ceiling). **Rollout position:** the rule fires by payload SHAPE and does NOT distinguish a domain-resource shape (`{secret, qr_code}`) from a single-key status/ack payload (`['message' => 'Webhook received']`) — kendo alone has 12+ of the latter (`AnthropicWebhookController` / `GithubWebhookController` / `Central/CliController` / `OAuthController` / `FeedbackController`). Both fire; the noise is accepted by disposition (discovery-by-shape, consistent with `EnforceAuditModelProtectionsRule`'s denylist inversion) rather than adding a key-count threshold (would false-negative a genuine single-field resource) or an ack-shape allowlist (territory-specific config the package forbids hardcoding). Both classes remediate the same way — a dedicated fleet-wide `MessageResponse` / `ErrorResponse` subclass, or baseline-suppress the throwaway acks. **Consumers sizing adoption should separate the ack-noise class from the resource-shape class — the 33/18 figure conflates the two and overstates the genuine ADR-0009 remediation surface.** Documented in the rule docblock (§Rollout position). **Versioning: candidate MAJOR** (surfaces new errors in already-clean code wherever a controller builds a JsonResponse from an array). Per the pre-1.0 caret convention `^0.7` excludes the next minor — tagging auto-adopts nobody; each consumer remediates on its own bump PR. -**Release-as-a-whole: candidate MAJOR** — two entries. Both `EnforceActionResultDtoRule` (war-room enforcement queue #136) and `ForbidInlineArrayJsonResponseInControllersRule` (queue #137) surface new errors in already-clean consumer code (an `array`-returning `execute()`; an inline-array `JsonResponse` in a controller — see their bullets), so the release as a whole classifies as candidate MAJOR. Per the pre-1.0 caret convention `^0.7` excludes the next minor, so tagging auto-adopts nobody — each consumer adopts on its own pin-bump PR. Seed: kendo PR #1653 (KD-0220 central-user 2FA — queue #136 + #137). +- `EnforceActionResultDtoRule` — new rule (war-room enforcement queue #136) enforcing that an `App\Actions\*` class's `execute()` method does not declare an `array` native return type. A compound result — more than one value handed to the caller — is a struct that should be a class (a Result DTO, ADR-0020), and `: array` is the detectable proxy for a struct that escaped typing: it names no fields, guarantees no keys, and forces every caller to re-derive the shape by reading the Action body. The rule inspects only the DECLARED native return type (`ClassMethod->returnType`) of the method literally named `execute` (Actions have exactly one public method by doctrine) inside a class whose namespace `str_starts_with` `App\Actions` (the hardcoded convention shared with `EnforceActionTransactionsRule` / `ForbidDatabaseManagerInActionsRule`, ADR-0021 §Action namespace assumption — no parameter today). It fires on bare `array`, `?array` (`NullableType` wrapping `array`), a union/intersection member that is `array` (`array|SomeResultDto` — a union member is still an escape hatch), and `iterable` in any of those forms (it admits arrays, the same hole in an adjacent spelling). It passes on Result-DTO classes, `void`, models, `Collection`, scalars, `bool`, and an absent declared type. Identifier: `enforceActionResultDto.arrayReturnFromExecute`. **Deliberately signature-only:** a phpdoc-only `@return array{...}` on an otherwise-untyped `execute()` is NOT chased — every consumer territory enforces native return types via its own tooling, so an untyped `execute()` already violates a different contract, and phpdoc-shape resolution buys parser complexity without closing a real evasion path (pinned by a `PhpdocOnlyArrayReturn` fixture). **No `list` carve-out** (Commander disposition 2026-07-06): a `list` return (e.g. recovery codes) is spelled `array` natively and converts to a Result DTO all the same — the moment `list` is exempt, someone returns `list` through the gap. Only the `execute()` method is inspected; a private array-returning helper inside an Action is a legitimate internal shuttle and stays silent. Doctrine: ADR-0020 (Input/Result DTO Split by Usage Direction) + ADR-0011 (Action Class Architecture). Seed: kendo PR #1653 (KD-0220 central-user 2FA) — `EnableCentralTwoFactorAction::execute()` returned `array{secret, qr_code}` ("Mooier om hier een Result DTO terug te sturen"). **Known-consumer impact:** kendo grep sizing 2026-07-06 shows ~23–39 of 256 Actions declaring `: array` on `execute()` — consumers adopt via baseline-absorb (0.3→0.6 playbook); this rule requires no consumer code change at adoption time. **Versioning: candidate MAJOR** (surfaces new errors in already-clean code wherever an Action's `execute()` declares an `array` return). Per the pre-1.0 caret convention `^0.7` excludes the next minor — tagging auto-adopts nobody; each consumer remediates on its own bump PR. -### Added +- `ForbidRawExceptionMessageInResponseRule` — new rule (war-room enforcement queue #140) that flags a raw `Throwable::getMessage()` — or a `Throwable` expression itself — flowing into a **client-facing response sink**. A raw exception message is internal detail (stack-trace fragments, SQL, file paths, driver errors); when it reaches an API response it is an information-disclosure leak (ISO 27001 A.5.33 / general defence-in-depth for the ISO 27001 / AVG / NEN 7510 consumer territories). The remediation is always the same: **log** the raw message server-side (`Log::`, `report()`) and hand the client a stable, app-authored message. The rule is the durable Level-2 backstop for the raw-exception-message leak family (the confirmed point-fix sites — ublgenie's 8 MCP tools + codebook `DeleteChapterTool`, each returning `Response::error('...' . $e->getMessage())` — and each consumer's release-pin adoption are separate, separately-tracked steps). **Sink model:** a sink is a `FQCN::method` signature, matched in BOTH call forms — a `StaticCall` whose resolved class equals the FQCN (`Response::error(...)`) and a `MethodCall` whose receiver type is a subtype of the FQCN (an injected persist-sink service). The built-in default sink is `Laravel\Mcp\Response::error` (the confirmed dominant shape, always armed); a consumer adds its own PERSIST sinks (an invoice-log setter, a `MarkInvoiceFailed` Action) via the new optional `rawExceptionMessageSinks` PHPStan parameter (`listOf(string())`, default `[]` — so the rule is safe to adopt with only the MCP shape armed). **Argument detection:** a matched sink call is flagged when any argument is, directly OR via string concatenation (`'context: ' . $e->getMessage()`), a `->getMessage()` call on an expression whose type is a subtype of `\Throwable`, or a `\Throwable` expression passed directly. **Type-aware discrimination is load-bearing:** `$validator->getMessage()` on a non-`Throwable` receiver does NOT fire — only a message pulled off an actual exception is a leak. **Mandatory false-positive exclusions (the remediation pattern, never the violation):** `Log::` / `logger()->` / PSR `LoggerInterface` log-level calls (`info` / `warning` / `error` / `critical` / `debug` / `log` / `notice` / `alert` / `emergency`) and `report()` — server-side logging of the raw message is exactly where it is *supposed* to go. Because a sink is keyed on `FQCN::method` a logger is never a sink under the default config; the exclusion additionally short-circuits BEFORE sink matching, so a consumer that adds a broad sink can never turn a logger into a false positive (pinned by tests that configure a logger method AS a sink and assert it still stays silent). **Exemptions (narrowest first):** the `safeMessageExceptionClasses` parameter (`listOf(string())`, default `[]`) lists exception FQCNs whose message discipline is proven app-authored — arch-test-pinned in the consuming territory, the codebook `DependentModelRelationException` shape — so a prove-safe class costs ONE config line, not a per-call-site annotation (type-aware, subtypes inherit; covers the **message only** — the Throwable itself still fires, `__toString` carries class/file/trace regardless of message discipline); a `// @leak-safe: ` comment on the sink call line (or in the contiguous comment block directly above it) suppresses a proven-safe call site the class list cannot express (the codebook `SendCodyReportAction` shape); the standard PHPStan inline-ignore mechanism on the identifier is the alternative. `$e?->getMessage()` (a `NullsafeMethodCall` — a distinct AST node) is matched like its unconditional sibling. Identifier: `forbidRawExceptionMessageInResponse.rawMessageInResponse`. **Deliberate misses (v1 scope):** `getTraceAsString()` / `__toString()` and other Throwable accessors (a future minor can widen the accessor set), a Throwable laundered through a helper/formatter call whose return type is no longer `Throwable`, and plain local-variable extraction (`$msg = $e->getMessage(); Response::error($msg);` — the type at the sink is `string`, provenance gone; closing it needs data-flow tracking) (ADR-0021 posture — false negatives acceptable, false positives are not). Doctrine: war-room §Architectural Principles — Explicit over implicit (#1); information-disclosure hardening. **Versioning: candidate MAJOR** (surfaces new errors in already-clean consumer code wherever a raw exception message reaches a response sink — the confirmed ublgenie/codebook MCP-tool sites). Per the pre-1.0 caret convention `^0.7` excludes the next minor, so tagging auto-adopts nobody — each consumer remediates and goes green on its own bump PR (a suppress-only / baseline-absorb posture). Seed: war-room enforcement queue #140. -- `ForbidInlineArrayJsonResponseInControllersRule` — new rule (war-room enforcement queue #137) that flags constructing the base `Illuminate\Http\JsonResponse` — or its `response()->json(...)` factory twin — with an **array** payload inside a class whose namespace `str_starts_with` any configured `controllerNamespacePrefixes` prefix (default `['App\Http\Controllers']`, shared with the other three controller-scoped rules). A response shape assembled from an inline array (or an array-typed variable) has no type-level contract: the frontend and every future reader re-derive the shape by reading the controller body. Response shapes belong to a Resource / ResourceData or a dedicated `JsonResponse` subclass. **Type-aware** (a blanket ban on `new JsonResponse(...)` would criminalize the compliant DTO / Resource / message-object payloads): fires only when the first argument's resolved type `isArray()->yes()`, catching both inline literals (`new JsonResponse(['enabled' => …])`) and array-typed variables (`new JsonResponse($result)` where `$result` is array-typed — the same violation laundered through a variable). Two AST shapes inspected, mirroring the sibling: (1) `New_` of EXACTLY `Illuminate\Http\JsonResponse` (`$scope->resolveName()` FQCN equality, **NOT** `isSuperTypeOf` — subclasses like `NoContentResponse` are the compliant fix and matching by supertype would criminalize it); (2) `MethodCall` named `json` whose receiver is the `response()` helper `FuncCall` (AST-shape match — the helper's `ResponseFactory` return type is unloaded in stub-only environments; the `json` factory always builds a base `JsonResponse`, so it is in scope for the same reason). Passes on: no arguments; a `null` first argument (`new JsonResponse(null, 204)` — not an array type; steering `null/204` toward `NoContentResponse` is a different rule's job); Resource / DTO / `JsonSerializable` / `Arrayable` / mixed / unknown payloads (uncertain types stay silent — false negatives are acceptable, false positives are not, ADR-0021 posture); and any JsonResponse **subclass** with any payload. Identifier: `forbidInlineArrayJsonResponseInControllers.arrayPayload`. **Sibling / inverse of `ForbidResourceWrappedInJsonResponseRule`** — both police the same `JsonResponse` × payload boundary from opposite directions and share the two-AST-shape match + the `controllerNamespacePrefixes` gate: the sibling fires when the payload IS a `JsonResource` (a resource is already a `Responsable`, do not double-wrap); this rule fires when the payload is a bare array (a struct that should be a Resource / DTO / dedicated response). The two are disjoint by construction — a `JsonResource`-typed payload is not an array type and vice versa — so a given call site fires at most one of them. **Deliberate miss:** `JsonResponse::fromJsonString(...)` (a static factory over a raw JSON string, not an array payload) is left uncovered. Doctrine: ADR-0009 (Unified ResourceData Pattern). Seed: kendo PR #1653 (KD-0220 central-user 2FA) — `TwoFactorController::status()` returned `new JsonResponse(['enabled' => …, 'has_recovery_codes' => …])` ("Voor consistentie opzich mooier om in deze controller ook Resources terug te sturen"), and `enable()` laundered the same violation through a variable. **Known-consumer impact:** kendo grep sizing 2026-07-06: 33 inline `new JsonResponse([` across 18 controllers, plus 1 `response()->json([...])` (`ProjectIssueController:246`) and an unbounded number of array-typed-variable sites the type-aware check additionally catches (33 is not the ceiling). **Rollout position:** the rule fires by payload SHAPE and does NOT distinguish a domain-resource shape (`{secret, qr_code}`) from a single-key status/ack payload (`['message' => 'Webhook received']`) — kendo alone has 12+ of the latter (`AnthropicWebhookController` / `GithubWebhookController` / `Central/CliController` / `OAuthController` / `FeedbackController`). Both fire; the noise is accepted by disposition (discovery-by-shape, consistent with `EnforceAuditModelProtectionsRule`'s denylist inversion) rather than adding a key-count threshold (would false-negative a genuine single-field resource) or an ack-shape allowlist (territory-specific config the package forbids hardcoding). Both classes remediate the same way — a dedicated fleet-wide `MessageResponse` / `ErrorResponse` subclass, or baseline-suppress the throwaway acks. **Consumers sizing adoption should separate the ack-noise class from the resource-shape class — the 33/18 figure conflates the two and overstates the genuine ADR-0009 remediation surface.** Documented in the rule docblock (§Rollout position). **Versioning: candidate MAJOR** (surfaces new errors in already-clean code wherever a controller builds a JsonResponse from an array). Per the pre-1.0 caret convention `^0.7` excludes the next minor — tagging auto-adopts nobody; each consumer remediates on its own bump PR. **NOT tagged** (release is ally-gated). +### Changed -- `EnforceActionResultDtoRule` — new rule (war-room enforcement queue #136) enforcing that an `App\Actions\*` class's `execute()` method does not declare an `array` native return type. A compound result — more than one value handed to the caller — is a struct that should be a class (a Result DTO, ADR-0020), and `: array` is the detectable proxy for a struct that escaped typing: it names no fields, guarantees no keys, and forces every caller to re-derive the shape by reading the Action body. The rule inspects only the DECLARED native return type (`ClassMethod->returnType`) of the method literally named `execute` (Actions have exactly one public method by doctrine) inside a class whose namespace `str_starts_with` `App\Actions` (the hardcoded convention shared with `EnforceActionTransactionsRule` / `ForbidDatabaseManagerInActionsRule`, ADR-0021 §Action namespace assumption — no parameter today). It fires on bare `array`, `?array` (`NullableType` wrapping `array`), a union/intersection member that is `array` (`array|SomeResultDto` — a union member is still an escape hatch), and `iterable` in any of those forms (it admits arrays, the same hole in an adjacent spelling). It passes on Result-DTO classes, `void`, models, `Collection`, scalars, `bool`, and an absent declared type. Identifier: `enforceActionResultDto.arrayReturnFromExecute`. **Deliberately signature-only:** a phpdoc-only `@return array{...}` on an otherwise-untyped `execute()` is NOT chased — every consumer territory enforces native return types via its own tooling, so an untyped `execute()` already violates a different contract, and phpdoc-shape resolution buys parser complexity without closing a real evasion path (pinned by a `PhpdocOnlyArrayReturn` fixture). **No `list` carve-out** (Commander disposition 2026-07-06): a `list` return (e.g. recovery codes) is spelled `array` natively and converts to a Result DTO all the same — the moment `list` is exempt, someone returns `list` through the gap. Only the `execute()` method is inspected; a private array-returning helper inside an Action is a legitimate internal shuttle and stays silent. Doctrine: ADR-0020 (Input/Result DTO Split by Usage Direction) + ADR-0011 (Action Class Architecture). Seed: kendo PR #1653 (KD-0220 central-user 2FA) — `EnableCentralTwoFactorAction::execute()` returned `array{secret, qr_code}` ("Mooier om hier een Result DTO terug te sturen"). **Known-consumer impact:** kendo grep sizing 2026-07-06 shows ~23–39 of 256 Actions declaring `: array` on `execute()` — consumers adopt via baseline-absorb (0.3→0.6 playbook); this rule requires no consumer code change at adoption time. **Versioning: candidate MAJOR** (surfaces new errors in already-clean code wherever an Action's `execute()` declares an `array` return). Per the pre-1.0 caret convention `^0.7` excludes the next minor — tagging auto-adopts nobody; each consumer remediates on its own bump PR. **NOT tagged** (release is ally-gated). +- `ForbidEloquentMutationInControllersRule` — registration moved off `Class_` onto `CallLike` (per-node), closing a receiver-type blind spot. The rule previously registered on `Class_` and manually walked every method body, resolving each receiver via `$scope->getType($node->var)` against the **class-entry** scope — a scope carrying no flow-derived knowledge of method-local variables, so any receiver born inside the method body (`$m = new Model; $m->save();`; `$m = Model::query()->…->firstOrFail(); $m->delete();`; a `Builder` held in a local var) resolved to `mixed` and **silently never fired** — only receivers typed from a method signature (typed parameters) matched. Empirically proven on tc-api PR #133 (PHPStan-baseline entries for exactly these shapes came back "unmatched" — the rule never emitted them). Now registers on `CallLike` and branches `MethodCall` / `StaticCall` (mirrors `EnforceCurrentUserAttributeRule` / `LogRule`), so PHPStan supplies a method-level flow scope and local-variable receivers resolve to their real Model / Builder types. The namespace gate (`controllerNamespacePrefixes`, unchanged), the `checkInstanceCall` / `checkStaticCall` type-matching, the blocklist, the error identifier `forbidEloquentMutationInControllers.eloquentMutationInController`, and the message format are all preserved byte-for-byte; the containing-controller FQCN for the message now comes from `$scope->getClassReflection()?->getName()` at the call site (the manual `Class_`-scope `resolveClassFqcn` + `walkNodes` helpers are deleted). The static-call path (`Model::destroy($id)`) is unaffected — it never depended on flow scope. Every pre-existing fixture still fires at the same line; new positive fixtures cover the three local-receiver shapes and a new compliant fixture pins that a local var of a NON-Model class stays clean (the type gate still discriminates under flow scope). Nullsafe `$m?->delete()` calls are covered without a dedicated branch: PHPStan's `NodeScopeResolver` emits a synthetic non-null-narrowed `MethodCall` node (attribute `virtualNullsafeMethodCall`) for every `?->` call, so the plain `MethodCall` branch already fires once — an explicit `NullsafeMethodCall` branch double-reports (the real node plus its synthetic twin). `TypeCombinator::removeNull()` is applied to the receiver before the type gate for the DISTINCT plain-call-on-nullable shape (a plain `->delete()` on a `?Post` from `->first()`: `Post|null` is only a `maybe()` Model supertype, never `yes()`, so without the strip it never fires; a nullable Model receiver carries the same audit-bypass risk). Per-node registration additionally reaches trait bodies analysed through a using class: a mutation in a trait declared under a controllers namespace (e.g. `App\Http\Controllers\Concerns\*`) now fires, message naming the using class — intended coverage the old `Class_` walk structurally never had (a trait file has no `Class_` node). These are pinned by new fixtures (`ViolationNullsafeDelete` — single-fire, no double-report; `ViolationPlainNullableDelete` — the `removeNull` path; `ViolationInTraitFile`). **Versioning: candidate MAJOR per ADR-0021 §Versioning** — surfaces new errors in previously-clean consumer code; per the pre-1.0 caret convention `^0.7` excludes the next minor, so tagging auto-adopts nobody — each consumer runs a pre-cascade audit and remediates on its own pin-bump PR. **Known-consumer impact — CORRECTED 2026-08-11 (the original "known: tc-api `EducationController::store`/`destroy`" line under-predicted by ~94×):** the seed sites were found via PR #133's baseline-unmatched residue, i.e. only the sites that happened to be baselined — a sampling bias that makes a seed a bad blast-radius estimator. Measured on tc-api at v0.8.0: **188 errors across 44 of 118 controllers** (fat legacy controllers are made almost entirely of the method-local-receiver shape this widening reaches). Consumers with legacy fat controllers MUST size adoption with a discovery pass, not from this changelog; a shrink-only suppression ratchet makes the bump unabsorbable without an ally-sanctioned exception. Seed: tc-api PR #133 baseline-unmatched finding; measurement: war-room WR-0533 tc-api leg. +- `EnforceResourceDataValidatorOptInRule` + `EnforceFormRequestToDtoRule` + `EnforceAuditModelProtectionsRule` — migrated the internal inheritance gate off the deprecated `ClassReflection::isSubclassOf(string)` API onto `isSubclassOfClass(ClassReflection)` (war-room enforcement queue #112). `isSubclassOf(string)` is `@deprecated Use isSubclassOfClass instead.` in PHPStan 2.2+ and **removed in PHPStan 3.x** — a latent break for every consumer's static analysis the day this package (the fleet's canonical static-analysis backbone) targets PHPStan 3. Each of the three rules now injects `PHPStan\Reflection\ReflectionProvider` (constructor DI, autowired by the PHPStan/Nette container — no `extension.neon` argument wiring needed, proven by each rule's container-resolution test) and resolves its configured/known base FQCN via `reflectionProvider->hasClass()/getClass()` before calling `isSubclassOfClass()`. **Behaviour is byte-for-byte preserved:** the migration inlines the deprecated method's own body (`if (!hasClass($fqcn)) return false; return isSubclassOfClass(getClass($fqcn));`), so the load-bearing unknown-base-class no-op — a tree lacking the configured base class (or `Illuminate\Database\Eloquent\Model` for the audit rule) silently does not fire, the "consumers analysing non-Laravel trees are unaffected" guarantee — is reproduced exactly. Pinned by a new base-class-absent no-op test per rule (a configured base FQCN absent from the analysed tree ⇒ zero errors); all existing positive tests stay green. **Versioning: PATCH per ADR-0021 §Versioning** (internal API migration / future-proofing — adds no errors, removes none, no consumer-visible behaviour change). Seed: war-room enforcement queue #112. ## [0.7.0] — 2026-07-10 @@ -32,7 +30,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Added - `ForbidEloquentMutationInControllersRule` + `EnforceCurrentUserAttributeRule` + `ForbidResourceWrappedInJsonResponseRule` — new shared optional `controllerNamespacePrefixes` PHPStan parameter (default `['App\Http\Controllers']`): a list of namespace prefixes whose classes are treated as controllers. A class is in scope when its namespace `str_starts_with` **any** listed prefix, so sub-namespaces (kendo's `App\Http\Controllers\Central\*`) still match the canonical prefix naturally — the default is behaviour-identical to the prior hardcoded `private const CONTROLLER_NAMESPACE_PREFIX = 'App\Http\Controllers'` + single-prefix `str_starts_with` gate all three rules carried before. Wired through `extension.neon` (`controllerNamespacePrefixes: ['App\Http\Controllers']` parameter + `listOf(string())` schema + `controllerNamespacePrefixes: %controllerNamespacePrefixes%` on all three rules' service registrations), mirroring the `formRequestBaseClass` / `resourceDataBaseClass` / `formRequestToDtoExemptClasses` parameter precedent. A consumer with divergent controller namespaces (emmie's `App\Http\Client\Controllers` + `App\Http\Admin\Controllers`) brings them into scope by adding the prefixes in its `phpstan.neon`. No consumer namespace is ever hardcoded in a rule body — the prefix list is *config*, preserving the package's "never by name inside the rule" convention. This resolves the `ForbidEloquentMutationInControllersRule` docblock's own standing TODO ("if a future consumer ships controllers under a divergent namespace, lift this into a `controllerNamespacePrefixes` parameter") — now done, and applied uniformly to all three controller-scoped rules so the one knob governs the whole controller surface. README documents the param + a "Covering sub-namespaced controllers" recipe using emmie's real namespaces. The default-unchanged invariant is pinned end-to-end by a container-resolved test per rule (`testRuleResolvesFromExtensionNeonAndFiresOnDefaultPrefix` — resolves the rule from the PHPStan container so the shipped NEON default + `%controllerNamespacePrefixes%` wiring are exercised, then asserts a canonical/sub-namespaced controller still flags) plus every pre-existing fixture and test (the kendo `App\Http\Controllers\Central\*` sub-namespace still flags under the default). A new sub-namespaced-controller fixture per rule proves the emmie shape is CLEAN under the default and FLAGGED once the sub-namespace prefix is configured. **Versioning: backward-compatible MINOR** (new optional parameter, default reproduces current behaviour ⇒ zero new errors in existing consumers — the default-list means no consumer sees behaviour change until it opts in). Do NOT push / tag from this entry — the release PR + tag is a separate ally-gated step. -- `EnforceAuditModelProtectionsRule` — new rule enforcing ADR-0001 §Append-only on audit-log models, discovered by SHAPE rather than a hand-maintained class list (a **denylist inversion**, war-room enforcement queue #46). An Eloquent `Model` subclass is treated as an audit record when its short name ends with any configured suffix (`auditModelNameSuffixes`, default `['AuditLog']`) **OR** its FQCN sits under any configured namespace prefix (`auditModelNamespacePrefixes`, default `['App\Models\Audit']`) — a union covering both fleet identification strategies: kendo's `*AuditLog` models scattered across `App\Models` + `App\Models\Central` (suffix), and the entreezuil / ublgenie `App\Models\Audit\*` collection including non-`AuditLog`-suffixed channel logs like `AuthEventLog` / `SmsEventLog` (namespace). The rule then flags three append-only protections, each firing independently at the class line: `HasFactory` present (`enforceAuditModelProtections.hasFactoryForbidden` — a factory is a direct-insert path bypassing the hash-chained writer), `SoftDeletes` present (`.softDeletesForbidden` — audit rows are never removed), and a mutable `updated_at` (`.updatedAtNotDisabled` — the model does not declare `public const UPDATED_AT = null`). Trait detection is transitive (inherited / composed traits count); abstract intermediates are exempt (their concrete leaves carry inherited violations); non-model classes named `*AuditLog` are excluded by the Eloquent `Model` type gate. **This is the inverse of the allowlist arch tests it supersedes** (kendo `tests/Arch/AuditTest.php`'s 13-FQCN `HasFactory` / `SoftDeletes` lists; entreezuil `tests/Architecture/AuditTest.php` + ublgenie `tests/Arch/AuditTest.php` namespace sweeps + `UPDATED_AT` reflection checks) — a hand-maintained list silently exempts every future audit model added outside it, the exact omission-escape the inversion closes; a territory retires its local model-side checks by moving the discovery convention into the two parameters. Configuration expresses patterns, never enumerated class names — no consumer class name is hardcoded in the rule body. Wired through `extension.neon` (`auditModelNamespacePrefixes` / `auditModelNameSuffixes` parameters + `listOf(string())` schemas + `%...%` arguments on the service registration). README documents discovery, the three protections, and the migration recipe. A model that disables timestamps wholesale (`public $timestamps = false;`) is recognised natively as satisfying the updated_at protection — no `ignoreErrors` suppression needed; the rule reads the native `$timestamps` default alongside the `UPDATED_AT` constant, matching Eloquent's own decision points — pinned by the `TimestamplessAuditLog` fixture. The container-resolved NEON test exercises the two shipped discovery defaults in ISOLATION (`ScatteredAuditLog` = suffix-only, `AuthEventLog` = namespace-only), so a quoting regression in either `extension.neon` parameter fails on its own instead of hiding behind the other signal. **Versioning: candidate MAJOR bump** (the rule surfaces new errors in any consumer territory that has an audit model using `HasFactory` / `SoftDeletes` or missing `const UPDATED_AT = null`). Per the pre-1.0 caret convention `^0.6` excludes the next minor, so tagging auto-adopts nobody — each consumer remediates and goes green on its own bump PR (a suppress-only / baseline-absorb posture: real gaps parked, rule bugs fixed upstream). Seed: kendo Quartermaster M13 F-1 (2026-04-22), war-room enforcement queue #46. **NOT tagged** (release is ally-gated). +- `EnforceAuditModelProtectionsRule` — new rule enforcing ADR-0001 §Append-only on audit-log models, discovered by SHAPE rather than a hand-maintained class list (a **denylist inversion**, war-room enforcement queue #46). An Eloquent `Model` subclass is treated as an audit record when its short name ends with any configured suffix (`auditModelNameSuffixes`, default `['AuditLog']`) **OR** its FQCN sits under any configured namespace prefix (`auditModelNamespacePrefixes`, default `['App\Models\Audit']`) — a union covering both fleet identification strategies: kendo's `*AuditLog` models scattered across `App\Models` + `App\Models\Central` (suffix), and the entreezuil / ublgenie `App\Models\Audit\*` collection including non-`AuditLog`-suffixed channel logs like `AuthEventLog` / `SmsEventLog` (namespace). The rule then flags three append-only protections, each firing independently at the class line: `HasFactory` present (`enforceAuditModelProtections.hasFactoryForbidden` — a factory is a direct-insert path bypassing the hash-chained writer), `SoftDeletes` present (`.softDeletesForbidden` — audit rows are never removed), and a mutable `updated_at` (`.updatedAtNotDisabled` — the model does not declare `public const UPDATED_AT = null`). Trait detection is transitive (inherited / composed traits count); abstract intermediates are exempt (their concrete leaves carry inherited violations); non-model classes named `*AuditLog` are excluded by the Eloquent `Model` type gate. **This is the inverse of the allowlist arch tests it supersedes** (kendo `tests/Arch/AuditTest.php`'s 13-FQCN `HasFactory` / `SoftDeletes` lists; entreezuil `tests/Architecture/AuditTest.php` + ublgenie `tests/Arch/AuditTest.php` namespace sweeps + `UPDATED_AT` reflection checks) — a hand-maintained list silently exempts every future audit model added outside it, the exact omission-escape the inversion closes; a territory retires its local model-side checks by moving the discovery convention into the two parameters. Configuration expresses patterns, never enumerated class names — no consumer class name is hardcoded in the rule body. Wired through `extension.neon` (`auditModelNamespacePrefixes` / `auditModelNameSuffixes` parameters + `listOf(string())` schemas + `%...%` arguments on the service registration). README documents discovery, the three protections, and the migration recipe. A model that disables timestamps wholesale (`public $timestamps = false;`) is recognised natively as satisfying the updated_at protection — no `ignoreErrors` suppression needed; the rule reads the native `$timestamps` default alongside the `UPDATED_AT` constant, matching Eloquent's own decision points — pinned by the `TimestamplessAuditLog` fixture. The container-resolved NEON test exercises the two shipped discovery defaults in ISOLATION (`ScatteredAuditLog` = suffix-only, `AuthEventLog` = namespace-only), so a quoting regression in either `extension.neon` parameter fails on its own instead of hiding behind the other signal. **Versioning: candidate MAJOR bump** (the rule surfaces new errors in any consumer territory that has an audit model using `HasFactory` / `SoftDeletes` or missing `const UPDATED_AT = null`). Per the pre-1.0 caret convention `^0.6` excludes the next minor, so tagging auto-adopts nobody — each consumer remediates and goes green on its own bump PR (a suppress-only / baseline-absorb posture: real gaps parked, rule bugs fixed upstream). Seed: kendo Quartermaster M13 F-1 (2026-04-22), war-room enforcement queue #46. ## [0.6.1] — 2026-07-01 diff --git a/CLAUDE.md b/CLAUDE.md index d06919e..fe3f335 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,8 @@ Composer package distributing war-room-doctrine PHPStan rules across `script-dev | `ForbidAbortHelperRule` | War-room §Explicit over implicit | `forbidAbortHelper.abortUsed` | | `ForbidHttpExceptionInActionsRule` | War-room §Explicit over implicit + §FormRequest → DTO → Action | `forbidHttpExceptionInActions.httpExceptionInAction` (type-aware sibling of `ForbidAbortHelperRule`; bans throwing the `Symfony\…\HttpException` family from `App\Actions\*`. `Illuminate\Validation\ValidationException` out of scope. shipped v0.5.0) | | `ForbidResourceWrappedInJsonResponseRule` | War-room §Explicit over implicit + ADR-0009 | `forbidResourceWrappedInJsonResponse.resourceWrapped` (type-aware; bans wrapping a `JsonResource` in `response()->json()` / `new JsonResponse()` in `App\Http\Controllers\*`. Named-envelope nesting excluded. shipped v0.5.0) | -| `ForbidInlineArrayJsonResponseInControllersRule` | ADR-0009 | `forbidInlineArrayJsonResponseInControllers.arrayPayload` (type-aware; bans constructing the base `JsonResponse` (exact-FQCN, NOT subclasses) / `response()->json()` with an ARRAY payload in `App\Http\Controllers\*`. Inverse of `ForbidResourceWrappedInJsonResponseRule`. `fromJsonString` a deliberate miss. Seed kendo PR #1653. on `main`, `[Unreleased]` — pending v0.8.0 tag (release PR #53)) | -| `ForbidRawExceptionMessageInResponseRule` | War-room §Explicit over implicit + info-disclosure hardening | `forbidRawExceptionMessageInResponse.rawMessageInResponse` (flags a raw `Throwable::getMessage()` — directly or via string concat — or the `Throwable` itself flowing into a client-facing response sink. Default sink `Laravel\Mcp\Response::error`; additional `FQCN::method` sinks via the `rawExceptionMessageSinks` param, default `[]`. Type-aware — only a `getMessage()` on a `\Throwable` receiver fires. Server-side logging (`Log::`/`logger()`/PSR `LoggerInterface`/`report()`) never flags. `// @leak-safe:` comment exemption. on `main`, `[Unreleased]`) | +| `ForbidInlineArrayJsonResponseInControllersRule` | ADR-0009 | `forbidInlineArrayJsonResponseInControllers.arrayPayload` (type-aware; bans constructing the base `JsonResponse` (exact-FQCN, NOT subclasses) / `response()->json()` with an ARRAY payload in `App\Http\Controllers\*`. Inverse of `ForbidResourceWrappedInJsonResponseRule`. `fromJsonString` a deliberate miss. Seed kendo PR #1653. shipped v0.8.0) | +| `ForbidRawExceptionMessageInResponseRule` | War-room §Explicit over implicit + info-disclosure hardening | `forbidRawExceptionMessageInResponse.rawMessageInResponse` (flags a raw `Throwable::getMessage()` — directly or via string concat — or the `Throwable` itself flowing into a client-facing response sink. Default sink `Laravel\Mcp\Response::error`; additional `FQCN::method` sinks via the `rawExceptionMessageSinks` param, default `[]`. Type-aware — only a `getMessage()` on a `\Throwable` receiver fires. Server-side logging (`Log::`/`logger()`/PSR `LoggerInterface`/`report()`) never flags. `// @leak-safe:` comment exemption. shipped v0.8.0) | | `LogRule` | ADR-0001 §Append-only | `logRule.logModification` (covers instance `update`/`delete`/`forceDelete`/`forceDeleteQuietly`; static `Model::destroy()` / `Model::forceDestroy()` shipped in v0.3.0) | | `LogBuilderTruncateRule` | ADR-0001 §Append-only | `logRule.logModification` (shared with `LogRule`; covers `Builder->truncate()` on Log-named tables — shipped in v0.3.0) | | `EnforceAuditSnapshotOnRetryRule` | ADR-0001 §Snapshot-on-Retry Safety | `enforceAuditSnapshotOnRetry.firstStatementMustResetState` | @@ -36,7 +36,7 @@ Composer package distributing war-room-doctrine PHPStan rules across `script-dev | `EnforceFormRequestToDtoRule` | ADR-0012 §FormRequest → DTO Flow | `enforceFormRequestToDto.missingToDtoMethod` | | `EnforceCurrentUserAttributeRule` | War-room §Explicit over implicit | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | | `EnforceAuditModelProtectionsRule` | ADR-0001 §Append-only | `enforceAuditModelProtections.hasFactoryForbidden` / `.softDeletesForbidden` / `.updatedAtNotDisabled` (denylist-inversion; discovers audit models by shape — `auditModelNameSuffixes` default `AuditLog` OR `auditModelNamespacePrefixes` default `App\Models\Audit` — and flags `HasFactory` / `SoftDeletes` / missing `const UPDATED_AT = null`. shipped v0.7.0) | -| `EnforceActionResultDtoRule` | ADR-0020 + ADR-0011 | `enforceActionResultDto.arrayReturnFromExecute` (signature-only; flags an `array` / `?array` / `array\|Dto` union / `iterable` native return type on `App\Actions\*` `execute()`. Phpdoc-only `@return array{...}` is a deliberate miss; no `list` carve-out. Seed kendo PR #1653. on `main`, `[Unreleased]` — pending v0.8.0 tag (release PR #53)) | +| `EnforceActionResultDtoRule` | ADR-0020 + ADR-0011 | `enforceActionResultDto.arrayReturnFromExecute` (signature-only; flags an `array` / `?array` / `array\|Dto` union / `iterable` native return type on `App\Actions\*` `execute()`. Phpdoc-only `@return array{...}` is a deliberate miss; no `list` carve-out. Seed kendo PR #1653. shipped v0.8.0) | | `ConnectionTransactionReturnTypeExtension` | (type extension, no rule) | — | Phase 2 expands the rule set: `EnforceAuditSnapshotOnRetryRule` (ADR-0001 §Snapshot-on-Retry Safety) was the first Phase 2 addition, promoted from cross-territory Pest arch tests (emmie PR #187, entreezuil PR #139, ublgenie PR #166, kendo PR #1029). `EnforceResourceDataValidatorOptInRule` (ADR-0009 §EAGER_LOAD validator opt-in) is the second Phase 2 addition, promoted from kendo PR #1084 under war-room enforcement queue #55. `EnforceFormRequestToDtoRule` (ADR-0012) is the third Phase 2 addition, promoted from entreezuil's `tests/Arch/FormRequestsTest.php` under the same queue #55 (instance 2). `EnforceExplicitHydrationRule` (ADR-0019) is the next Phase 2 candidate. @@ -86,7 +86,7 @@ SemVer per ADR-0021: > Distilled operational rules from cross-project Architecture Decision Records. > Canonical full ADRs at [adrs.script.nl](https://adrs.script.nl). This section is owned by the war room — do not edit directly. -> Last synced: 2026-07-14 (latest released tag v0.7.0; `EnforceActionResultDtoRule` + `ForbidInlineArrayJsonResponseInControllersRule` on `main`, pending the v0.8.0 tag / release PR #53) +> Last synced: 2026-08-11 (latest released tag v0.8.0) ### Applicable @@ -99,20 +99,20 @@ SemVer per ADR-0021: - ADR-0001 (Audit Logging) — package distributes `LogRule` + `LogBuilderTruncateRule` (both §Append-only), `EnforceAuditSnapshotOnRetryRule` (§Snapshot-on-Retry Safety), and `EnforceAuditModelProtectionsRule` (§Append-only — flags audit-log models, discovered by shape, that use `HasFactory` / `SoftDeletes` or fail to disable `updated_at`; a denylist inversion of the consumer-side audit-model arch tests, shipped v0.7.0); does not itself maintain audit logs. - ADR-0002 (Cascade Deletion) — no application surface. -- ADR-0009 (Unified ResourceData Pattern) — package distributes `EnforceResourceDataValidatorOptInRule` (§EAGER_LOAD validator opt-in, shipped in v0.3.0), `ForbidResourceWrappedInJsonResponseRule` (resources own their own response serialization — bans wrapping a `JsonResource` in `response()->json()` / `new JsonResponse()` inside controllers, shipped v0.5.0), and `ForbidInlineArrayJsonResponseInControllersRule` (the inverse — bans building the base `JsonResponse` / `response()->json()` from an ARRAY payload inside controllers; response shapes belong to a Resource / dedicated JsonResponse subclass, on `main`, `[Unreleased]` — pending v0.8.0 tag / PR #53); does not itself ship API resources. +- ADR-0009 (Unified ResourceData Pattern) — package distributes `EnforceResourceDataValidatorOptInRule` (§EAGER_LOAD validator opt-in, shipped in v0.3.0), `ForbidResourceWrappedInJsonResponseRule` (resources own their own response serialization — bans wrapping a `JsonResource` in `response()->json()` / `new JsonResponse()` inside controllers, shipped v0.5.0), and `ForbidInlineArrayJsonResponseInControllersRule` (the inverse — bans building the base `JsonResponse` / `response()->json()` from an ARRAY payload inside controllers; response shapes belong to a Resource / dedicated JsonResponse subclass, shipped v0.8.0); does not itself ship API resources. - ADR-0011 (Action Class Architecture) — package distributes `EnforceActionTransactionsRule` + `ForbidDatabaseManagerInActionsRule`, and `ForbidEloquentMutationInControllersRule` (ADR-0011 + ADR-0019, shipped v0.4.0); itself has no Actions. - ADR-0012 (FormRequest → DTO) — package distributes `EnforceFormRequestToDtoRule` (§FormRequest → DTO Flow, shipped v0.4.0; `toDtos()` plural support v0.6.1); itself has no HTTP surface. - ADR-0014 (Domain-Driven Frontend) — no frontend. - ADR-0016 (Config Attribute Injection) — no Laravel container surface. - ADR-0017 (Page Integration Tests) — no pages. - ADR-0019 (Explicit Model Hydration) — package distributes `ForbidEloquentMutationInControllersRule` (ADR-0011 + ADR-0019, shipped v0.4.0) covering the controller mutation surface; itself has no models. (The earlier Phase-2 `EnforceExplicitHydrationRule` candidate has been subsumed by the controller-mutation rule for the controller surface; a broader application-wide hydration rule remains a future candidate.) -- ADR-0020 (Input/Result DTO Split) — package distributes `EnforceActionResultDtoRule` (bans an `array` native return type on `App\Actions\*` `execute()` — a compound result is a Result DTO, not a bag of string keys; ADR-0020 + ADR-0011, on `main`, `[Unreleased]` — pending v0.8.0 tag / PR #53); itself has no DTOs. +- ADR-0020 (Input/Result DTO Split) — package distributes `EnforceActionResultDtoRule` (bans an `array` native return type on `App\Actions\*` `execute()` — a compound result is a Result DTO, not a bag of string keys; ADR-0020 + ADR-0011, shipped v0.8.0); itself has no DTOs. - ADR-0024 (Automated External Provisioning) — no provisioning surface. - ADR-0029 (Audit Row Durability Contract) — package distributes `EnforceAuditTransactionScopeRule` (§Decision rule 3 — flags non-transactional state mutations inside `transaction(...)` closures in `App\Actions\*`, shipped v0.4.0); itself maintains no audit rows. ### War-room Architectural Principle rules (no published ADR) -- **Explicit over implicit** — package distributes `ForbidAbortHelperRule` (bans `abort()` / `abort_if()` / `abort_unless()`; shipped), `EnforceCurrentUserAttributeRule` (flags `Request::user()` / `Auth::user()` / `auth()->user()` in `App\Http\Controllers`, steering to the `#[CurrentUser]` container attribute per Architectural Principle #9; shipped v0.4.0), `ForbidHttpExceptionInActionsRule` (type-aware sibling of `ForbidAbortHelperRule` — bans throwing the `Symfony\…\HttpException` family from `App\Actions\*`; HTTP status concerns belong to the HTTP layer per Principles #1 + #3; `ValidationException` deliberately out of scope; shipped v0.5.0), `ForbidResourceWrappedInJsonResponseRule` (bans wrapping a `JsonResource` in `response()->json()` / `new JsonResponse()` inside controllers per Principle #1 + ADR-0009; shipped v0.5.0), and `ForbidRawExceptionMessageInResponseRule` (bans a raw `Throwable::getMessage()` — or the `Throwable` itself — reaching a client-facing response sink per Principle #1 + information-disclosure hardening for the ISO 27001 / AVG / NEN 7510 consumers; default sink `Laravel\Mcp\Response::error`, configurable via `rawExceptionMessageSinks`; server-side logging never flags; `// @leak-safe:` exemption; on `main`, `[Unreleased]`). These enforce war-room §Architectural Principles (some also touching numbered ADRs) — each rule's docblock "Doctrine source" line names its authority. +- **Explicit over implicit** — package distributes `ForbidAbortHelperRule` (bans `abort()` / `abort_if()` / `abort_unless()`; shipped), `EnforceCurrentUserAttributeRule` (flags `Request::user()` / `Auth::user()` / `auth()->user()` in `App\Http\Controllers`, steering to the `#[CurrentUser]` container attribute per Architectural Principle #9; shipped v0.4.0), `ForbidHttpExceptionInActionsRule` (type-aware sibling of `ForbidAbortHelperRule` — bans throwing the `Symfony\…\HttpException` family from `App\Actions\*`; HTTP status concerns belong to the HTTP layer per Principles #1 + #3; `ValidationException` deliberately out of scope; shipped v0.5.0), `ForbidResourceWrappedInJsonResponseRule` (bans wrapping a `JsonResource` in `response()->json()` / `new JsonResponse()` inside controllers per Principle #1 + ADR-0009; shipped v0.5.0), and `ForbidRawExceptionMessageInResponseRule` (bans a raw `Throwable::getMessage()` — or the `Throwable` itself — reaching a client-facing response sink per Principle #1 + information-disclosure hardening for the ISO 27001 / AVG / NEN 7510 consumers; default sink `Laravel\Mcp\Response::error`, configurable via `rawExceptionMessageSinks`; server-side logging never flags; `// @leak-safe:` exemption; shipped v0.8.0). These enforce war-room §Architectural Principles (some also touching numbered ADRs) — each rule's docblock "Doctrine source" line names its authority. ### War-room internal ADRs