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:
| Target | Purpose | Actions? |
|---|---|---|
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 download | No. 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 dispatchRules:
anonhas zero grant onform_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_atTwo 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.
| Clock | Set by | On 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 state | Commercial | β 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.
The dead-link problem
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:
- Escalating notices at 60 / 30 / 7 / 1 days, to the owner and the secondary contact and the org admin. Not just the creator.
- 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.
- 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.
orphanedis 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:
- CSS custom properties (
--brand,--primary) - Computed colors of
header, primary buttons, links <meta name="theme-color">, manifesttheme_colorog:imagedominant 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
| Action | Notes |
|---|---|
| Store submission | Always on. The substrate. Viewer + CSV export. |
| Resend already wired. Two flavors: notify owner; optional receipt to submitter. | |
| Telegram | Cheap, no OAuth. Onboarding: user messages the bot, bot returns a 6-char code, they paste it β never ask for a chat ID. |
| Webhook | POST 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.
| Idea | Verdict |
|---|---|
| Submissions viewer + CSV export | Build β Tier 1 |
| Prefill links from a prior submission | Build β Tier 1.5 |
| Author-managed lookup tables feeding dropdowns | Later. Cheap and useful, but it is a reference table, not submissions β do not conflate |
| Submitting form A auto-sends form B | Defer β that is a workflow engine |
| Contact records unified across all forms | Do 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-livestatus 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
autocompletetokens per 1.3.5 β reuseinferAutocompletefromform-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.
| Phase | Contents |
|---|---|
| 0 | Schema (tenant-owned forms, versions, submissions, actions, deliveries) + render_html() from FormSpec + public GET with KV-cached render |
| 1 | Public POST through workers/api: Turnstile, rate limit, validate, store. Submissions viewer + CSV. |
| 2 | Actions: email, Telegram, webhook. Delivery retry + status UI. |
| 3 | Lifecycle: ends_at, closed page, escalating notices, Form Register, orphan detection |
| 4 | Personalization: brand extraction, contrast hardening, authorization checkbox |
| 5 | Prefill links Β· accessible PDF receipt Β· Slack |
| 6 | Inbound-link crawl report Β· Google Sheets Β· conditional routing |
Phases 0β3 are the product. 4 onward are differentiation.