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 —
parseCustomFormSchemafor reads (src/lib/schemas/custom-form-schema.ts:58, falls back toEMPTY_CUSTOM_FORM_SCHEMA) andparseCustomFormSchemaStrictfor writes (src/lib/schemas/custom-form-schema.ts:67, throws with the offending field path). - rsvp_actions / forms.actions —
parsePostSubmitActionfor reads (src/lib/schemas/post-submit-actions.ts:67, falls back toDEFAULT_POST_SUBMIT_ACTION) andparsePostSubmitActionStrictfor 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 toDEFAULT_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