Defect
make_run_scoped collapses to a bare call for a singleton Act, so it never exits an ambient scope frame:
libs/act/src/scoped.ts:78-82
export function make_run_scoped(
bag: Scoped | undefined
): <T>(fn: () => Promise<T>) => Promise<T> {
return bag ? (fn) => scoped.run(bag, fn) : (fn) => fn();
}
The doc-comment states the assumption directly — "a singleton Act needs no frame at all, so the runner collapses to calling fn — the non-scoped path pays nothing." That is true only when the singleton Act is invoked from outside any scope. When it is invoked from inside a scoped Act's AsyncLocalStorage frame, store() and cache() resolve through the ambient bag, and the singleton Act reads and writes the tenant's store.
Failure scenario
A tenant Act is constructed with ActOptions.scoped (its own store + cache, per the multi-tenant SaaS pattern the option exists for). One of its reaction handlers — or a lifecycle listener — dispatches into a shared, unscoped Act (an audit log, a global notifier). The shared Act's commit lands in the tenant's store.
GLOBAL store events: [] <-- expected ["Logged"]
TENANT store events: ["Bumped", "Logged"] <-- foreign aggregate written into the tenant log
Control — the identical dispatch performed outside the scoped frame:
GLOBAL store events: ["Logged"] GREEN
This is permanent: events written to the wrong log never migrate. It is also silent — nothing fails, and the tenant's log simply accumulates another application's aggregates. In a multi-tenant deployment, which is the documented reason scoped exists, that is a tenant-isolation break.
Proof (red)
Red-first probe run in the main loop:
AssertionError: expected [] to deeply equal [ 'Logged' ]
No cast, no private-state mutation. Mechanism confirmed by reading scoped.ts:81.
Note on provenance: the probe was written by the wave-21 multi-app hunter (which then hit its session limit); I re-ran it in the main loop and independently confirmed the root-cause line, but the probe construction is the hunter's rather than mine.
Contract violated
docs/docs/architecture/extension-points.md § Scoped ports presents ActOptions.scoped as the isolation mechanism for multi-tenant SaaS and parallel test workers. Isolation that leaks into the tenant store from an unscoped caller is the failure mode the option is meant to prevent. The framework threads the bag via AsyncLocalStorage precisely so internal store()/cache() calls resolve transparently — the gap is that "transparently" currently means "to whatever frame happens to be active", including for an Act that declared no scope.
Fix direction
Make the singleton path explicitly leave any ambient frame:
return bag ? (fn) => scoped.run(bag, fn) : (fn) => scoped.exit(fn);
AsyncLocalStorage.exit is the exact primitive for this, and the change is confined to one line in the module that already owns every ambient context (per the layering rule that scoped.ts owns ambient state and internal/ receives what it needs).
Worth confirming as part of the fix: whether the reacting-event ALS (reacting, same module) has the mirror-image issue for a singleton Act invoked from inside a scoped handler's frame.
Found by debug wave 21 (multi-Act / shared-store isolation lens).
Defect
make_run_scopedcollapses to a bare call for a singletonAct, so it never exits an ambient scope frame:libs/act/src/scoped.ts:78-82The doc-comment states the assumption directly — "a singleton Act needs no frame at all, so the runner collapses to calling
fn— the non-scoped path pays nothing." That is true only when the singletonActis invoked from outside any scope. When it is invoked from inside a scoped Act's AsyncLocalStorage frame,store()andcache()resolve through the ambient bag, and the singleton Act reads and writes the tenant's store.Failure scenario
A tenant
Actis constructed withActOptions.scoped(its own store + cache, per the multi-tenant SaaS pattern the option exists for). One of its reaction handlers — or a lifecycle listener — dispatches into a shared, unscopedAct(an audit log, a global notifier). The shared Act's commit lands in the tenant's store.Control — the identical dispatch performed outside the scoped frame:
This is permanent: events written to the wrong log never migrate. It is also silent — nothing fails, and the tenant's log simply accumulates another application's aggregates. In a multi-tenant deployment, which is the documented reason
scopedexists, that is a tenant-isolation break.Proof (red)
Red-first probe run in the main loop:
No cast, no private-state mutation. Mechanism confirmed by reading
scoped.ts:81.Note on provenance: the probe was written by the wave-21 multi-app hunter (which then hit its session limit); I re-ran it in the main loop and independently confirmed the root-cause line, but the probe construction is the hunter's rather than mine.
Contract violated
docs/docs/architecture/extension-points.md§ Scoped ports presentsActOptions.scopedas the isolation mechanism for multi-tenant SaaS and parallel test workers. Isolation that leaks into the tenant store from an unscoped caller is the failure mode the option is meant to prevent. The framework threads the bag via AsyncLocalStorage precisely so internalstore()/cache()calls resolve transparently — the gap is that "transparently" currently means "to whatever frame happens to be active", including for an Act that declared no scope.Fix direction
Make the singleton path explicitly leave any ambient frame:
AsyncLocalStorage.exitis the exact primitive for this, and the change is confined to one line in the module that already owns every ambient context (per the layering rule thatscoped.tsowns ambient state andinternal/receives what it needs).Worth confirming as part of the fix: whether the reacting-event ALS (
reacting, same module) has the mirror-image issue for a singleton Act invoked from inside a scoped handler's frame.Found by debug wave 21 (multi-Act / shared-store isolation lens).