feat: EnforceAuditModelProtectionsRule — audit-model denylist inversion (queue #46)#49
Conversation
Goosterhof
left a comment
There was a problem hiding this comment.
Own PR — self-review, COMMENT event, no self-approve. CI is still PENDING on both PHP 8.4/8.5 checks at the time of this review; the local-run numbers in the description (141 tests, MSI 85.18%) are not independently confirmed here. Verdict below is provisional on green CI, not a substitute for it.
Detection semantics check. matchesAuditModelIdentity (src/Rules/EnforceAuditModelProtectionsRule.php:331-350) is a genuine union of the two fleet strategies — short-name suffix (kendo's scattered *AuditLog) OR namespace prefix (entreezuil/ublgenie's App\Models\Audit\*), with the trailing-separator guard ($prefix . '\\') correctly keeping App\Models\AuditReport\* out of scope despite sharing a text prefix with App\Models\Audit. tests/Fixtures/AuditModelProtections/ReportSummary.php pins exactly this boundary and I hand-verified the fixture's line 22 (final class ReportSummary) is outside App\Models\Audit\ — the test is correct, not just present.
I walked all 12 line-number assertions in EnforceAuditModelProtectionsRuleTest.php against their fixture files by hand (not trusting the "tests pass" claim) — every expected [message, line] pair matches the actual class-declaration line in the corresponding fixture (e.g. FactoryAuditLog.php:15, AuthEventLog.php:18, ConcreteInheritedAuditLog.php:15, ComposedTraitAuditLog.php:16). The transitive trait walk (usesTraitTransitively, :358-369) is correct: it starts at $classReflection itself (not just ancestors) and calls getTraits(true) at each hierarchy level, so it correctly catches (a) a directly-used trait, (b) an inherited trait on an abstract base (ConcreteInheritedAuditLog via AbstractAuditBase), and (c) a trait-of-a-trait (ComposedTraitAuditLog via ComposedFactoryTrait). updatedAtIsDisabled correctly relies on native PHP constant-inheritance semantics (ConcreteInheritedAuditLog inherits UPDATED_AT = null from its abstract base without redeclaring it) rather than reimplementing inheritance lookup — that's the right call given the docblock's note that PHPStan's ClassConstantReflection::getValueType() doesn't reliably resolve untyped = null literals in the fixture environment.
Two evasion classes, both inherent to shape-based (not capability-based) detection — worth naming even though they don't block this PR:
- A model can bypass
hasFactoryForbiddenentirely by hand-rolling a staticfactory()/newFactory()method instead ofuse HasFactory;. The rule only walks the trait graph (getTraits(true)); a manually-implemented factory method that never touches the trait produces the same direct-insert risk the rule exists to close, with zero detection surface. - Same shape for
softDeletesForbidden— a custom global scope filtering on a hand-rolleddeleted_atcolumn, withoutuse SoftDeletes;, evades the trait check while reproducing exactly the "logically removable" behavior the protection forbids.
Neither is a regression against the arch tests it supersedes (those were also trait-keyed), so this isn't a blocker — but it's worth a one-line README caveat under "Discovery" so a future reader doesn't read trait-absence as capability-absence.
Concern — $timestamps = false forces a suppression instead of being recognized as compliant. The README's migration section (README.md:89-98) documents that a $timestamps = false audit model must add a per-file ignoreErrors suppression for updatedAtNotDisabled, since the rule only checks the UPDATED_AT constant. But $timestamps = false is itself a strictly stronger append-only guarantee than const UPDATED_AT = null (no updated_at column exists at all — nothing to mutate). updatedAtIsDisabled() (:385-390) could check the class's default $timestamps property value as an alternate satisfying condition, so a genuinely safe model doesn't need a manual carve-out on every adopting territory. Not a blocker — the documented suppression path works — but it's a design gap that generates avoidable per-file noise on every timestamp-free audit model the fleet has (present or future).
Nit. matchesAuditModelIdentity guards against an empty-string suffix/prefix matching everything ($suffix !== '' / $prefix !== '', :338/:344) — good defensive handling of a config list that could technically contain '' — but no fixture exercises that guard directly. Low value to add given it's pure defense-in-depth, flagging for completeness only.
Scope check against the deployment order. orders/phpstan-warroom-rules/2026-07-03-queue-46-denylist-inversion-deployment.md scoped this dispatch to the audit-protection allowlist shape only, explicitly deferring the ExternalHttpTimeoutTest external-service allowlist leg to queue #58's PHPStan-promotion path. The PR body and CHANGELOG both state this honestly ("Queue #46 marked Partial, not struck") rather than silently narrowing scope — correct discipline.
Versioning. "Candidate MAJOR" framing is consistent with the package's pre-1.0 convention (MINOR position = breaking under ^0.x caret semantics) — a consumer with an audit model currently using HasFactory/SoftDeletes will see new errors on bump, so treating this as non-patch is right. Correctly left untagged pending ally release.
No inline comments — every finding above traces to file:line inline in this body; nothing further to anchor.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
Town Crier Review · 8/10 · PASS · 🤝 Confirm — 🟡 4phpstan-warroom-rules #49 · AC anchor: PR description + in-diff docblock (no reachable board) · head Tip Reviewed EnforceAuditModelProtectionsRule end-to-end and corroborates the-general's self-review — the union suffix/namespace detection, all 12 fixture line assertions, and the deferred-scope/versioning framing all hold, with one harmless line-citation slip (17 vs. cited 22). Adds one new MINOR (the neon-path regression test never isolates the namespace-prefix wiring it claims to gate) and carries forward three of the-general's own flagged MINOR design gaps that our blind pass didn't independently surface. 4 finding(s) posted inline:
Bus thread · 1 prior review(s):
|
jasperboerhof
left a comment
There was a problem hiding this comment.
Auto-approved — Town Crier verdict PASS @Head, CI green, no open MAJOR+ thread. Our approval is our independent vote (approve-alongside): a peer's review / CHANGES_REQUESTED never withholds it — we verify every blocker ourselves, and a real one drops our own verdict below PASS. See the verdict comment + inline notes.
Author the audit-protection denylist-inversion rule: discover audit-log models by SHAPE — Eloquent Model subclasses whose short name ends with a configured suffix (auditModelNameSuffixes, default AuditLog) OR whose FQCN sits under a configured namespace prefix (auditModelNamespacePrefixes, default App\Models\Audit) — and flag any missing append-only protection, without a hand-maintained class list. Three independent checks: HasFactory present (hasFactoryForbidden), SoftDeletes present (softDeletesForbidden), updated_at not disabled (updatedAtNotDisabled). Supersedes the allowlist arch tests in kendo/entreezuil/ublgenie, which enumerate audit models by FQCN list or namespace sweep and thereby exempt any future model added outside the list. Discovery is a union of both fleet identification strategies (kendo's scattered *AuditLog suffix + entreezuil/ublgenie App\Models\Audit\* namespace incl. channel logs like AuthEventLog). Trait detection is transitive (parent-class + trait-of-a-trait); abstract intermediates are exempt; non-model *AuditLog classes excluded by the Model type gate. Configuration expresses patterns, never class names. - src/Rules/EnforceAuditModelProtectionsRule.php (InClassNode, pure ClassReflection; native reflection only inline for the UPDATED_AT literal) - extension.neon: two listOf(string()) params + schema + service wiring - 16 fixtures + 17 tests (incl. NEON container-resolution pin and boundary fixtures killing the trailing-separator + recursive-trait mutants) - README rule row + section (discovery, protections, migration recipe, false-positive suppression); CLAUDE.md rules table + ADR-0001 projection; CHANGELOG [Unreleased] (candidate MAJOR) Gates green on a CI-faithful fresh resolve: phpstan [OK], test 141/211, coverage 89.18% (new rule 98.41%), mutation aggregate MSI 85.18% / Covered 85% (new rule 97% Covered MSI; the 1 residual is the defensive (string) cast, a true equivalent mutant), audit clean, Pint clean. War-room enforcement queue #46. Seed: kendo Quartermaster M13 F-1. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01J59X48mgbPz9PyZfquygaC
… discovery defaults in the container test Two bus-review MINORs folded in pre-tag (town-crier request #245, reviews by the-general + dispatch at fac4264): - updatedAtIsDisabled() now also reads the native $timestamps property default — a model disabling timestamps wholesale never writes updated_at, so it satisfies the protection natively instead of forcing a per-file ignoreErrors suppression on every genuinely append-only $timestamps=false model. Error message + README recipe updated; pinned by the new TimestamplessAuditLog fixture. - testRuleResolvesFromExtensionNeonAndFires now analyses the two single-signal fixtures (ScatteredAuditLog = suffix-only, AuthEventLog = namespace-only) instead of the dual-matched FactoryAuditLog, so a NEON quoting regression in either shipped discovery default fails in isolation rather than hiding behind the other signal. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GpcHhKiY83TxVnhrbqokyr
fac4264 to
0fefaa7
Compare
Town Crier Review · 8/10 · PASS · 🤝 Confirm — 🟡 1phpstan-warroom-rules #49 · AC anchor: PR description (no reachable board) · head Tip Reviewed the EnforceAuditModelProtectionsRule end-to-end at the post-fix head (0fefaa7) and cross-examined the prior bus round plus Goosterhof's off-bus self-review: detection semantics, all 12 test/fixture line assertions, and the extension.neon wiring all hold, and the two MINORs the thread flagged pre-tag (neon-test isolation, $timestamps=false recognition) were genuinely fixed in this push. We add one new MINOR the thread missed — a duplicate README rule-table row — while the two remaining inherent-limitation MINORs (shape-based evasion, untested empty-string guard) stay open as documented, non-blocking gaps. 1 finding(s) posted inline:
Bus thread · 3 prior review(s):
|
jasperboerhof
left a comment
There was a problem hiding this comment.
Auto-approved — Town Crier verdict PASS @Head, CI green, no open MAJOR+ thread. Our approval is our independent vote (approve-alongside): a peer's review / CHANGES_REQUESTED never withholds it — we verify every blocker ourselves, and a real one drops our own verdict below PASS. See the verdict comment + inline notes.
What
EnforceAuditModelProtectionsRule— the audit-protection half of war-room enforcement queue #46 (Commander-approved 2026-05-06; prerequisite satisfied by the v0.6.0 AST harness). Discovers audit-log models by structural shape, not a hand-maintained list:Modelsubtype whose short name ends with a configured suffix (auditModelNameSuffixes, defaultAuditLog) or whose FQCN sits under a configured namespace prefix (auditModelNamespacePrefixes, defaultApp\Models\Audit) — the union covers both fleet strategies (kendo's scattered*AuditLog; entreezuil/ublgenie'sApp\Models\Audit\*).HasFactory(transitive),SoftDeletes, non-disabledupdated_at.Supersedes the allowlist arch tests in kendo (13-FQCN list) / entreezuil / ublgenie — nothing escapes by being forgotten off a list.
Scope honesty
The HTTP-egress/
ExternalHttpTimeoutTestleg of #46 is deliberately out (rides queue #58's PHPStan-promotion path); the harness is shaped so that rule slots in as a sibling. Queue #46 marked Partial, not struck.Adoption notes (next dispatch, not this PR)
Candidate major —
^0.6carets auto-adopt nobody. Cascade = per-consumer bump + retire the localAuditTestmodel checks; kendo needsauditModelNameSuffixeswidened for its two AI-channel logs (README migration section covers it). Release remains ally-gated.Verification
PHPStan self-analysis OK · 141 tests / 211 assertions (+17) · coverage 89.18% (new rule 98.41%) · mutation aggregate MSI 85.18% (new rule 97% covered-MSI; sole survivor is an equivalent defensive cast) · 16 fixtures incl. trailing-separator + recursive-trait boundary kills · Pint clean.
Provenance: seed kendo QM M13 F-1 (2026-04-22); orders
orders/phpstan-warroom-rules/2026-07-03-queue-46-denylist-inversion-deployment.md; execution reportreports/phpstan-warroom-rules/execution/2026-07-03-armorer-queue-46-denylist-inversion.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01J59X48mgbPz9PyZfquygaC