// Server-execution v2 fan-out stage B — the run supply's per-node state: // the KNOWN-SCOPE RATCHET, the instance-set function over (ratchet × // demanders), and B7's PRECISE per-instance dirtiness. Owner ruling // (2026-08-16): "if a space scoped calculation gets narrowed to user, // it'll have to run for all users that demand it" — scopes.md §2's // mechanism sentence: a principal's demand at a broad address is demand // for THAT principal's instance of every node that narrows beneath it. // // The node stays SINGULAR (the spec-model's C11b): instances live in // keys, basis rows, stamps — and, here, in one per-node record that // remembers what running has DISCOVERED (never what a schema or a body // declares — D11's no-static-analysis rule; the ratchet's only writers // are run outcomes). Nothing here is reached off the serving posture: // a node acquires this state only when the demand registry supplies // demanders for it, and the OFF arm's `runSchedulerAction` never does. // // The RATCHET is ragged-capable (scopes.md §2 as amended 2026-08-16, // the panel's Lens 1): the top hop — space→(user|session) — is // structural, so it is one node-level bit (the broad slot's redirect is // SHARED state; a node that narrows for one principal narrows for // everyone). Below it, depth is PER PRINCIPAL: a node may be user-scoped // for Alice and session-scoped for Bob (a data-dependent second hop), // so the ratchet is a set of session-deep principals, never a single // level. It only narrows — per principal — and is forgotten with the // node (re-learned by the next probe run). import { type CellScope, resolveScopeKey, type ScopeKey, type ScopeKeyIdentity, } from "@commonfabric/memory/v2"; import { scopeRank } from "../scope.ts"; import type { IMemorySpaceAddress } from "../storage/interface.ts"; import type { ReactivityLog } from "./types.ts"; /** One demanded instance run of a fanned-out node. */ export interface FanOutInstance { /** The RESOLUTION identity: a full (principal, session) pair so a * narrower read the run discovers still resolves to a REAL demanded * instance (design §F). For a user-scoped instance the session is the * REPRESENTATIVE one (the smallest demanded session of that principal) * — resolution scaffolding, never attribution. */ readonly identity: ScopeKeyIdentity; /** The instance key the run is stamped with: `space` for the probe, * `user:

` / `session:

