Skip to content

ERP form builder: per-institution custom fields and form templates

A Karnataka school needs SATS No on its student registration form. A Delhi school needs PEN No and will never see SATS. A corporate training client needs neither and wants employee ID and cost centre. Today all three get the same fixed form, so each keeps the difference in a spreadsheet beside Kwilo. On our side the mirror problem is worse: every "can you add one field" request is a database migration, a deploy and a code review, which is a cost that scales linearly with customer count. This doc covers letting an institution define its own fields and its own forms as data rather than schema — a per-institution field registry stored in a JSONB column, a policy layer that decides which of our built-in fields each institution sees, and versioned form templates that pin themselves to every record they capture.

Status: in build — Product Mindset Gate passed 2026-08-02. Epic kwiloai_webapp#1476 with fourteen child issues across five milestones. Backend complete on branch feat/erp-form-builder; the builder and renderer UI are in progress. The drag-and-drop designer (#1490) stays deferred.

Related: erp-form-lifecycle.md owns the other end of a form's life — unpublish, archive, delete, and the rule that decides which one a given form gets. Split out because this doc is at its length limit, and because retirement is a question it never answered. erp-registration.md is the concrete form this generalises. That work built the admissions desk — a full-page ~21-field registration form, the registration fee against the shared receipt sequence, and a printed form with declaration, signature lines and an office-use block. It is built, and this feature does not replace it: the registration form becomes the first template, its field set becomes the seeded built-in catalogue, and the policy layer is what lets a Delhi school hide SATS No on it. erp-student-standard-fields.md is the direct predecessor — it standardized demographics we chose on StudentProfile and explicitly deferred per-type field visibility ("conditional-by- org_unit_type visibility is a follow-up — it needs org-unit-type context in the dialog, which isn't wired there today"). The policy layer in this doc is that follow-up, generalised: instead of us wiring one conditional per field, each institution decides. erp-institution-standard-fields.md does the same at the institution level (UDISE, AISHE, affiliation number). erp-enquiry-and-numbering.md owns the registration/admission number sequences these forms consume. auth-and-onboarding.md — institution creation is where base templates get seeded.

Why this exists

  1. Who asked: A school ERP prospect, on a call on 31 Jul 2026, asked for six things. The sixth was "registration form fields should be addable as required, and all fields editable," and they volunteered that every school they know collects a different set. The competitor they were evaluating, SchoolEarth ERP, demos this in the first call. Honest split: the pain is customer-validated; generalising it into a form builder is our hypothesis on top of their request.
  2. User pain: An office admin at a Karnataka school opens student registration and cannot record SATS No, which the state asks for. So she keeps a parallel Excel sheet and re-keys it into UDISE+ once a year. That spreadsheet is the observed workaround, and it is the same workaround the statutory-codes work found at the institution level — the pattern repeats one level down, on people records.
  3. Cost of not doing it: We lose ERP deals to competitors who show custom fields in the first call. Worse, each institution we do win generates a steady trickle of "please add field X" tickets, each one a migration and a deploy. At 500 institutions that is not a slow path, it is an absent one. And every field we fail to capture keeps a spreadsheet alive beside us, which quietly demotes our student record from source of truth to one copy among several — fees, attendance and reporting all inherit that drift.
  4. Validated or guess: Validated for the need, reasoned for the shape. A customer asked directly and a competitor already ships it. The three-layer design (built-in fields, policy, custom fields) is the design call this doc pins down, and it is the part most likely to be wrong if we got it wrong.
  5. How we will know it worked: An admin adds a field, sees it on the form, and never files a ticket. Target: three of the first five onboarded institutions add at least one custom field within two weeks of go-live. Sean Ellis counterfactual: remove it a week after shipping and the Karnataka admin says "where did SATS No go, I can't complete registration" — she names the specific field, which is about as loud and specific as a complaint gets.

Personas

