Building a record type's field schema¶
An internal-form record type collects structured data — a cleaning log, a visitor register, a calibration entry. What fields it collects is described by a small piece of JSON called the field schema. This page is the complete guide to writing one, with a worked example for every field type.
You don't need to be a programmer — and you don't have to write JSON at all.
Prefer the visual builder
The record-type page has two tabs over the same draft. Build is a drag-and-drop field list: add a field, give it a label, pick its type, drag it (or use the ▲/▼ buttons) into the order you want. JSON is the same draft written as a field schema, for hand-tuning it or pasting one in from somewhere else. Switch between them freely — each shows the other's edits, and the live preview on the right renders the real form either way.
This page is for the JSON tab. Read it when you want to check exactly what the builder produced, paste a schema in from elsewhere, or use a construct the builder keeps but does not edit (it shows those as Advanced).
Let the AI assistant write it for you
You can describe the form in plain English to the QStack AI assistant and have it build the record type for you — see Generate a record type from a description at the end. This page still matters: it's how you read, check and tweak what the assistant produced.
The skeleton¶
Every field schema has the same outer shape:
"type": "object"— always this. A record is one filled-in form."properties"— the fields, one entry each. This is where your work goes."required"— the names of the fields a user must fill before submitting. Optional; leave it as[]if nothing is mandatory.
A field goes inside properties as "field_name": { …how it behaves… }:
{
"type": "object",
"properties": {
"equipment_id": { "type": "string", "title": "Equipment ID" }
},
"required": ["equipment_id"]
}
The field name (equipment_id) is the internal key — lowercase, no spaces,
use underscores. The title is the human label shown on the form. Always give
a title; without one QStack falls back to a tidied-up version of the name.
The field types¶
QStack deliberately supports a small, dependable set of field types. Here is every one, with the JSON that produces it.
| You want | Use |
|---|---|
| A short text box | {"type": "string"} |
| A long text area | {"type": "string", "format": "textarea"} |
| A date picker | {"type": "string", "format": "date"} |
| A dropdown (pick one) | {"type": "string", "enum": ["A", "B", "C"]} |
| A whole number | {"type": "integer"} |
| A decimal number | {"type": "number"} |
| A yes/no checkbox | {"type": "boolean"} |
| A file attachment | {"type": "string", "format": "file"} |
| A value the system fills in | {"type": "string", "x-server": "today"} (see below) |
Text field¶
Cap the length with maxLength:
Long text (textarea)¶
Date¶
Renders as a native date picker (<input type="date">):
Dropdown (choose one)¶
List the allowed choices in enum. The stored value is exactly the text you
list:
Whole number and decimal¶
"page_number": { "type": "integer", "title": "Page number" }
"ph_reading": { "type": "number", "title": "pH reading" }
Bound them with minimum / maximum:
Yes/no checkbox¶
A boolean listed in required must be ticked to submit — use that for an
attestation checkbox. An optional boolean is just a normal checkbox.
File attachment¶
An internal form can collect uploaded files alongside its typed fields — a calibration certificate, a photo of the equipment, a supplier's report:
One such field holds one or more files, not just one. Add several fields if you want the evidence kept apart:
"calibration_cert": { "type": "string", "format": "file", "title": "Calibration certificate" },
"photos": { "type": "string", "format": "file", "title": "Photos" }
Where it appears. A file field is not a box inside the form. It gets its own Attachments section further down the record page, with one upload slot per file field. Create the record, then attach files there — you can go on editing the form fields and attaching files in any order while the record is Open.
Attach files one at a time: pick a file, press Attach, and repeat for the next one. Each attached file is listed with its filename, size, SHA-256 checksum, and who uploaded it. Remove takes one off again. Once the record leaves Open, the attachment list is frozen along with the rest of the record — no more attaching or removing.
Because the attachment list is part of the record's data, it is signed, frozen and audit-trailed exactly like every other field, and filenames are picked up by record search.
File fields don't show in the preview
Test schema renders the form fields only, so a file field won't appear in the preview panel. That's expected — it isn't a form input. You'll see its upload slot on the first record you create.
Listing a file field in required means at least one file must be attached.
As with the other fields, that's checked when the record is submitted, not while
you fill it in:
{
"type": "object",
"properties": {
"instrument_id": { "type": "string", "title": "Instrument ID" },
"calibration_cert": { "type": "string", "format": "file", "title": "Calibration certificate" }
},
"required": ["instrument_id", "calibration_cert"]
}
Link to other records¶
A record often refers to other records — a deviation that names the cleaning log it found a problem in, a batch release that lists its CoAs. A record link field collects those references as part of the form:
"related_records": {
"type": "array",
"format": "record",
"items": { "type": "string" },
"title": "Related records"
}
(In the visual builder, pick Record link as the field type.)
The person filling the record types one or more record IDs, separated by
commas — the record number such as CLEAN-0001, in any case. Every ID is
checked when the form is saved: an ID that doesn't match an existing record is an
error, so a typo can't slip through. The record page then shows each one as a
link, and the linked record lists this one under Linked from.
Because the field is part of the form, its links are signed and frozen with the rest of the record. For a link that is just context — added later, or to a record that isn't a form — use the Linked records box on the record page instead; those can be added and removed at any status.
Not inside a table
A record link can't be a table column. Use a separate record link field next to the table.
Labels and help text¶
titleis the field's label.descriptionadds a line of help text under the field.
"cleaning_agent": {
"type": "string",
"title": "Cleaning agent",
"description": "The detergent or solvent used, e.g. 70% IPA."
}
Making fields mandatory¶
List the field names (not their titles) in required:
{
"type": "object",
"properties": {
"equipment_id": { "type": "string", "title": "Equipment ID" },
"cleaned_on": { "type": "string", "format": "date", "title": "Cleaning date" },
"cleaning_agent": { "type": "string", "title": "Cleaning agent" }
},
"required": ["equipment_id", "cleaned_on"]
}
Here the agent is optional; the equipment and date are not.
Field order¶
By default fields appear in the order you wrote them. To fix a specific order (handy after editing), set the Field order box — a JSON list of the field names:
Any field you leave out of the list still appears, after the ones you named.
Fields the system fills in¶
Some values shouldn't be typed by the user — the date the record was made, or
who made it. Mark those with x-server. They render disabled, and QStack
fills them at submission time, so they can't be faked:
x-server value |
QStack fills in |
|---|---|
"today" |
The date the record is created |
"person" |
The originator's name |
"performed_by": { "type": "string", "title": "Performed by", "x-server": "person" }
"logged_on": { "type": "string", "title": "Logged on", "x-server": "today" }
Never put an x-server field in required — the user can't fill it, so QStack
supplies it for you regardless.
A complete worked example¶
An Equipment Cleaning Log, end to end:
{
"type": "object",
"properties": {
"equipment_id": { "type": "string", "title": "Equipment ID", "maxLength": 50 },
"cleaned_on": { "type": "string", "format": "date", "title": "Cleaning date" },
"cleaning_agent": { "type": "string", "title": "Cleaning agent" },
"within_spec": { "type": "boolean", "title": "Visually clean and dry" },
"performed_by": { "type": "string", "title": "Performed by", "x-server": "person" },
"comments": { "type": "string", "format": "textarea", "title": "Comments" }
},
"required": ["equipment_id", "cleaned_on", "cleaning_agent"]
}
Field order:
Paste that into the editor, press Test schema, and you'll see the cleaning log render on the right.
Testing your schema¶
The editor validates continuously. When something is off you'll get a plain message pointing at the problem — a missing comma, an unknown field type, a misplaced bracket. Common ones:
- "not valid JSON" — usually a missing comma between fields, or a trailing
comma after the last one. JSON does not allow a comma before a closing
}. - "Unsupported field type" — you used a
typeoutside the supported set above (for example"array"). Pick one of the supported types. - "Add at least one field" —
propertiesis empty. A record type needs at least one field before it can be published.
Nothing is saved until you press Save, and no version exists until you Publish — so experiment freely.
What a field schema can't do¶
Keeping the set small keeps records dependable and searchable. Not supported:
- Repeating rows / tables (a variable-length list of line items). For now, either add a fixed set of fields, attach the tabular evidence to a file field, or capture it as an external upload.
- Nested sections (an object inside a field). Keep fields flat.
- Conditional fields (show B only if A is ticked). Every field always shows.
- References between schemas (
$ref,allOf). Write each type out in full.
If you need one of these, capture the evidence as an external-document record type instead, or talk to your QStack administrator.
Versioning — why editing is safe¶
Editing a field schema only touches the draft. Records already in flight keep the exact schema version they were created under and render against it forever. Your edit becomes a new immutable version only when you Publish (or when the governing Form document becomes Effective). So you can correct a schema without disturbing a single existing record. See Quality Records for the full versioning rules.
Generate a record type from a description¶
If you use the QStack AI assistant (or another MCP client — see API & AI-tool access), you can describe the form in words:
"Create an internal record type called Fridge Temperature Log, code FRIDGE, with fields: fridge ID, date (auto), temperature in °C, within-range yes/no, and a comments box. Temperature and fridge ID are required."
The assistant turns that into a field schema using exactly the types on this
page, checks it, and creates the record type (via the create_record_type
tool). You then open it here to review, tweak the schema, and publish — the
review-and-publish step stays with a human.