Skip to content

Gevell Forms β€” hosted form service

Take an existing form, rebuild it as a best-practice accessible form with the same text and fields, host it, wire it to actions, and give it an end date.

This document covers the design. It supersedes nothing β€” the existing tools/formspec IR and from_detected rebuild pass are the foundation it builds on.

1. The thesis

Two problems, one product.

Problem A β€” forms are the worst-remediated documents an org owns. They are authored by non-specialists, embedded everywhere, and almost never audited. The census pilot found 2 of 1500 PDFs passing PDF/UA-1; forms are worse, because a form fails for a keyboard user in ways a static document does not.

Problem B β€” orgs do not know what forms they have. The person who built the form left. It is linked from a page nobody maintains. Nobody can say whether it is still in use, so nobody retires it, so the inventory only grows.

A form nobody knows about is a form nobody can remediate. Problem B causes Problem A, and B is the one no competitor solves β€” because every other form vendor is incentivized to keep as many forms alive as possible.

So hosting is the product, and expiration is the feature. Every hosted form carries an end date. The org stops having to wonder what is live. Renewal is a deliberate act, and it is the natural moment to re-verify against the current WCAG target.

This is governance language, not SaaS language. Call the org-level view the Form Register.

2. Architecture: one FormSpec, two renderers

FormSpec (tools/formspec/formspec/spec.py) stays the single source of truth: title, content[] (blocks and fields interleaved), ThemeSpec, page_size, decoration. from_detected.py already rebuilds a detected form into a better-presented spec while preserving canonical field identity.

Two render targets from that one spec:

TargetPurposeActions?
render_html() (new)Hosted form at forms.gevell.com/f/<slug>Yes β€” this is where submit actions live
render.py (existing)Tagged PDF/UA-1 downloadNo. Print / offline / records copy

AcroForm submit buttons are not a path. SubmitForm is dead in Chrome, Edge, and Safari’s built-in viewers; only desktop Acrobat honors it, and it warns. Any submit action requires the HTML target. The PDF is an export, not the product.

3. Data layer

Supabase (vuvwmfxssjosfphzpzim) remains the backend β€” reuse of the existing tenant, auth, and credits model, not a new dependency. But the public write path does not go through PostgREST.

respondent
β”‚ GET /f/<slug> β†’ Worker serves cached HTML (0 DB queries)
β”‚ POST /f/<slug> β†’ workers/api
β–Ό
workers/api (Cloudflare)
β”‚ Turnstile β†’ rate limit (KV/Redis) β†’ size cap β†’ validate
β–Ό
Supabase (service role) R2 (uploads) action dispatch

Rules:

  • anon has zero grant on form_submissions. RLS is author/tenant read only. All writes are service-role, through the API. This is the same posture as migration 284.
  • Rate-limit counters live in KV or Redis, never Postgres. An abusive bot must not be able to generate database load.
  • A published form version is immutable, so its rendered HTML is cached in KV/R2 keyed by form_version_id. A form page render costs zero DB queries. This is what makes hosting thousands of forms operationally free, and the hosting model depends on it.
  • Uploads go to R2, consistent with the rest of the platform.
  • Expiry notifications run on a Worker cron β†’ API route, since they need Resend and Telegram regardless.

4. Schema sketch

Migration numbering continues from 286.

form_forms
id, tenant_id (NOT user_id β€” see Β§6), slug,
created_by, owner_contact_id, secondary_contact_id,
status: draft|published|closed|unpublished|orphaned,
ends_at, retention_until, current_version_id
form_versions
id, form_id, spec jsonb, -- the FormSpec
rendered_html_key, -- KV/R2 cache key
wcag_checked_at, wcag_result jsonb,
published_at -- immutable once set
form_submissions
id, form_id, form_version_id,
data jsonb, -- keyed by FieldSpec.name
submitted_at, source_ip_hash, redacted_at
form_actions
id, form_id, kind, config jsonb, enabled
form_action_deliveries
id, submission_id, action_id,
status, attempts, last_error, delivered_at

Two decisions that are expensive to reverse:

Submissions are JSONB keyed by FieldSpec.name, plus form_version_id. The author will edit the form after 50 submissions. name is the canonical identity from_detected already preserves through a rebuild, so it is the correct key. The viewer unions columns across versions and renders blanks for fields that did not exist yet.