Mrs. Latha, office admin at a Karnataka K-12 school. She configures the registration form once during onboarding. She is not technical, she is careful, and she has been burned by systems that lost data. She needs to add SATS No, hostel block and parent occupation, and she needs to be confident that nothing she does will break the 400 student records already in the system.

Mr. Kiran, front-desk clerk at the same school. He uses the rendered form thirty times a day with a parent standing across the counter. He never opens the builder. For him the feature succeeds if the form is fast, forgiving of interruption, and never loses a half-filled entry.

Dr. Anita, principal of the engineering college (returning from the statutory fields doc). She needs entrance exam rank and seat type on her admission form, and she never wants to see mother tongue or CWSN status, which are K-12 filing requirements. Some of what she needs is already a built-in column (quota, APAAR ID); some she will invent. She should not have to know which is which.

ICP exclusions

  • B2C learners (individual org units) have no institution configuring forms for them. The module is not exposed there.
  • Corporate and training units get the same machinery but none of the education-statutory built-ins, exactly as the statutory-codes work established.
  • Public, unauthenticated forms are out of scope for the first release. The threat model (spam, bot traffic, anonymous file upload) is different enough to deserve its own decision.

Outcomes (user outcomes, not deliverables)

  • An institution admin adds a field their state requires, and sees it on the form the same minute, without contacting us.
  • Two institutions on the same deployment run visibly different registration forms, and neither can see the other's field list.
  • A clerk interrupted mid-form comes back to a saved draft rather than an empty screen.
  • A form printed in January and reprinted in June is identical, even though the school edited it in March.
  • An institution that adds a field by mistake can retire it without losing the data already captured under it.

Non-goals

  • No new print pipeline. Printing already exists and is not ours to rebuild: erp-registration.md renders the registration form and a receipt through WeasyPrint on the hall-ticket pattern, complete with letterhead, declaration, signature lines and an office-use block, and erp-id-card-generation.md owns ID cards. What this feature owes that pipeline is a contract: a template declares which fields print, in what order, and which are office-use blanks that render on paper but are never captured on screen. The renderer honours the declaration; WeasyPrint keeps doing the rendering.
  • No document uploads yet — image fields only. A file-type custom field can neither be created nor uploaded to, gated behind a default-off PDF_UPLOADS_ENABLED constant, rejected at both the registry (422 on create) and the presign call. The reason is asymmetric protection: images are incidentally safe because EXIF-stripping decodes and re-encodes them, which rebuilds the file from pixel data and defuses most polyglot payloads. Nothing does the equivalent for a PDF, so one that satisfies the %PDF- magic-byte sniff would be promoted to safe byte-for-byte and served to staff. A college wanting a "Transfer Certificate" or "10th Marksheet" field cannot have one until content scanning lands. image is unaffected, and student photo capture — the registration desk's actual ask — works today.
  • No virus scanning, which is what gates the above. The design is recorded: scan inline between re-encode and promotion, on a min-instance-warmed Cloud Run revision rather than scale-to-zero, since a cold instance would pay for fetching the virus database inside a clerk-facing request. The alternative, quarantine-then-scan via a worker, decouples clerk latency but needs two things that do not exist — a "processing" affordance in the field renderer and a poll-or-refresh path — so it is the fallback, not the default. FormUploadStatus.QUARANTINED already exists for whichever is built.
  • No DPDP consent capture here. Verifiable parental consent under the DPDP Rules 2025 needs an append-only evidence record with policy versioning, not a checkbox stored beside a blood group. Separate track, and a serious one: the Act treats everyone under 18 as a child, which includes a 17-year-old engineering applicant.
  • No statutory export generation (UDISE+ SDMS, AISHE DCF) in this release, though the per-field mapping hook ships from the start because retrofitting it would mean re-editing every template.
  • No multi-campus template inheritance. Templates and fields belong to the org unit that owns the ERP module. A trust running three campuses configures three times in v1. Deliberate, and revisited only if a real customer hits it.
  • No drag-and-drop designer in the first release. It is the most visible part of the feature and the least load-bearing, and building it first would mean designing the underlying format speculatively.
  • No admin-authored regular expressions. Institutions pick from named validators (PAN, IFSC, pincode, mobile, numeric range, text length). Raw regex is both a denial-of-service vector and a support burden we would own forever.
  • No Aadhaar validator. We deliberately do not ship one, even though it is the obvious first entry on such a list. The student-standard-fields work dropped raw Aadhaar on purpose, relying on APAAR ID instead, to avoid inheriting encryption and parental-consent liability. Shipping an Aadhaar validator would be an invitation to recreate exactly the debt that decision removed, one institution at a time, in a column we do not control.

Mental model — three layers, not two

The temptation is two layers: our fields, and their fields. That is the design that fails, and it fails in a way that cannot be undone.

  • Built-in fields are real database columns on StudentProfile and its siblings. Admission number, name, gender, category, quota, APAAR ID, date of birth, guardians, plus the demographics the student-standard-fields work added (middle name, nationality, blood group, mother tongue, religion, CWSN status, disability type). We define them; changing them is a migration. Most of this catalogue already exists — this feature mostly declares it, rather than building it.
  • Field policy is each institution's answer to "which built-ins do I use?" A Karnataka school switches SATS No on. A Delhi school leaves it off and switches PEN No on. Neither invents anything; both are choosing.
  • Custom fields are the institution's own inventions — hostel block, parent occupation, transport route — stored in a JSON column beside the row.

Field policy is the layer that is easy to skip. Skip it, and an admin who cannot find "Full name" in her list creates a custom field called Student Name. Now the same person's name lives in two places, reports disagree, and once real data has accumulated under both there is no clean way back. The whole three-layer split exists to make that specific mistake unreachable.

In the builder, built-in and custom fields sit in one list and look identical. Mrs. Latha should not have to know or care which is which. The only visible difference is that a built-in field has no Remove option, and the panel explains why in one line.

Design decisions

Custom answers live in a JSON column, not extra tables or extra columns. Adding a column per institution requirement would mean roughly fifteen thousand columns across five hundred institutions; PostgreSQL tops out at sixteen hundred and degrades long before. The classic alternative, one row per field value, is what Odoo does and what Odoo pays for — list views and reports collapse under the joins. A JSON column keeps one row per student, and PostgreSQL indexes inside it.

We did not add a document database. The question came up directly. PostgreSQL JSONB already is a document store, and adding MongoDB alongside would cost us the things that make the ERP work: joins (custom answers would sit in a different store from the student row, so every list view becomes a merge in application code), transactions (converting an application into a student writes several tables at once today), and database-enforced tenant isolation. It would also add a datastore to our DPDP compliance surface in exchange for a capability we already have.

We are not buying a form builder. SurveyJS Creator is commercially licensed and brings its own theming engine; Form.io is copyleft-adjacent and brings its own server model. Both would fight every rule our design system enforces — translated strings everywhere, our components only, our colour tokens, accessibility standards. We already own the form runtime. We take the idea of a schema-driven form and write the renderer ourselves. A library is in scope only for the drag mechanics of the eventual canvas.

Templates version; records pin the version that captured them. A registration form is a legal document. If a school edits the declaration text in March, a form captured in January and reprinted in June must still show January's wording. This also means adding a required field never invalidates existing records — requirements apply going forward, or every student already in the system becomes unopenable.

Nothing is ever deleted. Three different actions, three different words: remove from this form unlinks it, archive retires it while keeping every value, and purge is real erasure reserved for data-protection requests. The word "delete" does not appear in the interface. An institution that retires a field and changes its mind six months later gets its data back.

We generalise the registration form rather than replacing it. The admissions desk already has a built, full-page form and a printed output that a prospect school signed off on. Rebuilding that as "template number one" and hoping it comes out the same would be a regression risk taken for no user benefit. Instead its ~21 fields seed the built-in catalogue, its layout seeds the first template, and the only thing that changes on day one is that a school can now hide SATS No or add transport route. If the generalised renderer cannot reproduce that form exactly, the renderer is wrong.

