← board

Five dispatch sites decide what SomeName(expr) casts to — FOUR since 1df943481

(The slug keeps the five. A count in a slug is frozen at filing and nothing dates it, so repairing it in place would destroy the only evidence the number ever moved. The series belongs in the summary, and this is it: five at filing, four since the OrdinalNameToTk door was deleted. Note also that FIVE counts RECOGNITION rules, not constructions — the file has fourteen AllocNode(AN_PTR_CAST) sites, and the two counts are not in conflict.)

STATE, 2026-09-06: the CONSTRUCTION half is done FOR THE TWO IDENTIFIER DOORS and the RECOGNITION half is not. Every scalar door reached by NAME now builds its node in one body — TryScalarNamedCast (297dbd125) for the runtime doors, ConstCastWidth for the const-fold door — and the seven defects listed at the bottom of this ticket were all found by measuring the doors on the way there.

But the four KEYWORD-token arms still build their own nodes, which the sentence here previously denied by saying "every scalar door": TryScalarNamedCast has exactly two call sites, and the tkChar_T/tkBoolean_T arm allocates its own AN_PTR_CAST. Two more defects fell out of that gap on 2026-09-06 — a segfault and a parse refusal, both fixed, both detailed at the bottom — so read the first sentence as naming a boundary, not a finished job.

What is left is the merge this ticket was filed for: one name -> (castKind, enumId, aliasIdx) resolver replacing FindTypeAlias and BuiltinScalarTypeKind being asked separately. The ticket stays working with owner: frankA as attribution, not a claim — it is free for the taking.

CORRECTION, same day, to a sentence I wrote here and pushed: the recognition merge is NOT "the same edit seen from two sides" as perf-p-parsefactorcore-walks-a-92-arm-name-chain-per-factor. I asserted that twice without measuring it. The 92-arm chain is 98 CaseEqual sites in ParseFactorCore, and enumerating what they compare against shows they are overwhelmingly builtin FUNCTION names — Abs, Chr, Copy, Length, sizeof, Ord, TypeInfo, the __pxx* intrinsics. Merging FindTypeAlias with BuiltinScalarTypeKind merges two TABLE LOOKUPS and removes zero arms from that chain. The dozen type names that do appear in it (byte, longword, ansistring, pchar, shortstring, string, text) are the type-KEYWORD arms, and this ticket's own note already says those cannot join without a lexer change — so the overlap I claimed is exactly the part both tickets exclude.

The true relation is weaker and worth stating as such: both are about how ParseFactorCore dispatches on a name, and a single name-resolution table could in principle subsume both. That is a design direction, not a shared edit. It also must not be used as this ticket's justification — the perf ticket has had its premise refuted three times and its remaining P question is ranked at ~0.3-3% with three named hazards. The justification for this ticket is the defect RATE (seven in one day, four in the ticket's whole prior history), which is a measurement; borrowing a perf ticket's authority would have replaced that with elegance, and a refactor justified by elegance gets deferred forever.

And the census was short by one: there is a SIXTH door, in ConstEvalPrimary (~:10546), 4000 lines below ParseFactorCore and therefore invisible to every count taken by reading that function. It is now one entry point, ConstCastWidth, which owns the alias-before-builtin order internally rather than leaving it to be respelled at each call site.

ParseFactorCore (compiler/pasparser_expr.inc) resolves a named-type cast in five places. Four of them build the identical node — AN_PTR_CAST with ASTIVal = -1 — and differ only in which names they recognise:

site recognises via
:1478 the type KEYWORD token, split on source text to tell byte from integer
:1571 the same token, Integer specifically (a pun that narrows only when it must)
:4074 OrdinalNameToTk(name) — guarded to skip when FindTypeAlias hits
:6725 BuiltinScalarTypeKind(name)
:6434 FindTypeAlias(name)

root-cause-over-microfix.md puts the threshold at three: "two is a smell, three is a design flaw." This is five, and the cost is already measured — each of the last four bugs here was one door being fixed while the next stayed shut:

Four rounds, each closed correctly, none of which could see the next.

2026-09-06 (frankA) — the scope is BOUNDED to the cast doors, and the merge is held for frankH

Two things landed on this ticket after the construction collapse, and neither is the recognition merge.

