JSONB validation

Every JSONB column is parsed through a schema in src/lib/schemas/ before the app trusts it — never an `as`-cast.

Four columns hold host-authored JSONB: events.page_schema, forms.schema, events.rsvp_actions/forms.actions, and events.submission_mode. Each has its own parser in src/lib/schemas/, and each follows the same read/write split: a lenient parser for reads that falls back to a safe default on malformed data and never throws, and — for three of the four — a stricter parser used only when a host actively saves, which throws a real error instead of silently corrupting what was there before.

Read vs. write, per column

  • page_schema — parsePageSchema (src/lib/schemas/page-schema.ts:110). There is no separate strict write-time variant: the same function is reused for both, since its per-block dropping behavior (see below) is itself the safety mechanism on write — the page builder’s save action calls it and rejects the save outright only if nothing valid remains (src/app/dashboard/events/[eventId]/actions.ts:212-215).
  • forms.schema — parseCustomFormSchema for reads (src/lib/schemas/custom-form-schema.ts:58, falls back to EMPTY_CUSTOM_FORM_SCHEMA) and parseCustomFormSchemaStrict for writes (src/lib/schemas/custom-form-schema.ts:67, throws with the offending field path).
  • rsvp_actions / forms.actions — parsePostSubmitAction for reads (src/lib/schemas/post-submit-actions.ts:67, falls back to DEFAULT_POST_SUBMIT_ACTION) and parsePostSubmitActionStrict for writes (src/lib/schemas/post-submit-actions.ts:77, throws with the first Zod issue’s message).
  • submission_mode — parseSubmissionMode (src/lib/schemas/submission-mode.ts:15). A single parser here too: it’s a bare three-value enum, so there’s no way for a write to be “partially” valid the way a page or form schema can be — any non-matching value just falls back to DEFAULT_SUBMISSION_MODE(“private”) on both paths.

Why page-schema.ts drops blocks instead of rejecting the page

safeParseBlock (src/lib/schemas/page-schema.ts:98-105) validates one block and returns null (after logging a warning) rather than throwing; parsePageSchema maps every entry in blocks through it and filters out the nulls (src/lib/schemas/page-schema.ts:116) before returning a page that still renders with whatever blocks were valid. The block config shape itself stays a loose z.record(z.string(), z.unknown()) (src/lib/schemas/page-schema.ts:56) rather than a per-block-type schema — the comment above it explains why: each block’s own Edit/Render component already reads its config defensively with fallbacks, so being strict here would risk rejecting an otherwise-valid row over unrelated schema drift in one block type.

custom-form-schema.ts makes the opposite call for the same kind of data: every field is validated per-kind through z.discriminatedUnion("kind", [...]) (src/lib/schemas/custom-form-schema.ts:24-47), and one invalid field fails the whole form’s parse (falling back to the empty schema, not a partially-dropped one). The file’s own comment (src/lib/schemas/custom-form-schema.ts:5-12) states why the two differ: a page’s blocks are already-trusted content a host is just re-viewing (so a stray bad block should degrade gracefully, not corrupt the read), whereas a custom form’s field schema directly gates what gets trusted from anonymous guest submissions later (src/lib/forms/validate-submission.ts) — looseness there would be a validation gap in the guest-facing write path, not a convenience.

The one legacy exception: form-schema.ts

src/lib/schemas/form-schema.ts— the RSVP form’s own, older engine — is hand-rolled sanitization and type-guard functions, not Zod: isFieldType and isFieldRole (src/lib/schemas/form-schema.ts:83-98) narrow raw values field by field, sanitizeField (src/lib/schemas/form-schema.ts:100-118) builds one FormField from an unknown value or returns null, and resolveFormSchema (src/lib/schemas/form-schema.ts:128-140) is the read-time entry point, falling back to DEFAULT_FORM_SCHEMA when every field fails sanitization.

This isn’t an oversight — it predates the generic Forms engine and the two are meant to stay separate. The comment at the top of src/lib/forms/types.ts:1-4 says so directly:

“Generic multi-form field-type vocabulary — deliberately separate from src/lib/schemas/form-schema.ts (the RSVP form’s own, narrower engine). See docs/01-product-definition.md’s dated entry on why these stay two engines instead of one.”

W1 (unvalidated JSONB trust) is resolved

docs/02-architecture-review.md’s W1 finding described resolvePageSchemachecking only “has a non-empty blocks array” and casting the rest as PageSchema. That function no longer exists in src/lib/schemas/page-schema.ts — parsePageSchema is real per-block Zod validation, and a grep for as PageSchema across src/ turns up nothing but the historical comment describing the old behavior (src/lib/schemas/page-schema.ts:5-10). W1 is fixed, not just documented as a plan.

Never an as-cast

Every read of a JSONB column goes through its lenient parser, which is guaranteed to return a value (a default, or whatever subset of the stored data is individually valid) and never throw. That guarantee is what keeps one corrupted or hand-edited block, field, or column from crashing the public guest page or the dashboard — the worst possible failure surface for a multi-tenant app. Strict parsers exist only on the save path, precisely so a bad value a host is actively typing gets rejected with a real error instead of being silently downgraded and overwriting a previously-valid saved config.