fix(act): give every Act its own ports frame - #1609
Merged
Merged
Conversation
A scoped Act reads its store and cache from an ambient frame; a singleton one reads the process-wide adapters. With both alive, which store a call reaches depends on the frame it happens to be running in, so a singleton Act dispatched from inside a scoped handler wrote its events to that tenant's log — silently, and permanently. Making that resolve some particular way would leave the ambiguity in place. The combination is refused instead: a process either scopes its ports or it does not. The count tracks live Acts, so building one, shutting it down and building the other kind is fine — only overlap is ambiguous. Registration happens once the Act exists and the build has stopped throwing, or a rejected build would hold a slot forever, and the count is cleared when the process disposes its ports, since an Act abandoned without shutdown has nothing left to give the slot back. Both example apps built their Act at the top of a module, so importing the file for anything else decided the port strategy for the whole process — wolfdesk's tests import bootstrap for its `builder` alone. They now hand the built app to the program that runs it. One act-diagram branch was covered only by parsing wolfdesk's real source, where a broken file left a hole in an act built in that same file. It has its own fixture now, so it no longer depends on how an example app happens to be laid out. Closes #1597 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF
An Act built without `ActOptions.scoped` ran in whatever frame it was called from. So a shared Act dispatched from inside a tenant's reaction handler resolved store() and cache() through that tenant's frame and committed the shared Act's events into the tenant's log — silently, and permanently, since events in the wrong log never move. Every Act now enters a frame. The one built without a bag carries the singleton adapters, so it resolves to them wherever it is called from, and a tenant Act called from a shared handler keeps resolving to its own bag as it already did. There is no longer a path that inherits someone else's ports. The default bag reads the adapters through getters rather than capturing them, and through the raw resolvers rather than the public store()/cache(), which consult the frame this bag is and would recurse. Entering the frame is not measurable at the level of an Act operation: 2.41ms unscoped against 2.39ms scoped on the scope-overhead bench, and 1.226 against 1.241 on the second case — both inside the noise. The AsyncLocalStorage cost is swamped by the work an operation does. Closes #1597 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 4, 2026
# [@rotorsoft/act-v1.31.15](https://github.com/Rotorsoft/act-root/compare/@rotorsoft/act-v1.31.14...@rotorsoft/act-v1.31.15) (2026-09-04) ### Bug Fixes * **act:** give every Act its own ports frame ([#1609](#1609)) ([32b415a](32b415a)), closes [#1597](#1597) * **act:** revive dates on read instead of re-validating the payload ([#1601](#1601)) ([97ed5b9](97ed5b9)), closes [#1594](#1594) * **act:** unsubscribe from notify before stopping the settle loop ([#1602](#1602)) ([2e07111](2e07111)), closes [#1468](#1468) [#1468](#1468) [#1596](#1596)
|
🎉 This PR is included in version @rotorsoft/act-v1.31.15 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 4, 2026
# [@rotorsoft/act-tck-v1.36.16](https://github.com/Rotorsoft/act-root/compare/@rotorsoft/act-tck-v1.36.15...@rotorsoft/act-tck-v1.36.16) (2026-09-04) ### Bug Fixes * **act:** give every Act its own ports frame ([#1609](#1609)) ([32b415a](32b415a)), closes [#1597](#1597) * **act:** unsubscribe from notify before stopping the settle loop ([#1602](#1602)) ([2e07111](2e07111)), closes [#1468](#1468) [#1468](#1468) [#1596](#1596)
|
🎉 This PR is included in version @rotorsoft/act-tck-v1.36.16 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
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 #1597.
A shared Act could write its events into a tenant's store. If a scoped Act's reaction handler dispatched into a shared one, the shared Act resolved
store()andcache()through the tenant's frame and committed there instead of to the global log. Silently, and permanently — events in the wrong log never move.What went wrong
A scoped Act reads its ports from an ambient frame. An Act built without
ActOptions.scopedread the process-wide adapters, andmake_run_scopedgave it a bare(fn) => fn()on the grounds that "a singleton Act needs no frame at all".That is true only when nobody else is holding a frame. Calling
fn()does not leave one, so a shared Act called from inside a tenant's handler inherited the tenant's ports.The fix
Every Act enters a frame. The one built without a bag carries the singleton adapters.
There is no longer a path that inherits someone else's ports: a shared Act resolves to the singleton adapters wherever it is called from, and a tenant Act keeps resolving to its own bag as it already did. One rule, no special case — the branch that made "unscoped" mean "whatever frame I am standing in" is gone.
Two details in
default_scope:store(new PgStore())in application setup runs beforebuild(), so a bag that captured them when it was made would pin whatever existed at construction.store()/cache(). Those consult the frame this bag is, and would recurse.What it costs
Nothing measurable. On the in-repo scope-overhead bench, at the level of a real Act operation:
scoped.runwrap)Both inside the noise — the AsyncLocalStorage entry is roughly 150 ns against work measured in milliseconds. Port lookups inside a frame do cost more than outside one (about 200 ns against 50 ns), which every scoped Act already paid; a handful per operation does not show up either.
What this replaces
The first version of this branch refused the combination instead: building an Act of one kind while an Act of the other kind was live threw. It worked and the whole suite passed, but it made coexistence illegal, and coexistence is documented and used —
docs/docs/architecture/extension-points.md:330blesses "an empty Act wrapping a raw store for tooling" beside scoped tenant Acts, and both example apps built their Act at module scope, so importing a file for itsbuilderdecided the port strategy for the whole process. Landing it meant restructuring both examples, a recipe, a test helper, and an act-diagram fixture.Putting the unscoped Act in a default scope fixes the same bug in the framework, with no migration and nothing to remember. The examples are untouched.
The narrower
scoped.exitthe issue proposed would also have worked, but it fixes the singleton path by making it undo something, where this makes both paths the same thing.Test plan
libs/act/test/default-scope.spec.ts— a shared Act called from inside a tenant's handler writes to the shared store; a tenant Act called from inside a shared handler writes to the tenant store; a call outside any Act still sees the singleton adapters; an adapter injected beforebuild()is the one usedpnpm test— 3614 passingpnpm typecheckclean across the workspace, biome clean, architecture spec passingStability charter impact
No public surface changed.
ActOptions.scopedkeeps its shape and meaning;default_scopeis@internalinports.ts, beside the resolvers it reads.current_ports()now returns a bag for every call made inside an Act rather than only inside a scoped one — internal, and its doc-comment says so.rfc-gate: exempt — the stability snapshot grew from the new internal helper and its doc comments. No public surface was added.
Found by
Debug wave 21, reproduced in the main loop.