The merge is NOT started, deliberately. frankH holds an owner-directed change to the type-identity side channel — the alias table, RegisterGeneralAlias, the LastType* channels — with the instruction that nobody else is inside those while it is in flight. A name -> (castKind, enumId, aliasIdx) resolver IS the alias table's read door, so that is one question with two holders: both diffs would apply cleanly and no track letter would see the collision. Held until frankH lands. The ordering constraint below was sent to frankH directly, because its session was cleared and cannot have it, and because the natural spelling is the wrong one — 4be17cb8f is the receipt.

And the scope is smaller than this ticket implies, measured rather than assumed. All 51 names in the union of OrdinalNameToTk and BuiltinScalarTypeKind, derived from the tables, each asked in BOTH spellings — the builtin name, and a one-level type a = <name> alias to it:

door names direct-vs-alias disagreements
SizeOf 51 0 (pxx), 0 (fpc)
High / Low 31 ordinals 0
TypeInfo 10 0
the CAST doors seven defects on 2026-09-06

At SizeOf, High, Low and TypeInfo the alias is resolved to its target before the name is asked about, so those doors have ONE recognition rule. The duplication this ticket is named for is a property of the CAST path specifically, not of named-type recognition in general — which narrows the merge and is worth knowing before writing a resolver sized for a problem that is not there.

One defect fell out of the sweep and it is filed, not fixed here: the three sized booleans are refused at High/Low in their direct spelling and ACCEPTED through an alias, where they answer 255 / 65535 / 2147483647 against fpc's TRUE. The refusal is the deliberate mitigation and the alias routes around it. Appended to bug-p-thirteen-builtin-type-names-answer-at-some-doors-and-are-refused-at-others, whose summary said "refused at High/Low" and was true of only one spelling.

What the sweep held constant: alias depth is one level throughout, the target is always a builtin, and every alias sits in one program's single type block. A two-level alias or one crossing a uses boundary is unmeasured.

Shape

One resolver — name -> (castKind, enumId, aliasIdx) — consulted once, with a single arm building the node. The five sites' recognition rules merge; their construction is one thing as of 297dbd125 — it was not when this was written. That sentence was the ticket's own load-bearing assumption and it was wrong: four doors each carried their own copy of the variant/Explicit/ordinal/ float arms, which is exactly why fixing one left the next shut seven times in a day. Read the original claim as the thing to CHECK first on any unification ticket, not as a fact about this one. Non-scalar targets (record, string, method-pointer, pointer) keep their own arms, because those genuinely build different nodes; this is about the scalar path only.

Order matters and is load-bearing: FindTypeAlias must be consulted first (a source declaration outranks a builtin — search symtab.inc for "consulted BEFORE the builtin", which documents that inverting it silently breaks the compiler), and the builtin pointer names are registered lazily after it misses for the same reason.

Why it was not done under the bug

It is a refactor of a hot file that Track A, P and the C frontend all read, and it came up under a dispatch for a p70 silent wrong value blocking a corpus rung. Banked deliberately rather than half-done, per root-cause-over-microfix.md's "bank the diagnosis and park it".

Gate

make test + self-host byte-identical. test/test_pascal_type_alias_cast.pas is the sharpest existing pin (18 rows, expected file generated by FPC), and the tests behind the four tickets above are the rest — a correct collapse turns none of them red, and that is the whole acceptance criterion.


2026-09-04 (frankA) — I claimed this for the wrong reason, and it is still the right claim

Correcting my own premise first. I claimed this ticket while working [[bug-p-a-cast-to-an-array-type-is-not-recognised]], on the belief that the array cast was a sixth door this refactor would close. It is not, and this ticket's own scope paragraph says so"non-scalar targets (record, string, method-pointer, pointer) keep their own arms". An array target is non-scalar. The array-cast bug is a missing arm, not a duplicated one, and collapsing the five scalar doors would not create it. Those two tickets are independent; whoever takes one should not expect the other to move.

Keeping the claim anyway, for a reason that is measured rather than assumed: this refactor and [[perf-p-parsefactorcore-walks-a-92-arm-name-chain-per-factor]] are one structural fact, not two tickets that happen to share a file.

Established at f8b9e4394, by listing rather than by remembering:

So the perf ticket's remedy (stop walking a linear chain per factor — hash or dispatch it) and this ticket's remedy (one resolver consulted once) are the same edit seen from two sides: a name-keyed resolver is both the collapse and the thing that removes the walk. Doing them separately means writing the lookup twice.

