fix(generator): stop shipping async [Remote] bodies to trimmed clients (TRIM-009) - #76
Merged
Merged
Conversation
…y falsified Ran the stub's declared first step before any design work: separate H1 (the feature-switch fold does not propagate out of the async state machine) from H2 (the fold works; elimination is defeated by the second catch arm and/or the awaiting lifecycle probes). Three generator-side variants, each published trimmed and probed. The apparatus was proven inert first — both knobs at default produced a byte-identical generated tree — and reverted after, with the emission re-diffed back to HEAD. V1 async, minus all four awaiting probes and the OCE catch arm -> STILL LEAKS V2 sync, plus an OCE catch arm and the IFactoryOnCancelled probe -> STILL CLEAN V3 guard in a sync wrapper, async body in a private core -> STILL LEAKS V1 and V2 settle it in both directions: H2's constructs are neither necessary nor sufficient. H1 is the mechanism. This falsifies the TRIM-004 story a third time, and for the first time additively. V3 is the most valuable result: it falsifies the remedy the stub itself predicted. The sync wrapper does put the fold back outside the state machine (<LocalFetchAsync>d__ disappears), but the body literal and both server-only names survive, because DAM covers NonPublicMethods and independently roots the private core. A guard-relocation-only fix would have shipped smaller, deleted a state machine, and left the IP on the client. Plan promoted Stub -> Drafted with an approach that pairs the sync wrapper with TRIM-008's holder indirection, explicitly marked as a prediction whose combination is unmeasured — Step 1 measures it before anything is built on it. Also closes code-review C3 / test-review T3: TRIM-008's absence gate cleared its first linux-x64 run post-merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… mine
Plan review returned CONCERNS. Every checkable finding was re-derived at the
keyboard before being accepted; all held.
The two that matter most are the arc's own recurring failure, found inside the
plan written to prevent it:
B1 V3's "half held" was a check that could not go red. The knob emits
LocalFetchAsync as a NON-async wrapper, so <LocalFetchAsync>d__ cannot
exist regardless of trimming. Its absence was a compile-time consequence
of the rename, not evidence the fold works. Row struck.
B2 <LocalFetchAsyncCore>d__ was cited from a table that never measured it
(the probe string cannot match the name). It was real -- measured by a
separate grep that never reached the archive -- and is now re-measured and
recorded. Separately: V3 had BOTH candidate roots live, so it cannot
attribute the core's survival to DAM at all. That goes to Step 1.
Two inventories declared exhaustive and weren't:
B3 Three roots, not two -- the local ctor's method-group assignment. It
targets the wrapper, so the central prediction is unaffected.
B4 Five emission sites, not three, and the stated rationale was inverted:
Can* methods are synchronous; LocalSave is its own async site. Leaving it
unwrapped would likely pass Step 1 while still shipping a guarded async
body.
Scope corrections:
A3 Class-level [Execute] is unconditionally async, guarded, and has no
harness target -- a Design source-of-truth pattern. New Step 2a.
A1 FactoryAttributes.cs was declared untouched while this plan falsifies the
contract documented in it. New Step 3a, XML-doc only.
A2 Step 7 missed every anchor the holder half falsifies, including
CLAUDE-DESIGN.md:760 and docs/trimming.md:249, which say protection comes
from the guard "not the choice of attribute target" -- exactly what this
plan reverses.
A4 Interface-factory leg shares both mechanisms and gets neither. Deferred as
row 20 rather than taken in scope, with its false doc claim release-blocking.
AC6 now has a stated disposition: it closes as written if Step 2a lands, else it
is narrowed in writing rather than closed over an unmeasured shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s (TRIM-009)
Async class-factory operations kept their server-only bodies -- [Service]
interface names, called members, and string literals -- on a publish-trimmed
Blazor WASM client, where they are decompilable. Sync operations in the same
assembly did not. This was the shape docs/trimming.md, the skill, and
CLAUDE-DESIGN.md all held up as the one that works.
CAUSE, separated from inside the generator before any design work:
V1 async minus all four lifecycle probes AND the OCE catch arm -> STILL LEAKED
V2 sync plus the catch arm and a type-test -> STILL CLEAN
So the async state machine is the mechanism, not the catch arm and not the
type-tests. The arc's TRIM-004 story ("early-throw guard plus try/catch defeats
unreachable-code elimination") is falsified a third time, additively.
Inside an async method the compiler lowers the whole body -- guard included --
into MoveNext, within the builder's protected region. ILLink folds the feature
switch there but does not eliminate the unreachable remainder.
FIX, both halves required:
1. Guarded async Local* methods are emitted as a NON-async wrapper carrying
the guard, forwarding to a private async core. Applied at all five emission
sites (read, class [Execute], write, LocalSave, Can*).
2. The class-factory assembly attribute names a generated single-method holder
NeatooClassFactoryRegistrar_{ClassName} instead of {X}Factory.
Neither suffices alone: the wrapper leaves DAM rooting the private core, because
DynamicallyAccessedMembers covers NonPublicMethods; the holder leaves the guard
inside MoveNext. The wrapper-only variant was measured and looked like progress
while changing nothing.
The delegate registrations and the ctor method-group assignment are untouched --
both reference the wrapper, whose post-guard call folds away.
RESULT: every leaking marker absent across read, write, LocalSave, and
class-level [Execute]. Trimmed assembly 66,560 -> 52,224 bytes. Untrimmed
self-check confirms the markers are present before trimming; harness exits 0
with every factory resolving, which catches a holder that silently fails to
forward through method?.Invoke.
BEHAVIOUR CHANGE: the server-only guard now throws synchronously from the
factory entry point rather than surfacing as a faulted Task, and because the
public entry point is non-async that throw escapes through I{X}Factory. Auth
failures, target casts, and DI failures are unaffected.
Also: class-level [Execute] gains its first harness coverage (it is emitted
async unconditionally and is a Design source-of-truth pattern); AC6 closes as
written rather than narrowed; the interface-factory leg is carved out in writing
as deferred item 20, with its published claim corrected from "Yes" to "not
established" rather than left false.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both gates ran. Neither reviewer proposed a change to the generator: the fix was
correct on the first pass and survived scrutiny unchanged. What needed fixing was
documentation, evidence citations, and one real gate blind spot.
GATE BLIND SPOT (test review M1), the one finding that was not cosmetic:
Every gate marker was a *body* signal, and body signals cannot separate "this
site was wrapped" from "an ancestor's fold removed the only reference to it".
LocalSaveCore routes to the Insert/Update/Delete WRAPPERS, so unwrapping the Save
site would have cleared every marker while shipping a guarded async body. This
plan documented that blind spot and then shipped one.
Closed by asserting the async state machines themselves: a wrapped site has no
<LocalX>d__ at all; unwrap it and the name returns AND its body survives. Four of
the six have archived pre-fix PRESENT baselines, so they are real discriminators.
RE-DERIVING M2 FOUND WORSE. The class-[Execute] untrimmed self-check first read
all five markers ABSENT, which would have meant the gate's checks for that leg
were vacuous. They were not: publish -r writes to a RID subfolder, so the
non-RID output still held a build predating the target. Rebuilt: all five
PRESENT. The stale-artifact trap is recorded in the evidence file, because the
first reading looks identical to a vacuous gate.
DOC FALSEHOODS THIS PLAN ITSELF INTRODUCED (code review V1, V3):
docs/trimming.md:236 contradicted a line 11 below that this plan had edited
CLAUDE-DESIGN.md:760 "Every factory shape emits its own forwarding holder" --
the interface leg emits none
Both from building the Step 7b list from the plan instead of from the files --
the third occurrence of the failure the doc-anchor inventory exists to prevent.
Also: docs/trimming.md:37 still asserted the interface leg's mechanism, the one
sentence claiming exactly what H1 measured insufficient, and deferred item 20
named that file by name as release-blocking (V2). Now qualified in all three
places.
A test was REMOVED rather than kept: an async-guarded Can* emission test that
would have passed for the wrong reason, because [AuthorizeFactory<T>] returning
Task<bool> yields a Can that is async but not server-only. The shape needs
[AspAuthorize], whose references the unit harness lacks. Reason recorded at the
test file; real coverage named.
Five Test Evidence claims corrected, including a size comparison against a
number that was a knob variant rather than HEAD -- withdrawn, not restated.
Green: 614+614 unit, 561+561 integration, 86+86 Design, harness 0, gate 0 with
10 named controls and six new per-site discriminators.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last shape in AC6 of the TRIM arc. With TRIM-008 (#75) this unblocks v1.7.0, and with it zTreatment PCB-003.
The defect
asyncclass-factory operations shipped their[Remote]bodies —[Service]interface names, called members, and string literals — to publish-trimmed Blazor WASM clients, where they are decompilable. Synchronous operations in the same assembly did not.This was the shape
docs/trimming.md, the distributable skill, andCLAUDE-DESIGN.mdall held up as the one that works. It affects any aggregate root with async operations — read or write — which is most of them.Cause, separated by measurement before any design work
The plan stub carried two surviving hypotheses. Rather than pick one, three generator variants were emitted and published trimmed:
OperationCanceledExceptionarmIFactoryOnCancelledtype-testprivatecoreV1 and V2 settle it in both directions: those constructs are neither necessary nor sufficient. The async state machine is the mechanism. Inside an
asyncmethod the compiler lowers the whole body — guard included — intoMoveNext, within the builder's own protected region; ILLink folds the feature switch there but does not eliminate the unreachable remainder.This also falsifies the arc's long-running TRIM-004 story ("early-throw guard plus try/catch defeats unreachable-code elimination") for the third time, and for the first time by direct additive test.
The apparatus was proven inert first — both knobs at default reproduce HEAD's generated tree byte-for-byte — and reverted after, re-diffed back to HEAD.
The fix — both halves are required
asyncLocal*methods are emitted as a sync wrapper carrying theIsServerRuntimeguard, forwarding to aprivate async …Core. Applied at all five emission sites (read, class-level[Execute], write,LocalSave,Can*) through one shared helper.NeatooClassFactoryRegistrar_{ClassName}instead of{X}Factory.Neither suffices alone — V3 proves it.
DynamicallyAccessedMembers(PublicMethods | NonPublicMethods)covers NonPublic, so the wrapper alone leaves DAM rooting the private core. The wrapper-only variant deleted a state machine and shrank the assembly while leaving the IP on the client.The delegate registrations and the ctor method-group assignment are untouched: both reference the wrapper, whose post-guard call folds away.
Behaviour change (for release notes)
The server-only guard now throws synchronously from the wrapper rather than surfacing as a faulted
Task. Whether that reaches the call site synchronously depends on the entry point — non-async ones propagate it,asyncones (Saveon an authorized factory) capture it back into a faultedTask. Authorization failures, target casts, and DI resolution failures are unaffected in every case.Verification
Class [Execute] factory resolved: True. This is the liveness check that catches a holder silently failing to forward throughmethod?.InvokeGenerator/Model/andGenerator/Builder/diffs are emptyEvidence archived under
reviews/009-evidence/.Gates
Plan review (8 veto-tier), test review, and code review all ran; records in
reviews/. No reviewer proposed a change to the generator — the fix was correct on its first pass. Every finding was a claim outrunning its evidence:LocalSavewould have passed CI while shipping a guarded async body. Closed by asserting the state machines themselves.Scope deliberately not taken
Interface factory | Yesbecame "not established" in the skill,docs/trimming.md, andCLAUDE-DESIGN.md, and AC6 carves it out in writing.Local{X}Corename-collision hazard (deferred item 21) — same family as items 14/15.internal staticregistrars.🤖 Generated with Claude Code