form_actions and form_action_deliveries are separate tables. Storage of the submission must succeed even when every action fails. Deliveries get their own status, attempt count, and retry β€” never inline in the request.

5. Lifecycle: three clocks, not one

Conflating these is the main risk in the whole design.

ClockSet byOn elapse
Form end date (ends_at)Author, deliberatelyβ†’ closed: stops accepting; page still resolves, explains itself, names a contact, offers β€œrequest an extension”
Retention (retention_until)Org policy→ submissions purged. Separate and typically much longer
Plan / billing stateCommercial→ grace period, read-only, escalating warnings

Billing lapse must never dark a form. A declined card that instantly closes 200 live forms is the worst support incident available to us. Grace period, read-only, loud warnings, human contact β€” in that order.

Expiry is closed, not 404. The closed page is a real accessible page: <h1>, plain-language explanation, the owning contact, a route to a human. Not a bare 410.

Forms get embedded in web pages, email signatures, print flyers, and QR codes on posters. An expired form behind a QR code on a printed poster will be blamed on us. Mitigations, in order of value:

  1. Escalating notices at 60 / 30 / 7 / 1 days, to the owner and the secondary contact and the org admin. Not just the creator.
  2. Inbound-link report. We already run headless Chromium for audits β€” crawl the customer’s own site and report where each form is linked from. This answers β€œwe don’t know what’s in use” directly, and no competitor can do it.
  3. Activity-aware notices: β€œthis form expires in 7 days and took 40 submissions last week β€” extend?” Notify on activity; never auto-extend, which would defeat the entire point.

6. Ownership: the root cause

Form sprawl exists because the person who built the form left. So:

  • Forms belong to the org tenant, never to a user_id.
  • A secondary contact is required at publish time.
  • The org admin console sees every form regardless of creator.
  • orphaned is a first-class status, set when the owner’s account deactivates. Orphaned forms surface at the top of the Register.

Hanging forms off user_id is the single easiest way to get this wrong and the most expensive to migrate later.

7. Personalization

Given an org name or email domain, brand the form β€” without touching trademarks.

Extraction order, cheapest first, using the audit app’s existing headless Chromium:

  1. CSS custom properties (--brand, --primary)
  2. Computed colors of header, primary buttons, links
  3. <meta name="theme-color">, manifest theme_color
  4. og:image dominant color, last resort

Three constraints:

Contrast overrides the brand. Extracted colors fail 4.5:1 constantly. Harden the hue the way resolveCustomColors does in org-chart β€” walk darker or lighter until AA passes β€” and then say so: β€œWe adjusted your #4A90E2 to #1F5FA8 to meet WCAG AA.” That message is a selling point.

Trademark posture. Colors alone are largely safe; keep a small blocklist for known trade dress (Tiffany blue, UPS brown, T-Mobile magenta). Org name as plain text is nominative use. Never fetch or embed their logo file, and never imitate a wordmark. The real protection is a representation, not a technical control: a required checkbox at generation time β€” β€œI am authorized to use this organization’s name and branding.” Then let them upload their own logo.

Confirm, never assume. Show β€œIs this Rapid City Area Schools? Here are the colors we found,” with an edit affordance. A wrong auto-brand is worse than none.

8. Submit actions

form_submissions storage is always on and is not an action. Every action is a retryable delivery layered on top of a submission that already durably exists.

Tier 1 β€” ship together

ActionNotes
Store submissionAlways on. The substrate. Viewer + CSV export.
EmailResend already wired. Two flavors: notify owner; optional receipt to submitter.
TelegramCheap, no OAuth. Onboarding: user messages the bot, bot returns a 6-char code, they paste it β€” never ask for a chat ID.
WebhookPOST JSON, HMAC-signed. The escape hatch that makes Zapier / Make / n8n work so we don’t build forty integrations.

Telegram caveat: great for solo operators and for us internally, but .edu and .gov environments frequently block or disallow it. Build it β€” it is the easiest one β€” but Slack and Teams matter more commercially.