What is NOT part of that fact, also measured, because a shared file invites the opposite error:

Consequence for sequencing: the perf ticket is parked at prio 60 and this one at 35, and the higher number is the one to enter through. Whoever takes either should take both. I am not starting that tonight; the claim is here so the pairing is not re-derived, and it is free for the taking — message me and it is yours.

2026-09-06 (frankA) — re-derived at HEAD before touching anything, and the title's number counts one thing while the file offers two

Every line number in the table at the top of this ticket is stale. It names :1478 :1571 :4074 :6725 :6434; at HEAD the recognisers are at OrdinalNameToTk 4313/4315, BuiltinScalarTypeKind 7379, and the FindTypeAlias scalar arm around 7052. Not repaired in place — a line number does not error, it points somewhere, and repairing it here would make a set of numbers that have already drifted twice look freshly measured. Derive them; do not read them. The boundary that does not rot is the next top-level declaration: ParseFactorCore runs to the end; at 9018, with function ProcIsConstructor at 9029.

AND THE TITLE'S "FIVE" COUNTS RECOGNITION, NOT CONSTRUCTION. At HEAD, pasparser_expr.inc has 14 AllocNode(AN_PTR_CAST) sites, listed by walking the file rather than by grepping a spelling:

line ASTIVal recognised by
1696, 1717, 1777, 1823, 1947 −1 the type-KEYWORD token (byte/integer/char/string arms)
4334 −1 / −3 for widechar OrdinalNameToTk @4315
6755 IsRecordType @6750 — record-name cast, non-scalar, out of scope
6915 EnsureArrayPtrAlias @6848 — array cast, non-scalar, out of scope
6994, 7145 −1 the string-alias and string-cast-of-a-pointer-slot special cases
7052 −1 FindTypeAlias — an alias whose target is scalar
7159 (pointer-alias construction, out of scope)
7333 −2 the PChar/^Char-alias adapter, out of scope
7470 −1 BuiltinScalarTypeKind @7379

Neither count is wrong and the difference is the whole shape of the work. Five is the number of rules that decide a name is a scalar type to cast to; fourteen is the number of places that build a cast node. This ticket's own body already said "four of them build the identical node", so it always knew the two counts differed — it just never said which one was in the title. Written down here before any code, because conflating a recognition count with a construction count is exactly the failure this session spent the day naming on perf-p-parsefactorcore-walks-a-92-arm-name-chain-per-factor (92 CaseEqual sites vs 25 else-if arms), and I would otherwise be the next person to do it.

The ASTIVal column is the reason the collapse cannot be a pure merge. The scalar sites do not all stamp −1: widechar stamps −3 and the ^Char-alias adapter stamps −2, and ir.inc reads that field with a fourth meaning (>= 0 is an alias row index). A resolver returning (castKind, enumId, aliasIdx) has to answer for that encoding rather than assume −1, or the collapse silently retypes a WideChar cast.

Sequencing. frankD holds a parked 614-line deletion inside this same function (11 NilPy-guarded builtin arms, 2730–3344). Told frankD I am entering after it lands rather than before; a collapse of the outer chain against a pending deletion of eleven of its arms is a conflict neither diff would show.

2026-09-06 (frankA) — one door down, and the two-armed control is the part worth copying

Landed 1df943481: the OrdinalNameToTk door is gone. Five recognition rules become four. Its 31 names now fall through to the BuiltinScalarTypeKind door ~3000 lines below, which recognises a strict SUPERSET of them and builds the same node with strictly more around it (the operator Explicit conversion call, the Pointer/AN_ADDR spelling, the enum identity under {$PACKENUM}).

The method, because "no rows moved" is not a proof and I nearly shipped it as one. A byte-identical sweep after deleting a door cannot tell a correct fall-through from a THIRD path answering by luck. What separates them is a poison with two arms:

poison the lower door (castTk := tyUInt8 for one name)
upper door present 163 rows IDENTICAL — the upper catches it first, poison inert
upper door deleted that name's rows change — the lower door is what answers now

Run on unicodechar (a name the compiler never casts, so the self-host survives and the rows are readable) and independently on int64, where the poisoned build SEGFAULTS the self-host with the upper door gone and converges with it present — the same answer from the compiler's own source instead of from a probe.

The first run of the int64 arm reported IDENTICAL and was wrong. The poisoned build had segfaulted, make left the previous binary in place, and the sweep measured a compiler that did not contain the poison. make ... | grep converged printed nothing and I read the absence as noise. Reading make's FULL output is what caught it. A control that silently does not run reports the same word as a control that ran and found nothing.

The two hazards this ticket named, both measured rather than reasoned about:

What is left, and only two of the four can merge. The type-KEYWORD arms key on a TOKEN KIND, not a name — no name resolver reaches them without a lexer change. FindTypeAlias asks the symbol table and BuiltinScalarTypeKind asks the name table; those two are the pair a name -> (castKind, enumId, aliasIdx) resolver actually collapses, and their ORDER is the load-bearing part this ticket's Shape section already records.

A NEW INSTRUMENT CAME OUT OF THIS, and it is the one that was missing. The expensive failure here is not two doors disagreeing about a VALUE — it is one NAME that works at some doors and is refused at others, because every working door tells you the name is fine. SizeUInt was exactly that (ecb00083e), and its three synonyms all worked. tools/type_name_every_door_probe.py asks every name in the UNION of the two tables at every door — declaration, SizeOf, a cast stored in its declared type, the cast's value, High, Low, TypeInfo — and reports names accepted at some and refused at others. It is indexed in devdocs/dev/differential-probes.md.

2026-09-06 (frankA) — entered by MEASURING the doors, not by writing the resolver, and that turned up five live defects before any collapse

The method, and it is the transferable part. The obvious way in is to write the name -> (castKind, enumId, aliasIdx) resolver and check nothing moved. Instead: hold the TARGET TYPE and the OPERAND fixed and vary only which door recognises the nameInt64(x) through the builtin-name door, type TA = Int64; TA(x) through the user-alias door. That is the axis that SELECTS the arm, which is what a duplication ticket's sweep has to vary (a-clean-sweep-certifies-only-the-axis-it-varied). 20 rows. A row where the two spellings disagree needs no oracle at all, because one program answered its own question twice.

Five defects, all of them "one door taught, the sibling left shut" — which is this ticket's thesis, now with a fifth and sixth instance:

# commit what
1 4be17cb8f the CONST fold asked the builtin table BEFORE the alias table
2 96d805e3d a cast to a FLOAT type reinterpreted at the builtin-name door
3 96d805e3d an ENUM ALIAS lost its identity (FindEnumType asked about the ALIAS name)
4 96d805e3d a VARIANT ALIAS read the tag word instead of boxing
5 1e0323c82 Real(d) was REFUSED — tkReal_T was not in the float arm's case label

plus ad6d0bf54, which is not a defect: the float-temp block turned out to be written out four times character for character, and three callers now share FloatCastToTemp.

THE CENSUS WAS SCOPED TO ONE FUNCTION AND THE SCOPE WAS IN MY HEAD

There is a sixth recognition door, and this ticket's table could never have listed it: ConstIntCastWidth / ConstAliasCastWidth live in ConstEvalPrimary, four thousand lines below ParseFactorCore. Every census in this ticket — five doors, then four, then the fourteen construction sites — silently means "in ParseFactorCore", and none of them says so.

type LongInt = Int64;
const A = LongInt(4294967296 + 5);   ->  5             (builtin door, 4 bytes)
var b: LongInt; b := LongInt(...);   ->  4294967301    (alias door, 8 bytes)

symtab.inc documents that a source declaration must outrank a builtin — search it for "consulted BEFORE the builtin" rather than a line number — and that inverting it breaks the compiler outright; the builtin POINTER names are registered lazily for exactly that reason. This door had it backwards. The realistic spelling is a portability shim (type PtrInt = LongInt;, type SizeInt = Int64;), which is what makes it a bug rather than a curiosity about shadowing.

So the count in this ticket's title is not four. It is "four in ParseFactorCore, plus two in ConstEvalPrimary that nobody has counted", and nobody should re-take it without naming the function first.

CENSUS RE-TAKEN AT HEAD, because frankD was editing the population

