Skip to content

PHASE-003: entry-call tracking and framework-owned AfterCommit drain - #80

Merged
keithdv merged 10 commits into
PHASEfrom
PHASE-003-aftercommit-entry-call-drain
Aug 15, 2026
Merged

PHASE-003: entry-call tracking and framework-owned AfterCommit drain#80
keithdv merged 10 commits into
PHASEfrom
PHASE-003-aftercommit-entry-call-drain

Conversation

@keithdv

@keithdv keithdv commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Stacked on #79. Until that merges, this PR's diff also shows PHASE-001's commits; it narrows to PHASE-003 alone once #79 lands. Merge #79 first.

Makes AfterCommit handlers actually run: the framework tracks entry factory calls (depth-aware, per scope) and drains the phase queue when the outermost call completes successfully — uniformly for HTTP-dispatched [Remote] calls, direct server/Logical invocation, client-raised events, and all three factory patterns.

What lands

  • Scheduler: BeginEntryCall / EndEntryCallAsync(success) / IsEntryCallActive; drain at the outermost success (sweeping AfterFlush first, no cancellation token); explicit clear on failure; lock-guarded state.
  • FactoryEntryCall (new, public in Internal): the wrapper generated code routes through — null-tolerant, keeps every generated wrapper non-async.
  • Choke point: HandleRemoteDelegateRequest marks the entry around delegate invocation and drains before the post-invoke cancellation check and relay collection, so events raised by drained handlers join the same response's relay batch.
  • Dispatcher: queues phased handlers only while an entry call is active; otherwise dispatches immediately (9005). Log 9006 for discard-at-exit.
  • Generator: every Local* method now splits into a non-async guarded wrapper → private Core, across class, interface, and static legs. The interface leg gained the TRIM-009 registrar-holder fix so its new private cores are not DAM-rooted with their bodies.

Decisions worth reviewer attention

  • Failure clears rather than relying on scope death — "rollback-discard is structural" was false for long-lived scopes (Blazor Server, Logical, the test harness), where a failed call's work would have drained into the next successful call.
  • The entry stays active through its own drain, so drained-handler raises join the drain instead of dispatching inline.
  • Entry drains pass CancellationToken.None; whether a post-completion drain should also swallow handler OperationCanceledException is handed to PHASE-004.
  • Documented limitation: concurrent flows sharing one DI scope share entry state (pinned by test, recorded in the Discovery Log).

Gates

Plan review (CONCERNS, 6 vetoes addressed pre-implementation), test review (two rounds), code review (1 veto — the interface registrar holder — fixed). Three deliberate wrong-implementations red-proofed the sharpest tests (reviews/003-redproof.log). Suites: unit 668×2, integration 579×2 (+5 pre-existing skips), Design 86×2 — 0 failures.

Sacred-test disclosure: beyond the six pre-declared PHASE-001 pins, two TRIM-009 emission-shape tests were amended (trimming intent re-asserted in the new shape) and two relay-collection tests that this change had silently weakened were restored. All listed in the plan's Test Evidence.

Docs: docs/todos/PHASE-phased-event-dispatch/plans/003-aftercommit-entry-call-drain.md + reviews/003-*.

🤖 Generated with Claude Code

keithdv and others added 10 commits August 14, 2026 15:04
Handlers registered with [FactoryEventHandler<T>] can now declare when they
run relative to the factory operation that raised the event:

- Immediate (default, unchanged): dispatched at Raise time, in the caller's
  transaction, observing staged state.
- AfterFlush / AfterCommit: deferred into a per-scope queue and run when that
  phase drains, so read-only projections stop inheriting the in-transaction
  contract built for atomic write handlers.

This plan lands the model, the registry phase, the queueing, and the drain
primitive (IFactoryEventPhaseScheduler in the Internal namespace). The drain
POINTS come next: entry-call tracking in PHASE-003, the consumer-facing
coordinator in PHASE-004.

Failure semantics key off the drain point rather than the phase: an
in-transaction drain propagates handler exceptions so the caller can roll back;
a post-completion drain logs (9003) and swallows, since a throw there can no
longer roll anything back. A drain covers the requested phase and every earlier
one, so nothing a handler enqueues mid-drain is silently dropped.

