Story
As a maintainer writing an Effection operation, I want event subscriptions to be
checked against their owning scope, so cancellation and teardown cannot leave a
listener acting after the work that registered it has ended.
Example
After the installed EffectionX package carries the correction tracked by
thefrontside/effectionx#251,
a one-event wait uses its scope-bound operation:
yield* once(socket, "close");
A longer subscription uses one stable handler and removes it from the same
scope that registered it:
const onData = (chunk: Uint8Array) => inbox.send(chunk);
socket.on("data", onData);
try {
yield* provide(handle);
} finally {
socket.off("data", onData);
}
If asynchronous teardown must wait for a value resolved by the listener, the
listener remains registered through that wait and is removed in a synchronous
finally inside the same ensure().
Current gap
The lint gate does not distinguish a subscription owned by an Effection scope
from one whose lifetime depends on its event firing. Raw
EventEmitter.prototype.once(), anonymous .on() handlers, and subscriptions
without matching teardown can therefore enter production and test-support code.
When the event never fires or the scope is cancelled first, the callback may
outlive its owner, retain dead state, or resolve a value nobody is waiting for.
An initial syntactic inventory at origin/main
7cff3462b75f6c5daf655acde61434c3f40f564a finds 106 .once(), .on(), and
.addEventListener() call sites across 38 files in the existing lint surface.
That is an inventory to classify, not a violation count: it includes compliant
scope-owned removal, process-lifetime fixtures, and browser listeners outside
an Effection-owned lifecycle.
The currently installed @effectionx/node@0.2.4 does not yet make its exported
once() scope-bound: constructing the operation registers eagerly, and halting
the interpreting scope leaves the listener registered until the event fires.
thefrontside/effectionx#251
owns that upstream correction.
Contract
The repository follows the EffectionX
Scope-Bound Event Registration Policy:
- a listener's lifetime depends only on its owning scope, never on whether its
event fires;
- raw
emitter.once(...) and { once: true } are not used in Effection-owned
code; the once() operation from @effectionx/node/events is valid only once
the installed package includes
thefrontside/effectionx#251's
correction and passes the cancellation regression below;
.on() and .addEventListener() receive a stable named handler with the
matching .off() or .removeEventListener() in the same scope's teardown;
- synchronous removal may live in
finally; removal following asynchronous
teardown lives in ensure(); and
- when teardown awaits a listener-resolved value, removal is in a synchronous
finally around that wait inside the same ensure().
A local Oxlint rule enforces those observable shapes throughout the repository's
existing lint targets. It distinguishes the scope-bound EffectionX operation
from emitter methods and does not report unrelated APIs merely because they
have a method named on or once.
The migration repairs every applicable violation present on the implementation
feedback revision. It uses no grandfathered baseline, directory-wide disable,
or permanent allowlist for repository-owned code. A genuinely non-Effection
listener lifetime remains outside the rule rather than being wrapped in a fake
scope; any unavoidable narrow suppression names the lifetime invariant and is
audited by the rule tests.
Acceptance
deno task lint rejects raw emitter .once() and { once: true } inside an
Effection-owned lifecycle.
- It rejects an anonymous subscription, a mismatched handler or emitter in
teardown, and a subscription with no removal on cancellation.
- It accepts the corrected installed
@effectionx/node/events once(), a named
subscription removed by the same scope, and the required ensure() plus
synchronous-finally ordering when teardown waits on the event.
- Constructing that
once() operation registers nothing; interpreting it
registers one listener; event completion, owner cancellation, and losing a
race() each leave no listener; and a later event cannot act on its resolver
or state.
- Negative controls prove that unrelated
.on()/.once() methods,
process-lifetime fixture listeners, browser-lifetime listeners, and generated
or vendored files outside the lint gate are not misclassified.
- Every applicable existing violation in production, test support, tests, and
repository scripts is migrated. Cancelling each affected owner leaves no
registered listener and a later event cannot act on its resolver or state.
- The rule is registered in the local plugin, enabled by the authoritative lint
configuration, included in the lint-policy inventory, and cannot be silently
disabled by the review configuration.
- Removing a migrated teardown or replacing the scope-bound EffectionX
operation with emitter .once() fails focused evidence.
Evidence
Add focused rule fixtures and tests at a recognizable entrypoint such as:
deno task test scripts/tests/scope-bound-event-registration.test.ts
deno task test scripts/tests/oxlint-policy.test.ts
deno task lint
Run the smallest affected lifecycle tests for every migrated production or
test-support owner. Listener-count and post-cancellation signals, rather than
elapsed time or garbage collection, establish that registrations are gone.
Relationship
Feedback and delivery depend on
thefrontside/effectionx#251
and a published corrected @effectionx/node version being installed and locked
in this repository. Planning, rule implementation, call-site classification,
and migrations may proceed in parallel. The implementation must not exempt the
currently pinned 0.2.4 helper or claim acceptance before the corrected
installed version passes the focused lifecycle regression.
This Story is independent of #717 and may proceed in parallel with PR #747.
PR #747 remains responsible for making the listeners introduced by that PR
scope-bound before it can pass architecture review; this Story does not move or
weaken #732's active implementation contract. Once this lint rule lands, every
later branch must rebase onto it and pass without adding a baseline.
Out of scope
- Changing public product behavior while migrating listener ownership.
- Editing generated or vendored snapshots that the lint gate deliberately
excludes.
- Requiring process- or browser-lifetime listeners outside Effection-owned work
to pretend they belong to an Effection scope.
Story
As a maintainer writing an Effection operation, I want event subscriptions to be
checked against their owning scope, so cancellation and teardown cannot leave a
listener acting after the work that registered it has ended.
Example
After the installed EffectionX package carries the correction tracked by
thefrontside/effectionx#251,
a one-event wait uses its scope-bound operation:
A longer subscription uses one stable handler and removes it from the same
scope that registered it:
If asynchronous teardown must wait for a value resolved by the listener, the
listener remains registered through that wait and is removed in a synchronous
finallyinside the sameensure().Current gap
The lint gate does not distinguish a subscription owned by an Effection scope
from one whose lifetime depends on its event firing. Raw
EventEmitter.prototype.once(), anonymous.on()handlers, and subscriptionswithout matching teardown can therefore enter production and test-support code.
When the event never fires or the scope is cancelled first, the callback may
outlive its owner, retain dead state, or resolve a value nobody is waiting for.
An initial syntactic inventory at
origin/main7cff3462b75f6c5daf655acde61434c3f40f564afinds 106.once(),.on(), and.addEventListener()call sites across 38 files in the existing lint surface.That is an inventory to classify, not a violation count: it includes compliant
scope-owned removal, process-lifetime fixtures, and browser listeners outside
an Effection-owned lifecycle.
The currently installed
@effectionx/node@0.2.4does not yet make its exportedonce()scope-bound: constructing the operation registers eagerly, and haltingthe interpreting scope leaves the listener registered until the event fires.
thefrontside/effectionx#251
owns that upstream correction.
Contract
The repository follows the EffectionX
Scope-Bound Event Registration Policy:
event fires;
emitter.once(...)and{ once: true }are not used in Effection-ownedcode; the
once()operation from@effectionx/node/eventsis valid only oncethe installed package includes
thefrontside/effectionx#251's
correction and passes the cancellation regression below;
.on()and.addEventListener()receive a stable named handler with thematching
.off()or.removeEventListener()in the same scope's teardown;finally; removal following asynchronousteardown lives in
ensure(); andfinallyaround that wait inside the sameensure().A local Oxlint rule enforces those observable shapes throughout the repository's
existing lint targets. It distinguishes the scope-bound EffectionX operation
from emitter methods and does not report unrelated APIs merely because they
have a method named
onoronce.The migration repairs every applicable violation present on the implementation
feedback revision. It uses no grandfathered baseline, directory-wide disable,
or permanent allowlist for repository-owned code. A genuinely non-Effection
listener lifetime remains outside the rule rather than being wrapped in a fake
scope; any unavoidable narrow suppression names the lifetime invariant and is
audited by the rule tests.
Acceptance
deno task lintrejects raw emitter.once()and{ once: true }inside anEffection-owned lifecycle.
teardown, and a subscription with no removal on cancellation.
@effectionx/node/eventsonce(), a namedsubscription removed by the same scope, and the required
ensure()plussynchronous-
finallyordering when teardown waits on the event.once()operation registers nothing; interpreting itregisters one listener; event completion, owner cancellation, and losing a
race()each leave no listener; and a later event cannot act on its resolveror state.
.on()/.once()methods,process-lifetime fixture listeners, browser-lifetime listeners, and generated
or vendored files outside the lint gate are not misclassified.
repository scripts is migrated. Cancelling each affected owner leaves no
registered listener and a later event cannot act on its resolver or state.
configuration, included in the lint-policy inventory, and cannot be silently
disabled by the review configuration.
operation with emitter
.once()fails focused evidence.Evidence
Add focused rule fixtures and tests at a recognizable entrypoint such as:
Run the smallest affected lifecycle tests for every migrated production or
test-support owner. Listener-count and post-cancellation signals, rather than
elapsed time or garbage collection, establish that registrations are gone.
Relationship
Feedback and delivery depend on
thefrontside/effectionx#251
and a published corrected
@effectionx/nodeversion being installed and lockedin this repository. Planning, rule implementation, call-site classification,
and migrations may proceed in parallel. The implementation must not exempt the
currently pinned
0.2.4helper or claim acceptance before the correctedinstalled version passes the focused lifecycle regression.
This Story is independent of #717 and may proceed in parallel with PR #747.
PR #747 remains responsible for making the listeners introduced by that PR
scope-bound before it can pass architecture review; this Story does not move or
weaken #732's active implementation contract. Once this lint rule lands, every
later branch must rebase onto it and pass without adding a baseline.
Out of scope
excludes.
to pretend they belong to an Effection scope.