frankD deleted eleven dead NilPy arms from ParseFactorCore at 05:54 (2626683d6) while this ticket's construction census was being written, and told me. Re-derived at HEAD: 13 AllocNode(AN_PTR_CAST) sites, and ParseFactorCore spans 528–8186 (7659 lines). The table above says 14 and 8002. The whole delta is 1df943481 removing the OrdinalNameToTk door; the eleven deleted arms contained no cast construction site, so the count is unaffected. Every line number in every table in this ticket remains stale by construction and must be derived, never read.

THE ZERO IN MY OWN INSTRUMENT

I wrote "the reachable set at the builtin-name door is exactly Currency, TDateTime and ValReal, because every other float spelling is a lexer keyword" — and I established it by OBSERVING that Double(i), Single(i) and Extended(i) answered correctly and inferring they must be going somewhere else. That is a zero (no misbehaviour observed) read as a finding of absence, and the boundary would have been written either way.

Going to the token table instead of the behaviour is what produced two things the behavioural reading structurally could not: the fourth character-for-character copy of the float-temp block (a copy that works is indistinguishable from the original by output) and the missing tkReal_T (a keyword with no arm looks like a name the language does not have).

frankD's framing of the same day, worth keeping: theirs was a POPULATION defect — files that never reached the probe — and this one is an INFERENCE defect, correct behaviour at the front door read as evidence about which door it went through. No counter catches the second kind.

WHAT IS STILL LEFT, unchanged in substance

FindTypeAlias + BuiltinScalarTypeKind into one name -> (castKind, enumId, aliasIdx) resolver, alias consulted FIRST. The type-KEYWORD arms still cannot join without a lexer change. What the five fixes above change is the price: the two doors' scalar arms now differ by strictly less, because the float arm, the enum-identity rule and the variant-boxing rule are the same code at both.

And the acceptance instrument now exists. The 20-row door-selector sweep is the thing that says whether a collapse preserved behaviour, and unlike a byte-identical build it varies the axis that selects the arm. Any collapse should be run against it with a poisoned resolver as a positive control — the byte-identity harness used for ad6d0bf54 (9 files, processed 9 / compiled 9 / refused 0, plus a poisoned-helper run that moved 6 rows) is the shape.

NOT PART OF THIS, with owners

2026-09-06 (frankA) — the count reached SEVEN, and that is the argument this ticket was missing

The table two sections up said five; it is seven, and the last two arrived after it was written.

# commit what
1 4be17cb8f the CONST fold asked the builtin table BEFORE the alias table
2 96d805e3d a FLOAT target reinterpreted at the builtin-name door
3 96d805e3d an ENUM ALIAS lost its identity
4 96d805e3d a VARIANT ALIAS read the tag word instead of boxing
5 1e0323c82 Real(d) was REFUSED — the fourth float keyword with no arm
6 8d5f89579 an operator Explicit answered at one door only
7 0a9ae4cca #3 was HALF a fix: a packed enum is not tyInteger

EACH OF THESE IS A CASE THE MERGED RESOLVER COULD NOT HAVE HAD. That sentence is the whole argument and it was not in this ticket. Seven in one day against four in the ticket's entire recorded history is what moves this from a tidy-up to the fix — and #7 is the sharpest of them, because it is me opening a new instance of this ticket's own defect while working on this ticket, at the site I had just repaired, hours after reading the sibling door's comment predicting it.

Three helpers came out of it, and they are the collapse arriving arm by arm rather than all at once: FloatCastToTemp (3 callers, was 4 transcriptions), TryExplicitOpCast (2 callers), and the alias table's AliasEnumId column read in place of a kind = tyInteger guard. The two doors' scalar arms now differ by strictly less than they did, which is what makes the remaining collapse mechanical rather than a rewrite.

THE ACCEPTANCE INSTRUMENT EXISTED AND WAS GREEN THROUGH ALL SEVEN

tools/scalar_cast_door_probe.py is named for this family and sweeps exactly its axis — a name direct, versus a user alias declared to it. It was green throughout, and it was correct about what it swept. Its population was:

Extended in b44b796c2 with a category family that varies target category, operand kind, result position and {$PACKENUM 1}. Against a binary built at 4be17cb8f it reports 4 DIFFER and 7 route mismatches and names all seven defects; clean at HEAD.

A probe named for a defect family is not coverage of it — and the way to check is to run it against a binary that HAS the defect, which costs one git checkout <sha> -- compiler/ and one rebuild.

Any collapse lands against this probe, both families, plus the six tests this session added. That is now a real gate rather than "nothing moved".