FactoryEventHandlerAttribute<T> moved to its own file: FactoryAttributes.cs is
linked into the netstandard2.0 generator, and compiling DispatchPhase there
would duplicate a public runtime type. The generator matches the attribute by
metadata name, so it never needed the type.

Backward compatible: no phase argument means Immediate, the generator's
existing two-argument RegisterHandler call is untouched, and no existing test
was modified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ordering re-split recorded: PHASE-003 worked ahead of PHASE-002 (independent;
riskiest first; RFEF blocked on it). Pre-flight Current State walked against
the choke point, all three renderer seams, and the sync-method shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…draft amendment

Failure path now clears explicitly at outermost exit (never drains); entry
stays active through the drain (B-V3); interface-renderer walk corrected
(inline guard, no split, TRIM item 20); drain pinned before the choke point's
post-invoke cancellation check; pin-amendment set widened to six named tests;
client-raise relay gap logged as deferred discovery (A-V1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (PHASE-003)

Runtime: IFactoryEventPhaseScheduler gains depth-aware BeginEntryCall/
EndEntryCallAsync — drain (AfterCommit sweep, no token) at outermost success,
explicit clear on failure; scheduler state now lock-guarded. Dispatcher queues
phased handlers only while an entry call is active (9005 outside-entry log,
9006 failure-clear log). HandleRemoteDelegateRequest wraps delegate invocation
as the remote entry — drain before the post-invoke cancellation check and
relay collection.

Generator: every Local* method splits into a non-async guarded wrapper routing
through FactoryEntryCall (new) to a private Core — class, interface (split
introduced; TRIM item 20 note updated), and static (DI lambda) legs. Sync
non-Task factories block-drain only when pending (no-silent-loss).

Tests: six pre-declared PHASE-001 pin amendments (intent restated under entry
semantics; re-entrancy test re-pointed to discriminate on drain-join order),
two TRIM-009 emission-shape pins amended to the new wrapper shape, new
FactoryEntryCallTests + outside-entry/failure-clear dispatcher tests. 662x2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 new tests in FactoryEventPhaseEntryTests: HTTP + Logical entry drains with
the method-done ordering discriminator, LocalSave nesting (local and under the
choke point), AfterFlush-before-AfterCommit sweep at the entry, failure paths
never running queued handlers, failure-then-success same-scope clear (A-V2
fixture), falsifiable forbidden-inner-call via the throwing interface-factory
auth shape, handler-throw swallow with survivor, drained-handler events joining
the same response's relay batch, client-raise entry semantics proven by 9001
logs, and post-success token cancellation not skipping the drain.
Unit 662x2, integration 575x2, Design 86x2 — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…og; 9005/9006 log-table rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AspForbidException success-shaped denial exercised end-to-end; concurrent-
flows-share-entry-state semantics pinned as a documented limitation; interface
renderer emission-shape pin added; nested-save inner-vs-outer discriminator;
post-OCE entry-exit clear + double-End tolerance pin; relay-collection tests'
entry-active premise restored; positive 9005 emission pin; caught-nested-
failure and handler-invokes-factory re-entrancy pins; interface success-path
and generated-sync-shape integration coverage. Unit 668x2, integration 579x2,
Design 86x2 — 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…remise pin)

Gate closed: unit 668x2, integration 579x2, Design 86x2 — 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…003 code review)

V1: interface factories now emit a single-method registrar holder
(NeatooInterfaceFactoryRegistrar_ prefix) and point the assembly attribute at
it — the Local*Core split no longer sits in the DAM-roots-everything shape
TRIM-009 measured as insufficient. C1: EndEntryCallAsync collapsed to one lock
acquisition. C4: 9006 renamed FactoryEventPhaseDiscardedAtExit with a cause-
neutral message. C9: FactoryEntryCall emitted global::-qualified. C3: sync
block-drain deadlock caveat documented. C10: stale guard comments fixed; RFEF
substrate + TRIM direction recorded in the Discovery Log. PHASE-003 Done.
Unit 668x2, integration 579x2, Design 86x2 — 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@keithdv
keithdv merged commit 1f2e773 into PHASE Aug 15, 2026
2 checks passed
@keithdv
keithdv deleted the PHASE-003-aftercommit-entry-call-drain branch August 17, 2026 02:37
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