:` at the ratchet's depth for `p`. */ readonly key: ScopeKey; } interface InstanceRecord { /** Whether this instance has an accepted result to retain while gated. */ hasCommittedResult?: boolean; /** The latest sealed publication; write-free reruns retain its obligation. */ pendingWaveRun?: object; identity: ScopeKeyIdentity; log?: ReactivityLog; } export interface FanOutNodeState { /** The top hop: some run discovered a scope narrower than `space`. * Structural (the shared redirect), so node-level. */ narrowed: boolean; /** Principals whose discovered depth is SESSION (ragged; per principal; * only ever grows). A principal outside it runs once, as its user * instance, while `narrowed`. */ readonly sessionPrincipals: Set; /** Every instance key this node has run (or is running) at the current * ratchet, with its resolution identity and its LAST COMMITTED * reactivity log — the union of the logs is the node's subscription * (B7: skipped instances keep their reads registered). */ readonly instances: Map; /** Instance keys whose last run is CURRENT (B7): a change dirties only * the instances whose reads covered it; everything not in here runs on * the next pass. Emptied by an untargeted invalidation. */ readonly clean: Set; /** Per-key dirtiness generation: a run marks its key clean at finalize * only if no cause dirtied it since the run started (a change that * lands mid-run must not be absorbed by the run that predates it). */ readonly dirtyGen: Map; } export function newFanOutNodeState(): FanOutNodeState { return { narrowed: false, sessionPrincipals: new Set(), instances: new Map(), clean: new Set(), dirtyGen: new Map(), }; } const pairOrder = (a: ScopeKeyIdentity, b: ScopeKeyIdentity): number => { const p = String(a.principal).localeCompare(String(b.principal)); if (p !== 0) return p; return String(a.sessionId ?? "").localeCompare(String(b.sessionId ?? "")); }; /** * The instance-set function (design §B2), over the ratchet and the * demanders D: * * - not narrowed → ONE run, the PROBE: identity = min(D) (deterministic; * resolution scaffolding only — no annotation may depend on it), key * `space`; * - narrowed → one run per distinct principal p in D at its depth: key * `user:p` with the representative session, or — if p is session-deep * — one run per (p, s) pair in D, key `session:p:s`. * * Demanders without a principal (anonymous sessions) cannot own an * instance and are dropped; an empty D is the caller's fallback (the * wave-level identity), never this function's concern. */ export function fanOutInstances( state: Pick, demanders: readonly ScopeKeyIdentity[], ): FanOutInstance[] { const owned = demanders .filter((d) => d.principal !== undefined) .sort(pairOrder); if (owned.length === 0) return []; if (!state.narrowed) { return [{ identity: owned[0], key: "space" }]; } const instances: FanOutInstance[] = []; const seen = new Set(); for (const demander of owned) { const principal = demander.principal!; if (state.sessionPrincipals.has(principal)) { if (demander.sessionId === undefined) continue; const key = resolveScopeKey("session", demander); if (seen.has(key)) continue; seen.add(key); instances.push({ identity: demander, key }); } else { const key = resolveScopeKey("user", demander); if (seen.has(key)) continue; seen.add(key); // Sorted, so the first pair of a principal carries its smallest // session — the representative. instances.push({ identity: demander, key }); } } return instances; } /** The depth the ratchet holds for `identity`'s principal, as a scope. */ export function ratchetDepthFor( state: Pick, identity: ScopeKeyIdentity, ): CellScope { if (!state.narrowed) return "space"; return identity.principal !== undefined && state.sessionPrincipals.has(identity.principal) ? "session" : "user"; } /** * Learn a run's DISCOVERED scope (design §B3, the discovery re-arm's * ratchet half): only ever narrows — the top hop for everyone, the second * hop for this run's principal. Returns true when the ratchet moved (the * caller then re-derives the instance set: siblings appear and run in * the same pass). */ export function ratchetDiscovered( state: FanOutNodeState, identity: ScopeKeyIdentity, discovered: CellScope, ): boolean { let moved = false; if (scopeRank(discovered) > scopeRank("space") && !state.narrowed) { state.narrowed = true; moved = true; } if ( discovered === "session" && identity.principal !== undefined && !state.sessionPrincipals.has(identity.principal) ) { state.sessionPrincipals.add(identity.principal); moved = true; } return moved; } /** The key `identity` runs under at the CURRENT ratchet — the instance a * finished run's outcome is recorded against (a run stamped `user:p` that * discovered session is recorded as `session:p:s_rep`, the instance that * now exists; its stamped key has left the set). Undefined when the * identity cannot resolve the depth (a sessionless pair at session * depth). */ export function keyAtRatchet( state: Pick, identity: ScopeKeyIdentity, ): ScopeKey | undefined { try { return resolveScopeKey(ratchetDepthFor(state, identity), identity); } catch { return undefined; } } /** Every instance key `identity` covers at ANY depth on its own chain — * the keys a cause resolving to one of them dirties. */ function keysOnChain(identity: ScopeKeyIdentity): Set { const keys = new Set(["space"]); try { keys.add(resolveScopeKey("user", identity)); keys.add(resolveScopeKey("session", identity)); } catch { // whatever resolved is the chain } return keys; } /** * B7 — precise per-instance dirtiness. A cause address that NAMES its * instance (`scopeKey`, stage A's keyed notification addresses) dirties * exactly the instances whose identity resolves that address's scope to * that key — Bob's write to `user:bob/X` dirties Bob's user instance and * Bob's session instances (they read `user:bob` too), never Alice's. A * cause with no key — a space doc, or an address the ambient identity * would resolve — dirties every instance. Returns the keys dirtied. */ export function dirtyFanOutForCause( state: FanOutNodeState, cause: Pick, ): void { const causeKey = cause.scopeKey; if (causeKey === undefined || causeKey === "space") { dirtyFanOutAll(state); return; } for (const [key, record] of state.instances) { if (key === "space") continue; if (keysOnChain(record.identity).has(causeKey)) { dirtyFanOutKey(state, key); } } } export function dirtyFanOutAll(state: FanOutNodeState): void { for (const key of [...state.clean, ...state.instances.keys()]) { dirtyFanOutKey(state, key); } } export function dirtyFanOutKey(state: FanOutNodeState, key: ScopeKey): void { state.clean.delete(key); state.dirtyGen.set(key, (state.dirtyGen.get(key) ?? 0) + 1); } /** The instances of the current set that must run: never run at this * ratchet, or dirtied since. */ export function fanOutInstancesToRun( state: FanOutNodeState, instances: readonly FanOutInstance[], ): FanOutInstance[] { return instances.filter((instance) => !state.clean.has(instance.key)); } /** * Retire instance state for keys that left the current set (a demander * departed, or the ratchet re-keyed the principal): their reads leave the * union subscription on the next resubscribe. */ export function pruneFanOutInstances( state: FanOutNodeState, current: readonly FanOutInstance[], ): void { const live = new Set(current.map((instance) => instance.key)); for (const key of [...state.instances.keys()]) { if (!live.has(key)) { state.instances.delete(key); state.clean.delete(key); state.dirtyGen.delete(key); } } } /** Record that `instance` is starting a run: its dirtiness generation at * start, compared at finalize. */ export function fanOutRunStarted( state: FanOutNodeState, instance: FanOutInstance, ): number { if (!state.instances.has(instance.key)) { state.instances.set(instance.key, { identity: instance.identity }); } return state.dirtyGen.get(instance.key) ?? 0; } /** * Record a finished run's outcome: the ratchet learns the discovered * scope; the run's key at the (possibly moved) ratchet is marked clean — * unless a cause dirtied the STAMPED key while it ran — and holds the * committed log. Returns whether the ratchet moved. * * Reached only from a run whose commit was KICKED (a thrown non-retry * error still commits its transaction through the ordinary path — OFF's * shape); a run that ends in `RetryImmediately` never reaches it, so its * key stays non-clean and the loop defers it (run.ts, review F1). */ export function fanOutRunFinished( state: FanOutNodeState, instance: FanOutInstance, outcome: { discovered: CellScope; startGen: number; log: ReactivityLog | undefined; }, ): boolean { const moved = ratchetDiscovered(state, instance.identity, outcome.discovered); const key = keyAtRatchet(state, instance.identity) ?? instance.key; const dirtiedMeanwhile = (state.dirtyGen.get(instance.key) ?? 0) !== outcome.startGen; if (key !== instance.key) { // Re-keyed by the ratchet: the stamped instance has left the set. state.instances.delete(instance.key); state.clean.delete(instance.key); state.dirtyGen.delete(instance.key); } const record = state.instances.get(key) ?? { identity: instance.identity }; record.identity = instance.identity; if (outcome.log !== undefined) record.log = outcome.log; state.instances.set(key, record); if (!dirtiedMeanwhile) { state.clean.add(key); } else { state.clean.delete(key); } return moved; } /** The union of every current instance's last log — the node's ONE * subscription (stage A's union resubscribe, persisted per instance so a * pass that skips clean instances keeps their reads registered). */ export function fanOutUnionLog(state: FanOutNodeState): ReactivityLog { const reads: IMemorySpaceAddress[] = []; const shallowReads: IMemorySpaceAddress[] = []; const writes: IMemorySpaceAddress[] = []; for (const record of state.instances.values()) { if (record.log === undefined) continue; for (const read of record.log.reads) reads.push(read); for (const read of record.log.shallowReads) shallowReads.push(read); for (const write of record.log.writes) writes.push(write); } return { reads, shallowReads, writes }; }