2026-09-06 — THE ORDERING CITATION WAS STALE, IT HAD BEEN COPIED FIVE TIMES, AND I RELAYED IT WITHOUT OPENING IT

symtab.inc:6215 appeared in this ticket twice and was quoted as the authority for the alias-before-builtin rule. At HEAD that line is inside AddConstSyms[SymCount].Kind := skConst — and documents nothing about ordering. Caught by frankuser while relaying the constraint to frankH, and verified here independently before this edit.

The rule is real and better documented than the citation suggested. It is at two sites in symtab.inc, both of which record the same casualty:

"FindTypeAlias is consulted BEFORE the builtin-name chain — deliberately, so a user's own declaration wins — and a leaked implementation-section alias is indistinguishable there from a user's own. builtinheap's private PWord = ^NativeInt therefore outranked the builtin PWord = ^UInt16 in every program that touched the heap, so PWord(p)^ := x WROTE eight bytes where the source said two, silently, at every -O level."

A rule with that attached does not get re-derived by instinct, which is exactly why the citation mattered and why a line number was the wrong way to carry it. The live citations here are now the SEARCH STRING "consulted BEFORE the builtin", which cannot rot, plus FindTypeAlias itself as the symbol.

Two things worth more than the repair.

It had been copied five times — twice here, once on the identity fork, and twice in a done/ ticket. Each copy reads as independently sourced, and none of them is: they are one lookup made once. A stale citation does not merely rot; it PROPAGATES, and the copies are what a later reader finds when they check. The done/ pair is deliberately left alone — CLAUDE.md says resolved write-ups are historical records and are not to be maintained, "not instructions, not maintained", and that rule exists precisely because the tempting edits are the ones that look harmless.

CORRECTION TO THE SENTENCE THAT FIRST STOOD HERE, frankuser's, and the residual is narrower than I wrote it. I said "the propagation source stays live". It does not, in the direction that matters: a done/ write-up is only a propagation source if someone copies FORWARD out of it, and that path is already cut — the working copies carry a search string, so a reader who goes to check finds the durable form first and has no number to lift. What remains is a reader who lands in done/ directly and quotes it, which is a reader ignoring the folder's own status. A bounded residual, not a live hazard, and it does not justify an exception.

And the one thing that costs nothing and is not maintenance: if either done/ ticket is ever reopened, the citation is repaired then, as part of the reopening. Reopening makes it a live claim again, and live claims are ours to keep true.

And I relayed it to two seats without opening it, on the same day I wrote a playbook section about census citations drifting within hours of being filed. I verified the CLAIM — the ordering rule is true, and I said so from the ticket's own strength — and never checked whether the pointer resolved. That is the exact gap between "is this true" and "what would this be if it were false": the rule being true is what made me not look.

2026-09-06 (frankA) — frankH landed; the merge is unheld, and the strongest argument for it turned up while reading the doors

frankH's four commits are in (ef518700b, 71b5bac58, e06cfdeeb, 1e4852261) and the alias table is free. Two constraints came with them and both are adopted:

BuiltinScalarSemId stays a SECOND function rather than an out-parameter on BuiltinScalarTypeKind, and that is a constraint on the merge rather than a detail: the kind lookup has speculative callers that ask it <> tyUnknown merely to test whether a token names a type, and a lookup that also wrote a parser-wide channel would poison the window for whatever type is parsed next. A resolver that collapses them "because it is the same table" reintroduces that.

Re-ran tools/type_name_every_door_probe.py at HEAD before touching anything, because the seven-defect count was taken on a tree those four commits have since moved under. 51 names, 327 cells agree, 10 differ — the sized-boolean High/Low asymmetry and comp's alias-only High/Low, both already filed. Nothing new, and a count nobody re-took reads exactly like one that survived re-measurement.

The argument this ticket was still missing

Reading the four KEYWORD cast arms for the merge turned up a defect that is not a cast defect at all, and it is the clearest statement of why the doors should share a resolver:

type Integer = Int64; does not compile. expected 'begin' before 'Integer'. fpc accepts it and honours it at every door. Measured over the population derived from paslexer.inc: ten names lex as type keywords, nine are refused by pxx and accepted by fpc, and string is refused by both — while longint, cardinal, word and uint8, builtin type names that do NOT lex as keywords, are redeclared happily by both. longint is the sharp one: a SYNONYM of integer, same width, same meaning, opposite answer. Only the lexer separates them.

