Skip to content

fix(#2171,#2172): canonical column names, enforced scanner_version, correct blocker validation - #2174

Merged
jonesrussell merged 2 commits into
mainfrom
fix/2171-preflight-reserved-words
Aug 3, 2026
Merged

fix(#2171,#2172): canonical column names, enforced scanner_version, correct blocker validation#2174
jonesrussell merged 2 commits into
mainfrom
fix/2171-preflight-reserved-words

Conversation

@jonesrussell

Copy link
Copy Markdown
Collaborator

fix(#2171, #2172): reserved-word columns and unverifiable preflight provenance

Closes #2171
Closes #2172

Found while moving jonesrussell/rhtcircle onto the published v0.1.0-alpha.284 artifacts, where the field-access preflight could never reach ready: true.

1. Reserved-word columns broke the preflight — three callsites

Doctrine keys listTableColumns() by the column's quoted name whenever the identifier needs quoting: a column named key arrives under the array key '"key"', while Column::getName() remains the canonical 'key'. array_keys() on that map yields identifier literals, not column names.

Callsite Pattern Consequence
packages/cli/src/Security/DatabaseFieldAccessInventoryScanner.php:65 array_keys(listTableColumns(...)) emitted a live key <type>|*|"key" that no field definition can classify, so unclassified_entries was never empty and ready was never true — for any consumer with a reserved-word column. Also poisoned the schema fingerprint with the same literal.
packages/foundation/src/Kernel/Preflight/LiveEntitySchemaFingerprint.php:34 array_keys(listTableColumns(...)) the boot-side half of the same fingerprint
packages/oidc/src/Repository/DatabaseAuthorizationCodeRepository.php:164 isset($columns[$column]) reported a reserved-word column as absent, re-running ALTER TABLE … ADD COLUMN on every boot

Why both fingerprint halves had to change together

They were consistently wrong, so they agreed by accident. Correcting only the scanner would make every production boot fail as "stale for the current framework or schema" on any entity with a reserved-word column — strictly worse than the original defect. ReservedWordColumnPreflightTest::the_boot_side_and_artifact_side_fingerprints_agree pins the agreement, and a companion mutation control proves a genuine schema change still moves the fingerprint (otherwise agreement could hold vacuously).

Canonical fix

Same root cause as #2163 (DBALSchema::fieldExists()) at callsites that fix did not reach. A fourth independent instance of one mistake is why this adds a single accessor rather than patching three call sites:

packages/database-legacy/src/Schema/TableColumnNames.phpfor() and sortedFor(), returning canonical names via getName(). All three callsites now route through it.

packages/field/migrations/2026_05_25_000005_index_classification_label.php:49 uses array_key_exists() on the same map. classification_label is not reserved, so it is currently benign; left unchanged and noted.

2. Unverifiable preflight provenance was accepted (#2171, second half)

Correction to the original report

The issue originally claimed stale artifacts "fail open" because framework_version and schema_fingerprint drift went unchecked. That was wrong and is retracted. FieldAccessActivationPreflight::assertReady() already failed closed on: a missing artifact, a malformed one, framework_version drift, schema_fingerprint drift, checksum mismatch, a forged ready flag, and an honest ready: false. Those paths are unchanged here and are now pinned by regression tests.

The genuine gap

scanner_version was parsed (FieldAccessActivationPreflight.php:29) and then never compared to anything.

The generation determines what a preflight actually swept: v2 narrowed the schema fingerprint from every physical table to entity storage only (#2143). An artifact from a different generation therefore answers a different question than the running framework asks — while remaining internally consistent, carrying a valid checksum, and potentially matching on fingerprint where the two table sets coincide. Nothing downstream catches it.

The checksum does not help: scanner_version is inside canonicalData(), so a hand-edited value is caught, but an artifact legitimately produced by another generation is self-consistent.

Fixed with FieldAccessPreflightData::CURRENT_SCANNER_VERSION, enforced in both directions — a newer artifact is as opaque to this framework as an older one.

3. Every real blocker was misreported as a malformed artifact (#2172)

array_all() invokes its callback as ($value, $key), so the bare array_all($value, 'is_string') raised ArgumentCountError on any non-empty list. Since stringList() parses conflicts, unclassified_entries, v1_drivers, serialized_entities and legacy_payloads, every artifact carrying a blocker — exactly the case the guard exists to report — was diagnosed as a corrupt file instead of by name.

Boot still refused, so this was never a hole. It was an operator handed the wrong diagnosis mid-deploy, and it is why the honest not-ready path had no coverage. An empty list short-circuits before the callback runs, which is why clean artifacts worked and it went unnoticed. Swept packages/ for the same misuse: sole instance.

Tests

packages/cli/tests/Unit/Security/ReservedWordColumnPreflightTest.php — 14 tests across key, order, group, index:

  • canonical inventory naming, and the general claim that no live key carries quote characters
  • the preflight reaching ready: true for a fully classified schema
  • boot/artifact fingerprint agreement (the lockstep above)
  • a mutation control: an added column must still move the fingerprint
  • a pin on Doctrine's quoted-key behaviour, so if upstream stops quoting these keys the next reader is told the premise changed rather than the fix quietly becoming dead code

packages/foundation/tests/Unit/Kernel/Preflight/FieldAccessActivationFailsClosedTest.php — 10 tests:

  • both scanner-generation directions refused
  • the pre-existing fail-closed matrix pinned as regression protection
  • a positive control (a current clean artifact is accepted), without which every assertion could pass because the guard rejects everything

packages/foundation/tests/Unit/Kernel/FieldAccessActivationPreflightTest.php — fixture pinned to CURRENT_SCANNER_VERSION instead of a stale 1. This is not an accommodation: two tests in that file expect exceptions, and with a stale literal they would have passed for the scanner-version reason rather than the tampering and blocker reasons they name.

Not included

No release. This branch is validated locally and linked into RHT Circle for a full linked-stack acceptance run before any tag is cut.

…venance

Doctrine keys listTableColumns() by the column's QUOTED name when the
identifier needs quoting, while Column::getName() stays canonical. Three
production callsites read array_keys()/isset() on that map:

  - DatabaseFieldAccessInventoryScanner emitted a live key <type>|*|"key"
    that no definition can classify, so unclassified_entries was never
    empty and ready was never true for any consumer with a reserved-word
    column, and the schema fingerprint carried the same literal;
  - LiveEntitySchemaFingerprint computed the boot-side half of that same
    fingerprint;
  - DatabaseAuthorizationCodeRepository::ensureColumn() reported such a
    column absent and re-ran ADD COLUMN on every boot.

Both fingerprint halves were consistently wrong, so they agreed by
accident. Correcting only the scanner would make every production boot
fail as "stale for the current framework or schema" on a reserved-word
column, which is worse than the defect, so they change together and a
regression test pins their agreement with a mutation control proving a
genuine schema change still moves the fingerprint.

Same root cause as #2163 at callsites that fix did not reach. A fourth
instance of one mistake is why TableColumnNames is now the only way to
ask the question.

Also #2171: scanner_version was parsed and never compared. The generation
decides which tables the fingerprint covers (v2 narrowed it to entity
storage, #2143), so an artifact from another generation answers a
different question while staying internally consistent with a valid
checksum. CURRENT_SCANNER_VERSION is now enforced in both directions.

Retracts the original report's broader claim: stale artifacts did NOT
fail open. assertReady() already refused a missing or malformed artifact,
framework-version drift, schema-fingerprint drift, checksum mismatch, a
forged ready flag, and an honest ready:false. Those paths are unchanged
and now pinned, with a positive control so the suite cannot pass by
rejecting everything.

Also #2172: array_all() invokes its callback as ($value, $key), so the
bare array_all($value, 'is_string') raised ArgumentCountError for every
NON-EMPTY blocker list — meaning any artifact that actually carried a
blocker was reported as "malformed" instead of naming it. Failed closed,
so never a hole; an operator was handed the wrong diagnosis mid-deploy,
and it is why the honest not-ready path had no coverage.

spec-reviewed: docs/specs/infrastructure.md
spec-drift flagged cli-kernel.md and entity-system.md as stale. Both are
genuine contract changes, so they are documented rather than silenced
with a trailer:

  - cli-kernel.md: preflight inventory keys are canonical column names.
    The key FORMAT is unchanged (entityType|bundle|field); what changed
    is that `field` is now the canonical name rather than whatever
    Doctrine used as a map key, which for a reserved word was the quoted
    identifier.

  - entity-system.md: CURRENT_SCANNER_VERSION is now a contract rather
    than a recorded value, enforced in both directions, with a note on
    when to bump it — when the MEANING of a scan changes, not its
    implementation. Carries the correction that the pre-existing
    fail-closed checks were never broken.

infrastructure.md was already acknowledged by the trailer on 09b6bd5.

Note: the detector also warns that packages/oidc/ maps to no spec, so
the DatabaseAuthorizationCodeRepository change was not coupling-checked.
Left as-is: adding an oidc entry to PATTERN_TO_SPEC is a tooling change
outside this fix's scope.
@jonesrussell
jonesrussell merged commit 9b2ace5 into main Aug 3, 2026
18 checks passed
@jonesrussell
jonesrussell deleted the fix/2171-preflight-reserved-words branch August 3, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant