acdream/docs/research/2026-08-04-c4-route-3-architecture-review-round2.md
Erik e0f96a55bf fix(physics): C4 route 3 — portal placement authority (local player)
Removes a duplicate placement authority for local-player portal arrival.
Portalling worked before this change and works after it — this is not a
bug fix, EXCEPT that it found and fixed one dead-code production bug.

THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the
accepted destination at Place time, but TryBeginPortalReveal already
consumes that slot at Aim time — so the arm was 100% dead code and every
real portal Place refused with host-token-unavailable. Found only
because we refused to accept 7 skipped tests instead of chasing the
count to zero.

RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING:
SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with
flags 0x1012, followed by PlayerPositionUpdated.

BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed
here (ConstrainTo @0x0045418A) and velocity is zeroed
(set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook
runs AFTER placement (@0x004538AE).

THE THREE-ROUND DEFECT CHAIN, HONESTLY:
- Round 1 released the player at the pre-teleport position while the
  anim stream marched on — the contract wrongly assumed Place re-fires
  (process rule 1's third occurrence this campaign).
- Round 2's fix inferred commit from a global PendingCount, which three
  non-committing paths also clear — making the SAME bug complete
  cleanly and silently. Strictly worse than round 1: round 1 at least
  tripped portal-complete-before-materialized.
- Round 3 latches the commit where it actually happens
  (ReconcileAndAcknowledgePortal), keyed on reveal generation and
  teleport sequence, via TryConsumePortalCommit. Two of the three
  required regression tests landed and are sabotage-verified on both
  hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted /
  HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted).
  The third (force-arm-takes-the-slot) was judged unnecessary on review:
  with the inference gone, PendingCount is only a "don't ask yet" guard
  at both gates, so a force operation occupying or vacating the slot no
  longer changes an input the commit decision reads — the case collapses
  into what the landed test already discriminates.

THE B2/P3 RESOLUTION: both round-2 reviews were right about different
branches of the same synchronous call. RuntimePlacementProjectionSubscription
.OnPlacement acknowledges the FIFO head only when TryApply returns true;
a Place whose portal authority went stale (transit ended/superseded
while parked) used to return false, wedging every later entity's
placement receipt behind it forever. Both sinks
(RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink)
now acknowledge-and-ignore a stale-authority Place instead of refusing
it. The regression test (RuntimePlacementPresentationSinkTests
.PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had
been asserting the old, wrong `false` behaviour; it now asserts and
sabotage-verifies the fix.

Also lands: AP-144 (register discipline — the portal movement-event
send reuses the stricter UsePositionFromServer gate where retail's
SendMovementEvent is the looser autonomy_level != 0 test, diverging
only at level 1, currently unreachable), AP-145 + issue #318 (the
local-player collision-shadow presentation write bypasses its own
publisher's ShadowObjects write via a direct cache .Set(), self-healing
only once dedup diverges — filed, not fixed, pending a composition
test), AD-42 deleted (its last citation retired by the canonical portal
arm), AD-2 updated (the wait-cue's trigger predicate now covers a
second cause), and two documentation corrections: the enter_world
misattribution (both call sites are in SmartBox::HandleCreateObject,
only one in the player branch — portal arrival is TeleportPlayer, not
enter_world) and the stale "local player never reaches this path"
comment on the generic-remote-render-pose write.

Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing
weakened.

STILL OWED: the connected two-client gate, with
ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines
actually appear in the capture — and explicitly NOT scored as covering
issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects
directly).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:57:37 +02:00

24 KiB

C4 route 3 — architecture / adversarial review, ROUND 2 (delta) — 2026-08-05

Verdict: FAIL.

Reviewed: the uncommitted working tree at HEAD cd3129e9, +2,570/-232 across 16 files. Round-1 report: 2026-08-04-c4-route-3-architecture-review.md. dotnet build AcDream.slnx -c Debug exits 0.

Round-1 findings closed: A4, A6, A9, A10(a). A1, A2, A3 were addressed with real design work that is directionally right — the A1 fix in particular (inverting the readiness feed instead of touching the sequencer) is the correct architectural answer to a hard constraint, and I want that stated plainly.

The FAIL is one defect, present symmetrically on both hosts, introduced by the A1/A3 fixes themselves: both new "am I committed yet?" gates infer commit from the drive controller's global pending slot being empty. That slot empties on at least three paths that do not commit — including the one the drive's own doc comment names as the expected outcome of a park. When it does, the graphical controller latches _placementCommitted = true and the headless projection reports IsCollisionReady: true, and both hosts then march the full completion sequence against an unmoved body. That is round-1's A1/A3 restored, and on the graphical side it is now worse, because AcknowledgePortalMaterialized succeeds where it previously failed its invariant.

Numbering continues as Bn to avoid collision with round 1.


MAJOR — the FAIL

B1 — PendingCount == 0 is not "committed"; both hosts infer commit from a signal that is also set by three non-committing paths

Severity: MAJOR (FAIL basis). Both hosts. Uncovered by any test.

Graphical, src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:646-658:

if (_awaitingDeferredWake)
{
    if (_acceptedPositionDrive.PendingCount != 0)
        return false;
    _awaitingDeferredWake = false;
    _placementCommitted = true;      // <-- infers commit from "not pending"
    return true;
}

Headless, src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:816-821:

if (_awaitingPortalWake)
{
    committed = _acceptedPositionDrive.PendingCount == 0;
    if (committed)
        _awaitingPortalWake = false;
}

PendingCount is RuntimeAcceptedPositionDriveController.cs:330_pending is null ? 0 : 1. It is (a) global, not portal-scoped, and (b) cleared by every terminal path, committing or not. Advance() clears _pending at five sites; three of them run without a portal commit:

site condition committed?
:975 "The watch died — most likely a subsequent accepted Position's merge-time Forget" no
:920 A2's new abandon-at-wake (!IsPortalAuthorityCurrent) body moved by RetryDeferred, suffix skipped
:998 A2's new abandon at the prepare-retry branch (CancelToken) no — nothing ever placed

The :975 path is the modal case, not a corner case. The method's own doc comment (:880-892) says it verbatim:

RuntimeEntityObjectLifetime.TryApplyPosition calls Forget on EVERY accepted Position for this entity … ACE broadcasts at 5-10 Hz, so a DeferredCell park surviving past one broadcast interval is cancelled before its collision generation can ever commit it — the exact far-destination case the park exists to serve.

So: park → within ~100-200 ms an ordinary broadcast Forgets it → Advance clears _pendingPendingCount == 0 → both gates declare success.

Concrete failure scenario (graphical). Portal to a landblock whose collision generation has not committed. TryExecuteAcceptedPortalArrival returns DeferredCell; _awaitingDeferredWake = true. One ACE broadcast later the park is Forgotten and _pending clears. Next Tick: _placementCommitted = trueplacementReady = true → the sequencer leaves Tunnel and fires Place → the if (!_placementCommitted) return; guard at :557 passes_placement.Place(_pendingRotation) writes the render entity from the unmoved controller.Position and rebuckets to the source cell → ObserveMaterialized(_pendingRevealGeneration, sequence, _pendingCell) succeeds (the reveal is still active and current) → PlayExitSound reveals the world viewport → FireLoginComplete sends LoginComplete, and _transit.Complete(generation) now passes its portal-complete-before-materialized check because materialization was falsely acknowledged. The player is released into the world standing at the pre-teleport position, the transit reports a clean completion, and nothing logs an invariant failure. Round 1's A1 at least tripped FailInvariant("portal-complete-before-materialized"); this does not.

Concrete failure scenario (headless). Identical shape: PrepareDestination returns IsCollisionReady: true, so RuntimeLiveEntitySessionController.TryAdvancePortalCompletion runs AcknowledgeDestinationReadinessAcknowledgePortalMaterializedCompleteTerminalProjectedLoginCompleteEndTeleport, and sets controller.State = PlayerState.InWorld, all with the body unmoved. That is round-1 A3 verbatim.

Secondary hazard from the same root: because the slot is global, a portal park killed by a merge-time Forget can be immediately replaced by the force arm's own RetainPending from that same merge (TryExecuteAcceptedLocalPositionRetainPending). The portal gate then polls a ForcePosition operation, waits for it, and latches "portal committed" when the force operation settles.

Why no test caught it. Both new park tests (PortalDeferredCell_ParksThenCommitsExactlyOnceOnTheCollisionGenerationWake, HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake) commit the destination's collision generation so the park resolves by committing. PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale asserts the drive's behaviour (Assert.Empty(gameActions)) and stops there — it never asks what the host gate concludes from the resulting PendingCount == 0. The seam between "the drive retired the park without committing" and "the host decides the portal is placed" is exactly where the defect lives and is exactly what no test crosses.

Fix direction. Stop inferring. The drive already knows the answer with certainty — ReconcileAndAcknowledgePortal runs on commit and only on commit. Publish that fact:

  • add a portal-commit observable to RuntimeAcceptedPositionDriveController — e.g. bool TryConsumePortalCommit(long revealGeneration, ushort teleportSequence) latched in ReconcileAndAcknowledgePortal and cleared on consumption, or an Action<RuntimePortalPlacementAuthority> commit callback supplied at construction alongside isPortalAuthorityCurrent;
  • have both host gates consume that, keyed on the reveal generation/sequence they are waiting for, so a force pending, a Forgotten park, and an abandoned park are all correctly "still not committed";
  • give the park a terminal "abandoned" outcome the host can see, so it can either re-attempt cleanly (_awaitingDeferredWake = false and try Begin again next tick) or converge through the existing transit cancellation rather than silently succeeding.

Required tests (both must fail against the current code):

  1. Graphical: park, then kill the park with a merge-time Forget (an ordinary accepted Position — the fixture's OfferDestination already performs the merge) instead of committing the collision generation; drive 100 ticks; assert Placement.Called == false, Movement.Controller.Position unchanged, Reveal.PortalMaterializationCount == 0, Session.LoginCompleteCount == 0, Controller.IsActive == true.
  2. Headless: same, asserting PrepareDestination keeps returning IsCollisionReady: false and controller.State stays PortalSpace.
  3. Graphical: park, then let the force arm take the pending slot; assert the portal gate does not latch when the force operation settles.

MEDIUM

B2 — A2's re-validation does not close the FIFO wedge it was written for; it only narrows reachability

The implementer's own note is accurate and I confirm it: RetryDeferredCommitCanonical publishes the Place receipt asynchronously, and IsPortalAuthorityCurrent runs only afterwards, inside Advance, gating the suffix. But the wedge was never in the suffix — it is in the receipt:

  • Advance:920's abandon branch is reached only after TryPeekAcknowledgedPlacement succeeded, i.e. the sink already accepted the receipt. On that path there was never a wedge to prevent.
  • The wedge path is the one where the sink refuses: RuntimePlacementPresentationSink.TryApplyRuntimeWorldTransitState.IsCurrentPlacementAuthority false → return false → RuntimePlacementProjectionSubscription.OnPlacement leaves it at the FIFO head → every later placement receipt for every entity is blocked and the drive's pending never converges. IsPortalAuthorityCurrent never runs on that path, because TryPeekAcknowledgedPlacement never yields.

What the A1 fix does buy is reachability: with placementReady false during a park, the sequencer cannot reach FireLoginComplete, so the transit no longer ends underneath an outstanding park in the ordinary flow. The remaining entries are mid-transit supersession (a second F751 while parked → OnTeleportStartedResetTransitEndTeleport), ResetSession, and ResetGenerationPresentation — i.e. exactly the §9 "honest gap" cases.

That is a genuine narrowing and I credit it. It is not closure, and the consequence remains unbounded (whole-FIFO stall + non-convergent shutdown), so contract stop condition 4 ("P3 finds a portal receipt no mechanism can consume or retire — the FIFO-wedge shape changes the design, not the test") still applies.

Fix direction. The retire path has to exist at the receipt, not the suffix. Either (a) let the sinks treat a Place whose entity/versions are current but whose portal authority is dead as acknowledge-and-ignore (the same shape Discard/ExecutorCompleted/WithdrawalRestored already use, and for the same stated reason — refusing wedges the ordered stream), or (b) have CommitCanonical drop a portal suffix it can already see is not current rather than publishing a receipt nothing can consume. (a) is smaller and matches the existing precedent in both sinks' own doc comments.

B3 — Headless _awaitingPortalWake is not reset across teleports within one session

HeadlessSessionWorldProjection.cs:762. The graphical twin (_awaitingDeferredWake) is cleared in ResetTransit:932, which OnTeleportStarted calls — clean. The headless field has no equivalent: it is cleared only when the poll declares success, and HeadlessSessionWorldProjection is constructed per session, not per teleport.

Scenario. Teleport 1 parks → _awaitingPortalWake = true. Before the next PumpPortalCompletion, a new F751 arrives; TryCompletePortal overwrites _pendingPortalCompletion with reveal 2. The next pump calls PrepareDestination(reveal 2), which takes the poll branch left over from reveal 1 — so reveal 2's placement is never even attempted, and if PendingCount happens to be 0 it is immediately declared committed. This is B1's inference bug plus a stale latch, on a path that does not require a Forgotten park.

Fix direction. Key the latch to the reveal generation (or clear it in BeginTeleport, which already runs per teleport on this host).

B4 — Both hosts' retry loops are unbounded and undiagnosable

A refusal loop (Contention, stale reveal, or a park that never converges) now retries forever with no timeout and no terminal path.

  • Graphical: the player holds in the tunnel with the retail wait cue after 5 s. This is a modelled end state — AD-2 already documents "predicate never satisfies → portal transit remains in the authored tunnel and presents the centered wait cue" — so an infinite stall is strictly better than round 1's silent release, and I do not consider it blocking. Two gaps, though: AD-2 attributes that state to streaming/DAT failure only, and now a placement refusal produces the identical user-visible state; and _holdSeconds / ObserveWait are now driven by !placementReady rather than !dataReady, which is a real semantic change to the wait cue's meaning. The [tp-probe] REFUSED cause=… line does distinguish them in the log — good — but AD-2 should say so.
  • Headless: worse, because there is no cue and no bound. A bot whose destination collision never becomes resident sits in PlayerState.PortalSpace indefinitely, connected and healthy-looking; PumpPortalCompletion is entirely silent. Under K4's 30-session envelope this is an invisible stuck session. At minimum emit one probe line on the first N retries; ideally bound the wait and fail loudly.

B5 — §8 items 8/9/10 (the committed-receipt presentation suite) — does NOT block, with conditions

Answering the coordinator's direct question.

Does not block route 3. Reasons, in order of weight:

  1. The render-entity half now has a proven mechanism, not a claim. LiveEntityRuntime.TryApplyRuntimePlacementPlace:1394-1402 performs entity.SetPosition(projection.WorldPosition), entity.Rotation = …, entity.ParentCellId = token.ExactCellId and RebucketLiveEntity, synchronously inside CommitCanonical, before TryPublishPlace snapshots. That path is shared with route 2's force arm and C3c's first entry and has existing coverage. Round 2 correctly rewrote the class doc to say so (round-1 A10(a) closed).
  2. The restored Movement.Controller.Position/CellId assertions prove the canonical body resolved the offered destination — which is the half route 3 actually changed ownership of.
  3. The shadow half is a pre-existing route-2 defect, not a route-3 regression: LocalPlayerShadowState.Set (written by the sink) updates only the dedup cache, never PhysicsEngine.ShadowObjects, while LocalPlayerShadowSynchronizer.SyncPose dedups against that same cache. It self-heals on the player's first >1 cm move. Route 3 widens the window (via LocalPlayerProjectionController.Project:102's PortalSpace early return) but does not change its kind.

Conditions — all three, or it does block:

  • It is recorded as an open issue with a number (#312's layer / route 2's B2 gap, now two campaigns old) and carried explicitly into C5's parity-test scope. Not a comment; a tracked item.
  • The connected gate is not scored as covering it. The probe fields (leash, autorun, hookTail) say nothing about the shadow; there is no visual for it. If the user's session passes, the shadow claim remains test-verified-nowhere and must be reported that way (the §9 "honest gap" discipline).
  • The register gets one line under AD-2 or a sibling row naming the cache-without-publish asymmetry, so the next reader does not assume LocalPlayerShadowState.Current means "published".

Concretely, what the test needs (in tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs, whose fixture already owns BeginPortal and a LocalShadow):

  1. Committed local-player portal Place through the real sink. Pre-seed the render WorldEntity at a different (wire) pose — this is also §8 item 9's T8-ordering half. Drive a Place receipt for the local player carrying a VALID portal authority. Assert, after: entity.Position/Rotation/ ParentCellId equal the receipt's WorldPosition/Orientation/ ExactCellId (the wire pose did not survive); the spatial bucket moved to the destination landblock; LocalShadow.Current equals the resolved pose; and PhysicsEngine.ShadowObjects actually holds a row for the player at the destination cell — that last assertion is the one that discriminates cache-only from published, and is the whole point.
  2. Discrimination half. The same receipt with a stale/superseded portal authority must be refused and then retired — not left at the FIFO head. This is also B2's regression test.
  3. Refused-Place presentation (§8 item 10). Already partly covered by RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears; extend it to assert the pre-teleport pose is still the presented pose (entity + world snapshot store), not only that the body is unmoved.
  4. Sabotages that must fail: remove the sink's entity.SetPosition → (1) fails; leave the shadow write as cache-only → (1)'s ShadowObjects assertion fails; make the stale-authority receipt return false forever → (2) fails.

MINOR

B6 — isPortalAuthorityCurrent should be a required constructor parameter

The coordinator's specific concern, checked: both production sites wire itSessionPlayerComposition.cs:597-603 and HeadlessSessionHost.cs:665-671, both to RuntimeWorldTransitState.CanPlacePortalDestination. Four test sites do not, which is fine.

The residual is that a null default silently restores the round-1 defect, and nothing catches a future production site that forgets it — there is no architecture guard for this the way RuntimePhysicsOwnershipTests.ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel guards the placement channel. With only 2 production + 4 test constructions, making the parameter required (tests pass static _ => true) converts a silent regression into a compile error for ~6 lines of churn. Same argument will apply to B1's commit observable.

B7 — _placementCommitted is checked once and never re-validated

LocalPlayerTeleportController.cs:557. Round 1's Place handler ran CanPlacePortalDestination immediately before mutating; round 2 moved that check into TryAdvancePortalCommit's non-deferred branch only (:668-676). Once _placementCommitted latches, the only guard before _placement.Place() / ObserveMaterialized is IsCurrentLifetime. If the reveal is cancelled between the commit and the Place event, ObserveMaterialized refuses (IsCurrentPortalDestination) but the presentation suffix has already run and the stream continues to FireLoginComplete with an unmaterialized reveal. Much less severe than B1 — the body genuinely is at the destination — but it is the same family. Cheap fix: keep the CanPlacePortalDestination re-check in the Place handler alongside _placementCommitted.

B8 — A8 (round 1) confirmed still open and confirmed non-blocking

Five portal tests still call ConvergePortalHost as the last statement of the test body rather than in a finally, so an assertion failure is still masked by a Dispose() throw during unwinding. Correctly flagged rather than silently dropped. Test hygiene only — no production effect. It does mean that when B1's new tests are written, a genuine failure may again present as a teardown throw; fixing the try/finally first would save that debugging round.

B9 — round-1 A11/A12 unchanged

PhysicsDiagnostics.LocalTeleportHostKind process-global (accepted); AddSyntheticIndoorCell geometry-free (accepted — and less load-bearing now that A6's position assertions are restored).


Closed since round 1

round-1 finding status evidence
A4 — route facts unread, inversions hardcoded CLOSED RuntimeAuthoritativePositionRoute.RunsTeleportHook:164 / .ConstrainAfterRouting:170; ReconcileAndAcknowledgePortal reads both; CommitCanonicalTeleportFrame(bool zeroVelocity, bool rearmConstraintLeash) branches on them. ConstrainPhase.None sabotage now fails PortalCommitted_MovesBodyArmsLeashOnceCancelsAutorunAndSendsExactlyOneMovementEvent's stale-anchor assertion, as the contract's §8 item 11 intended.
A6 — superseded-teleport discriminator removed CLOSED NewerStart_ReplacesOldDestinationWithoutReusingIt asserts Movement.Controller.Position.X/Y == 2 and the cell; SameLandblockDestination_… asserts (20,30,4) + 0x20210123; the Z-vs-X/Y reasoning in the comment is sound.
A9 — two sequence sources CLOSED The authority now uses destination.TeleportSequence, with a Debug.Assert pinning it equal to the caller's copy.
A10(a) — class doc claimed the suffix was the render entity's only mover CLOSED Doc rewritten to state the writes are redundant repeats of the canonical receipt's mutation, with the ordering cited.
A1 — refused Place did not stop the anim stream partially — the mechanism is right (readiness inversion, sequencer untouched), and the Contention path is now correctly held and tested (RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears). B1 reopens it for the DeferredCell path.
A2 — no wake re-validation partially — D-T2.4's re-validation now exists and is wired in both hosts and tested; B2 shows it does not close the FIFO wedge.
A3 — headless discarded the status partially — status now honoured, throws on Rejected/NotApplicable and on a missing drive, retryable pump added. B1/B3 reopen it.
A5 — presentation suite still open — see B5 for the definite blocks/does-not-block answer.
A7 — dual-host parity CLOSED HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake drives a real wired drive end to end and asserts body position, cell, and PlayerState.
A8 open, non-blocking (B8).
A11 / A12 unchanged (B9).

Also verified this round

  • _pendingDestination lifetime — round-1 judgment stands, not re-litigated.
  • The sequencer's own invariants survive the late worldReady: Tunnel is explicitly a hold state ("Hold here until worldReady"), TickTunnel still runs on the hold path so CurrentAnimationFrame keeps advancing into TunnelContinue's exit window, and maxForce at 5 s covers a stale frame. worldReady has exactly one consumer (case TeleportAnimState.Tunnel), so no other transition changed meaning.
  • No double-Begin headless: TryCompletePortal's own TryGetAcceptedTeleportDestination/TryBeginPortalReveal prefix cannot succeed twice for one reveal (the destination slot is consumed), and _awaitingPortalWake suppresses a second concurrent Begin while parked. (The stale-latch problem is B3, a different failure.)
  • _placementCommitted / _awaitingDeferredWake are both cleared in ResetTransit:931-932, so the graphical latches do not survive a new F751, a session reset, or a generation reset.
  • Build green.

Summary

# Severity Finding
B1 MAJOR PendingCount == 0 inferred as "committed" on both hosts; three non-committing paths clear it, including the drive's own documented modal outcome. Reintroduces A1 (graphical, now with a successful false materialization) and A3 (headless). Untested.
B2 MEDIUM A2's re-validation gates the suffix, not the receipt; the FIFO wedge is narrowed (supersession/reset only) but not closed.
B3 MEDIUM Headless _awaitingPortalWake is not reset per teleport; a stale latch can skip reveal N+1's placement attempt entirely.
B4 MEDIUM Unbounded, undiagnosable retry loops on both hosts; headless has no cue, no bound, and no log.
B5 §8 8/9/10 presentation suite: does not block, subject to three stated conditions; required test spelled out.
B6 MINOR Make isPortalAuthorityCurrent (and B1's commit observable) required constructor parameters.
B7 MINOR _placementCommitted never re-validated before the presentation suffix.
B8 MINOR A8 still open (5 tests), confirmed non-blocking.
B9 MINOR A11/A12 unchanged.