Skip to content
31 changes: 21 additions & 10 deletions EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,12 +538,20 @@ These events are additive in schema version `"1"` and emitted only when
The schema is validated before any model request, with Ajv defaults and
`allErrors: true` (draft-07, or Ajv2020 for a declared draft 2020-12). Validation
does not coerce types, insert defaults or remove extra properties.

`--output-schema-retries <n>` permits N additional corrective attempts (default 2).
Zero permits exactly one attempt. `--output-result <file>` writes only accepted
JSON using two-space indentation and a trailing newline, with a temporary file
in the destination directory followed by an atomic rename. Failures preserve the
file. Both flags require `--output-schema`; the destination directory must exist and be writable at configuration time.
Serialized schemas are limited to 64 KiB, depth 64 and 10,000 nested objects.
The schema file is trusted instruction input: annotations (`description`, `title`,
`examples`) are shown to the model in the StructuredOutput tool description.

`--output-schema-retries <n>` permits N additional corrective turns (0–10, default 2).
Zero permits only the initial turn; N permits the initial turn and at most N
corrective turns. A step can contain multiple StructuredOutput
calls; every rejected call is reported and counted even after the budget is spent.
`--output-result <file>` writes only accepted JSON using two-space indentation and
a trailing newline, with a temporary file in the destination directory followed
by an atomic rename. Failures preserve the file. Both flags require
`--output-schema`; the destination directory must exist and be writable at
configuration time. Relative paths resolve against `--dir` when it is given, and
configuration errors name the resolved path after the reason.
Without `--output-result`, formatted mode prints final JSON; NDJSON includes the
value in the terminal event below.

Expand All @@ -562,7 +570,8 @@ and a model turn that finishes in prose without a final tool call:
```

The usual event envelope fields also apply. `attempt` is one-based;
`maxAttempts` equals `retryCount + 1`. Diagnostics include at most ten errors,
`maxAttempts` equals `retryCount + 1`, the corrective turn budget; `attempt` can
exceed it when a step contains multiple calls. Diagnostics include at most ten errors,
with bounded JSON-pointer paths, keywords and messages. Their encoded payload
is under 2 KB and never includes submitted values or raw JSON arguments. A `path`
can contain property names submitted by the model (for example, in map or
Expand All @@ -578,7 +587,8 @@ validation always uses the canonical schema.

### `structured_output`

Exactly one terminal event per schema-enabled session, before `session_complete`:
Exactly one terminal event per schema-enabled prompt run, before `session_complete`.
A headless `aictrl run` has one prompt run:

```json
{ "type": "structured_output", "status": "accepted", "attempts": 2, "value": { "result": "ok" } }
Expand All @@ -595,8 +605,9 @@ and prose-only finishes without a final call. Other tool calls do not count.
Compaction turns do not consume a missing-result attempt. Discarded provider
streams restore the budget and captured result; their rejection events remain
as telemetry. A valid call in a parallel step wins regardless of call order if
that step started with budget remaining, and its attempt count stays within the
configured maximum.
that step started with budget remaining. The attempt count includes every call
in that step and can exceed `maxAttempts`; the N+1 bound applies to corrective
turns.
When the budget is spent, the last rejection determines the reason: `missing`
for a prose-only finish, otherwise `exhausted`. Failure reasons are:

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,13 @@ aictrl run --format json "review this PR" | jq '.type'
### Validated Final Results

`aictrl run --output-schema <file>` validates the final JSON result.
Use `--output-schema-retries <n>` for additional corrective attempts (default 2)
Use `--output-schema-retries <n>` for additional corrective turns (0–10, default 2)
and `--output-result <file>` to atomically write the accepted value.
Zero retries permits only the initial turn; N permits the initial turn and at
most N corrective turns. Serialized schemas are limited to 64 KiB, depth 64 and 10,000
nested objects.
Schema mode emits exactly one terminal `structured_output` event per prompt run;
a headless `aictrl run` has one prompt run.
Exit codes: **0** accepted, **2** configuration error, **3** missing or invalid result;
provider failures and stream timeouts use **1**, signals use **130**/**143**.
See the [full contract](packages/cli/README.md#validated-final-results)
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,34 +34,43 @@ aictrl run --format json "scan for secrets" | jq '.properties.part.text'
while the agent can continue using tools. The schema root must be `type: "object"`.
Schemas use Ajv defaults (strict validation, draft-07); a declared draft 2020-12
schema uses Ajv2020. Invalid configuration exits before any model request.
Serialized schemas are limited to 64 KiB, depth 64 and 10,000 nested objects.

```bash
aictrl run --output-schema result.schema.json --output-schema-retries 2 \
--output-result result.json "review this change"
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Shared path-resolution note nested under one flag's bullet.

Suggested change
Move the shared note to after the `--output-result` bullet (or into the surrounding prose), or split it so each flag's bullet carries only its own path-resolution sentence.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/README.md:42-43):

Problem: Shared path-resolution note nested under one flag's bullet
Detail: The added note "Relative `--output-schema` and `--output-result` paths resolve against `--dir`..." is indented as a continuation of the `--output-schema` bullet, but it governs two flags and forward-references `--output-result`, whose own bullet appears three bullets later ("--output-result <file>: atomically write..."). Every other sub-line in these bullets describes only its own flag. The shared behavior reads better placed where both flags are in scope.
Suggested fix: Move the shared note to after the `--output-result` bullet (or into the surrounding prose), or split it so each flag's bullet carries only its own path-resolution sentence.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The added note "Relative --output-schema and --output-result paths resolve against --dir..." is indented as a continuation of the --output-schema bullet, but it governs two flags and forward-references --output-result, whose own bullet appears three bullets later ("--output-result : atomically write..."). Every other sub-line in these bullets describes only its own flag. The shared behavior reads better placed where both flags are in scope.

- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.
  Relative `--output-schema` and `--output-result` paths resolve against `--dir`
  when it is given (as `--file` paths do); configuration errors print the resolved path.
- `--output-schema-retries <n>`: additional corrective turns, integer from 0 to 10,

- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 64 KiB schema cap is enforced but undocumented.

Suggested change
- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.
Add to the `--output-schema <file>` bullet (and EVENTS.md's schema-validation paragraph): "The serialized schema must not exceed 64 KiB."
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/README.md:43):

Problem: 64 KiB schema cap is enforced but undocumented
Detail: output-schema.ts now rejects serialized schemas over 64 KiB ("serialized schema must not exceed 64 KiB") with test coverage, but none of the three docs this PR updates (README.md, packages/cli/README.md, EVENTS.md) mention the cap — the --output-schema bullet and the retries bullet document other bounds (0–10, 2 KB diagnostics) while staying silent on the new size limit. Users hitting the config error on a large schema have no documented contract.
Suggested fix: Add to the `--output-schema <file>` bullet (and EVENTS.md's schema-validation paragraph): "The serialized schema must not exceed 64 KiB."

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

output-schema.ts now rejects serialized schemas over 64 KiB ("serialized schema must not exceed 64 KiB") with test coverage, but none of the three docs this PR updates (README.md, packages/cli/README.md, EVENTS.md) mention the cap — the --output-schema bullet and the retries bullet document other bounds (0–10, 2 KB diagnostics) while staying silent on the new size limit. Users hitting the config error on a large schema have no documented contract.


- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.
- `--output-schema-retries <n>`: additional corrective turns, integer from 0 to 10,
  default 2. Zero permits one model request; N permits at most N+1 corrective
  requests. Each rejected call is counted, including multiple calls in one step.
- `--output-result <file>`: atomically write only the validated JSON value,

- `--output-schema-retries <n>`: additional corrective attempts, integer >= 0,
default 2. Zero permits one attempt; N permits at most N+1 attempts.
The file is trusted instruction input: annotations (`description`, `title`,
`examples`) are shown to the model in the StructuredOutput tool description.
- `--output-schema-retries <n>`: additional corrective turns, integer from 0 to 10,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Docs mix 'corrective requests' and 'corrective turns'.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/README.md:44-46):

Problem: Docs mix 'corrective requests' and 'corrective turns'
Detail: README.md and EVENTS.md standardize on "corrective turns" (EVENTS once glossing "turns/requests"), but packages/cli/README.md introduces "N permits at most N+1 corrective requests" / "Zero permits one model request" — a third term in the same reterming pass. If the request-vs-turn distinction is deliberate (parallel calls within a turn), say so; otherwise pick one term for the budget.
Suggested fix: Use "corrective turns" in packages/cli/README.md, or keep "requests" but add the same turns/requests gloss EVENTS.md uses.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

README.md and EVENTS.md standardize on "corrective turns" (EVENTS once glossing "turns/requests"), but packages/cli/README.md introduces "N permits at most N+1 corrective requests" / "Zero permits one model request" — a third term in the same reterming pass. If the request-vs-turn distinction is deliberate (parallel calls within a turn), say so; otherwise pick one term for the budget.

- `--output-schema <file>`: JSON Schema file, forwarded in local and `--attach` runs.\n- `--output-schema-retries <n>`: additional corrective turns, integer from 0 to 10,\n  default 2. Zero permits one model request; N permits at most N+1 corrective\n  requests. Each rejected call is counted, including multiple calls in one step.

default 2. Zero permits only the initial turn; N permits the initial turn and
at most N corrective turns. Each rejected call is counted, including multiple
calls in one step.
- `--output-result <file>`: atomically write only the validated JSON value,
indented with two spaces and a trailing newline. Failures preserve an existing
file and leave an absent file absent. The destination directory must exist.

Relative `--output-schema` and `--output-result` paths resolve against `--dir` when
it is given (as `--file` paths do); configuration errors print the resolved path.

Ajv strict mode rejects unknown schema keywords and unregistered `format`
values such as `date-time` and `uri` as configuration errors (exit **2**).
The result parent directory must exist and be writable at configuration time.

The retries and result flags require `--output-schema`. Without a result file,
formatted mode prints validated JSON at the end; `--format json` emits it in a
terminal `structured_output` event. Rejections emit `structured_output_rejected`
terminal `structured_output` event, exactly one per prompt run. A headless
`aictrl run` has one prompt run. Rejections emit `structured_output_rejected`
with bounded JSON-pointer diagnostics. See [EVENTS.md](../../EVENTS.md) for the contract.
No valid result means failure, including missing output and the agent step limit;
there is no prose fallback. A prose-only finish consumes one attempt and receives
a reminder to call StructuredOutput while retries remain. Earlier tools are not
replayed for corrective attempts.
replayed for corrective turns.

Exit codes: **0** for accepted results, **2** for schema configuration errors,
**3** for exhausted attempts, missing output or a step limit without a result.
**3** for exhausted corrective turns, missing output or a step limit without a result.
Provider failures and stream timeouts retain **1**; SIGINT/SIGTERM retain
**130**/**143**. Invocations without `--output-schema` keep their existing output.

Expand Down
24 changes: 15 additions & 9 deletions packages/cli/src/cli/cmd/run.output.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from "path"
// Bun content I/O preserves createPath: false; fs/promises supplies metadata and atomic rename/removal.
import fs from "fs/promises"
import { OutputSchema } from "@/session/output-schema"

Expand All @@ -12,32 +13,37 @@ export async function outputSchema(input: { schema?: string; retries?: number; r
throw new Error("--output-schema-retries and --output-result require --output-schema")
return
}
if (input.retries !== undefined && (!Number.isSafeInteger(input.retries) || input.retries < 0))
throw new Error("--output-schema-retries must be an integer >= 0")
const text = await Bun.file(input.schema)
if (input.retries !== undefined && (!Number.isSafeInteger(input.retries) || input.retries < 0 || input.retries > 10))
throw new Error("--output-schema-retries must be an integer between 0 and 10")
// Relative paths resolve against --dir (run changes into it first), like --file.
// Report the resolved path after the reason, so clipping (300 chars) drops path
// text rather than the cause.
const file = path.resolve(input.schema)
const text = await Bun.file(file)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Absolute path prefix can push reason past 300-char cap.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/cli/cmd/run.output.ts:21-35):

Problem: Absolute path prefix can push reason past 300-char cap
Detail: Config-error messages now embed the fully resolved absolute path (`${file}: cannot read schema file`, `${file}: invalid JSON`, `${file}: schema root must be a JSON object`, `${file}: serialized schema must not exceed 64 KiB`, `${file}: <ajv strict-mode message>`), but the CLI caps invocation_error messages at 300 chars (the test harness asserts message <= 300). The path prefix is unbounded (PATH_MAX 4096): with a cwd/--dir nesting deep enough that the prefix approaches ~260+ chars, the reason suffix falls past the cap and users get only path text with no failure cause. The base behavior echoed the user-supplied (typically short relative) path, so the degradation is introduced by the absolutization added here; the NDJSON clip test was even reworked (toStartWith(path.sep)) showing awareness of the longer prefix.
Suggested fix: When composing these errors, bound the path portion (e.g. keep the last ~120 chars with an ellipsis marker) or emit `reason (path: ${file})` so the 300-char truncation eats path text rather than the diagnostic reason.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Config-error messages now embed the fully resolved absolute path (${file}: cannot read schema file, ${file}: invalid JSON, ${file}: schema root must be a JSON object, ${file}: serialized schema must not exceed 64 KiB, ${file}: <ajv strict-mode message>), but the CLI caps invocation_error messages at 300 chars (the test harness asserts message <= 300). The path prefix is unbounded (PATH_MAX 4096): with a cwd/--dir nesting deep enough that the prefix approaches ~260+ chars, the reason suffix falls past the cap and users get only path text with no failure cause. The base behavior echoed the user-supplied (typically short relative) path, so the degradation is introduced by the absolutization added here; the NDJSON clip test was even reworked (toStartWith(path.sep)) showing awareness of the longer prefix.

  // Relative paths resolve against --dir (run changes into it first), like --file.
  // Report the resolved path so a misplaced relative path is obvious.
  const file = path.resolve(input.schema)
  const text = await Bun.file(file)
    .text()
    .catch(() => {
      throw new Error(`${file}: cannot read schema file`)
    })

