Skip to content

fix(act): give every Act its own ports frame - #1609

Merged
Rotorsoft merged 2 commits into
masterfrom
act-1597-no-mixing
Sep 4, 2026
Merged

fix(act): give every Act its own ports frame#1609
Rotorsoft merged 2 commits into
masterfrom
act-1597-no-mixing

Conversation

@Rotorsoft

@Rotorsoft Rotorsoft commented Aug 31, 2026

Copy link
Copy Markdown
Owner

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() and cache() 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.scoped read the process-wide adapters, and make_run_scoped gave 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.

this._ports = options.scoped ?? default_scope();
this._scoped = make_run_scoped(this._ports);

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:

  • The bag reads through getters, not captured values. The adapters resolve lazily and store(new PgStore()) in application setup runs before build(), so a bag that captured them when it was made would pin whatever existed at construction.
  • It reads the raw resolvers, not the public 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:

unscoped (no-op wrap) scoped (real scoped.run wrap)
mean 2.4116 ms 2.3925 ms
second case 1.2256 ms 1.2412 ms

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:330 blesses "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 its builder decided 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.exit the 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 before build() is the one used
  • pnpm test — 3614 passing
  • 100% coverage on statements, branches, functions and lines
  • pnpm typecheck clean across the workspace, biome clean, architecture spec passing
  • CI green
  • Review

Stability charter impact

No public surface changed. ActOptions.scoped keeps its shape and meaning; default_scope is @internal in ports.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.

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
@Rotorsoft Rotorsoft added the bug Something isn't working label Aug 31, 2026
@Rotorsoft Rotorsoft self-assigned this Aug 31, 2026
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
@Rotorsoft Rotorsoft changed the title fix(act): refuse a process that mixes scoped and singleton Acts fix(act): give every Act its own ports frame Sep 4, 2026
@Rotorsoft
Rotorsoft merged commit 32b415a into master Sep 4, 2026
17 checks passed
@Rotorsoft
Rotorsoft deleted the act-1597-no-mixing branch September 4, 2026 15:47
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)
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

🎉 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)
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

🎉 This PR is included in version @rotorsoft/act-tck-v1.36.16 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An unscoped Act invoked inside a scoped Act's frame writes to the scoped tenant store

1 participant