diff --git a/packages/loopover-miner/lib/chat-action-dispatch.d.ts b/packages/loopover-miner/lib/chat-action-dispatch.d.ts new file mode 100644 index 0000000000..d7e6dd96ca --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-dispatch.d.ts @@ -0,0 +1,21 @@ +import type { ChatActionRegistry, ChatActionRequest } from "./chat-action-registry.js"; + +export const CHAT_ACTION_DISPATCH_FLAG: string; +export const CHAT_ACTION_DISPATCH_ENABLE_VALUE: string; + +export function isChatActionDispatchEnabled(env?: Record): boolean; + +export type ChatActionDispatchResult = { + ok: boolean; + status: string; + action: string | null; + [key: string]: unknown; +}; + +export function dispatchChatAction( + request: ChatActionRequest, + options?: { + env?: Record; + registry?: ChatActionRegistry; + }, +): Promise; diff --git a/packages/loopover-miner/lib/chat-action-dispatch.js b/packages/loopover-miner/lib/chat-action-dispatch.js new file mode 100644 index 0000000000..11b44bf251 --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-dispatch.js @@ -0,0 +1,81 @@ +// Chat action-dispatch chokepoint (#6519). +// +// SINGLE ENTRY POINT, NEVER BYPASS: every action a miner-chat message issues MUST go through +// `dispatchChatAction` here -- never a parallel or direct call into a registered handler, an HTTP endpoint, +// or a local-write tool. This function is the one place the config flag is checked and the one place a +// registered handler is looked up and invoked. It adds NO second safety check of its own: the real +// fail-closed enforcement lives in packages/loopover-engine/src/governor/chokepoint.ts (the precedence +// ladder) reached through the packages/loopover-miner/lib/governor-chokepoint.js stateful wrapper, which the +// registry's `governorGatedHandler` contract forces every registered handler through. Dispatch only gates on +// the flag, rejects unknown actions, and runs the registered params-validator before invoking the handler. +// +// Disabled by default: the flag fails closed (off unless explicitly enabled), and the shared registry +// (chat-action-registry.js) ships empty, so no action can execute until a child issue registers a handler +// AND an operator flips the flag on. + +import { chatActionRegistry } from "./chat-action-registry.js"; + +/** Env var an operator sets to turn the chat-action dispatch layer on. */ +export const CHAT_ACTION_DISPATCH_FLAG = "LOOPOVER_MINER_CHAT_ACTIONS"; +/** The one and only value that enables dispatch. Anything else (unset, empty, "true", "1", ...) stays off. */ +export const CHAT_ACTION_DISPATCH_ENABLE_VALUE = "enabled"; + +/** + * Fail-closed config-flag gate: enabled only when the flag is set to exactly the enable value (trimmed). + * Unset, empty, or any other value -- including truthy-looking ones like "true"/"1" -- reads as disabled. + * + * @param {Record} [env] + * @returns {boolean} + */ +export function isChatActionDispatchEnabled(env = process.env) { + const raw = env?.[CHAT_ACTION_DISPATCH_FLAG]; + return typeof raw === "string" && raw.trim() === CHAT_ACTION_DISPATCH_ENABLE_VALUE; +} + +/** + * The single entry point every chat-issued action goes through. In order: + * 1. Check the config flag FIRST -- before touching the registry or validating params. When disabled, + * return a clearly-typed `"disabled"` result and look up nothing. + * 2. Reject an unknown (unregistered) action. + * 3. Run the action's own registered params-validator; reject on failure without coercing or dropping + * fields (the caller's `params` is passed through unchanged). + * 4. Invoke the registered (governor-gated) handler and return its result. + * + * @param {{ action?: string, params?: unknown, governorInput?: unknown }} request + * @param {{ env?: Record, registry?: typeof chatActionRegistry }} [options] + * @returns {Promise<{ ok: boolean, status: string, action: string | null, [k: string]: unknown }>} + */ +export async function dispatchChatAction(request, options = {}) { + const env = options.env ?? process.env; + + // Flag first -- before touching the registry or validating params. Fail closed. + if (!isChatActionDispatchEnabled(env)) { + return { ok: false, status: "disabled", action: readAction(request) }; + } + + const registry = options.registry ?? chatActionRegistry; + const action = readAction(request); + if (action === null || !registry.has(action)) { + return { ok: false, status: "unknown_action", action }; + } + + const registered = registry.get(action); + let valid; + try { + valid = registered.paramsValidator(request?.params) === true; + } catch (error) { + // A validator that throws is treated as a rejection (fail closed), not as a dispatch error. + return { ok: false, status: "invalid_params", action, error: error instanceof Error ? error.message : String(error) }; + } + if (!valid) { + return { ok: false, status: "invalid_params", action }; + } + + const result = await registered.handler(request); + return { ok: true, status: "dispatched", action, result }; +} + +/** The requested action name, or null when the request omits a string action. */ +function readAction(request) { + return request && typeof request.action === "string" ? request.action : null; +} diff --git a/packages/loopover-miner/lib/chat-action-registry.d.ts b/packages/loopover-miner/lib/chat-action-registry.d.ts new file mode 100644 index 0000000000..3066db03e3 --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-registry.d.ts @@ -0,0 +1,42 @@ +export type ChatActionRequest = { + action?: string; + params?: unknown; + governorInput?: unknown; +}; + +/** A handler produced by {@link governorGatedHandler}; the only shape {@link ChatActionRegistry.register} accepts. */ +export type GovernorGatedHandler = (request: ChatActionRequest) => Promise>; + +export type ChatActionDefinition = { + paramsValidator: (params: unknown) => boolean; + handler: GovernorGatedHandler; +}; + +export type ChatActionEntry = { + paramsValidator: (params: unknown) => boolean; + handler: GovernorGatedHandler; +}; + +export type ChatActionRegistry = { + register(name: string, definition: ChatActionDefinition): ChatActionEntry; + get(name: string): ChatActionEntry | undefined; + has(name: string): boolean; + names(): string[]; + readonly size: number; +}; + +export function governorGatedHandler( + run: (request: ChatActionRequest, gate: unknown) => unknown, + options?: { + evaluateGate?: (input: unknown, gateOptions?: unknown) => unknown; + gateOptions?: unknown; + }, +): GovernorGatedHandler; + +export function isGovernorGatedHandler(handler: unknown): boolean; + +export function createChatActionRegistry(): ChatActionRegistry; + +export const chatActionRegistry: ChatActionRegistry; + +export function registerChatAction(name: string, definition: ChatActionDefinition): ChatActionEntry; diff --git a/packages/loopover-miner/lib/chat-action-registry.js b/packages/loopover-miner/lib/chat-action-registry.js new file mode 100644 index 0000000000..aa3f2837d5 --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-registry.js @@ -0,0 +1,103 @@ +// Allowlist registry + governor-gated handler contract for chat-issued miner actions (#6519). +// +// Shared scaffolding ONLY: this module ships with ZERO registered actions. The three action-family child +// issues (portfolio release/requeue, governor pause/resume, discover/attempt) register their handlers into +// this registry -- none are added here, and the default `chatActionRegistry` instance starts empty. +// +// The registration contract is the safety boundary. `register` refuses any handler that was not produced by +// `governorGatedHandler()`, and `governorGatedHandler()` routes every invocation through +// `evaluateGovernorChokepointGate` (packages/loopover-miner/lib/governor-chokepoint.js) and, through it, the +// fail-closed precedence ladder in packages/loopover-engine/src/governor/chokepoint.ts. Because a raw +// function can never be registered, a chat action can never perform a write on a path that bypasses the +// Governor chokepoint -- the contract enforces it structurally, not by review discipline. This module adds +// no second, competing safety check; it only forces every registered handler onto the existing one. + +import { evaluateGovernorChokepointGate } from "./governor-chokepoint.js"; + +// Private brand. Not exported, so external code cannot forge a "gated" marker onto a raw function: the only +// way to obtain a handler that passes `isGovernorGatedHandler` is to build it through `governorGatedHandler`. +const GOVERNOR_GATED = Symbol("loopover.chat-action.governor-gated"); + +/** + * Wrap a local-write `run` function into a Governor-gated chat-action handler. The returned handler + * evaluates the write against the full precedence ladder (via `evaluateGovernorChokepointGate`) BEFORE + * running `run`, and only invokes `run` on a final `"allow"` verdict -- any other stage returns a gated + * result and `run` never executes. This is the ONLY factory that produces a handler `register` accepts. + * + * @param {(request: unknown, gate: object) => unknown} run the local write to perform once the gate allows + * @param {{ evaluateGate?: typeof evaluateGovernorChokepointGate, gateOptions?: object }} [options] + * @returns {((request: { governorInput?: unknown }) => Promise)} + */ +export function governorGatedHandler(run, options = {}) { + if (typeof run !== "function") { + throw new TypeError("governorGatedHandler(run): run must be a function"); + } + const evaluateGate = options.evaluateGate ?? evaluateGovernorChokepointGate; + if (typeof evaluateGate !== "function") { + throw new TypeError("governorGatedHandler: options.evaluateGate must be a function when supplied"); + } + + const handler = async (request) => { + const gate = evaluateGate(request?.governorInput, options.gateOptions); + if (gate?.decision?.stage !== "allow") { + return { ok: false, status: "gated", decision: gate?.decision ?? null }; + } + const result = await run(request, gate); + return { ok: true, status: "executed", decision: gate.decision, result }; + }; + Object.defineProperty(handler, GOVERNOR_GATED, { value: true }); + return handler; +} + +/** True only for a handler produced by {@link governorGatedHandler}. */ +export function isGovernorGatedHandler(handler) { + return typeof handler === "function" && handler[GOVERNOR_GATED] === true; +} + +/** + * Build an isolated chat-action registry. Child issues register into the shared {@link chatActionRegistry}; + * this factory exists so tests (and any future multi-registry consumer) can register without polluting it. + */ +export function createChatActionRegistry() { + const actions = new Map(); + + function register(name, definition = {}) { + if (typeof name !== "string" || name.trim() === "") { + throw new TypeError("registerChatAction(name): name must be a non-empty string"); + } + if (actions.has(name)) { + throw new Error(`registerChatAction: action "${name}" is already registered`); + } + const { paramsValidator, handler } = definition; + if (typeof paramsValidator !== "function") { + throw new TypeError(`registerChatAction("${name}"): paramsValidator must be a function`); + } + if (!isGovernorGatedHandler(handler)) { + throw new Error( + `registerChatAction("${name}"): handler must be produced by governorGatedHandler() so every ` + + "chat-triggered write routes through governor-chokepoint.js -- a raw handler is rejected.", + ); + } + const entry = { paramsValidator, handler }; + actions.set(name, entry); + return entry; + } + + return { + register, + get: (name) => actions.get(name), + has: (name) => actions.has(name), + names: () => [...actions.keys()], + get size() { + return actions.size; + }, + }; +} + +/** The single shared registry the dispatch layer reads. Ships EMPTY (#6519); child issues register into it. */ +export const chatActionRegistry = createChatActionRegistry(); + +/** Register a chat action on the shared {@link chatActionRegistry}. */ +export function registerChatAction(name, definition) { + return chatActionRegistry.register(name, definition); +} diff --git a/test/unit/miner-chat-action-dispatch.test.ts b/test/unit/miner-chat-action-dispatch.test.ts new file mode 100644 index 0000000000..f8df273c1b --- /dev/null +++ b/test/unit/miner-chat-action-dispatch.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; + +// chat-action-dispatch.js -> chat-action-registry.js -> governor-chokepoint.js -> @loopover/engine, whose dist +// is not built in the test workspace; resolve it against source like the sibling miner tests. +vi.mock("@loopover/engine", async () => { + return import("../../packages/loopover-engine/src/index"); +}); + +import { + CHAT_ACTION_DISPATCH_ENABLE_VALUE, + CHAT_ACTION_DISPATCH_FLAG, + dispatchChatAction, + isChatActionDispatchEnabled, +} from "../../packages/loopover-miner/lib/chat-action-dispatch.js"; +import { + createChatActionRegistry, + governorGatedHandler, +} from "../../packages/loopover-miner/lib/chat-action-registry.js"; + +const enabledEnv = { [CHAT_ACTION_DISPATCH_FLAG]: CHAT_ACTION_DISPATCH_ENABLE_VALUE }; +const allowGate = () => ({ decision: { stage: "allow" } }); + +function registryWith(name: string, paramsValidator: (params: unknown) => boolean, run = () => "written") { + const registry = createChatActionRegistry(); + registry.register(name, { paramsValidator, handler: governorGatedHandler(run, { evaluateGate: allowGate }) }); + return registry; +} + +describe("isChatActionDispatchEnabled (#6519)", () => { + it("is disabled when unset, empty, or set to any non-enable value", () => { + expect(isChatActionDispatchEnabled({})).toBe(false); + expect(isChatActionDispatchEnabled({ [CHAT_ACTION_DISPATCH_FLAG]: "" })).toBe(false); + expect(isChatActionDispatchEnabled({ [CHAT_ACTION_DISPATCH_FLAG]: "true" })).toBe(false); + expect(isChatActionDispatchEnabled({ [CHAT_ACTION_DISPATCH_FLAG]: "1" })).toBe(false); + expect(isChatActionDispatchEnabled({ [CHAT_ACTION_DISPATCH_FLAG]: "ENABLED" })).toBe(false); + }); + + it("is enabled only for the exact enable value (trimmed)", () => { + expect(isChatActionDispatchEnabled({ [CHAT_ACTION_DISPATCH_FLAG]: "enabled" })).toBe(true); + expect(isChatActionDispatchEnabled({ [CHAT_ACTION_DISPATCH_FLAG]: " enabled " })).toBe(true); + }); +}); + +describe("dispatchChatAction (#6519)", () => { + it("short-circuits with a disabled result and never touches the registry when the flag is off", async () => { + const registry = { + has: () => { + throw new Error("registry must not be consulted while the flag is off"); + }, + get: () => { + throw new Error("registry must not be consulted while the flag is off"); + }, + }; + const result = await dispatchChatAction( + { action: "portfolio.release", params: {} }, + // @ts-expect-error trap registry proving the flag gate runs before any lookup + { env: {}, registry }, + ); + expect(result).toEqual({ ok: false, status: "disabled", action: "portfolio.release" }); + }); + + it("reports action:null in the disabled result when the request omits an action", async () => { + const result = await dispatchChatAction({}, { env: {} }); + expect(result).toEqual({ ok: false, status: "disabled", action: null }); + }); + + it("defaults env to process.env and the shared (empty) registry when options are omitted", async () => { + // process.env has no enable flag by default -> disabled, exercising the `options.env ?? process.env` default. + const disabled = await dispatchChatAction({ action: "demo" }); + expect(disabled).toEqual({ ok: false, status: "disabled", action: "demo" }); + + // With the flag flipped on in the real environment and no registry override, dispatch reads the shared + // empty registry -> unknown_action, exercising the `options.registry ?? chatActionRegistry` default. + const prev = process.env[CHAT_ACTION_DISPATCH_FLAG]; + process.env[CHAT_ACTION_DISPATCH_FLAG] = CHAT_ACTION_DISPATCH_ENABLE_VALUE; + try { + const unknown = await dispatchChatAction({ action: "demo" }); + expect(unknown).toEqual({ ok: false, status: "unknown_action", action: "demo" }); + } finally { + if (prev === undefined) delete process.env[CHAT_ACTION_DISPATCH_FLAG]; + else process.env[CHAT_ACTION_DISPATCH_FLAG] = prev; + } + }); + + it("rejects an unknown action when enabled", async () => { + const registry = createChatActionRegistry(); + const result = await dispatchChatAction({ action: "nope" }, { env: enabledEnv, registry }); + expect(result).toEqual({ ok: false, status: "unknown_action", action: "nope" }); + }); + + it("rejects a request whose action is not a string", async () => { + const registry = createChatActionRegistry(); + const result = await dispatchChatAction( + { action: 7 as unknown as string }, + { env: enabledEnv, registry }, + ); + expect(result).toEqual({ ok: false, status: "unknown_action", action: null }); + }); + + it("dispatches to the handler when the params-validator passes", async () => { + const run = vi.fn(() => "did-write"); + const registry = createChatActionRegistry(); + registry.register("demo", { + paramsValidator: (params) => params !== null && typeof params === "object", + handler: governorGatedHandler(run, { evaluateGate: allowGate }), + }); + const result = await dispatchChatAction({ action: "demo", params: { a: 1 } }, { env: enabledEnv, registry }); + expect(run).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + ok: true, + status: "dispatched", + action: "demo", + result: { ok: true, status: "executed", decision: { stage: "allow" }, result: "did-write" }, + }); + }); + + it("rejects with invalid_params when the validator returns falsy and never invokes the handler", async () => { + const run = vi.fn(() => "did-write"); + const registry = registryWith("demo", (params) => typeof params === "string", run); + const result = await dispatchChatAction({ action: "demo", params: { a: 1 } }, { env: enabledEnv, registry }); + expect(run).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: false, status: "invalid_params", action: "demo" }); + }); + + it("treats a throwing validator as a rejection (fail closed), not a dispatch error", async () => { + const run = vi.fn(() => "did-write"); + const registry = createChatActionRegistry(); + registry.register("demo", { + paramsValidator: () => { + throw new Error("bad params shape"); + }, + handler: governorGatedHandler(run, { evaluateGate: allowGate }), + }); + const result = await dispatchChatAction({ action: "demo", params: {} }, { env: enabledEnv, registry }); + expect(run).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: false, status: "invalid_params", action: "demo", error: "bad params shape" }); + }); + + it("stringifies a non-Error thrown by the validator", async () => { + const registry = createChatActionRegistry(); + registry.register("demo", { + paramsValidator: () => { + throw "boom"; + }, + handler: governorGatedHandler(() => "x", { evaluateGate: allowGate }), + }); + const result = await dispatchChatAction({ action: "demo", params: {} }, { env: enabledEnv, registry }); + expect(result).toEqual({ ok: false, status: "invalid_params", action: "demo", error: "boom" }); + }); +}); diff --git a/test/unit/miner-chat-action-registry.test.ts b/test/unit/miner-chat-action-registry.test.ts new file mode 100644 index 0000000000..7b4da846f5 --- /dev/null +++ b/test/unit/miner-chat-action-registry.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; + +// governor-chokepoint.js (imported transitively by chat-action-registry.js) pulls in @loopover/engine, whose +// dist is not built in the test workspace -- resolve it against source, matching the sibling miner tests. +vi.mock("@loopover/engine", async () => { + return import("../../packages/loopover-engine/src/index"); +}); + +import { + chatActionRegistry, + createChatActionRegistry, + governorGatedHandler, + isGovernorGatedHandler, + registerChatAction, +} from "../../packages/loopover-miner/lib/chat-action-registry.js"; + +const allowGate = () => ({ decision: { stage: "allow" } }); +const denyGate = () => ({ decision: { stage: "kill_switch" } }); + +describe("chat-action-registry (#6519)", () => { + describe("governorGatedHandler", () => { + it("marks its output as governor-gated and rejects a raw function", () => { + const wrapped = governorGatedHandler(() => "written", { evaluateGate: allowGate }); + expect(isGovernorGatedHandler(wrapped)).toBe(true); + expect(isGovernorGatedHandler(() => "written")).toBe(false); + expect(isGovernorGatedHandler(null)).toBe(false); + }); + + it("throws when run is not a function", () => { + // @ts-expect-error deliberately passing a non-function to exercise the guard + expect(() => governorGatedHandler("nope")).toThrow(/run must be a function/); + }); + + it("throws when a supplied evaluateGate is not a function", () => { + // @ts-expect-error deliberately passing a non-function gate + expect(() => governorGatedHandler(() => {}, { evaluateGate: "nope" })).toThrow(/evaluateGate must be a function/); + }); + + it("runs the wrapped write only when the gate allows", async () => { + const run = vi.fn(() => "did-write"); + const wrapped = governorGatedHandler(run, { evaluateGate: allowGate }); + const result = await wrapped({ governorInput: {} }); + expect(run).toHaveBeenCalledTimes(1); + expect(result).toEqual({ ok: true, status: "executed", decision: { stage: "allow" }, result: "did-write" }); + }); + + it("returns a gated result and never runs the write when the gate denies", async () => { + const run = vi.fn(() => "did-write"); + const wrapped = governorGatedHandler(run, { evaluateGate: denyGate }); + const result = await wrapped({ governorInput: {} }); + expect(run).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: false, status: "gated", decision: { stage: "kill_switch" } }); + }); + + it("treats a gate result with no decision as denied (fail closed)", async () => { + const run = vi.fn(() => "did-write"); + const wrapped = governorGatedHandler(run, { evaluateGate: () => undefined }); + const result = await wrapped({ governorInput: {} }); + expect(run).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: false, status: "gated", decision: null }); + }); + + it("routes through the real evaluateGovernorChokepointGate by default", async () => { + // No evaluateGate override: the handler falls back to the real governor-chokepoint.js wrapper, proving + // the safety contract is wired to the actual precedence ladder, not just an injectable stub. A clean + // input resolves to an `allow` stage; the ledger append is stubbed so the test stays side-effect-free. + const run = vi.fn(() => "did-write"); + const wrapped = governorGatedHandler(run, { gateOptions: { append: (event: unknown) => ({ event }) } }); + const request = { + governorInput: { + actionClass: "open_pr", + repoFullName: "acme/widgets", + nowMs: 10_000, + wouldBeAction: { action: "open_pr", title: "Fix bug" }, + killSwitchGlobal: false, + killSwitchRepoPaused: false, + liveModeGlobalOptIn: true, + liveModeRepoOptIn: "live", + rateLimitBuckets: { global: {}, perRepo: {} }, + rateLimitBackoffAttempts: {}, + capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 }, + capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 }, + convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false }, + }, + }; + const result = await wrapped(request); + expect(run).toHaveBeenCalledTimes(1); + expect(result.ok).toBe(true); + expect(result.status).toBe("executed"); + expect((result.decision as { stage: string }).stage).toBe("allow"); + expect(result.result).toBe("did-write"); + }); + }); + + describe("register", () => { + const wrapped = governorGatedHandler(() => "ok", { evaluateGate: allowGate }); + + it("accepts a governor-gated handler with a params-validator", () => { + const registry = createChatActionRegistry(); + const entry = registry.register("demo", { paramsValidator: () => true, handler: wrapped }); + expect(registry.has("demo")).toBe(true); + expect(registry.size).toBe(1); + expect(registry.names()).toEqual(["demo"]); + expect(registry.get("demo")).toBe(entry); + }); + + it("rejects a raw (unwrapped) handler at registration time", () => { + const registry = createChatActionRegistry(); + expect(() => + registry.register("demo", { + paramsValidator: () => true, + handler: (() => "write") as unknown as ReturnType, + }), + ).toThrow(/handler must be produced by governorGatedHandler/); + expect(registry.size).toBe(0); + }); + + it("rejects a missing or non-function params-validator", () => { + const registry = createChatActionRegistry(); + expect(() => + registry.register("demo", { handler: wrapped } as unknown as Parameters[1]), + ).toThrow(/paramsValidator must be a function/); + expect(() => + registry.register("demo", { paramsValidator: "nope" as unknown as () => boolean, handler: wrapped }), + ).toThrow(/paramsValidator must be a function/); + }); + + it("rejects an empty or non-string name", () => { + const registry = createChatActionRegistry(); + expect(() => registry.register("", { paramsValidator: () => true, handler: wrapped })).toThrow( + /name must be a non-empty string/, + ); + expect(() => + registry.register(" ", { paramsValidator: () => true, handler: wrapped }), + ).toThrow(/name must be a non-empty string/); + expect(() => + registry.register(42 as unknown as string, { paramsValidator: () => true, handler: wrapped }), + ).toThrow(/name must be a non-empty string/); + }); + + it("rejects a duplicate registration", () => { + const registry = createChatActionRegistry(); + registry.register("demo", { paramsValidator: () => true, handler: wrapped }); + expect(() => + registry.register("demo", { paramsValidator: () => true, handler: wrapped }), + ).toThrow(/already registered/); + }); + }); + + describe("shared registry", () => { + it("REGRESSION: ships empty -- no action family is pre-registered in this scaffolding issue", () => { + expect(chatActionRegistry.size).toBe(0); + expect(chatActionRegistry.names()).toEqual([]); + }); + + it("exposes registerChatAction bound to the shared registry contract", () => { + // Prove the convenience wrapper enforces the same handler contract without mutating the shared registry. + expect(() => + registerChatAction("demo", { + paramsValidator: () => true, + handler: (() => "write") as unknown as ReturnType, + }), + ).toThrow(/handler must be produced by governorGatedHandler/); + expect(chatActionRegistry.size).toBe(0); + }); + }); +});