.text()
.catch(() => {
throw new Error(`${input.schema}: cannot read schema file`)
throw new Error(`cannot read schema file (${file})`)
})
const schema: unknown = (() => {
try {
return JSON.parse(text)
} catch {
throw new Error(`${input.schema}: invalid JSON`)
throw new Error(`invalid JSON (${file})`)
}
})()
if (!schema || typeof schema !== "object" || Array.isArray(schema))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Divergent root-schema error wording between layers.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #134, packages/cli/src/cli/cmd/run.output.ts:30-31):

Problem: Divergent root-schema error wording between layers
Detail: The config layer now throws "${file}: schema root must be a JSON object" (array/null/scalar roots) while OutputSchema.compile throws 'schema root must have type "object"' (non-object `type` value). Both surface as OUTPUT_SCHEMA_CONFIG invocation_errors per the tests; the near-duplicate phrasing for adjacent failure modes makes logs harder to match to a layer, and compile()'s message lacks the file prefix the CLI messages carry.
Suggested fix: Unify phrasing across both layers (e.g. both "schema root must be a JSON object with type \"object\""), or add the `${input.schema}:` file prefix when compile() errors are surfaced from run.output.ts.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The config layer now throws "${file}: schema root must be a JSON object" (array/null/scalar roots) while OutputSchema.compile throws 'schema root must have type "object"' (non-object type value). Both surface as OUTPUT_SCHEMA_CONFIG invocation_errors per the tests; the near-duplicate phrasing for adjacent failure modes makes logs harder to match to a layer, and compile()'s message lacks the file prefix the CLI messages carry.

      })()\n      if (!schema || typeof schema !== "object" || Array.isArray(schema))\n        throw new Error(`${input.schema}: schema root must be a JSON object`)\n      const canonical = schema as Record<string, unknown>