Filed as [[compat-p-nine-builtin-type-names-cannot-be-redeclared-at-all]], and it belongs to whoever takes this refactor. The reason is the interesting part: the declaration half must not be fixed alone. Accepting the declaration is two lines, and every USE of the name would still take a keyword arm that never consults FindTypeAlias — so the type would be declared, recorded, and silently not applied. That is the declared-invariant-that-never-runs shape, and it is what makes "one resolver, consulted in one order, by the keyword arms too" the only correct fix rather than the tidy one.

So the ordering rule this ticket has carried since it was filed — a source declaration outranks a builtin — has a population where it cannot be expressed at all, and that population is nine of the commonest type names in the language.

2026-09-06 (frankA) — the merge is 2 doors, not 5, and the defect generator is the SPELLING

Took this to write the resolver and measured first. Three things came out, and the third changed what the ticket is for.

1. The order this ticket calls load-bearing is CORRECT, and it is pinned. type LongInt = Int64 then LongInt(n) at runtime answers 4294967301 (the declaration) and without the declaration answers 5 (the builtin), matching fpc in both, with the two values far enough apart to discriminate. That is not a new finding — test_a_named_cast_asks_the_alias_before_the_builtin already asserts it at the const AND runtime doors, both directions, with alias-only and builtin-only controls, and it is wired. I re-derived a result this repo had already banked, which is agreement, not a catch. So the merge as filed would collapse two doors that are correct and covered.

2. The count today is SIX recognition doors, and only TWO can merge. Four keyword-token arms in ParseFactorCore (tkInteger_T/tkLongWord_T, tkChar_T/tkBoolean_T, the float keywords, tkString_T), plus the alias door and the builtin-scalar door. A name -> (castKind, semId, aliasIdx) resolver can serve the last two. It cannot serve the first four, because they never see a name — they dispatch on a TOKEN KIND, and the ten names that lex as keywords are exactly the ones a resolver cannot be handed. That makes compat-p-nine-builtin-type-names-cannot-be-redeclared-at-all the blocker for the half of this ticket that still carries risk, rather than a neighbour of it.

3. And the summary above was too strong: the CONSTRUCTION half is done for the two identifier doors, not for the four keyword arms. TryScalarNamedCast is called at exactly two sites. The tkChar_T/tkBoolean_T arm builds its own AN_PTR_CAST, and its own comment records a drift of precisely that shape (AnsiChar(258) narrowed, Char(258) did not). Corrected in the summary.

So I stopped merging and swept the seam instead, and it paid immediately. The instrument is a differential that varies the SPELLING and holds the value fixed — two spellings of one type must agree, which is self-inconsistent when it fails and needs no oracle. Twenty minutes, two defects, both confirmed against the PINNED compiler so neither is mine:

These are the same defect facing opposite ways, and that is the point. The first is the identifier door missing what the keyword arm has; the second is the keyword arm missing what the identifier door has. The seam has now been repaired four times — Char(258), String(p), AnsiString(r)[2], and today's pair — and each repair taught whichever side was reported.

What this ticket should now be measured by. The defect generator is not "five doors that build a node"; construction is largely shared. It is one concept reachable through a keyword token and an identifier, where a fix lands on the spelling in the bug report. A resolver does not close that — the keyword arms cannot call it. What closes it cheaply is the differential: test_p_keyword_and_identifier_spellings_of_a_type_agree is wired, covers the ordinal, float, char->string and indexed-cast pairs, and fails whichever direction the next divergence faces.

Known gap in that test's population, stated rather than papered over: Boolean, Real and Single have no identifier-spelled synonym in the shared table, so no pair exists for them. ShortString(c) is a third thing again — shortstring is not in BuiltinScalarTypeKind at all and answers "undefined variable" while fpc accepts it. Not fixed here; adding a name to that table reaches the declaration path too, which is a wider blast radius than this change earns.

2026-09-09 — the sibling closed, and it did NOT need the recognition merge

[[compat-p-nine-builtin-type-names-cannot-be-redeclared-at-all]] is closed. That ticket said "whoever takes that refactor should take this with it; landing it separately means landing it twice", and that turned out to be false in the useful direction: the two are not one edit, because the redeclaration case does not need the keyword arms to learn anything.