Free text stays free text where the desk needs it. The registration work deliberately made class applying and stream free text, because placement into real academic structures happens after admission, not at the desk with a parent waiting. Our field catalogue offers a linked-record type for things like program and section, but it does not retro-fit it onto those two. A form builder that makes the desk slower has failed.

Photo upload is the handshake between the two features. The registration work shipped a photo box on the printed form and a photo_url column, but no upload path, and named it the follow-up the desk asks for first. Our upload pipeline and image field, including webcam capture, are exactly that path. So this is not two features that happen to touch — one has a hole and the other is shaped to fill it.

The built-in fields carry over at admission; only custom answers need a map. Admission demographics already flow onto StudentProfile at admit via StudentProfileCreate, so there is nothing to build for them. Custom fields are different: they are scoped per record type, so an institution's "transport route" on an application and on a student are two separate definitions. Converting an application would silently drop them. So a template declares which custom answers carry forward. Explicit is also more correct than automatic — interview notes should not land on the student record.

Field count is capped, and the cap is visible. Both Salesforce and HubSpot cap custom fields per object, and both do it deliberately: on shared infrastructure, limits are the mechanism that forces curation. We default to one hundred per record type and show consumption in the interface, because a limit discovered at the moment you hit it is a bad experience and a limit you can see coming is a planning tool.

Risks & mitigations

  • An admin recreates a built-in field as a custom one. The failure that cannot be undone. Mitigation: adding a field is search-first — typing "Student Name" surfaces the existing Full name field, including when it is currently hidden, before offering to create anything. This single control is the highest- value piece of interface in the feature.
  • The JSON column becomes a junk drawer. Without validation, staff type "Bus", "School Bus" and "bus route 7" into one transport field and no report ever works again. Mitigation: the field registry is an allowlist. Values for unknown fields are rejected on write, not cleaned up later, and a field defined as a dropdown cannot accept anything outside its options.
  • A form and the server disagree about which fields are required. Conditional logic is evaluated in two places, so drift is silently possible. Mitigation: one rule format, a deliberately narrow set of operators chosen so the two implementations cannot diverge, and a test suite in CI that runs both and fails if they disagree.
  • Sensitive fields. Institutions will invent fields holding caste, medical conditions and government IDs. Mitigation: the registry carries a sensitivity flag, sensitive values are withheld from users without the right permission at the server rather than hidden in the browser, and the flag is what makes a data-protection request answerable at all — it is the only inventory of institution-invented personal data that exists.
  • Uploaded documents. Admission files are attacker-supplied content later opened by staff. Mitigation: uploads go to an isolated area, are validated by content rather than filename, are re-encoded to strip embedded data, and are served only after passing. Virus scanning is a stated, accepted gap for the first release, and the pipeline is shaped so adding a scanner is a new worker rather than a redesign.
  • Configuration cannot move between environments. This is Odoo Studio's known trap: customisations live in database rows and do not travel. Mitigation: export and import of self-contained template bundles, which is also the only way our own starter templates can ship at all.

Success signals

  • Institutions stop keeping side spreadsheets of the fields we do not capture.
  • "Can you add a field" stops appearing in support, replaced by admins doing it themselves.
  • Onboarding a new institution stops blocking on a list of extra fields.
  • A second institution in a different state onboards without a single code change.

Rollout

Five milestones. The customer commitment is met at the third.

  1. Foundation — the field registry, the policy layer, and validation compiled from the registry. Backend only, nothing visible.
  2. Renderer — the field components, the form itself, conditional logic and file uploads. Staff can use forms with custom fields; admins cannot yet configure them outside the API.
  3. Builder — the Forms module, the workspace where admins edit forms, and template versioning. This is the release. An admin adds a field and sees it live.
  4. Scale — starter templates, export and import, and bulk import of existing spreadsheets. Not part of the customer commitment, but the difference between onboarding one institution and onboarding twenty.
  5. Designer — the drag-and-drop canvas, built on a renderer already proven in production.

