acdream/docs/research/2026-08-12-fa2-review-blast.md
Erik 2e97924801 docs: FA2 blast review -- APPROVE-WITH-FIXES (2 MUST-FIX, 7 SHOULD-FIX)
Blast-radius review of FA2 (1c401048, 369729f0, cced83b4, 12053e61)
along the axes the implementer did not traverse.

MF-1: GameRuntime.cs:716-725 -- CompletedTeardownStages case 9 claims
FellowshipDisposed one stage early (10 flags where the pre-FA2 case had
exactly 9, ending at CommunicationDisposed). Only observable on the
teardown failure path, which is precisely when the ledger must be
honest. No test pins intermediate stages, so nothing caught it.

MF-2: seeding RuntimeAllegianceState from 0x027C AllegianceInfoResponse
is a retail divergence with no register row. Retail's
CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470
unpacks into a STACK-LOCAL CAllegianceProfile and destroys it on return;
Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0 uses it only
as a read source for AddTextToScroll. 0x027C is text-only in retail; the
panel is fed exclusively by 0x0020. Concrete risk: 0x027C carries no
rank, so an @allegiance info before the first 0x0020 leaves the owner at
HasProfile=true with a fabricated Rank=0 for FA3's panel to render.

SHOULD-FIX: the seam doc still carries the false dispatcher claim FA2
disproved (only the plan ledger and a code comment were corrected);
no disposition recorded for the two deliberately-skipped dead events;
three count claims wrong (15 delegate holes -> 10; 12 Send wrappers ->
11; 11 S->C events -> 10); no test covers the router->owner plumb
including the one non-trivial lambda; ResetSession's disposal guard
diverges from the precedent it cites; allegiance reconnect-survival has
no register row; GetVassals allocates against the stated view contract.

Verified clean and enumerated exhaustively: every WireAll site (one
production, shared by both hosts), both bindings sites, every
IGameRuntimeCommands/IGameRuntimeView implementer (no bot-reachable
stub), zero auto-fire on all 11 new Send wrappers incl. 0x00A6/0x001F,
reset- and teardown-stage renumbering at every enumeration point, the
central accepting gate, host-adapter self-guid and owner-borrow
equivalence, the K-slice bot policies + trace recorder (21/21), the
@allegiance info live path (79/79), and the suite accounting -- measured
13,201/4/0 (13,205 total) with the +43 reconciled per test file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 01:46:57 +02:00

24 KiB
Raw Blame History

Campaign FA slice FA2 — BLAST-RADIUS review

Date: 2026-08-12 Reviewer lens: blast radius — what else could these commits have touched, along the axes the implementer did not traverse. Target commits (branch claude/latest-commits-cb0c8f):

Commit Subject
1c401048 feat(net) — fellowship/allegiance outbound wrappers + inbound wiring
369729f0 feat(runtime)RuntimeFellowshipState + RuntimeAllegianceState sibling J-owners
cced83b4 feat(app) — fellowship/allegiance command routing for graphical + headless hosts
12053e61 docs — divergence register TS-81/TS-80 + campaign ledger update

Method: read-only. Full enumeration of every construction/call site of the four widened contracts across src/, tests/, tools/; retail decomp cross-check of the one inbound semantic decision; targeted dotnet test --no-build -c Release runs on the post-FA2 binaries (built 2026-08-12 01:32:4501:32:50, i.e. at the FA2 tip). No builds, no client launches, no subagents.


VERDICT: APPROVE-WITH-FIXES — 2 MUST-FIX, 7 SHOULD-FIX

The slice's structural claim holds: one inbound registration site serves both hosts, both host command adapters borrow the exact canonical owners, no new outbound wrapper auto-fires, and the K-slice bot policies + trace recorder are genuinely unbroken. The suite accounting reconciles to the test, not approximately. Two defects: an off-by-one in the teardown-stage ledger that no test can see, and one inbound seeding decision that retail's own handler refutes and that carries no register row.


MUST-FIX

MF-1 — CompletedTeardownStages case 9 is off by one: it claims FellowshipDisposed one stage early

src/AcDream.Runtime/GameRuntime.cs:716-725

_disposeStage == N means stages 0..N-1 have completed (Dispose() increments after DrainCurrentStage() returns true, GameRuntime.cs:641-656). Stage 8 disposes Communication; stage 9 disposes Fellowship (DrainCurrentStage, :775-780). So at _disposeStage == 9 the completed set must end at CommunicationDisposed9 flags.

The pre-FA2 code got this right by construction: 9 => Complete & ~IdentityDisposed & ~EntityObjectsDisposed = 11 2 = 9 flags. The FA2 rewrite spelled the case out longhand and added one flag too many:

9 => GameRuntimeTeardownStage.HostLeasesReleased
    | ... | GameRuntimeTeardownStage.InventoryDisposed
    | GameRuntimeTeardownStage.CommunicationDisposed
    | GameRuntimeTeardownStage.FellowshipDisposed,   // ← one stage early

That is 10 flags. Every other case is correct (10 = Complete & ~Allegiance & ~Identity & ~EntityObjects = 13 3 = 10 ✓; 11 = 11 ✓; 12 = 12 ✓).

Why it matters, not just cosmetics. The one moment CompletedTeardownStages is observable at an intermediate value is the failure path: Dispose() throws "GameRuntime teardown stage 9 did not complete." (:651-655) or an inner exception propagates, and the caller then reads Ownership.CompletedTeardownStages to learn how far teardown got. In exactly that case the ledger asserts FellowshipDisposed for the stage that just failed to dispose the fellowship. This is the class of ledger lie the Slice-J ownership accounting exists to prevent.

Why no test caught it. Only two assertions touch this property, and both sample the endpoints: GameRuntimeTests.cs:97 (None at stage 0) and :204-205 (Complete at the end). No test pins an intermediate stage. Grep of CompletedTeardownStages across src/ + tests/ returns exactly those two plus the definition.

Fix: delete the | GameRuntimeTeardownStage.FellowshipDisposed line from case 9, or restore the original subtraction spelling (9 => Complete & ~Fellowship & ~Allegiance & ~Identity & ~EntityObjects), which is self-checking. Add one test that walks the intermediate ledger so the next owner insertion cannot repeat this.


MF-2 — Seeding RuntimeAllegianceState from 0x027C is a retail divergence with no register row, and it fabricates Rank

src/AcDream.Core.Net/GameEventWiring.cs:231-241 (the fold), src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs:95-116 (ApplyInfoResponseSelf), class doc :29-41.

The class doc states RuntimeAllegianceState is "Seeded by 0x0020 AllegianceUpdate … and, self-gated, by 0x027C AllegianceInfoResponse". The retail client does not do the second half.

Primary source. CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470 (named-retail/acclient_2013_pseudo_c.txt:686294-686320) constructs a stack-local CAllegianceProfile var_118, unpacks the wire into it, hands it to the handler, and destroys it on return — nothing is retained. ClientAllegianceSystem::Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0 (:375144-375228) uses that profile purely as a read source for AddTextToScroll lines (the asterisk note, the Allegiance information for %hs%s header, Patron:, Vassals: and the per-vassal lines) and destroys its own local CAllegianceData var_38 on exit. 0x027C is text-only in retail. Retail's allegiance panel is fed exclusively by 0x0020.

Concrete downstream risk, not theoretical. ApplyInfoResponseSelf replaces _monarch, _records, _allegianceName, _totalMembers, _totalVassals, sets _hasProfile = true and latches _hasServerSeed = true — but the 0x027C wire carries no rank, so _rank keeps whatever it had, which is 0 before any 0x0020 lands. A player who types @allegiance info (empty name ⇒ self) before the first 0x0020 push leaves the canonical owner reporting HasProfile = true, HasServerSeed = true, Rank = 0. FA3's panel — whose whole point is reading this snapshot — would then render rank 0 for a real allegiance member, sourced from a message retail treats as chat text. The code comment at :98-99 ("the last known rank (if any) is retained") shows the gap was seen and classified as a retain rather than as a divergence.

The self-gate (TargetGuid == playerGuid()) is a good instinct and correctly prevents the worse failure — a by-name query overwriting your own tree — but it does not make the seeding retail-faithful.

Fix (either is acceptable, the register row is not optional): (a) drop the onAllegianceInfoResponseSelf seeding entirely and let 0x0020 be the sole profile source, matching retail; or (b) keep it as a deliberate adaptation and add the register row in the same commit per CLAUDE.md's binding rule, naming the retail oracle above and the Rank-fabrication risk in the "Risk if assumption breaks" column. If (b), gate the seed on _hasServerSeed (or carry a RankIsAuthoritative flag) so a 0x027C-only profile cannot present a fabricated rank as truth.


