0107 — effect-row reuse: subeffecting at reuse sites, open-row ascription deferred (#94)
- Status: Accepted
- Summary: #94 (
unifyRow's single-shared-row-var "first cut" rejected reusing ONE row-polymorphic binding at two genuinely different effect rows in one program, even though each use alone type-checks) is ruled: adopt subeffecting (φ_use ⊆ φ_bound) at reuse sites, via the ALREADY-PROVEN-SOUNDsubRowrelation this checker already uses elsewhere (checkSC's.thunk/.annotSarms,subsumeAppV's own #119 precedent) — NOT full Rémy independent-tails row polymorphism, which stays deferred behind its own consumer gate (no program needs INCOMPARABLE-row reuse today). The kernel's row algebra (Bang/Core/EffectRow.lean, ADR-0001/ADR-0018) is untouched; this is a purely FRONTEND widening of which programs the checker admits. - Depends-on: ADR-0001 (rows are idempotent
Finsets — the join-semilattice this ruling's own⊆/∪relations are stated over), ADR-0018 (the kernel's already-shipped lacks-constrained open row quantifiers — the machinery this ruling's OWN "no kernel change" claim rests on), #119 (thesubRow/subsumeAppVfork-1 precedent this ruling'sS1slice reuses verbatim, not reinvents) - Date: 2026-07-12
- Deciders: operator (Frontend lane,
docs/notes/type-power-entry-design.md's Wave-E entry-slice design, ratified as task #164) - Ties: docs/notes/type-power-entry-design.md (the full consumer-verdict + entry-slice
design this ADR formalizes the S1 decision from), issue #94 (the isolated non-cap repro),
examples/stage-swap/README.md§"Known gate" (the flagship-demo corpus witness), issue #84 (the per-stage handler-swap thesis this reuse wall was found inside),docs/notes/calc-typer-experiment-findings.md(the independent theory corroboration — subeffecting is Cousot's consequence rule, derived not postulated)
Context
Bang's checker infers a row-POLYMORPHIC type for any let-generalized binding whose body
forces two of its own function-typed parameters — let compose = {fun p => fun q => fun x => ($p)(($q) x)} types as ∀ρ. (b→c!ρ)→(a→b!ρ)→(a→c!ρ) (ADR-0075 bite-0b item 3). Two SEPARATE
uses of compose, each internally at one consistent row, already worked (rowPolyDivSrc's own
inc∘dbl at ⊥ AND countdown∘countdown at {Div}, coexisting in one program — the row
POLYMORPHISM itself was proven, not the gap this ADR closes).
The gap: joinRow's "single-ρ first cut" (TypeCheck.lean:503's own doc comment) COLLAPSES
compose's two internal row vars (ρp, ρq) to ONE shared var during compose's own body
elaboration — a sound, deliberate over-approximation (ADR-0075's own scoping). The consequence:
compose's domain for p and q share ONE row variable, so applying compose to a PURE
function (inc, row ⊥) then a {Div}-rowed function (cd) WITHIN ONE compose application
pins that shared var to ⊥ on the first use, then demands EXACT equality against {Div} on the
second — unifyRow's EffectRow.unify closed/closed case (EffectRow.lean:120-121) has no
leeway, so the reuse fails loud ("effect row mismatch"), even though ⊥ ⊆ {Div} makes the
combination semantically unproblematic.
This is not a hypothetical: examples/stage-swap's own flagship demo of the per-stage
handler-swap thesis (#84 — "the stage IS the handler") hits this WALL reusing its test
installer against an effectful logic body then a pure pureBody — the README's own "Known
gate" section names it, the checker's own rowPolyDivSrc corpus pins it as an expected FAIL,
and #94 reproduces it with ZERO capability/user-effect involvement (compose incPure <effectful>), proving the wall is row-inference-GENERAL, not a capability-wrapper artifact.
Decision
Adopt subeffecting (φ_use ⊆ φ_bound) at row-polymorphic reuse sites. unifyRow's ONE call
site in the whole checker (unifyV's .U φ B, .U φ' B' case — every .U-typed comparison in
the checker funnels through here) now falls back to subeffecting when EffectRow.unify's exact
match fails:
- If EITHER side's row, BEFORE resolution, carried an open tail variable (
.tail = some v), re-bind that variable to the WIDER JOIN (a.labels ∪ b.labels) instead of failing. The narrower use was always⊆the join;EffectRow.applyR's own resolve-time union (EffectRow.lean:107) is exactly the mechanism that then makes every SUBSEQUENT lookup of that variable see the widened set.rassign's list-prepend semantics mean this shadows the earlier (narrower) binding going forward without retracting anything the earlier binding already committed to at ITS OWN call site. - If BOTH sides are already closed (a handler discharges the row before the two sides are ever
compared — the stage-swap shape, where
test's return type is alreadyIntwith no row var), admit the JOIN directly when one side's label set is a⊆of the other's — the same subset relation, applied where there is no variable left to re-bind because the discharge already erased it. - If NEITHER condition holds (both sides closed, NEITHER a subset of the other — a genuinely
INCOMPARABLE-row reuse, e.g.
{Net}vs{Log}), fail loud with the #94-naming diagnostic (task #164's own S0 slice, landed first) — subeffecting only ever ADMITS more programs, it never silently accepts an actually-incompatible pair.
Full Rémy independent-tails row polymorphism (open-row ascription, ∀(α # L). τ surface
syntax over ADR-0018's kernel form) is the NAMED NEXT RUNG, deferred behind its own consumer
gate. No program in the corpus needs INCOMPARABLE-row reuse today (subeffecting closes every
CITED consumer — the stage-swap witness, #94's repro, rowPolyDivSrc's pinned corpus); building
the general independent-tail machinery ahead of a real consumer would be exactly the
speculative-generality the No-Free-Lunch discipline forbids (docs/notes/type-power-entry-design.md
§9's own slice map, S3).
Rejected / staged
- Status-quo single-ρ collapse (do nothing) — REJECTED: it IS #94, a feature-gating
incompleteness with FOUR independent, cited consumer signals (a corpus witness, a filed issue,
an in-checker pinned expected-failure, and an independent theory corroboration —
type-power-entry-design.md§2). Leaving it unfixed keeps a real demo (examples/stage-swap) artificially limited to separately-named installer bindings. - Full Rémy FIRST, before any subeffecting slice — REJECTED: no incomparable-row consumer
exists to justify the extra machinery (independent lacks-constrained tail variables, a real
∀(α # L)surface binder, per-use fresh instantiation threading throughgeneralize) ahead of demand. Subeffecting is STRICTLY cheaper (reusessubRow's EXISTING soundness proof,unify_sound, rather than needing a new one) and closes every cited consumer on its own. - ρ-map /
lift-based multi-instance rows — REJECTED: ADR-0018's own accepted cost ("NOT vialift/ρ-maps, which would re-introduce a non-idempotent (multiset) row algebra and break everything below"). This ADR's own∪/⊆relations are stated overFinset, matching ADR-0001's idempotent-set invariant throughout — nothing here reintroduces ordering or multiplicity. - Retroactively re-checking every PAST use of a widened row var — CONSIDERED, not needed: the
earlier (narrower) use already type-checked CORRECTLY against the row it saw at that time
(
⊥forinc, in the worked example) — subeffecting never invalidates a PAST judgment, it only widens what a row var resolves to for FUTURE lookups. No re-verification pass is required because the earlier judgment's own soundness never depended on the row staying narrow forever.
Consequences
examples/stage-swap's README "Known gate" section is now STALE (the program it names as failing now type-checks and runs) — a documentation follow-up, not gated by THIS ADR (the README itself is out ofBang/**'s own leaf discipline).- The checker's
rowPolyDivSrccorpus (renamedrowSubeffectSrc) and the stage-swap witness corpus (renamedstageSwapReuseSrc) both FLIPPED from pinned expected-FAILURE to pinned expected-SUCCESS, each with arunTypedYieldsIntdifferential check againstSource.eval— the SAME corpus now documents the FIX instead of the gap. - No kernel change, no census re-proof: the 18→20 headline theorem count (and every
just axiomsbaseline) is UNCHANGED by this ADR — confirmed live (S0 and S1's own landing commits both show a byte-identical axiom census to the pre-change baseline). unifyV's.Ucase is no longer a PURE unification call — it now has a fallback branch with its own (proven-sound-by-composition, not independently proven) soundness argument. Future work touchingunifyV/unifyRowmust preserve this fallback's own three-way disjunction (var-widen / closed-subset / fail), not just the exact-match fast path.
Revisit if
A real program needs reuse at two GENUINELY INCOMPARABLE rows (neither ⊆ the other — e.g. a
binding genuinely needing {Net} in one use and {Log} in another, with neither row a subset)
— that is Slice B / S3's own trigger, type-power-entry-design.md §5's Slice B surface sketch
(∀(α # L). … ! φ ⊔ α) is the pre-designed next rung. OR a soundness gap is found in the
subeffecting fallback itself (the subsumeAppV/subRow precedent this ADR reuses has its OWN
soundness proof, unify_sound, but the NEW three-way disjunction in unifyRow composing that
relation with EffectRow.unify's existing cases has not been independently re-proven — a
falsifier here would be a program the checker WRONGLY accepts, caught by the differential test
against Source.eval, per this project's own MGU-is-not-the-contract, soundness-via-differential-test
posture).