Open questions

  • Should virus scanning land with the upload pipeline, or with the later public- forms work that actually needs it?
  • At what point does a trust running several campuses justify template inheritance? We are deliberately shipping without it and want a real complaint before building it.
  • Does the field registry eventually replace the "student and teacher standardization" track the statutory-fields doc deferred, or do the most common invented fields graduate into built-ins over time? Our current bet is graduation: if every institution invents the same field, that is evidence it should be ours.

Changelog

  • 2026-08-02 — Backend built on feat/erp-form-builder. Migrations erp0029 (six tables), erp0030 (the custom_data JSONB column on student_profiles / employees / applications, with a jsonb_path_ops GIN index), erp0031 (ref_entity_type), and 00076_seed_erp_forms_flag.

Five things the build changed about the design, each found by something trying to use the contract rather than by review:

The custom_data columns did not exist. The epic's entire storage model assumed them and no migration created them. Found by an agent checking its own completion claim against the model file rather than against its earlier report.

entity_ref had no target. A field could say it referenced another record but not which kind, so "sibling" (a student) and "reporting manager" (staff) were indistinguishable once stored and the picker had nothing to search. Surfaced only when a component tried to render the picker. Now a column with a paired CHECK, immutable after create for the same reason key is.

Built-in and custom labels cannot share a representation. Built-ins carry label_key resolved through t() because Kwilo ships and translates them; custom fields carry label_i18n, a locale map the institution typed. So the merge into one ordered field list happens on the client, which has t(), rather than on the server, which would have to invent an i18n map for fields that do not have one. No endpoint returns the merged shape.

Field visibility can cascade. Hiding is_cwsn forces disability_type hidden regardless of its own policy, so a school that switches off disability tracking does not get a stray follow-up question about a child's disability. Resolved server-side; clients must trust is_visible rather than recompute it.

The statutory floor is enforced three times, deliberately — in catalogue defaults, in a write-time guard that 422s before the row is touched, and re-asserted after the merge. The third exists so that tightening the catalogue later cannot be bypassed by an override saved before the change.

Also: document uploads gated off (see Non-goals), and the JSONLogic evaluators are proven equivalent by 56 shared fixture cases run from both languages rather than by assertion — the Python port hand-implements ECMA-262 semantics instead of delegating to Python's own coercion and truthiness. - 2026-08-02 — Reconciled against erp-registration.md, which merged after this doc was drafted and is already built (phases 1–3, PR kwiloai_webapp#1469, migration erp0023). Three corrections. The "no print output" non-goal was wrong — WeasyPrint already renders the registration form and receipt on the hall-ticket pattern, including the office-use block this doc had presented as a novel requirement; what we owe that pipeline is a declaration contract, not a new renderer. The linked-record field type is not retro-fitted onto class applying and stream, which that work deliberately left free text so the desk stays fast. And photo upload, which it named as its first follow-up, is precisely what our upload pipeline and webcam-capable image field deliver — the two features interlock rather than merely coexist. - 2026-08-02 — Reconciled against the ERP docs merged the same day. Three corrections: blood group is a built-in field as of erp-student-standard-fields.md (migration erp0022, PR kwiloai_webapp#1465), so it stopped being the worked example of a custom field; the Aadhaar validator was dropped because that work deliberately rejected raw Aadhaar in favour of APAAR ID and a validator would invite institutions to recreate the liability; and built-in demographics already carry from application to student via StudentProfileCreate, so the carry-forward map narrows to custom answers only. Also established that the policy layer here is the "per-type field visibility" follow-up that doc deferred, which means it has a prior owner's rationale, not just this one. - 2026-08-02 — Doc created. Product Mindset Gate passed. Epic kwiloai_webapp#1476 and fourteen child issues filed. Design reviewed twice before filing; the reviews reversed three earlier decisions (dropped admin-triggered database index creation as an operational risk, adopted a shared rule format with a narrowed operator set for cross-language safety, and promoted configuration export/import from a nice-to-have to a blocker once it became clear starter templates cannot ship without it).