--[[ Blocker — value-based emit deferral, as a POLICY on top of `state:Gate`. `.claude/base/blocker-plan.md` "메커니즘 (확정)", `.claude/base/gate-plan.md` 5번 (`blocker:Policy(emit)`), as-is. Blocker() -> blocker blocker:On() -- IsBlocked = true, nothing else blocker:Off() -- IsBlocked = false FIRST, then every registered handle with emit=true blocker:OffWithoutEmit() -- same path with emit=false: the withheld batch is DISCARDED blocker:IsOn() -> boolean -- thin read of `IsBlocked` blocker:Policy(emit) -> onUpstreamEmit -- this blocker's gate policy as a value; registers the -- onunblock handle at THIS call (weak-key set) state:Apply(blocker) -- == state:Gate(function(emit) return blocker:Policy(emit) end) -- via the method-form `__apply` (`H-158`; old `state:Block` is gone) Ownership (`H-63`): the handle set is weak-key; the strong owner of a handle is the `onUpstreamEmit` closure the policy returns (upvalue), so a handle lives exactly as long as its GateNode. A Blocker holding handles strongly was rejected — it would pin every gated state and its upstream chain. `Off`/`OffWithoutEmit` snapshot the set before walking (a flush can create new gates mid-walk). `IsBlocked` is a plain boolean — nesting the same Blocker is deliberately unsupported; make a new one per overlapping batch. "HasBlockedEmit" has no field here: it IS `next(gate._withheld) ~= nil`, and the gate's `emit(commit) -> boolean` is the only channel to it. Dependency-free leaf (`Brand` only) — it never touches lifetime gates, so it is shared across quad instances like `Ref`. ]] local Brand = require("./Brand") local QuadTypes = require("../roblox_packages/quad_types") export type Blocker = QuadTypes.Blocker local BlockerBrand = Brand.BlockerBrand local WEAK_KEY_MT = { __mode = "k" } local BlockerImpl = {} BlockerImpl.__index = BlockerImpl function BlockerImpl.IsOn(self: any): boolean return self.IsBlocked end function BlockerImpl.On(self: any): any self.IsBlocked = true return self end -- Shared by Off/OffWithoutEmit — the only difference is the flag passed on. local function runHandles(self: any, doEmit: boolean) local snapshot: { (boolean) -> () } = {} for handle in self._handles do -- `H-63` (3): snapshot, then walk snapshot[#snapshot + 1] = handle end for _, handle in snapshot do if self.IsBlocked then -- `H-203` (a): a downstream fired by an earlier handle's flush re-blocked -- this blocker — stop; the remaining handles keep their withheld batches -- for the next Off. A half-flushed stop is fine by quad's contract -- (user, 2026-08-31: "중간에서 멈춰도 quad 정의 상 문제는 없는 상태"). return end handle(doEmit) end end function BlockerImpl.Off(self: any): any self.IsBlocked = false -- first, so a flush that re-enters sees the blocker open runHandles(self, true) return self end function BlockerImpl.OffWithoutEmit(self: any): any self.IsBlocked = false runHandles(self, false) return self end -- The gate policy, as a value. `emit` is the gate's flush: `emit()` propagates -- the withheld batch, `emit(false)` discards it, both no-ops on an empty batch -- (so the handle is idempotent for free — gate-plan 8번). function BlockerImpl.Policy(self: any, emit: (boolean?) -> boolean): () -> () local function handle(doEmit: boolean) if doEmit then emit() else emit(false) end end self._handles[handle] = true -- `H-63` (1): weak-key set return function() -- onUpstreamEmit — `H-63` (2): THIS closure is the handle's strong owner if self.IsBlocked then return -- the gate already put the source into its withheld set; nothing to do end handle(true) -- open: pass through — going through `handle` is what keeps it alive (upvalue) end end -- Applicative factory, method form (`H-158`): `state:Apply(blocker)`. function BlockerImpl.__apply(self: any, state: any): any return state:Gate(function(emit) return self:Policy(emit) end) end local function Blocker(): Blocker local self = setmetatable({ IsBlocked = false, _handles = setmetatable({}, WEAK_KEY_MT), }, BlockerImpl) BlockerBrand:register(self) return (self :: any) :: Blocker end return Blocker