Tier 2

  • Slack β€” incoming webhook, same shape as Telegram, roughly an hour.
  • Google Sheets append β€” most requested, and the expensive one: OAuth app, Google verification review for the Sheets scope, token refresh, column drift. Do it after webhook, because webhook β†’ Zapier β†’ Sheets covers it meanwhile.
  • Accessible PDF receipt β€” emit a filled, tagged, PDF/UA-1 copy of the submission to both parties. Nobody else in the forms market can do this. It is what makes this a Gevell product rather than a Google Forms clone.

Tier 3

.ics calendar invite (RSVP and appointment forms) Β· Stripe payment (already wired) Β· conditional routing (β€œif Department = X, notify Y”) β€” cheap once the action set exists, and it is what sells to institutions.

Skip: SMS / Twilio. 10DLC registration pain and per-message cost for low value.

9. Submissions viewer

Lives in apps/forms/src/app/dashboard/, which already exists.

The grid must be accessible, and data grids are the hardest thing in WCAG. For a company selling accessibility, an inaccessible submissions table is the one bug we cannot ship. Therefore: semantic <table>, sortable <th> with aria-sort, real pagination. Not AG Grid, not virtualized canvas, not infinite scroll.

PII is real here. Mask SSN-shaped and similar fields by default at the column level, and log exports β€” who exported what, when. .edu and .gov procurement will ask.

Offer a forward-only mode that stores nothing, for orgs whose counsel objects to us holding submissions at all.

10. Prefill (the useful 80% of β€œfollow-up forms”)

Author selects a prior submission β†’ generates a link β†’ the recipient opens a form already populated, headed β€œWe’ve prefilled this from your January 3 submission β€” correct anything that’s changed.”

Implementation is a signed, expiring token in the URL that resolves server-side. Never the values themselves in the query string.

This covers annual renewals, accommodation-request re-ups, and advising forms β€” precisely the .edu market β€” and needs no machinery beyond a token and a resolver.

11. Scope boundary

One form’s submissions are a table. A person across forms is a CRM. Stop at the table.

IdeaVerdict
Submissions viewer + CSV exportBuild β€” Tier 1
Prefill links from a prior submissionBuild β€” Tier 1.5
Author-managed lookup tables feeding dropdownsLater. Cheap and useful, but it is a reference table, not submissions β€” do not conflate
Submitting form A auto-sends form BDefer β€” that is a workflow engine
Contact records unified across all formsDo not build β€” that is Salesforce, and it eats the product

The trap is that the last two sound like small extensions of the first. They are not. Conditional routing (Tier 3) already scratches most of the itch that auto-sequencing does, for a tenth of the cost.

12. Abuse and safety

The public endpoint accepts unauthenticated writes. Non-negotiable:

  • Cloudflare Turnstile β€” accessible, unlike CAPTCHA, and we are already on Cloudflare. Never a visual-puzzle CAPTCHA on an accessibility product.
  • Honeypot field, per-form and per-IP rate limits (KV/Redis), request size caps
  • Never re-render submitted text as HTML anywhere β€” viewer, email, or PDF
  • Uploads: extension and MIME allowlist, size cap, scanned, stored in R2, served only to the authenticated author

13. Accessibility requirements

The submit flow is the part every competitor ships broken:

  • Error summary at the top of the form, with in-page links to each bad field
  • aria-live status region for async submit state
  • Focus moves to the first error on failed validation
  • A real success page, not only a toast
  • No session timeout on a form (2.2.1)
  • Every field labelled, with autocomplete tokens per 1.3.5 β€” reuse inferAutocomplete from form-label-jury
  • Touch targets β‰₯ 44Γ—44px; 12px minimum font size
  • Verified in both light and dark mode before merge

14. Phasing

Each phase is independently shippable.

PhaseContents
0Schema (tenant-owned forms, versions, submissions, actions, deliveries) + render_html() from FormSpec + public GET with KV-cached render
1Public POST through workers/api: Turnstile, rate limit, validate, store. Submissions viewer + CSV.
2Actions: email, Telegram, webhook. Delivery retry + status UI.
3Lifecycle: ends_at, closed page, escalating notices, Form Register, orphan detection
4Personalization: brand extraction, contrast hardening, authorization checkbox
5Prefill links Β· accessible PDF receipt Β· Slack
6Inbound-link crawl report Β· Google Sheets Β· conditional routing

Phases 0–3 are the product. 4 onward are differentiation.