SHOULD-FIX

SF-1 — The seam doc still carries the false dispatcher claim the slice disproved

docs/research/2026-08-11-fa-acdream-seams.md:262-264 still reads that a second registrar.Register for the same type is fine because "the dispatcher supports multiple owned handlers per type … and accept that both fire"; :853 repeats it ("The dispatcher permits multiple owned handlers per type"). Both are wrong — GameEventDispatcher.Dispatch (src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117) does _handlers.TryGetValue(...) and invokes the single stored RegistrationNode; RegisterOwned replaces.

The correction was recorded in the plan ledger row and in a GameEventWiring.cs code comment — but not in the seam map, which is the document FA3/FA4/FA5 are contracted to read. The FA1 round established the pattern of fixing this doc in place with a dated addendum (511ba6e5, 1bb707e2); FA2 did not follow it. Add the dated addendum at :262-264 and :853.

SF-2 — Seam doc §2.3's event table has no FA2 disposition for 0x01C9/0x01CA

The table at :240-254 is titled "The 11 events FA must register" and lists 12 rows. FA2 registered 10 (9 new + the 0x027C fold). The two it skipped — 0x01C9 FellowshipFellowUpdateDone, 0x01CA FellowshipFellowStatsDone — are correctly skipped: FA1 established both COMDAT-fold onto the identical no-op body in the Sept-2013 client and are parse-and-ignore only (src/AcDream.Core.Net/Messages/GameEvents.cs:779-803). But nothing in the FA2 commits records that disposition, so FA3 will re-derive it or, worse, "fix" the gap. One line in the seam-doc table closes it.

SF-3 — Three count claims in the ledger/commit messages are wrong

  • Plan ledger (docs/plans/2026-08-11-fellowship-allegiance-campaign.md, FA2 row) and 1c401048's message both say "15 new GameEventWiring.WireAll delegate holes". The actual count is 10 (GameEventWiring.cs:107-120: onFellowshipFullUpdate, onFellowshipUpdateFellow, onFellowshipQuit, onFellowshipDismiss, onFellowshipDisband, onAllegianceUpdate, onAllegianceInfoResponseSelf, onAllegianceUpdateDone, onAllegianceUpdateAborted, onAllegianceLoginNotification).
  • Plan ledger says "12 new WorldSession.Send* wrappers". The actual count is 11 (WorldSession.cs:2318-2404: 7 fellowship + 4 allegiance). SendAllegianceInfoRequest (:2312) pre-dates FA2. The 12 figure is correct for cced83b4's *RuntimeCmd records / LiveSessionCommandBindings send delegates (12, because SendAllegianceInfoRequest gets an App-bus record over the pre-existing wrapper) — the ledger conflated the two.
  • 1c401048's message says the holes cover "the 11 S→C fellowship/allegiance events"; 10 event types are handled.

These are the numbers a future reviewer reconciles against. Correct them in the ledger.

SF-4 — Nothing tests the router → owner plumb, the one non-trivial lambda included

The single production registration site (src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:237-264) is untested. Both Runtime router-test factories still construct LiveSocialSessionBindings without the new trailing arguments (tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs:110-115 and :770-775), so Fellowship/Allegiance default to null and every FA2 lambda no-ops through ?. in every router test.

GameEventWiringTests proves the delegates fire; RuntimeFellowshipStateTests proves Apply* behaves. Neither covers the seam between them, and that seam is not uniformly trivial: onFellowshipQuit/onFellowshipDismiss supply inventory.PlayerGuid() as the self-guid, which is what selects "remove one member" vs "clear the whole snapshot". A transposed argument or a wrong guid source there is invisible to every test in the slice. Add one router test that wires real owners and dispatches a self-quit and an other-quit.

(inventory.PlayerGuid itself is safe: non-nullable Func<uint> on LiveInventorySessionBindings:35, null-checked in LiveSessionEventRouter.Validate:626.)

SF-5 — RuntimeFellowshipState.ResetSession() throws when disposed, unlike the precedent it cites