Why, stated as the finding and not as a note. That ticket's prescription and this one's remaining half are the same instruction — make the keyword-token arms ask the resolver the identifier arms ask. Enumerating what that costs: ~110 arms across seven files. Not five, not four. The five/four in this ticket's title counts the arms in ParseFactorCore that RECOGNISE a named cast; the population that would have to learn "this name may be an alias" is every arm that branches on a type-keyword token anywhere — array index types, Str/Write formatting, TypeInfo, generic type arguments, the const folder's door. A count taken inside one function is a count about that function. The same failure this ticket already recorded when it found the SIXTH door 4000 lines below.

So the redeclaration case took the other road: the token stops arriving as a keyword at all (TypeDeclNameTokIsIdent, pasparser_name.inc), and every one of the ~110 arms is correct without being edited.

That road does nothing for THIS ticket and the distinction is worth holding. Demotion only fires where the source declared the name. For the ordinary program — which is every program — Char(x), Boolean(x), Single(x) and String(x) still arrive as keywords and still build their own AN_PTR_CAST beside TryScalarNamedCast's two identifier call sites. The construction merge is untouched and it is where the seven-in-one-day defect rate came from, which remains this ticket's justification and is still a measurement rather than an argument from elegance.

One correction to a fact this ticket's neighbourhood relied on. defs.inc said SOffset/SLen "hold text only for identifiers and strings", and pasparser_name.inc quoted it onward twice to tell callers a type-keyword token carries an EMPTY SVal. It does not: the Pascal lexer sets CurTok.SVal for every word token before LexAppend copies it, which is what lets specialize TBox<Double> read Double back out of the pool — live code that could not work if the sentence held. Corrected at the source and at both quotes. Anyone building the merged resolver reads names out of that channel, so the wrong sentence would have been read as "you cannot".

2026-09-09 — the two keyword arms that COULD share the builder now do

tkChar_T, tkBoolean_T and tkSingle_T/tkDouble_T/tkExtended_T/tkReal_T call TryScalarNamedCast instead of building their own nodes. Each of those arms was the shared builder's corresponding branch MINUS its first arm, TryExplicitOpCast, and that missing arm was live:

with operator Explicit(a: TRec): X in scope before fpc 3.2.2
Boolean(r) TRUE (reinterpreted the bytes) FALSE
TMyBool(r) FALSE FALSE
Double(r) refused: cannot assign record to Double 16.25
TMyDbl(r) 16.25 16.25

A silent wrong value at one spelling and a refusal at the other, with the identifier alias of the SAME type correct in both cases. That is this ticket's defect rate continuing, not a new family — and it is the two arms the state note above named as still building their own nodes.

The Char row of the first probe passed while the defect was live, because the operator was Chr(a.v) and a.v was 65: the reinterpret and the operator both answer 'A'. An expected value that collides with the failure value is a row that cannot fail, whatever else is right about the control. The fixture's operator adds one so that 'B' is reachable only through it.

Byte-identity control, since "one body now" is a codegen claim no output test can make: 35 files in test/ that spell one of the six keyword cast targets, compiled by the pre-change and post-change compilers — 34 built by both, 34 byte-identical, 0 differing, 1 (test_parallel_for_private) built by NEITHER and therefore pre-existing. The harness's positive control is the probe itself: it reports DIFFERS there, with Boolean keyword TRUE against FALSE. PXX_ALLOW_FULL_SUITE=1 was needed for the sweep — an identity claim is exactly what the quick tier's value assertions cannot make.

Order equivalence is why the merge is byte-identical rather than merely green: PromoDemoteToInt64 is a no-op on a record or class and VariantCastToTemp declines a non-variant operand, so moving TryExplicitOpCast ahead of both changes nothing for any operand that is not the one it fixes.

STILL OPEN, and it is now the whole of this ticket. The RECOGNITION merge: one name -> (castKind, enumId, aliasIdx) resolver replacing FindTypeAlias and BuiltinScalarTypeKind asked separately. The two keyword arms that remain outside the shared builder are outside it correctly — tkInteger_T/ tkLongWord_T build an AN_CALL passthrough (the Ord/Chr model, not a cast node) and tkString_T is a target TryScalarNamedCast explicitly declines, along with tyPointer, for the reasons its own header gives.