/** * TASK-0126 — the AI page-generation PLAN schema (structured tool calls). * * The LLM emits a PLAN: an ordered list of tool calls the API executor runs * SEQUENTIALLY through the existing domain services (never raw SQL, never * elevated permissions). The plan is the model's UNTRUSTED output, so it is * validated by zod here before ANY write (AGENTS #19/#20): * * - an unknown tool name is REJECTED (the executor never guesses at a * capability the model invented); * - the `createDatabase` properties REUSE TASK-0124's * {@link CreateDatabaseSchema} (the 19/24 generatable property types) — * no re-derivation of the allowlist (AGENTS #4 reuse, #5 no duplication). * * **Runtime references (recorded design decision, rule 29):** the model CANNOT * emit the real `pageId` / `databaseId` of a resource it is itself creating * (those ids only exist once the executor runs). So the plan is REF-based, not * id-based: * * - a `createPage` may declare a short `ref` (an alias the later calls use); * - `insertBlocks` targets a `pageRef` (an alias, or — for the first page — * omitted, meaning "the page this plan creates"); * - a `createDatabase` may declare a `ref` and targets a `pageRef` (the * host page alias); * - a `createRelation` targets a `databaseRef` + a `targetPageRef`. * * The executor keeps a `ref → real id` map as it runs (the `createdRefs` / * rollback set is built from it). The endpoint's `parentId` (the real id of * the page the generated page nests under, if any) is injected by the * EXECUTOR into the first `createPage` — it is NOT model-emitted, so it is * kept out of the plan schema entirely. */ import { z } from "zod"; import { CreateDatabaseSchema } from "./database.js"; /** A short, unique alias a plan call gives a resource it creates (the * executor maps it to the real id at run time). */ export const PLAN_REF_REGEX = /^[a-z0-9][a-z0-9_-]{0,31}$/i; /** One content block the plan asks the executor to insert. `content` is the * same shape the domain block service accepts. */ export const GenerateBlockPayloadSchema = z.object({ type: z.string().min(1), content: z.record(z.unknown()).default({}), }); export type GenerateBlockPayload = z.infer; /** `createPage({ ref?, title, blocks? })`. `parentId` is executor-injected * (the endpoint body param), so it is NOT model-emitted. */ export const CreatePageCallSchema = z.object({ ref: z.string().regex(PLAN_REF_REGEX).optional(), title: z.string().min(1).max(200), blocks: z.array(GenerateBlockPayloadSchema).max(200).optional(), }); export type CreatePageCall = z.infer; /** `insertBlocks({ pageRef?, blocks[] })`. A missing `pageRef` targets the * FIRST page the plan created. The blocks are appended in order (each after * the previous). */ export const InsertBlocksCallSchema = z.object({ pageRef: z.string().regex(PLAN_REF_REGEX).optional(), blocks: z.array(GenerateBlockPayloadSchema).min(1).max(200), }); export type InsertBlocksCall = z.infer; /** `createDatabase({ ref?, pageRef?, title, properties[], sampleRecords? })` * — reuses TASK-0124's validated structure. */ export const CreateDatabaseCallSchema = CreateDatabaseSchema.extend({ ref: z.string().regex(PLAN_REF_REGEX).optional(), /** The host page alias (the database is created as a child of it). */ pageRef: z.string().regex(PLAN_REF_REGEX).optional(), }); export type CreateDatabaseCall = z.infer; /** `createRelation({ databaseRef, targetPageRef, name })` — links the plan's * own generated database to a target database the plan also generated. */ export const CreateRelationCallSchema = z.object({ databaseRef: z.string().regex(PLAN_REF_REGEX), targetPageRef: z.string().regex(PLAN_REF_REGEX), name: z.string().min(1).max(100), }); export type CreateRelationCall = z.infer; export type PlanToolCall = | { name: "createPage"; args: CreatePageCall } | { name: "insertBlocks"; args: InsertBlocksCall } | { name: "createDatabase"; args: CreateDatabaseCall } | { name: "createRelation"; args: CreateRelationCall }; /** * The full generation plan. A discriminated union over the tool name so an * unknown `name` (e.g. a model-hallucinated `deletePage`) is REJECTED by the * schema, not silently dropped. At least one call is required (a plan that * creates nothing is a validation error, not a no-op success). */ export const GeneratePlanSchema = z .object({ tools: z .array( z.discriminatedUnion("name", [ z.object({ name: z.literal("createPage"), args: CreatePageCallSchema }), z.object({ name: z.literal("insertBlocks"), args: InsertBlocksCallSchema }), z.object({ name: z.literal("createDatabase"), args: CreateDatabaseCallSchema }), z.object({ name: z.literal("createRelation"), args: CreateRelationCallSchema }), ]), ) .min(1) .max(50), }) .strict(); export type GeneratePlan = z.infer; /** Parse a raw (model-emitted) plan; returns the typed plan or the errors. */ export function parseGeneratePlan(raw: unknown): { ok: true; plan: GeneratePlan } | { ok: false; errors: z.ZodError } { const r = GeneratePlanSchema.safeParse(raw); return r.success ? { ok: true, plan: r.data } : { ok: false, errors: r.error }; }