src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs:205-209 opens with ObjectDisposedException.ThrowIf(IsDisposed, this). The commit message and the class doc both claim this owner matches "the ExternalContainer precedent" — but RuntimeInventoryState.ResetExternalContainer (:104) and RuntimeCommunicationState.ResetNegotiatedChannels (:167) are bare delegations with no disposal guard.

Not reachable today (teardown drains pending resets at stage 3, fellowship disposes at stage 9), but the reset transaction is retryable — a RuntimeGenerationResetStage.Fellowship that throws can never converge on retry, since disposal is terminal. Either drop the guard to match the precedent, or make ResetSession a no-op when disposed.

SF-6 — RuntimeAllegianceState surviving generation reset has no register row

The design (allegiance persists across reconnect; HasServerSeed clears only at Dispose) is deliberate, documented at RuntimeAllegianceState.cs:16-27, and pinned by RuntimeGenerationResetTests.FellowshipClearsAtResetButAllegianceSurvivesReconnect. It is also un-retail-able: retail's client cannot survive a reconnect in-process, so there is no retail behavior this matches — it is an acdream adaptation, and adaptations get rows. The stale window is narrow today (reconnect targets the same character; in-process character switching does not exist — AD-76), but the row is what stops a future in-process character-select from silently inheriting the previous character's monarch/records.

SF-7 — IRuntimeAllegianceView.GetVassals allocates against the stated view convention

RuntimeAllegianceState.cs:254-271 builds and returns a fresh List<> per call. The seam doc's view contract line (:815) states the convention as "Snapshot record struct + TryGet*; no allocation". The reason is understandable (cannot yield inside the lock), but a per-frame panel poll in FA3 will allocate. Either document the exception at the call site or return a caller-supplied buffer.


VERIFIED CLEAN

Each item below was enumerated exhaustively, not spot-checked.

1. WireAll call sites — every one, src + tests + tools. Exactly one production site: LiveSessionEventRouter.cs:185. Because LiveSessionEventRouter is shared by both hosts (the K-slice unification), the "single registration site serves both hosts" claim is structurally true, not merely asserted. Every other caller is a test: GameEventWiringTests.cs (24 sites), Messages/ClientCommandResponsesTests.cs (4), WorldSessionWiringOwnershipTests.cs (3), AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs (2). All use named/positional prefixes and take the new parameters as null — which is the correct pre-FA2 behavior, since the FA2 registrations are individually is not null-gated (GameEventWiring.cs:246-320). No site silently drops events for one host. No tools/ site exists.

2. Both LiveSocialSessionBindings production sites pass the same owners. src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:262-263 (_domain.Runtime.FellowshipOwner / AllegianceOwner) and src/AcDream.Headless/Hosting/HeadlessSessionHost.cs:786-787 (Runtime.FellowshipOwner / AllegianceOwner) — same canonical instances off the same GameRuntime. No semantic divergence between hosts at the bindings layer. The two test sites (LiveMovementStatsApplierTests.cs:68-72, LiveSessionEventRouterTests.cs:110/770) default to null — see SF-4.

3. No competing registration for any newly-registered GameEventType. Grep of GameEventType.Fellowship* / GameEventType.Allegiance* across src/ returns only GameEventWiring.cs. The 0x027C collision the implementer found was the only one, and the fold at :231-241 preserves the pre-existing @allegiance info chat output unconditionally before the (gated) Runtime callback. The dispatcher-replacement bug they describe is real — confirmed at GameEventDispatcher.cs:95-117.

4. The accepting gate covers all 9 new registrations. It is applied centrally inside OwnedGameEventRegistrar.Register (GameEventWiring.cs:1050-1061), not per-lambda, so the new handlers inherit retiring-generation suppression with no per-site opt-in. Same for RegistrationBuildScope's all-or-nothing ownership.

5. IGameRuntimeCommands implementers — all four found, none is a reachable throwing stub.

  • DirectGameRuntimeCommandAdapter (src/AcDream.Runtime/Session/, :31-32, :85-86) — real implementations of both new interfaces. This is the headless bot command surface.
  • CurrentGameRuntimeAdapter (src/AcDream.App/Runtime/, :118-121) delegating to CurrentGameRuntimeCommandAdapter (:30-31) — real implementations.
  • InteractionUiRuntimeSourcesTests.FakeRuntime (tests/AcDream.App.Tests/ Composition/InteractionUiRuntimeSourcesTests.cs:255-256) — null! stubs. App-layer double, never handed to a bot policy.
  • HeadlessProcessSchedulerTests' recording policies (:726-780) accept IGameRuntimeCommands but never dereference Fellowship/Allegiance.

