Skip to content

fix(generator): stop shipping async [Remote] bodies to trimmed clients (TRIM-009) - #76

Merged
keithdv merged 4 commits into
mainfrom
TRIM-009-async-local-method-body-retention
Aug 14, 2026
Merged

fix(generator): stop shipping async [Remote] bodies to trimmed clients (TRIM-009)#76
keithdv merged 4 commits into
mainfrom
TRIM-009-async-local-method-body-retention

Conversation

@keithdv

@keithdv keithdv commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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

async class-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, and CLAUDE-DESIGN.md all 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:

Variant Emitted shape Result
V1 async, minus all four awaiting lifecycle probes and the OperationCanceledException arm still leaked
V2 sync, plus an OCE arm and an IFactoryOnCancelled type-test still clean
V3 guard moved to a sync wrapper, async body in a private core still leaked

V1 and V2 settle it in both directions: those constructs are neither necessary nor sufficient. The async state machine is the mechanism. Inside an async method the compiler lowers the whole body — guard included — into MoveNext, 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

  1. Non-async guard wrapper. Guarded async Local* methods are emitted as a sync wrapper carrying the IsServerRuntime guard, forwarding to a private async …Core. Applied at all five emission sites (read, class-level [Execute], write, LocalSave, Can*) through one shared helper.
  2. Holder indirection. The class-factory assembly attribute names a generated single-method holder 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, async ones (Save on an authorized factory) capture it back into a faulted Task. Authorization failures, target casts, and DI resolution failures are unaffected in every case.

Verification

  • 614+614 unit (3 new), 561+561 integration (5 pre-existing skips), 86+86 Design
  • Trimming gate exit 0 — 10 named positive controls, plus six new per-site wrapper discriminators
  • Harness exit 0 — every factory resolves, including Class [Execute] factory resolved: True. This is the liveness check that catches a holder silently failing to forward through method?.Invoke
  • Untrimmed self-check — every body marker PRESENT before trimming, so the absences are trimming results rather than build artifacts
  • Red before green — breaking the holder prefix and disabling the split turned exactly three tests red while the sync-path control stayed green
  • Zero incremental-cache delta: Generator/Model/ and Generator/Builder/ diffs are empty

Evidence 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:

  • Plan review caught two claims in a plan written to avoid exactly that — including a V3 check that could never have gone red, and a causal attribution the data could not support.
  • Test review found a real gate blind spot: body markers cannot distinguish "this site was wrapped" from "an ancestor's fold removed the reference". Unwrapping LocalSave would have passed CI while shipping a guarded async body. Closed by asserting the state machines themselves.
  • Code review found two false doc sentences this plan wrote while fixing the previous plan's.

Scope deliberately not taken

  • Interface-factory leg (deferred item 20) — carries the identical shape and receives neither fix, and deferred item 19 makes it structurally unmeasurable from a client-side harness. The work is deferred; the claim is not: Interface factory | Yes became "not established" in the skill, docs/trimming.md, and CLAUDE-DESIGN.md, and AC6 carves it out in writing.
  • Local{X}Core name-collision hazard (deferred item 21) — same family as items 14/15.
  • Narrowing the attribute's DAM — rejected at TRIM-008 review and still rejected; it would silently break prebuilt libraries with internal static registrars.

🤖 Generated with Claude Code

keithdv and others added 4 commits August 13, 2026 20:31
…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>
@keithdv
keithdv merged commit 7d790db into main Aug 14, 2026
2 checks passed
@keithdv
keithdv deleted the TRIM-009-async-local-method-body-retention branch August 14, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant