You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Process note — I'm at the helm of this proposal: the problem, the trade-offs, and the scoping decisions are mine. I used an AI assistant to pressure-test the design, refine the wording, and shape it into a clear feature-request format. I'd rather say that openly than have the polish read as autogenerated — the articulation is assisted, the thinking is human. That division (human in command, AI as a sharp tool) is the very thing this proposal argues for.
TL;DR — The Effect-native diagnostics already steer asynchronous partiality toward Effect (asyncFunction, newPromise), but there is no equivalent for synchronous partiality. This proposes two off-by-default diagnostics that complete the axis: a null/undefined return → suggest Option, and a synchronous throw → suggest Either. Diagnostic-only to start (no autofix), exactly how asyncFunction ships today. The payoff is human DX and — increasingly the bigger story — a machine-readable steering signal that stops LLM coding agents from generating defensive boilerplate at every call site.
The problem
A function with a dishonest contract pushes work onto every caller:
function find(id): User | null — every consumer writes if (user === null). The contract says absence is possible but not what it means (not found? error? both?), so each caller re-invents the guard.
function parse(raw): Config { /* … */ throw new Error("bad input") } — the failure is invisible: it never appears in the type. Callers don't know to handle it until it throws at runtime.
Both are the same disease — partiality (the function isn't total) leaking out as a nullable union or an untyped throw, instead of being modeled as a value. Effect already has the honest forms: Option for absence, Either for synchronous failure. Nothing currently nudges plain-TS code toward them.
The gap: a hole in the Effect-native category
The existing Effect-native diagnostics encode "plain-TS construct → prefer the Effect counterpart." On the partiality axis they cover async but not sync:
partiality shape
already covered
proposed
async / Promise control flow
✅ asyncFunction, newPromise
—
synchronous throw (failure with info)
❌
preferEitherOverThrow → Either
null / undefined return (absence, no info)
❌
preferOptionOverNullable → Option
The right target depends on what the function is — Effect has a deliberate three-rung ladder:
rung
when
function coloring
runtime cost
Option<A>
absence, no error info
colorless
~free (tagged union)
Either<A, E>
synchronous failure, carries info
colorless
~free (tagged union)
Effect<A, E, R>
failure + async / resources / deps
viral
interpreted (fiber runtime)
flowchart TD
fn([Function may not return a plain value]) --> q1{"Signals absence,<br/>no error info?"}
q1 -->|yes| opt["Option<A><br/>colorless · ~free"]
q1 -->|"no: fails with info"| q2{"Synchronous?<br/>no async / resources / deps"}
q2 -->|yes| either["Either<A, E><br/>colorless · ~free"]
q2 -->|no| eff["Effect<A, E, R><br/>viral color · interpreted"]
opt -. proposed .-> ruleA[["preferOptionOverNullable"]]
either -. proposed .-> ruleB[["preferEitherOverThrow"]]
eff -. already exists .-> ruleC[["asyncFunction"]]
Loading
The boundary with asyncFunction is deliberate and keeps the rules from overlapping: asynchronous failure is already Effect's job — these two rules own only the synchronous rungs.
The two rules
preferEitherOverThrow — a synchronous function that can throw is the value-level twin of Either's Left. Suggest returning Either<A, E> so the failure becomes a value the compiler tracks. (Async-that-throws stays with asyncFunction → Effect.)
preferOptionOverNullable — a function whose return type includes null is signaling absence. Suggest Option<A>, making "no value" a first-class case instead of a sentinel every caller must remember to check.
Both default to off (see Severity) and ship diagnostic-only first — exactly how asyncFunction and newPromise already work.
Why the suggestion carries a matrix, not a bare verdict
"Why not just suggest Effect for everything?" — because that over-prescribes, and the reason it over-prescribes is the thing both humans and agents need in the message. Two axes justify staying on the lowest honest rung.
Function coloring (Nystrom, What Color is Your Function?, 2015). A throw is an invisible color — an exceptional channel that propagates but never shows in the type. Option/Either are colorless: callable anywhere, pattern-matched inline, no runtime. Effect introduces a visible, viral color — to consume it you must be in an Effect context or call run* at a boundary, and it spreads up the call graph exactly like async. For a function whose only sin is a synchronous throw, Either removes the invisible color without painting on a viral one.
Diagram — how the color spreads
flowchart LR
subgraph S1["Either / Option — colorless"]
direction TB
a1[caller] --> leaf["leaf returns Either<A,E>"]
a2[caller] --> leaf
end
subgraph S2["Effect — color propagates upward"]
direction TB
b1["caller: forced into Effect"] --> b2["caller: forced into Effect"] --> bleaf["leaf returns Effect<A,E,R>"]
end
class b1,b2,bleaf colored
classDef colored fill:#ffd9d9,stroke:#cc3333,color:#000
Loading
Runtime cost.Either is a tagged union — construct + tag-check. Effect is interpreted: even synchronous execution steps through the fiber run-loop. Negligible at IO boundaries; real on hot synchronous paths — e.g. a wire-format decoder invoked once per network packet. Suggesting Effect there silently regresses the hot loop.
So the proposal is verdict-led, matrix-justified: the diagnostic names the rung that matches the detected shape (sync-throw → Either, nullable → Option), then surfaces the ladder above as the why, and when to choose otherwise. A reader — human or agent — can then override with reason (escalate to Effect when the function genuinely needs a dependency; stay on Either precisely because it's hot). A preferEffectOverEither option exists for codebases that deliberately want one target everywhere.
More than a linter: generation-steering for coding agents
We think this is becoming one of the most valuable things Effect's tooling can do, and it is the part most worth scrutinising.
LLM coding harnesses share one mechanism: your codebase is the few-shot example set the model generates from (a point Michael Arnaldi has made repeatedly about using AI agents with Effect; Karpathy's "models are weak at niche code" is the same coin's other face — local context is what compensates). So a dishonest contract doesn't only cost human readers — it teaches the model to emit more of the same:
For T | null, an agent pattern-matches the nullable signature and generates more defensive if (x === null) guards at every new call site.
For throw, it generates none — nothing in the type signals the need — and the code crashes at runtime.
Either way the dishonest contract propagates at the scale of generated code, with no machine signal steering toward the honest type. These diagnostics are that signal. End to end:
flowchart TD
d["Diagnostic at the definition<br/>nullable return · or · sync throw"] --> r["Agent reads it<br/>via typecheck output, in its edit→check loop"]
r --> f["Agent fixes the definition<br/>→ Option / Either"]
f --> o["TypeScript surfaces typed obligations<br/>at every call site"]
o --> m["Agent discharges each with<br/>Option.match / Either.match,<br/>not ad-hoc null guards"]
m --> h([Honest contract,<br/>aligned consumers])
o -. "each obligation re-enters the loop" .-> r
Loading
The pivotal move: the local fix at the definition makes the contract honest, and TypeScript then re-surfaces the previously-invisible obligations as typed errors at the call sites — which an agent discharges with Option.match / Either.match in its normal edit→typecheck loop, instead of inventing ad-hoc guards. The breakage is the steering. A linter that does this is an active participant in code generation, not a passive checker — and that is squarely the "Effect makes agents productive" story the ecosystem is already telling.
On the delivery channel (honest caveat). This steering only works through a channel the agent actually consumes. The robust, harness-agnostic one is the typecheck CLI output — suggestion-severity diagnostics print without failing the exit code, so any agent that runs a typecheck in its loop reads them after every edit. The editor/LSP channel additionally serves humans, but note that some agent harnesses are push-only LSP clients that never issue textDocument/diagnostic pull requests, so they will not surface editor-style Hints. That is a harness-side limitation, not a language-service one — and the CLI channel routes around it entirely.
Full trigger specification
preferOptionOverNullable
Fires when all hold:
The function's resolved return type — annotated or inferred — is a union that includes null. (undefined is opt-in via checkUndefinedReturns; it appears incidentally far more often, so it is off by default.)
There is a meaningful success typeT: the type is T | null where T is not never / void / undefined-alone. A pure void/undefined-returning function is "no result," not "absence of a T."
The function does not already return Option / Either / Effect.
Larger unions are fine: A | B | null → Option<A | B>.
preferEitherOverThrow
Fires when all hold:
The function is synchronous — not async, and its return type is not Promise / PromiseLike. (This is the hard boundary with asyncFunction: async-that-throws is its domain → Effect.)
It contains ≥1 throwlexically in its own body — nested function/arrow scopes are separate functions, analysed on their own.
The throw can escape — it is not enclosed by a try whose catch is in the same function (locally-caught throws are internal control flow, not part of the failure contract).
Direct throws only. Transitive throws (a callee that throws) need interprocedural analysis TypeScript cannot do reliably without checked exceptions → explicit non-goal (see Scope).
Fires regardless of the thrown value type (Error, subclass, or throw "literal").
Defect caveat, not auto-classification. v1 does not try to syntactically distinguish a domain failure from a defect (throw new Error("unreachable")). It fires, but the message notes: if this is a defect / impossible-state rather than an expected failure, prefer leaving it (or Effect.die) — Either is for expected failures. (Assertion-signature functions are hard-excluded — see Exclusions.)
Exclusions & scope
Shared (both rules):
Already-monadic returns — function already returns Option / Either / Effect.
.d.ts / ambient declarations — no body to analyse; usually describes APIs you don't own.
Test files — throw and nullable fixtures are normal in tests; excluded by a configurable glob.
preferOptionOverNullable only:
React/JSX components — a component returning JSX.Element | null / ReactNode is idiomatic "render nothing." Hard-exclude functions whose return type is assignable to ReactNode / ReactElement / JSX.Element. (This is the highest-frequency false positive.)
preferEitherOverThrow only:
Assertion signatures (asserts x is T / asserts x) and type-predicate guards — their job is to throw/narrow; typing them as Either defeats the purpose.
constructor methods — cannot return Either. The honest remedy is a smart constructor / factory, which is a structural refactor, not a return-type change → excluded in v1, noted as a future extension.
ignoredThrowingFunctions: string[] — config allowlist for untyped invariant() / assert() helpers the asserts-signature exclusion won't catch.
Scope — exported/public by default. The whole point is the consumer boundary (defensive code lives where a dishonest contract crosses a module edge). An internal helper used once in-file creates no consumer sprawl, so by default the rules fire on exported functions only. includeInternal: true widens to all functions.
Severity & configuration
Default off — consistent with every opinionated "prefer-Effect-counterpart" rule in the Effect-native category (asyncFunction, newPromise, globalConsole, processEnv, …). Correctness rules earn error; opinions default to off. A codebase can legitimately use the language service purely for its correctness rules.
Recommended opt-in tier: suggestion — surfaces in editors and in typecheck CLI output without gating CI. Codebases that want the agent-steering set it deliberately.
Applies to both Effect v3 and v4 — Option and Either exist in both.
option
rule
default
effect
preferEffectOverEither
preferEitherOverThrow
false
suggest Effect instead of Either (Effect-maximalist codebases)
checkUndefinedReturns
preferOptionOverNullable
false
also fire on undefined in the return union, not just null
includeInternal
both
false
fire on non-exported functions too
ignoredThrowingFunctions
preferEitherOverThrow
[]
function-name allowlist for invariant/assert-style helpers
Feasibility & done-ness roadmap (Gate A → B → C)
Gate A — diagnostic-only (this issue's ask). Both rules fire with the verdict + matrix message; no autofix. Feasibility: high / already-proven. This is exactly what asyncFunction and newPromise are — Effect-native control-flow rules shipped diagnostic-only. Detection is in-engine: return-type inspection (channel-type inspection already exists for missingEffectError) and throw-statement AST walks (body-shape analysis already exists).
Gate B — local code action. A quickfix rewriting within the function's own boundary: return type → Option<T> / Either<A,E>; return null → Option.none(), return x → Option.some(x), throw e → return Either.left(e); auto-insert the import. The matrix becomes interactive — offer multiple actions ("Convert to Option / Either / Effect"). Feasibility: medium / precedented. Many rules ship 🔧 fixes doing exactly this class of local rewrite-plus-import. The hard part is semantic completeness: transform everyreturn/throw path, or bail when control flow is too complex (loops, finally, nested branches).
Gate C — caller propagation. Rewriting call sites after the signature change. Feasibility: low / deferred by design — explicit non-goal. Interprocedural, cross-file, and call sites are semantically diverse (null-check vs ?. vs ?? vs pass-through). That is a refactor-engine problem, not a quickfix. Leaving callers as typed errors (Gate B) is the intended, desirable behaviour — see the generation-steering section.
Prior art & references
Sibling rules (this project).asyncFunction and newPromise already steer async partiality toward Effect; preferSchemaOverJson and instanceOfSchema already steer toward a non-Effect ecosystem type (Schema) — establishing that steering toward Option/Either is in-mandate for the Effect-native category.
Errors-as-values / "don't return null" — Scott Wlaschin, Railway Oriented Programming and Domain Modeling Made Functional. The "don't return null / Null Object" advice is long-standing; Option is its total, type-checked form.
Codebase-as-few-shot-context for agents — Michael Arnaldi's talks on using AI agents with Effect; Andrej Karpathy on LLMs and niche code (Dwarkesh Podcast, 2025).
TypeScript has no checked-exception information (microsoft/TypeScript#13219) — the reason transitive-throw detection is a non-goal.
Asking for: Gate A (diagnostic-only) for both preferOptionOverNullable and preferEitherOverThrow; off by default; v3 + v4; verdict-led messages carrying the rung ladder.
Auto-classifying defects vs domain failures — the message flags the distinction; it does not decide it.
Happy to split into two issues if you'd rather track the rules separately, or to move this to Effect-TS/language-service if that is the better home for rule semantics — the effect-tsgo-vs-language-service boundary looked in-flux from the outside.
Process note — I'm at the helm of this proposal: the problem, the trade-offs, and the scoping decisions are mine. I used an AI assistant to pressure-test the design, refine the wording, and shape it into a clear feature-request format. I'd rather say that openly than have the polish read as autogenerated — the articulation is assisted, the thinking is human. That division (human in command, AI as a sharp tool) is the very thing this proposal argues for.
The problem
A function with a dishonest contract pushes work onto every caller:
function find(id): User | null— every consumer writesif (user === null). The contract says absence is possible but not what it means (not found? error? both?), so each caller re-invents the guard.function parse(raw): Config { /* … */ throw new Error("bad input") }— the failure is invisible: it never appears in the type. Callers don't know to handle it until it throws at runtime.Both are the same disease — partiality (the function isn't total) leaking out as a nullable union or an untyped throw, instead of being modeled as a value. Effect already has the honest forms:
Optionfor absence,Eitherfor synchronous failure. Nothing currently nudges plain-TS code toward them.The gap: a hole in the
Effect-nativecategoryThe existing
Effect-nativediagnostics encode "plain-TS construct → prefer the Effect counterpart." On the partiality axis they cover async but not sync:async/ Promise control flowasyncFunction,newPromisethrow(failure with info)preferEitherOverThrow→Eithernull/undefinedreturn (absence, no info)preferOptionOverNullable→OptionThe right target depends on what the function is — Effect has a deliberate three-rung ladder:
Option<A>Either<A, E>Effect<A, E, R>flowchart TD fn([Function may not return a plain value]) --> q1{"Signals absence,<br/>no error info?"} q1 -->|yes| opt["Option<A><br/>colorless · ~free"] q1 -->|"no: fails with info"| q2{"Synchronous?<br/>no async / resources / deps"} q2 -->|yes| either["Either<A, E><br/>colorless · ~free"] q2 -->|no| eff["Effect<A, E, R><br/>viral color · interpreted"] opt -. proposed .-> ruleA[["preferOptionOverNullable"]] either -. proposed .-> ruleB[["preferEitherOverThrow"]] eff -. already exists .-> ruleC[["asyncFunction"]]The boundary with
asyncFunctionis deliberate and keeps the rules from overlapping: asynchronous failure is alreadyEffect's job — these two rules own only the synchronous rungs.The two rules
preferEitherOverThrow— a synchronous function that canthrowis the value-level twin ofEither'sLeft. Suggest returningEither<A, E>so the failure becomes a value the compiler tracks. (Async-that-throws stays withasyncFunction→Effect.)preferOptionOverNullable— a function whose return type includesnullis signaling absence. SuggestOption<A>, making "no value" a first-class case instead of a sentinel every caller must remember to check.Both default to
off(see Severity) and ship diagnostic-only first — exactly howasyncFunctionandnewPromisealready work.Why the suggestion carries a matrix, not a bare verdict
"Why not just suggest
Effectfor everything?" — because that over-prescribes, and the reason it over-prescribes is the thing both humans and agents need in the message. Two axes justify staying on the lowest honest rung.Function coloring (Nystrom, What Color is Your Function?, 2015). A
throwis an invisible color — an exceptional channel that propagates but never shows in the type.Option/Eitherare colorless: callable anywhere, pattern-matched inline, no runtime.Effectintroduces a visible, viral color — to consume it you must be in an Effect context or callrun*at a boundary, and it spreads up the call graph exactly likeasync. For a function whose only sin is a synchronousthrow,Eitherremoves the invisible color without painting on a viral one.Diagram — how the color spreads
flowchart LR subgraph S1["Either / Option — colorless"] direction TB a1[caller] --> leaf["leaf returns Either<A,E>"] a2[caller] --> leaf end subgraph S2["Effect — color propagates upward"] direction TB b1["caller: forced into Effect"] --> b2["caller: forced into Effect"] --> bleaf["leaf returns Effect<A,E,R>"] end class b1,b2,bleaf colored classDef colored fill:#ffd9d9,stroke:#cc3333,color:#000Runtime cost.
Eitheris a tagged union — construct + tag-check.Effectis interpreted: even synchronous execution steps through the fiber run-loop. Negligible at IO boundaries; real on hot synchronous paths — e.g. a wire-format decoder invoked once per network packet. SuggestingEffectthere silently regresses the hot loop.So the proposal is verdict-led, matrix-justified: the diagnostic names the rung that matches the detected shape (sync-throw →
Either, nullable →Option), then surfaces the ladder above as the why, and when to choose otherwise. A reader — human or agent — can then override with reason (escalate toEffectwhen the function genuinely needs a dependency; stay onEitherprecisely because it's hot). ApreferEffectOverEitheroption exists for codebases that deliberately want one target everywhere.More than a linter: generation-steering for coding agents
LLM coding harnesses share one mechanism: your codebase is the few-shot example set the model generates from (a point Michael Arnaldi has made repeatedly about using AI agents with Effect; Karpathy's "models are weak at niche code" is the same coin's other face — local context is what compensates). So a dishonest contract doesn't only cost human readers — it teaches the model to emit more of the same:
T | null, an agent pattern-matches the nullable signature and generates more defensiveif (x === null)guards at every new call site.throw, it generates none — nothing in the type signals the need — and the code crashes at runtime.Either way the dishonest contract propagates at the scale of generated code, with no machine signal steering toward the honest type. These diagnostics are that signal. End to end:
flowchart TD d["Diagnostic at the definition<br/>nullable return · or · sync throw"] --> r["Agent reads it<br/>via typecheck output, in its edit→check loop"] r --> f["Agent fixes the definition<br/>→ Option / Either"] f --> o["TypeScript surfaces typed obligations<br/>at every call site"] o --> m["Agent discharges each with<br/>Option.match / Either.match,<br/>not ad-hoc null guards"] m --> h([Honest contract,<br/>aligned consumers]) o -. "each obligation re-enters the loop" .-> rThe pivotal move: the local fix at the definition makes the contract honest, and TypeScript then re-surfaces the previously-invisible obligations as typed errors at the call sites — which an agent discharges with
Option.match/Either.matchin its normal edit→typecheck loop, instead of inventing ad-hoc guards. The breakage is the steering. A linter that does this is an active participant in code generation, not a passive checker — and that is squarely the "Effect makes agents productive" story the ecosystem is already telling.Full trigger specification
preferOptionOverNullableFires when all hold:
null. (undefinedis opt-in viacheckUndefinedReturns; it appears incidentally far more often, so it is off by default.)T: the type isT | nullwhereTis notnever/void/undefined-alone. A purevoid/undefined-returning function is "no result," not "absence of aT."Option/Either/Effect.Larger unions are fine:
A | B | null→Option<A | B>.preferEitherOverThrowFires when all hold:
async, and its return type is notPromise/PromiseLike. (This is the hard boundary withasyncFunction: async-that-throws is its domain →Effect.)throwlexically in its own body — nested function/arrow scopes are separate functions, analysed on their own.trywhosecatchis in the same function (locally-caught throws are internal control flow, not part of the failure contract).Error, subclass, orthrow "literal").Defect caveat, not auto-classification. v1 does not try to syntactically distinguish a domain failure from a defect (
throw new Error("unreachable")). It fires, but the message notes: if this is a defect / impossible-state rather than an expected failure, prefer leaving it (orEffect.die) —Eitheris for expected failures. (Assertion-signature functions are hard-excluded — see Exclusions.)Exclusions & scope
Shared (both rules):
Option/Either/Effect..d.ts/ ambient declarations — no body to analyse; usually describes APIs you don't own.throwand nullable fixtures are normal in tests; excluded by a configurable glob.preferOptionOverNullableonly:JSX.Element | null/ReactNodeis idiomatic "render nothing." Hard-exclude functions whose return type is assignable toReactNode/ReactElement/JSX.Element. (This is the highest-frequency false positive.)preferEitherOverThrowonly:asserts x is T/asserts x) and type-predicate guards — their job is to throw/narrow; typing them asEitherdefeats the purpose.constructormethods — cannot returnEither. The honest remedy is a smart constructor / factory, which is a structural refactor, not a return-type change → excluded in v1, noted as a future extension.ignoredThrowingFunctions: string[]— config allowlist for untypedinvariant()/assert()helpers theasserts-signature exclusion won't catch.Scope — exported/public by default. The whole point is the consumer boundary (defensive code lives where a dishonest contract crosses a module edge). An internal helper used once in-file creates no consumer sprawl, so by default the rules fire on exported functions only.
includeInternal: truewidens to all functions.Severity & configuration
Default
off— consistent with every opinionated "prefer-Effect-counterpart" rule in theEffect-nativecategory (asyncFunction,newPromise,globalConsole,processEnv, …). Correctness rules earnerror; opinions default tooff. A codebase can legitimately use the language service purely for its correctness rules.Recommended opt-in tier:
suggestion— surfaces in editors and in typecheck CLI output without gating CI. Codebases that want the agent-steering set it deliberately.Applies to both Effect
v3andv4—OptionandEitherexist in both.preferEffectOverEitherpreferEitherOverThrowfalseEffectinstead ofEither(Effect-maximalist codebases)checkUndefinedReturnspreferOptionOverNullablefalseundefinedin the return union, not justnullincludeInternalfalseignoredThrowingFunctionspreferEitherOverThrow[]invariant/assert-style helpersFeasibility & done-ness roadmap (Gate A → B → C)
Gate A — diagnostic-only (this issue's ask). Both rules fire with the verdict + matrix message; no autofix.
Feasibility: high / already-proven. This is exactly what
asyncFunctionandnewPromiseare — Effect-native control-flow rules shipped diagnostic-only. Detection is in-engine: return-type inspection (channel-type inspection already exists formissingEffectError) andthrow-statement AST walks (body-shape analysis already exists).Gate B — local code action. A quickfix rewriting within the function's own boundary: return type →
Option<T>/Either<A,E>;return null→Option.none(),return x→Option.some(x),throw e→return Either.left(e); auto-insert the import. The matrix becomes interactive — offer multiple actions ("Convert to Option / Either / Effect").Feasibility: medium / precedented. Many rules ship
🔧fixes doing exactly this class of local rewrite-plus-import. The hard part is semantic completeness: transform everyreturn/throwpath, or bail when control flow is too complex (loops,finally, nested branches).Gate C — caller propagation. Rewriting call sites after the signature change.
Feasibility: low / deferred by design — explicit non-goal. Interprocedural, cross-file, and call sites are semantically diverse (null-check vs
?.vs??vs pass-through). That is a refactor-engine problem, not a quickfix. Leaving callers as typed errors (Gate B) is the intended, desirable behaviour — see the generation-steering section.Prior art & references
Sibling rules (this project).
asyncFunctionandnewPromisealready steer async partiality towardEffect;preferSchemaOverJsonandinstanceOfSchemaalready steer toward a non-Effectecosystem type (Schema) — establishing that steering towardOption/Eitheris in-mandate for theEffect-nativecategory.Concepts.
Optionis its total, type-checked form.Effect docs.
Option·Either.Scope of this issue
Asking for: Gate A (diagnostic-only) for both
preferOptionOverNullableandpreferEitherOverThrow;offby default;v3+v4; verdict-led messages carrying the rung ladder.Explicit non-goals (this issue):
Happy to split into two issues if you'd rather track the rules separately, or to move this to
Effect-TS/language-serviceif that is the better home for rule semantics — theeffect-tsgo-vs-language-serviceboundary looked in-flux from the outside.