No HeadlessBotPolicy touches either group — grep of commands. across src/AcDream.Headless/Policies/HeadlessBotPolicy.cs returns only Chat/Portal/Session/Movement. No bot policy can reach a stub.

6. IGameRuntimeView implementers (the same widening, one axis over). GameRuntime:499-500 and CurrentGameRuntimeAdapter:89-90 are real; the two test doubles (InteractionUiRuntimeSourcesTests.cs:237-238null!, GameplayInputCommandControllerTests.cs:230-231throw NotSupportedException) are App-only and unreachable from headless.

7. No new Send* wrapper auto-fires; 0x00A6 and 0x001F have zero live callers. Every one of the 11 new WorldSession.Send* methods is reached only from DirectGameRuntimeCommandAdapter and, via LiveSessionCommandBindings delegates + *RuntimeCmd records, from CurrentGameRuntimeCommandAdapter. SendFellowshipUpdateRequest (0x00A6) is reached only by IRuntimeFellowshipCommands.SetPanelOpen on both adapters (DirectGameRuntimeCommandAdapter.cs:949, CurrentGameRuntimeCommandAdapter.cs SetPanelOpen), and SetPanelOpen has no callers anywhere — grep of FellowshipCommands/AllegianceCommands and .Fellowship./.Allegiance. across src/ + tools/ returns only the interface declarations, the adapter properties, and the trace-recorder field reads. Same for SendAllegianceUpdateRequest (0x001F) via SetUpdateSubscription. Nothing fires until FA3/FA4 wires a caller.

8. Reset-stage renumbering is safe at every enumeration point. RuntimeGenerationResetStage is enumerated in exactly one place — the while switch in RuntimeGenerationReset.Drain (:250-360) — and the insertion of Fellowship = 12 shifted the tail 12→21 consistently across the enum, the switch, and the state.Stage = ... jumps. No test pins a stage count or a numeric ordinal; the three tests that name a stage (RuntimeGenerationResetTests.cs:121/160, LiveSessionResetPlanTests.cs:279) name stage identities (DrainHostProjection, CompleteHostProjection, RetireEntities) whose meaning is unchanged. No assertion was updated to pass without understanding — the only reset-test edit in the slice is the new FellowshipClearsAtResetButAllegianceSurvivesReconnect fact, which pins real behavior on both sides. Headless teardown has no separate stage list.

9. Teardown-stage renumbering is consistent everywhere except MF-1. TeardownStageCount 11→13 (GameRuntime.cs:129), DrainCurrentStage (:778-790) and IsCurrentStageComplete (:808-812) both renumbered in lockstep, flag bits shifted 1<<9..1<<12 with Complete extended (:34-53), construction fault points added (GameRuntimeConstructionPoint.FellowshipCreated/AllegianceCreated) and covered by two new [InlineData] cases. Only CompletedTeardownStages case 9 is wrong.

10. Both host adapters read the same self-guid and borrow the same owner. DirectGameRuntimeCommandAdapter.Quit uses _runtime.PlayerIdentity.ServerGuid; CurrentGameRuntimeCommandAdapter.Quit uses _view.Lifecycle.PlayerGuid — and GameRuntime.Lifecycle (:484-488) populates that field from PlayerIdentity.ServerGuid, with CurrentGameRuntimeAdapter.Lifecycle (:68-79) only overriding State and HasTransport. Same value. CurrentGameRuntimeAdapter:52 passes runtime.FellowshipOwner — the exact canonical instance, not a second copy. The leader-hand-off rule therefore behaves identically on both hosts.

11. The EmitUnsupported vs EmitResult spelling difference is not a semantic divergence. DirectGameRuntimeCommandAdapter uses EmitUnsupported for its FA2 argument rejections, CurrentGameRuntimeCommandAdapter uses EmitResult. EmitUnsupported(domain, op, status, guid) (DirectGameRuntimeCommandAdapter.cs:1355-1367) differs from EmitResult(domain, op, status, guid, text: null) (:1369-1383) only in its default status — and every FA2 site passes RuntimeCommandStatus.Rejected explicitly. Identical emitted event and identical returned result.

