ERP forms: how a form leaves the list¶
The Form Builder could create forms and publish them. It could not do anything else with one. Every draft an admin made while learning the builder, every duplicate started from the wrong template, every "test form 3" stayed in the forms list permanently, in the same list an admin picks from when they need the real admission form. This doc covers the other end of a form's life: unpublish, archive, delete, and the rule that decides which one a given form gets.
Status: in build — Product Mindset Gate answered 2026-08-03, honestly and
with a weak validation signal (see below). Part of the Form Builder epic
kwiloai_webapp#1476,
built on branch feat/erp-form-builder.
Related: erp-form-builder-and-custom-fields.md is the parent — it defines the field registry, the policy layer, and the versioning model this lifecycle has to respect. Split out rather than added to it because that doc is already at its length limit, and because this is a question it never answered rather than a refinement of one it did.
Why this exists¶
-
Who asked: Nobody outside the team. Bhanu, building the builder, noticed the forms list had no way to remove anything from it. Recording this as a self-initiated hypothesis rather than dressing it up as a customer request.
-
What user pain: An admin evaluating the builder makes three or four throwaway forms in the first sitting, because that is what learning a builder looks like. A week later the forms list reads "Admission Form 2026", "Untitled", "test", "Untitled 2", "Consent — trip". Two of those are real. The picker an admin uses under time pressure during admission season is the same list, at its worst exactly when it matters most.
-
Cost of not doing it: It compounds and never recovers. Every experiment is permanent. The list only grows, and the ratio of real forms to noise gets worse with every month of use — the opposite of how a tool should age.
-
Validated or a guess: A guess, and worth saying plainly. No institution has used the builder yet, so nobody has complained about clutter. What is not a guess is that the capability is absent: there is no endpoint, no column and no button, so the question "how do I get rid of this form" currently has no answer at all.
-
How we know it worked: The forms list length tracks the number of forms an institution actually uses, rather than the number it has ever created. Sean Ellis counterfactual, answered honestly: weak. Remove this a week after shipping and nobody files a ticket — they live with the clutter, the way people live with a messy drawer. That is a real signal about priority. It is a small feature and it is being built as one.
Personas¶
- Dr. Anita, unit manager at a degree college. Builds the forms. Made the test forms. The only person who ever sees this surface — the whole Form Builder is manager-gated at the router.
Outcomes¶
- An admin who made a mistake can undo it, in the obvious place, without asking support.
- A form that ran last admission season and is not running this one leaves the picker without anyone losing its responses.
- No admin is ever offered a button that would destroy responses.
Non-goals¶
- No bulk actions. One form at a time. An institution has tens of forms, not thousands, and multi-select on a list this size is chrome that earns nothing.
- No trash / undo window for delete. Delete only ever reaches a form with nothing behind it (see below), so there is nothing a recovery window would be protecting. Everything with something to preserve archives instead, and archive is the reversible option.
- No per-version delete. The catalog lists one row per key. Version-level surgery would need a version-history UI that does not exist, and the version-restore flow already covers "go back to how v1 looked".
- No scheduled retirement.
closes_atalready stops a form taking submissions on a date. Auto-archiving on that date would remove a row an admin did not ask to remove.
The rule¶
Three exits, and which one a form gets is a fact about the form rather than a choice the admin makes. Exactly one is offered per row.
| Form state | Offered | Why |
|---|---|---|
| A version is published | Unpublish | It is live. Nothing else can happen until it comes offline. |
| Offline, has been published or has submissions | Archive | There is something to preserve. |
| Offline, never published, no submissions | Delete | Nothing references it. |
Why "never published" is not enough on its own¶
unpublish_template clears published_at. So a form that went live, collected
forty responses and was then taken offline is byte-for-byte
indistinguishable from a pristine draft, if you only look at published state.
Shipping the naive rule would have made exactly one class of form deletable that
must never be: the one with real data behind it.
The guard is therefore two conditions — no version currently published and no submission cites the key. The second is the one that actually protects data; the first is what makes the answer predictable while a form is live.
Why delete is a hard delete¶
Because a submission can only exist against a published template
(submissions/service.py refuses an unpublished one), a form that has never
been published provably has no submissions. Not "we checked and found none" —
cannot have any, structurally. Adding a second soft-delete flag for rows that
reference nothing would be storing tombstones to protect data that does not
exist.
The audit row is written before the delete and outlives it. Once the rows are gone it is the only record that the key ever existed.
Why archive is key-scoped¶
The catalog is DISTINCT ON (org_unit_id, key) — one row per form, showing its
latest version. Archiving a single version would hide that row and reveal the
version before it in its place, which is not "the form left the list", it is
"the form got older". So archived_at is set on every version of the key at
once, and cleared the same way.
Design decisions¶
-
Unpublish, then archive — two steps, not one. Archiving could have unpublished as a side effect. It does not: taking a live form offline is a thing people need to mean, especially mid-admission-season, and burying it inside another action makes it possible to do by accident.
-
The list badge reads the key, not the row. This started as an implementation detail and turned out to be a bug fix. Publishing v1 and then editing it creates v2 as a draft while v1 stays live — so the catalog row (v2) had
published_at: nulland the list showed Draft for a form that was, at that moment, accepting submissions. The catalog now returnspublished_version_id/published_versionper key. The badge reads them, the caption says "Version 1 is live" when the live version is not the one on screen, and the Unpublish action targets the live version rather than the row the admin clicked. -
has_historyrather thancan_delete. The row is told a fact about the form (it has been published, or people have submitted to it) and derives the action from that. The server enforces the same rule independently. Shipping a permission boolean would have put the decision in two places and made the payload lie the moment the rule changed. -
Archived forms are read-only, not just hidden. Editing, publishing and version-restoring an archived template all 409. A form coming out of the archive comes out as a draft, deliberately — it was retired for a reason and deserves a read before it takes responses again.
-
The action is a worded button on the row, not a menu. First build put it behind a kebab, reasoning that the action changes per form and a shifting icon would have to be read rather than scanned. That was solving the wrong half of the problem: a worded button says what it is without the icon carrying any load, and a menu holding exactly one item spends a click on nothing. Bhanu called it on first look at the built screen — the actions were there, and invisible.
-
Same action in the builder, same rule. An admin who opens a form to look at it can retire it from there. This is why the detail endpoint returns the key's state too: the builder and the list run the same
formLifecycleIntent()over the same three fields, so they cannot disagree about the form in front of the user. In the builder it sits at the far left of the action cluster, a gap away from Save — retiring is the rarest thing done on that screen and the worst to hit by accident. Archive and delete leave nothing to come back to, so they navigate to the list; unpublish stays put, because an admin taking a form offline is usually about to keep editing it. -
Ghost styling, including for delete. Delete only ever reaches a form nobody has filled in. A filled red button on every draft row would shout about the safest action on the page, so the red lives in the label.
-
All three destructive-ish actions are confirmed; restore is not. Unpublish and archive are both reversible, and both are confirmed anyway: one takes a form away from people filling it in, the other empties a row someone may have been looking for. Restore puts a row back in a list, in front of the person who asked for it. Nothing to confirm.
Rejected alternatives¶
-
Status filter chips (All / Published / Draft / Archived) across the top. More discoverable than a collapsed disclosure. Rejected because it adds permanent chrome to a list that is usually five rows long, to surface a category that is usually empty. The disclosure costs nothing when there is nothing archived — it does not render.
-
Soft-delete everything; never hard-delete. Consistent, and consistency is worth something. Rejected because it makes "delete" a lie: an admin who deletes their test form expects it gone, and a tombstone that keeps the key occupied would then block them recreating a form with the same name.
-
Cascade-delete submissions with the form. Would make delete available everywhere and the rule trivial. Rejected outright — submissions are records an institution may be legally required to keep, and no forms-list dropdown should be able to destroy them.
-
Delete individual draft versions from version history. More granular. Rejected for now: there is no version-history UI with per-row actions to hang it off, and a key that has never been published only ever has one version anyway (the fork to v2 only happens on publish), so the granularity would be theoretical.
Success signals¶
- Forms lists in production stay short relative to forms actually in use.
- Zero support contacts of the shape "how do I get rid of this form".
- No 409 spike on delete, which would mean the row is offering delete where the server refuses it — the client and server rules having drifted apart.
Open questions¶
- Does anyone want the archive at all, or only delete? The two-tier model is our judgement, not a request. If archived forms are never restored in the first six months, the archive is ceremony and the honest fix is to make retirement one-way and quiet.
- Should an archived form still print? The blank-form PDF endpoint does not
check
archived_attoday. Printing a retired form is arguably right — it is reference material — but nobody has asked.
Changelog¶
-
2026-08-03 (same day, after first look) — The actions shipped behind a kebab and were, in practice, invisible: "i dont see delete and archive on FE". They were there; nobody found them. Replaced with worded buttons on the row itself, and added the same button to the builder header so a form can be retired from the screen an admin is already on. The detail endpoint now returns the key's state (
published_version_id,published_version,has_history) for that reason, andformLifecycleIntent()moved toerp/forms/lifecycle.tsso both surfaces run one rule. Row order settled as name, badge, action, chevron — the chevron last, where a list's "this opens something" marker is looked for. -
2026-08-03 — Built on
feat/erp-form-builder. Migrationerp0034addsform_template.archived_atand widens thecustom_field_auditaction CHECK to admitdelete. Three endpoints (POST /{id}/archive,POST /{id}/unarchive,DELETE /{id}), all key-scoped; the list endpoint gains anarchivedswitch and returnspublished_version_id,published_versionandhas_historyper row.
Two things the build changed about the design:
The naive "drafts are deletable" rule had a hole big enough to lose data
through. Unpublishing clears published_at, so a form with forty responses
reads as a pristine draft. Found by asking what unpublish actually writes
before implementing the guard, not by review. The rule became two conditions.
The list badge was already wrong. Not a new bug — a v2 draft over a live v1 has always displayed "Draft" while v1 took submissions. It surfaced only because archiving needed to know whether the key was live, which the row could not answer. Fixed on the way past.