import { z } from "zod"; import type { DefaultContext } from "./context.js"; import { getParamsSchema, type InferParams, type ParamsLike } from "./params.js"; /** * A registered query: schema-typed params/return shape, optional access predicate * and summary metadata, and the runtime function the executor calls. The `query` * function is whatever the user wires up — Drizzle, Prisma, raw SQL, in-memory — * as long as it satisfies the `(params) => Promise` signature. * * The `returns` schema is the source of truth for `$bind` resolution during plan * validation — the runtime output of `query` is never inspected by core. */ export interface CarteEntry< P extends ParamsLike = z.ZodType, R extends z.ZodType = z.ZodType, TCtx = DefaultContext, > { id: string; description: string; exampleQuestions?: string[]; params: P; returns: R; access?: (ctx: TCtx) => boolean; /** * Optional human-facing title template rendered by the framework when this * entry is shown in a UI panel. Supports typed slot interpolation: each * `{paramName}` placeholder is filled with the corresponding param value * the LLM emitted (after Zod parsing). The LLM never authors the literal * string — it only fills slots, preserving the no-paraphrase contract. * * The renderer reads this template via `useInjectedTitle(panelIndex)` and * passes the rendered string as the `title` prop on every component. * Components MUST NOT receive `title` from the LLM-emitted plan; the * catalog excludes it from each component's prop schema. */ titleTemplate?: string; /** * Optional callback that returns small, prompt-time metadata about this * entry — counts, ranges, default values, enum lists, freshness timestamps. * The return value is rendered verbatim into the LLM's prompt via * `JSON.stringify`, so it lives on the bounded surface. * * **Security contract** (the renamed-from-`summaryStats` shape, v0.2.1): * * 1. The function takes NO arguments. There is no request context. The * same value is computed for every user, every role, every tenant — * role-conditional hints are a denormalization smell and should be * modeled as separate entries with different `access` predicates. * 2. The function MUST be deterministic — calling it twice in the same * process MUST return equal values. The framework cannot statically * prove this in JavaScript, but enabling `CARTE_VERIFY_STATIC_HINTS` * causes `generatePrompt` to call the function twice and assert * equality, catching the "I'll just memoize a query" foot-gun in CI. * 3. The return value is restricted to `StaticHints` — a flat record of * primitives and primitive arrays. Row data, sample rows, arrays of * objects, and anything derived from query results at runtime are * structurally rejected at prompt-generation time. * * Together these mean the value cannot exfiltrate runtime data: it is * authoring-time-shaped, deterministic, and structurally bounded. */ staticHints?: () => StaticHints | Promise; query: (params: InferParams

) => Promise>; } export type Carte = Record>; /** * A single value permitted inside a `staticHints` payload. Deliberately * narrow: primitives and arrays of primitives only. Restricting the shape * structurally rules out the foot-gun of an author returning row-shaped * data (e.g. `{ recentUsers: [{ id, email }, ...] }`) and silently * leaking it through the prompt. */ export type StaticHintValue = | string | number | boolean | null | undefined | ReadonlyArray; /** * Shape `staticHints` callbacks must return. A flat record of primitives * and primitive arrays — counts, ranges, defaults, enum lists, freshness * timestamps. The runtime guard in `generatePrompt` rejects payloads that * violate this shape. * * The contract is "authoring-time, deterministic, structurally bounded" — * see the field-level docs on `CarteEntry.staticHints` for the full * security rationale, and `skills/carte/references/static-hints.md` for * authoring guidance. */ export type StaticHints = Readonly>; /** * Indexes carte entries by `id`, verifies uniqueness, and enforces * **Invariant 1** for entries that haven't already been through `defineEntry`. * * `defineEntry` is the recommended path (it anchors TypeScript inference) and * already runs `assertStaticSchema` on its argument; entries it returns are * frozen, so we re-check only entries that arrive raw. The frozen-skip is an * optimization — running the check twice is correct, just wasted work. */ export function defineCarte(entries: ReadonlyArray>): Carte { const map: Carte = {}; for (const entry of entries) { if (map[entry.id]) { throw new Error(`Duplicate carte entry id: ${entry.id}`); } if (!Object.isFrozen(entry)) { assertStaticSchema(getParamsSchema(entry.params), entry.id, "params"); assertStaticSchema(entry.returns, entry.id, "returns"); } map[entry.id] = entry; } return map; } /** * Identity helper that anchors TypeScript's contextual typing so the `query` * callback's params and return are inferred from the entry's `params` and * `returns` schemas in the same object literal. * * Without this, writing the entry literal directly inside `defineCarte([...])` * widens the entry to `CarteEntry` and `params` becomes * `unknown`. Wrapping with `defineEntry({...})` introduces a generic inference * point so destructuring `({ limit })` is typed as `number`. * * Also enforces **Invariant 1** (security): `params` and `returns` must be * Zod schemas declared at authoring time, not derived from runtime values. * The check is best-effort — it catches the obvious foot-guns (non-Zod * objects, `z.lazy(() => fresh-schema-each-call)`) but cannot prove general * staticness. Treat it as a guard, not a security boundary; the contract is * enforced by code review, with the runtime check as a backstop. */ export function defineEntry