12. Both hosts gate the FA2 commands with their own standard in-world gate. Direct: Validate(gen, out session) requires _route is not null && session is not null && _runtime.Session.IsInWorld (:1385-1406). App: Validate(gen, requireWorld: true) (:1045-1074) — the same value every other gameplay command family in that adapter passes. No FA2 command is looser than its neighbours.

13. K-slice bot policies + trace recorder: confirmed unbroken (the D2 promise). No IRuntimeEventObserver member was added — the trace recorder gained two appended interpolated fields only (GameRuntimeEvents.cs:184-189), reading RuntimeStateCheckpoint's new = default parameters, so any checkpoint built without them prints fellowship=0:False:0;allegiance=0:False:0 rather than faulting. RuntimeCommandDomain gained two trailing values (11, 12) — additive, no renumber. Targeted run: AcDream.Headless.Tests --filter "Policy|Scheduler|Bot"21/21 passed.

14. The @allegiance info live path is still green on post-FA2 binaries. Targeted run AcDream.Core.Net.Tests --filter "Allegiance|Fellowship"79/79 passed, including the FA1 live-surface pins (WireAll_AllegianceInfoResponse_ReachesChatTranscript, WireAll_AllegianceInfoResponse_MalformedTree_PrintsNothing) and the new WireAll_AllegianceInfoResponse_SelfGated_FiresOnlyForOwnGuid. The fold preserves the chat output unconditionally; the Runtime callback is additionally gated on playerGuid is not null, so the two WireAll(...) sites in ClientCommandResponsesTests that pass no playerGuid behave exactly as before. (The semantics of that seeding are MF-2; the chat path is intact.)

15. Suite accounting reconciles exactly — measured, not quoted. Full per-project runs on the post-FA2 Release binaries:

Project Passed Skipped Total
AcDream.Runtime.Tests 1593 0 1593
AcDream.Core.Net.Tests 895 0 895
AcDream.App.Tests 4853 3 4856
AcDream.Headless.Tests 119 0 119
five untouched projects (from FA1's measured breakdown) 5742
total 13,201 4 13,205

Matches the ledger's 13,201/4/0 (13,205 total) claim exactly. The +43 delta reconciles against the actual test-file diffs, per file:

File Added
RuntimeFellowshipStateTests.cs 10 [Fact] + 2 [Theory]×2 [InlineData] = 14
RuntimeAllegianceStateTests.cs 7
RuntimeGenerationResetTests.cs 1
GameRuntimeTests.cs 2 (new [InlineData] on an existing [Theory])
Session/DirectGameRuntimeCommandAdapterTests.cs 10
GameEventWiringTests.cs 9
total 43

cced83b4 added zero tests — its three test-file edits (InteractionUiRuntimeSourcesTests, GameplayInputCommandControllerTests, LiveSessionCommandRouterTests) are compile-only interface/record fills, which matches App.Tests holding at 4856. RuntimeGameplayOwnershipTests.cs and GameRuntimeContractTests.cs are likewise compile-only — and importantly, no assertion in either was loosened: the ownership tests construct the two new owners, dispose them alongside the existing ones, and keep every Assert.True(retired.IsConverged) intact (which now additionally requires the two new IsConverged clauses added at RuntimeGameplayOwnership.cs:20-26).

16. Divergence-register bookkeeping is exact. Section-4 header 47 → 48 active rows, and the measured | TS- row count in the section is 48. TS-81 is well-formed (7 pipes / 6 columns, matching the table header) with a real "Where" cite (RuntimeAllegianceState.cs ApplyLoginNotification), a rationale grounded in CLAUDE.md's no-invented-English rule, and the retail oracle chain (ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220). TS-80's narrowing is dated and correctly scopes the remaining gap to FA4. The BN-mislabel caution is the right call — this is exactly the artifact class the project has been burned by before.

The one register gap is MF-2 (and SF-6): FA2 introduced deviations that got no rows.


Appendix — what I did not review

Mechanism-level correctness of the FA1 parsers, the retail-faithfulness of the fellowship apply rules (REPLACE / UPSERT / self-vs-other), and the leader-hand-off ordering against lane B §2.5/§3.6 are the mechanism review's scope, not this one. I checked them only where a blast-radius question forced it (MF-2's retail decomp read; the Quit self-guid equivalence across hosts).