Skip to content
Open
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

### Added

- `ForbidAdHocDateParsingRule` — new rule (war-room enforcement queue #222) forbidding the construction of a date/time value from a **string** anywhere outside the namespaces where the boundary decode is allowed to live. Registered in `extension.neon` with one new parameter, `dateParsingNamespaces` (default `['App\Support\Time', 'App\Support\DateTime', 'App\Casts']`). Identifier: `forbidAdHocDateParsing.stringParsedOutsideBoundary`. Doctrine: **ADR-0020 Amendment 1** (Semantic Boundary Types — "decoding happens once, at the boundary") + **ADR-0031** (instant vs wall-clock), which supplies the semantics the boundary type is obliged to carry.

**The bug class.** The failure is not a parse that throws — it is a parse that SUCCEEDS in fifty places with fifty slightly different readings of the same string: a month-only bound, a `24:00` end-of-day, a bare `T` separator, an IANA zone the next Action drops, a day treated as an instant. Each reading is local and defensible; the disagreement surfaces only in a report that does not balance, which is the most expensive place to find it. Measured seed: **30 of 354** crit review findings in one week were date/cursor-bound semantics, each re-implemented per Action, and lokalekeuze PR #190 drew **seven review rounds** on a single parser. emmie carries 77 files calling `CarbonImmutable::parse(` and 20 calling `createFromFormat(` across `app/` against **one** strict helper (`App\Support\DateTime\InstantParser`) used by four of them — the ratio the rule exists to invert.

**Detection — three call shapes, one registration.** `getNodeType()` returns `CallLike`, so a single service sees all three (mirrors `EnforceCurrentUserAttributeRule`): (1) a **static call** whose class RESOLVES to a `DateTimeInterface` subtype or to the `Illuminate\Support\Facades\Date` facade, with a method in the thirteen-entry `PARSING_METHODS` list (`parse`, `rawParse`, the six `createFrom*Format` / `createFromTimeString` / `createFromDate` / `createFromTime` factories, `create`, `make`, `createStrict`, `createSafe`); (2) **`new` on a `DateTimeInterface` subtype**; (3) a **function call** whose callee RESOLVES to `strtotime`, `date_create`, `date_create_immutable`, `date_create_from_format`, `date_create_immutable_from_format`, `date_parse` or `date_parse_from_format` — resolved through PHPStan's `ReflectionProvider`, never read off the written token, because PHP resolves an unqualified call to a same-namespace declaration when one exists (an `App\Support\strtotime()` helper is not the global function and must not fire) and `use function strtotime as decode;` is the same question from the other side. A `FuncCall` whose name is an `Expr` — a variable function — has no resolvable callee and stays out of scope. **All three pass through one argument gate**: the call fires only when the argument in its DECODED SLOT is present and its type is not provably non-string. The slot is named per verb rather than assumed to be argument zero — `createFromFormat` decodes its SECOND parameter and the locale-aware pair their THIRD, so "argument zero" is the format string for five of the thirteen methods and three of the seven functions — and it is addressed by parameter NAME first and position second, because since PHP 8.0 any caller may name any argument: `create(timezone: 'Europe/Amsterdam')` hands the call no date input at all, while `create(month: 1, year: $raw)` puts a string at index 1 where a source-order read finds an integer. The accepted spellings are asserted against the real Carbon and native signatures by a test (`testEveryDecodedSlotMatchesTheParameterItNames`), in both directions — every slot names a parameter that exists at that index, and every accepted spelling is earned by a real signature — because a rename upstream would disable the named lookup in silence; teeth-proved against both a renamed slot and a shifted position. The names alone do not discriminate — Carbon's `create()` delegates to `parse()` exactly when `$year` is a non-numeric string, and the same `create`/`createFromDate`/`createFromTime`/`createStrict`/`createSafe` assemble a date from integers, `make`/`parse` re-wrap an existing value, and every factory is a clock read with no argument. The gate's **direction** is deliberate: `mixed`, `int|string` and `?string` fire, because an untyped value at a parse site (`$request->input('from')`) is exactly what the boundary type exists to pin down.

**One anchor, not two.** `Carbon\CarbonInterface` extends `DateTimeInterface`, so a single supertype check covers `Carbon\Carbon`, `Carbon\CarbonImmutable`, `Illuminate\Support\Carbon`, `\DateTime`, `\DateTimeImmutable` and every subclass. A second, Carbon-specific condition would be redundant and — worse — **unfalsifiable**: no input could distinguish the two, so nothing could ever prove the Carbon half still worked. That inheritance is a fact about an upstream package rather than about this code, so it is **asserted in the suite** (`testCarbonInterfaceExtendsTheDateTimeAnchor`) rather than claimed in a docblock. A Carbon release that stopped extending `DateTimeInterface` would otherwise narrow the rule to the two PHP natives with every fixture still green.

**The static method name is folded, the class is resolved by TYPE.** PHP dispatches `PARSE()` and `parse()` to the same method, so the written identifier is lower-cased before the lookup and reported back with the casing the source wrote — that is the token the reader has to change. **Class resolution is by TYPE, never by the written token.** An aliased import (`use Carbon\CarbonImmutable as C; C::parse(…)`) fires and reports under the real class; a local class merely NAMED `Carbon` that declares a static `parse()` does **not** — both pinned by fixtures, and the second one reds the moment the type check is swapped for a name match.

**Deliberate misses, and the reason is the same one every time — nothing here decodes a string.** Any listed call whose decoded slot is absent or provably not a string — zero-argument construction and factories, a named call that fills no date slot (`Carbon::create(timezone: …)`) (`new DateTimeImmutable()`, `Carbon::create()`, `date_create()` are "now"), integer components (`Carbon::create(2026, 9, 7)`), `null`, an existing `DateTimeInterface` value (`Carbon::make($carbon)`); the clock reads `now` / `today` / `yesterday` / `tomorrow`; the `createFromTimestamp*` family plus `instance` and `fromSerialized`, because a timestamp is already an instant; instance calls on an already-decoded value (`->format()`, `->addDays()`, `->startOfDay()`), which are what a boundary type EXISTS to make safe; and a dynamic class expression (`$class::parse()`), where the receiver has no resolvable name and guessing one would be a false-positive source a boundary rule cannot afford; and a **first-class callable** (`CarbonImmutable::parse(...)`, `strtotime(...)`), which decodes nothing where it is written — the string arrives wherever the callable is later invoked. That last one is silent STRUCTURALLY rather than by exemption: PHPStan substitutes `StaticMethodCallableNode` / `FunctionCallableNode` / `MethodCallableNode` for a first-class callable, none of which extends `CallLike`, so the rule's registration cannot receive one. That is also why `decodedSlotMayBeAString()` may call `CallLike::getArgs()` unguarded even though it asserts `!isFirstClassCallable()`; being a fact about PHPStan rather than about this code, it is **asserted in the suite** (`testPhpstanFirstClassCallableNodesAreNotCallLike`) rather than claimed in a docblock — the same treatment as the Carbon anchor above — with fixture lines on both delivered shapes as the tripwire. A class in the **global namespace** is outside every configured prefix and therefore fires — consumers analyse `app/`, which is namespaced throughout.

**The boundary is configuration, not a carve-out.** A namespace is inside `dateParsingNamespaces` when it EQUALS an entry or continues one across a namespace SEPARATOR, so `App\Casts\Money` is inside `App\Casts` and `App\CastsReport` — which merely shares an opening substring — is not. A configured prefix's trailing backslash is normalised once, so `App\Casts\` is not a second, never-matching spelling of the same boundary. The default names the two doors ADR-0020 Amd 1 recognises: a dedicated decode helper (emmie's `App\Support\DateTime\InstantParser`; lokalekeuze's lands under `App\Support\Time`) and the row-to-model cast (`App\Casts`), where a column genuinely arrives as a string from outside and must become a value object exactly once. A territory narrows or widens the list from its own `phpstan.neon`; the rule knows nothing about any territory's layout. There is deliberately **no per-call-site comment exemption** — a call site that would need one is a boundary namespace nobody has named yet.

**Teeth, both directions.** Positive: six shape fixtures assert an exact message at an exact line (Action `parse`, FormRequest `createFromFormat`, aliased import, `Date` facade, `new \DateTimeImmutable($string)`, `strtotime` in a service). Negative: five assert `[]` — the two boundary namespaces, the clock/timestamp/instance-call set, the component-factory / re-wrap / zero-argument set, and the local class named `Carbon`; a sixth positive fixture pins the gate's direction (`mixed`, `int|string`, `?string` fire). Five teeth probes were run and each reddened exactly the assertion that pins it, then restored: disabling the namespace gate reds both boundary fixtures (4 failures); removing the argument gate reds the component-factory fixture; flipping the gate to demand a *proven* string reds the direction fixture; dropping the two `*_from_format` functions reds the denominator; replacing the type check with a name match reds the local-`Carbon` fixture. **Denominator:** `AllParsingMethods.php` names every entry of both lists exactly once, each handed a string in first position, and asserts 23 errors at 23 named lines, so an entry dropped by hand or by a mutation operator fails by line rather than silently narrowing what the rule can see. **Configuration is proved in both directions plus a control** — an override to `['App\Domain\Clock']` makes the default boundary fixture fire AND silences a fixture under the overridden prefix, and that same fixture fires under the shipped default, so the silence is the parameter talking rather than a fixture that could never have been flagged. `extension.neon` wiring is pinned end-to-end by resolving the rule from the PHPStan container, which exercises the shipped NEON default rather than the PHP constructor default. Four further fixtures pin the four things the written token cannot answer, each reverted to its pre-fix shape and seen to red exactly the assertion that pins it: a same-namespace `strtotime()` helper beside an aliased import of the global one; `create(timezone: …)` silent beside `create(month: 1, year: $raw)` firing; `CarbonImmutable::PARSE($raw)` firing; and `App\CastsReport` firing beside a control — `App\Casts\Reporting` — that stays exempt, so tightening the prefix test to an exact match reds rather than passing.

**Versioning: MINOR** (a new rule; no existing rule changes and no option's default moves). It surfaces new errors wherever a consumer parses a date outside a boundary namespace — which today is most of them — so adoption is a baseline-or-drain exercise per territory. Per the pre-1.0 caret convention `^0.8` excludes the next minor, so tagging auto-adopts nobody; each consumer adopts on its own pin-bump PR. **NOT tagged** (release is a General/Commander step).

- `ForbidCredentialCastBypassRule` — new rule (war-room enforcement queue #217) forbidding a `hashed` / `encrypted` / `encrypted:*` cast column from appearing as a key in a **query-builder** write payload: `Model::query()->…->update([...])`, `->insert(...)`, `->insertOrIgnore(...)`, `->insertGetId(...)`, `->upsert(...)`, `->updateOrInsert(...)`, the Postgres-only `updateFrom` / `insertOrIgnoreReturning`, `incrementOrCreate`, the increment family loud and quiet, a relation-derived builder write, or a `DB::table('…')` write on a mapped table. Registered in `extension.neon` with one new parameter, `credentialCastTableModels` (default `[]`). Identifier: `forbidCredentialCastBypass.castBypassedByBuilderWrite`. Doctrine: war-room §Architectural Principles #1 (Explicit over implicit) + #10; ISO 27001 A.5.33 and AVG on the compliance territories. Seed: lokalekeuze PR #65.

**The bug class.** Attribute casts fire on the MODEL path only — `setAttribute()` runs the cast when you assign `$model->password = $plain` and `save()`. `Illuminate\Database\Eloquent\Builder::update()` delegates to `toBase()->update()`, which ships the array straight to SQL: the credential lands in the column verbatim, with no hash, no encryption, no exception, and a **green test suite** (a test that reads the column back gets exactly what it wrote). The failure is silent at every layer and is normally discovered by reading the database. In the seed, `ReissueVoucherAction` wrote through the model by CHOICE while `BlockVoucherAction`'s builder idiom sat one file away — nothing but author preference separated the safe site from the unsafe one.
Expand Down Expand Up @@ -83,7 +101,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

### Security

- `league/commonmark` 2.9.0 → 2.10.0 in `composer.lock` (GHSA-8rr7-cvq3-gmfh; the 2.9.1 advisories GHSA-jjv6-8j6v-6j52, GHSA-f8fg-pg57-v4j8 and GHSA-j8pm-gj4c-rq4x close with it). A **runtime** dependency of this package: `illuminate/mail` is in `require` and pulls `league/commonmark ^2.7`, so `composer install --no-dev` installs it. This lock pins only what this repo's own CI and self-analysis tree install — a consumer territory resolves the range through its own lock and must bump there — but the stale pin here was reddening every check on every open PR, because `composer install` audits the lock on every job (WR-1256).
- `league/commonmark` 2.9.0 → 2.10.0 in `composer.lock` (GHSA-8rr7-cvq3-gmfh; the 2.9.1 advisories GHSA-jjv6-8j6v-6j52, GHSA-f8fg-pg57-v4j8 and GHSA-j8pm-gj4c-rq4x close with it). A **runtime** dependency of this package: `illuminate/mail` is in `require` and pulls `league/commonmark ^2.7`, so `composer install --no-dev` installs it. This lock pins only what this repo's own CI and self-analysis tree install — a consumer territory resolves the range through its own lock and must bump there — but the stale pin here was reddening every open PR, because the `check` job runs an explicit `composer audit` step on both of its PHP legs (WR-1256). It is that step alone: `check-production-tree` installs `--no-dev` and `check-lowest-laravel` runs `composer update`, and neither audits.

## [0.8.0] — 2026-08-11

Expand Down
Loading
Loading