( entry: CarteEntry, ): CarteEntry { assertStaticSchema(getParamsSchema(entry.params), entry.id, "params"); assertStaticSchema(entry.returns, entry.id, "returns"); // Shallow-freeze so a misuse like `entry.returns = somethingElse` after // `defineEntry` returns is rejected by the runtime in strict mode and // silently ignored otherwise. Does not prevent deep mutation of the schema's // internals — Zod schemas have mutable `_def` — but documents intent. return Object.freeze({ ...entry }); } /** * Best-effort check that a schema was declared statically at authoring time. * Rejects: * - non-Zod values (catches typos, `as any` casts that smuggled through); * - `z.lazy(() => …)` whose getter returns a different schema each call * (a strong signal the schema is being constructed from runtime data — * which would mean its shape can leak row content into the prompt or * model context, violating Invariant 1). * * Recurses into composite types (unions, intersections, arrays, tuples, * objects, records) so a buried `z.lazy` foot-gun still trips the check. * * NOT exhaustive: cannot detect `() => z.object({ ...computeFromRows() })` * if it returns the *same* shape every time, and cannot detect schemas * mutated post-construction. Documented as a security invariant in the * README; the runtime check is the safety net, not the proof. */ function assertStaticSchema( schema: unknown, entryId: string, field: "params" | "returns", seen: Set = new Set(), ): void { if (!(schema instanceof z.ZodType)) { throw new Error( `Carte entry "${entryId}".${field} must be a Zod schema declared at authoring time. ` + `Got: ${describe(schema)}. This is a security invariant — see the "Invariant: returns is statically declared" section of the README.`, ); } if (seen.has(schema)) return; seen.add(schema); if (schema instanceof z.ZodLazy) { // `z.lazy` is supported (recursive schemas need it), but the thunk must // resolve to a stable schema reference. A thunk that builds a new schema // on each call is the foot-gun this check exists for. const def = schema._def as unknown as { getter: () => z.ZodType }; const a = def.getter(); const b = def.getter(); if (a !== b) { throw new Error( `Carte entry "${entryId}".${field} uses z.lazy() whose thunk returns a different schema each call. ` + `Schemas must be statically declared at authoring time so their shape cannot leak runtime values into the LLM's prompt. ` + `Bind the schema to a stable variable and reference it from the lazy thunk.`, ); } assertStaticSchema(a, entryId, field, seen); return; } // Recurse into composite types via the same `_def` shape Zod exposes for // its built-in introspection. The cast is deliberate — Zod's internal // `_def` typing is intentionally loose to accommodate plugins. const def = (schema as unknown as { _def?: unknown })._def; if (def && typeof def === "object") { for (const child of collectChildSchemas(def as Record)) { assertStaticSchema(child, entryId, field, seen); } } } function collectChildSchemas(def: Record): z.ZodType[] { const out: z.ZodType[] = []; const visit = (v: unknown): void => { if (v instanceof z.ZodType) { out.push(v); return; } if (Array.isArray(v)) { for (const item of v) visit(item); return; } if (v && typeof v === "object") { for (const item of Object.values(v as Record)) visit(item); } }; // Walk known shape-bearing keys; covers ZodObject (`shape`), ZodArray // (`element`/`type`), ZodUnion (`options`), ZodIntersection (`left`/`right`), // ZodTuple (`items`), ZodRecord (`valueType`/`keyType`), ZodMap, ZodSet, // ZodOptional/ZodNullable (`innerType`), ZodEffects (`schema`), etc. for (const key of [ "shape", "element", "type", "options", "left", "right", "items", "valueType", "keyType", "innerType", "schema", "in", "out", ]) { if (key in def) visit(def[key]); } return out; } function describe(value: unknown): string { if (value === null) return "null"; if (value === undefined) return "undefined"; if (typeof value === "function") return "a function (did you mean to call it? z.object({...}) not z.object)"; return typeof value; }