1/** 2 * TASK-0126 — the AI page-generation PLAN schema (structured tool calls). 3 * 4 * The LLM emits a PLAN: an ordered list of tool calls the API executor runs 5 * SEQUENTIALLY through the existing domain services (never raw SQL, never 6 * elevated permissions). The plan is the model's UNTRUSTED output, so it is 7 * validated by zod here before ANY write (AGENTS #19/#20): 8 * 9 * - an unknown tool name is REJECTED (the executor never guesses at a 10 * capability the model invented); 11 * - the `createDatabase` properties REUSE TASK-0124's 12 * {@link CreateDatabaseSchema} (the 19/24 generatable property types) — 13 * no re-derivation of the allowlist (AGENTS #4 reuse, #5 no duplication). 14 * 15 * **Runtime references (recorded design decision, rule 29):** the model CANNOT 16 * emit the real `pageId` / `databaseId` of a resource it is itself creating 17 * (those ids only exist once the executor runs). So the plan is REF-based, not 18 * id-based: 19 * 20 * - a `createPage` may declare a short `ref` (an alias the later calls use); 21 * - `insertBlocks` targets a `pageRef` (an alias, or — for the first page — 22 * omitted, meaning "the page this plan creates"); 23 * - a `createDatabase` may declare a `ref` and targets a `pageRef` (the 24 * host page alias); 25 * - a `createRelation` targets a `databaseRef` + a `targetPageRef`. 26 * 27 * The executor keeps a `ref → real id` map as it runs (the `createdRefs` / 28 * rollback set is built from it). The endpoint's `parentId` (the real id of 29 * the page the generated page nests under, if any) is injected by the 30 * EXECUTOR into the first `createPage` — it is NOT model-emitted, so it is 31 * kept out of the plan schema entirely. 32 */ 33import { z } from "zod"; 34import { CreateDatabaseSchema } from "./database.js"; 35 36/** A short, unique alias a plan call gives a resource it creates (the 37 * executor maps it to the real id at run time). */ 38export const PLAN_REF_REGEX = /^[a-z0-9][a-z0-9_-]{0,31}$/i; 39 40/** One content block the plan asks the executor to insert. `content` is the 41 * same shape the domain block service accepts. */ 42export const GenerateBlockPayloadSchema = z.object({ 43 type: z.string().min(1), 44 content: z.record(z.unknown()).default({}), 45}); 46export type GenerateBlockPayload = z.infer<typeof GenerateBlockPayloadSchema>; 47 48/** `createPage({ ref?, title, blocks? })`. `parentId` is executor-injected 49 * (the endpoint body param), so it is NOT model-emitted. */ 50export const CreatePageCallSchema = z.object({ 51 ref: z.string().regex(PLAN_REF_REGEX).optional(), 52 title: z.string().min(1).max(200), 53 blocks: z.array(GenerateBlockPayloadSchema).max(200).optional(), 54}); 55export type CreatePageCall = z.infer<typeof CreatePageCallSchema>; 56 57/** `insertBlocks({ pageRef?, blocks[] })`. A missing `pageRef` targets the 58 * FIRST page the plan created. The blocks are appended in order (each after 59 * the previous). */ 60export const InsertBlocksCallSchema = z.object({ 61 pageRef: z.string().regex(PLAN_REF_REGEX).optional(), 62 blocks: z.array(GenerateBlockPayloadSchema).min(1).max(200), 63}); 64export type InsertBlocksCall = z.infer<typeof InsertBlocksCallSchema>; 65 66/** `createDatabase({ ref?, pageRef?, title, properties[], sampleRecords? })` 67 * — reuses TASK-0124's validated structure. */ 68export const CreateDatabaseCallSchema = CreateDatabaseSchema.extend({ 69 ref: z.string().regex(PLAN_REF_REGEX).optional(), 70 /** The host page alias (the database is created as a child of it). */ 71 pageRef: z.string().regex(PLAN_REF_REGEX).optional(), 72}); 73export type CreateDatabaseCall = z.infer<typeof CreateDatabaseCallSchema>; 74 75/** `createRelation({ databaseRef, targetPageRef, name })` — links the plan's 76 * own generated database to a target database the plan also generated. */ 77export const CreateRelationCallSchema = z.object({ 78 databaseRef: z.string().regex(PLAN_REF_REGEX), 79 targetPageRef: z.string().regex(PLAN_REF_REGEX), 80 name: z.string().min(1).max(100), 81}); 82export type CreateRelationCall = z.infer<typeof CreateRelationCallSchema>; 83 84export type PlanToolCall = 85 | { name: "createPage"; args: CreatePageCall } 86 | { name: "insertBlocks"; args: InsertBlocksCall } 87 | { name: "createDatabase"; args: CreateDatabaseCall } 88 | { name: "createRelation"; args: CreateRelationCall }; 89 90/** 91 * The full generation plan. A discriminated union over the tool name so an 92 * unknown `name` (e.g. a model-hallucinated `deletePage`) is REJECTED by the 93 * schema, not silently dropped. At least one call is required (a plan that 94 * creates nothing is a validation error, not a no-op success). 95 */ 96export const GeneratePlanSchema = z 97 .object({ 98 tools: z 99 .array( 100 z.discriminatedUnion("name", [ 101 z.object({ name: z.literal("createPage"), args: CreatePageCallSchema }), 102 z.object({ name: z.literal("insertBlocks"), args: InsertBlocksCallSchema }), 103 z.object({ name: z.literal("createDatabase"), args: CreateDatabaseCallSchema }), 104 z.object({ name: z.literal("createRelation"), args: CreateRelationCallSchema }), 105 ]), 106 ) 107 .min(1) 108 .max(50), 109 }) 110 .strict(); 111export type GeneratePlan = z.infer<typeof GeneratePlanSchema>; 112 113/** Parse a raw (model-emitted) plan; returns the typed plan or the errors. */ 114export function parseGeneratePlan(raw: unknown): { ok: true; plan: GeneratePlan } | { ok: false; errors: z.ZodError } { 115 const r = GeneratePlanSchema.safeParse(raw); 116 return r.success ? { ok: true, plan: r.data } : { ok: false, errors: r.error }; 117} 118