throw new Error(`${input.schema}: schema root must have type "object"`)
throw new Error(`schema root must be a JSON object (${file})`)
const canonical = schema as Record<string, unknown>
const validate = (() => {
try {
return OutputSchema.compile(canonical)
} catch (error) {
throw new Error(`${input.schema}: ${error instanceof Error ? error.message : "cannot compile schema"}`)
throw new Error(`${error instanceof Error ? error.message : "cannot compile schema"} (${file})`)
}
})()
if (input.result) {
const directory = path.dirname(input.result)
const result = path.resolve(input.result)
const directory = path.dirname(result)
const writable = await fs
.stat(directory)
.then(async (stat) => {
Expand All @@ -46,7 +52,7 @@ export async function outputSchema(input: { schema?: string; retries?: number; r
return true
})
.catch(() => false)
if (!writable) throw new Error(`${input.result}: output-result parent directory must exist and be writable`)
if (!writable) throw new Error(`output-result parent directory must exist and be writable (${result})`)
}
return { format: { type: "json_schema" as const, schema: canonical, retryCount: input.retries ?? 2 }, validate }
}
Expand Down
55 changes: 44 additions & 11 deletions packages/cli/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ export const RunCommand = cmd({
describe: "format: default (formatted) or json (raw JSON events)",
})
.option("output-schema", { type: "string", describe: "JSON Schema file for the final result (object root)" })
.option("output-schema-retries", { type: "number", describe: "additional corrective attempts (default: 2)" })
.option("output-schema-retries", { type: "number", describe: "additional corrective turns (0-10, default: 2)" })
.option("output-result", {
type: "string",
describe: "atomically write only the validated JSON result to this file",
Expand Down Expand Up @@ -561,7 +561,8 @@ export const RunCommand = cmd({
return false
}

const events = await sdk.event.subscribe()
const subscriptionAbort = new AbortController()
const events = await sdk.event.subscribe({ signal: subscriptionAbort.signal })
let error: string | undefined
const startTime = Date.now()
const childSessions = new Set<string>()
Expand Down Expand Up @@ -1014,12 +1015,21 @@ export const RunCommand = cmd({
})
}

const loopDone = loop()
.then(() => complete())
.catch(reject)
const loopDone = loop().then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
)
const finish = async (result: Awaited<typeof loopDone>) => {
if (result.ok) return complete()
return reject(result.cause)
}
let failure: { cause: unknown } | undefined
const failed = (cause: unknown) => {
failure = { cause }
}

if (control.current) {
await loopDone
await finish(await loopDone)
await Shutdown.flush()
return
}
Expand All @@ -1034,7 +1044,7 @@ export const RunCommand = cmd({
...(schema ? { format: schema.format } : {}),
}
const request = sdk.session.command(input(sessionID, opts))
await (schema ? request.catch(reject) : request)
await (schema ? request.catch(failed) : request)
} else {
const model = args.model ? Provider.parseModel(args.model) : undefined
const opts = {
Expand All @@ -1045,11 +1055,15 @@ export const RunCommand = cmd({
...(schema ? { format: schema.format } : {}),
}
const request = sdk.session.prompt(input(sessionID, opts))
await (schema ? request.catch(reject) : request)
await (schema ? request.catch(failed) : request)
}

if (schema && error) {
if (schema && (failure || error)) {
abort()
subscriptionAbort.abort()
const result = await loopDone
if (failure) await reject(failure.cause)
if (!failure) await finish(result)
await Shutdown.flush()
return
}
Expand All @@ -1059,7 +1073,19 @@ export const RunCommand = cmd({
// (e.g., Session.get() fails, model not found), no session.status idle event
// is emitted, so loopDone would hang forever. Racing ensures we surface
// the error and exit.
await Promise.race([loopDone, promptResult.then(() => loopDone, reject)])
const result = await Promise.race([
loopDone,
promptResult.then(
() => loopDone,
(cause: unknown) => ({ ok: false as const, cause }),
),
])
if (schema && !result.ok) {
abort()
subscriptionAbort.abort()
await loopDone
}
await finish(result)
await Shutdown.flush()
}

Expand Down Expand Up @@ -1124,27 +1150,34 @@ export const RunCommand = cmd({
},
},
event: {
async subscribe() {
async subscribe(opts: { signal: AbortSignal }) {
const queue: any[] = []
let resolve: (() => void) | null = null
const handler = (event: { payload: any }) => {
queue.push(event.payload)
resolve?.()
}
GlobalBus.on("event", handler)
const stop = () => {
GlobalBus.off("event", handler)
resolve?.()
}
opts.signal.addEventListener("abort", stop)

const stream = (async function* () {
try {
while (true) {
while (queue.length > 0) {
yield queue.shift()
}
if (opts.signal.aborted) break
await new Promise<void>((r) => {
resolve = r
})
}
} finally {
GlobalBus.off("event", handler)
opts.signal.removeEventListener("abort", stop)
}
})()
return { stream }
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type StreamTextResult,
type Tool,
type ToolSet,
type ToolChoice,
tool,
jsonSchema,
} from "ai"
Expand Down Expand Up @@ -40,7 +41,7 @@ export namespace LLM {
small?: boolean
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
toolChoice?: ToolChoice<ToolSet>
structured?: { reject: (input: string) => Promise<string>; checkpoint: () => () => void; repairs: Set<string> }
}

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ export namespace MessageV2 {
.object({
type: z.literal("json_schema"),
schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }),
retryCount: z.number().int().min(0).default(2),
retryCount: z
.number()
.int()
.min(0)
.transform((n) => Math.min(n, 10))
.default(2),
})
.meta({
ref: "OutputFormatJsonSchema",
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/src/session/output-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Ajv, { type ErrorObject } from "ajv"
import Ajv2020 from "ajv/dist/2020"
import z from "zod"
import { createHash } from "crypto"

export namespace OutputSchema {
export const Diagnostic = z.object({ path: z.string(), keyword: z.string(), message: z.string() })
Expand All @@ -19,9 +20,34 @@ export namespace OutputSchema {
// acceptance, including schemas cloned by Zod or loaded from session storage.
const cache = new Map<string, ReturnType<Ajv["compile"]>>()

// Ajv codegen recurses per nesting level: ~2,000 levels fit in 16 KB yet overflow
// the stack, so bound structure as well as bytes. Iterative, so the check itself
// cannot overflow.
const MAX_DEPTH = 64
const MAX_NODES = 10_000
function bound(schema: unknown) {
const stack: [unknown, number][] = [[schema, 0]]
let nodes = 0
while (stack.length) {
const [value, depth] = stack.pop()!
if (!value || typeof value !== "object") continue
if (depth > MAX_DEPTH) throw new Error(`schema nesting must not exceed ${MAX_DEPTH} levels`)
if (++nodes > MAX_NODES) throw new Error(`schema must not exceed ${MAX_NODES} nested objects`)
for (const child of Object.values(value)) stack.push([child, depth + 1])
}
}

export function canonical(schema: Record<string, unknown>) {
bound(schema)
const text = JSON.stringify(schema)
const bytes = Buffer.byteLength(text)
if (bytes > 64 * 1024) throw new Error("serialized schema must not exceed 64 KiB")
return { text, bytes }
}

export function compile(schema: Record<string, unknown>) {
if (schema.type !== "object") throw new Error('schema root must have type "object"')
const key = JSON.stringify(schema)
const key = createHash("sha256").update(canonical(schema).text).digest("hex")
const cached = cache.get(key)
if (cached) return cached
const ajv =
Expand Down
Loading
Loading