Survey JSON Shapes v1
Survey JSON Shapes v1
Purpose
This document defines the current JSON shapes for:
- questions
- answers / stored response payloads
- rules
- scoring
It also makes the expected request body shape explicit for creation endpoints.
Scoring is intentionally left blank for now.
1. Questions
Purpose
Questions are stored as JSON in question_schema.
Each question follows one shared outer shape:
familylabelschemaui
family determines the inner schema.
Stored JSON shape (question_schema)
{
"label": "...",
"family": "...name...",
"...name...": {
"schema": {},
"ui": {}
}
}
Families
Choice
{
"label": "Select your hobbies",
"family": "choice",
"choice": {
"schema": {
"options": [
{ "id": "A", "label": "Reading" },
{ "id": "B", "label": "Traveling" },
{ "id": "C", "label": "Cooking" }
],
"min_selected": 1,
"max_selected": 3
},
"ui": {
}
}
}
Field
{
"label": "What is your email address?",
"family": "field",
"field": {
"schema": {
"field_type": "email"
},
"ui": {
"placeholder": "name@example.com"
}
}
}
Matching
{
"label": "Match each country to its capital city",
"family": "matching",
"matching": {
"schema": {
"prompts": [
{ "id": "p_A", "label": "Australia" },
{ "id": "p_B", "label": "France" }
],
"matches": [
{ "id": "m_A", "label": "Canberra" },
{ "id": "m_B", "label": "Paris" },
{ "id": "m_C", "label": "Madrid" }
]
},
"ui": {
}
}
}
Rating
{
"label": "How satisfied are you?",
"family": "rating",
"rating": {
"schema": {
"range": {
"min": -5,
"max": 5
},
"left_label": "Not satisfied",
"right_label": "Very satisfied"
},
"ui": {
"style": "slider",
"step": 1
}
}
}
Create question request body
{
"question_key": "q_favourite_colour",
"question_schema": {
"family": "choice",
"label": "What is your favourite colour?",
"schema": {
"options": [
{ "id": "a1", "label": "Red" },
{ "id": "a2", "label": "Blue" }
],
"min_selected": 1,
"max_selected": 1
},
"ui": {
"style": "radio"
}
}
}
Constraints
question_schemamust be a JSON object.question_schemamust contain the shared top-level shape:family,label,schema,ui.familymust be one of:choice,field,matching,rating.labelis the human-readable question text.schemacontains family-specific structure and validation rules.uicontains presentation details only.question_keymust be unique per survey version.- Backend validation should enforce deeper family-specific rules such as:
- valid option arrays for choice questions
- valid
field_typefor field questions - valid left/right item arrays for matching questions
- valid
min/maxfor rating questions
2. Answers / Stored response payloads
Purpose
Each stored answer row keeps question_key, answer_family, and answer_value outside the JSON.
The JSON stored in answer_value is only the family-specific payload.
Base rule
There is no wrapper object inside answer_value.
Do not duplicate:
question_keyanswer_family- metadata
inside answer_value.
Stored JSON shapes (answer_value)
Choice
{
"selected": ["a2"]
}
Field
{
"value": "name@example.com"
}
Matching
{
"matches": [
{ "left_id": "c1", "right_id": "r1" }
]
}
Rating
{
"value": 4
}
Submission request body
This is the request body shape used to create a submission.
{
"is_anonymous": false,
"started_at": "2026-04-08T21:00:00Z",
"submitted_at": "2026-04-08T21:02:00Z",
"answers": [
{
"question_key": "q_favourite_colour",
"answer_family": "choice",
"answer_value": {
"selected": ["a2"]
}
},
{
"question_key": "q_email",
"answer_family": "field",
"answer_value": {
"value": "name@example.com"
}
},
{
"question_key": "q_match_capitals",
"answer_family": "matching",
"answer_value": {
"matches": [
{ "left_id": "c1", "right_id": "r1" }
]
}
},
{
"question_key": "q_satisfaction",
"answer_family": "rating",
"answer_value": {
"value": 4
}
}
],
"metadata": {}
}
Constraints
answer_valuemust be a JSON object.answer_familymust determine the allowed JSON shape.- Formats must not be mixed across families.
choiceuses{ "selected": [...] }.fielduses{ "value": ... }.matchinguses{ "matches": [...] }.ratinguses{ "value": number }.- IDs in answers must match IDs defined by the related question schema.
choice.selectedis always an array, even for single-select questions.rating.valuemust fit within the related question’smin/max.metadatais request-level metadata, not answer-level payload.
3. Rules
Purpose
Rules define declarative browser-side survey behaviour.
They are not trigger-based.
Each rule describes:
- a
target - a
condition - one or more
effects
The browser evaluates rules against current answer state while the user is filling out the survey.
Stored JSON shape (rule_schema)
{
"target": "q3",
"condition": {},
"effects": {}
}
Current project extension
We also allow:
{
"target": "q3",
"sort_order": 20,
"condition": {},
"effects": {}
}
sort_order is used for deterministic conflict resolution.
Create rule request body
{
"rule_key": "show_q3_when_q1_is_yes",
"rule_schema": {
"target": "q3",
"sort_order": 20,
"condition": {
"fact": "answers.q1",
"operator": "equals",
"value": "yes"
},
"effects": {
"visible": true
}
}
}
Condition shapes
Simple
{
"fact": "answers.q1",
"operator": "equals",
"value": "a2"
}
Grouped AND
{
"all": [
{
"fact": "answers.q1",
"operator": "equals",
"value": "a2"
},
{
"fact": "answers.q2",
"operator": "contains_all",
"value": ["a2", "a4"]
}
]
}
Grouped OR
{
"any": [
{
"fact": "answers.q1",
"operator": "equals",
"value": "a1"
},
{
"fact": "answers.q1",
"operator": "equals",
"value": "a2"
}
]
}
NOT
{
"not": {
"fact": "answers.q5",
"operator": "is_answered"
}
}
Supported facts
For v1, facts use:
answers.<question_key>
Examples:
answers.q1answers.q2answers.q_favourite_colour
Supported operators
equalsnot_equalsis_answeredis_emptycontainscontains_anycontains_allgtgteltltebetween
Effects shape
{
"visible": false,
"required": false,
"disabled": true
}
Supported effect keys:
visiblerequireddisabled
Constraints
rule_schemamust be a JSON object.- Required top-level keys are:
targetconditioneffects
- Optional top-level key:
sort_order
targetmust be a non-empty string.conditionmust be an object.effectsmust be an object.sort_order, if present, must be numeric.- Recursive condition validation belongs mainly in the app layer, not deep SQL checks.
- Effects should contain only supported v1 effect keys.
- Current conflict rule: evaluate matching rules in ascending
sort_order; later matching rules override earlier ones for the effect keys they set. - If
sort_orderis omitted, treat it as0.
4. Scoring
Status
Defined for v1.
Intended create request body wrapper
{
"scoring_key": "score_q_satisfaction",
"scoring_schema": {
"target": "q_satisfaction",
"bucket": "total",
"condition": null,
"strategy": "rating_direct",
"config": {
"multiplier": 1
}
}
}
Scoring shape
Each scoring rule follows this shape:
{
"target": "q_satisfaction",
"bucket": "total",
"condition": null,
"strategy": "rating_direct",
"config": {}
}
Fields:
target: the question key being scoredbucket: the score bucket this rule contributes to, such astotalorriskcondition: optional extra condition using the same condition shape as normal rules; if false, the scoring rule contributes nothingstrategy: the scoring method used for the target questionconfig: strategy-specific scoring configuration
Recommended v1 strategies
Choice: choice_option_map
{
"target": "q_favourite_colour",
"bucket": "total",
"condition": null,
"strategy": "choice_option_map",
"config": {
"option_scores": {
"a1": 0,
"a2": 2,
"a3": 5
},
"combine": "sum"
}
}
Notes:
- Reads
answer_value.selected - Maps selected option IDs to points
combineshould besumormaxin v1
Matching: matching_answer_key
{
"target": "q_match_capitals",
"bucket": "total",
"condition": null,
"strategy": "matching_answer_key",
"config": {
"correct_pairs": [
{ "left_id": "c1", "right_id": "r1" },
{ "left_id": "c2", "right_id": "r2" }
],
"points_per_correct": 1,
"penalty_per_incorrect": 0,
"max_score": 2
}
}
Notes:
- Reads
answer_value.matches - Compares submitted matches to
correct_pairs - Awards points per correct pair
- May apply penalty and max score
Rating: rating_direct
{
"target": "q_satisfaction",
"bucket": "total",
"condition": null,
"strategy": "rating_direct",
"config": {
"multiplier": 1
}
}
Notes:
- Reads
answer_value.value - Multiplies the rating by
multiplier
Field: field_numeric_ranges
{
"target": "q_years_experience",
"bucket": "total",
"condition": null,
"strategy": "field_numeric_ranges",
"config": {
"ranges": [
{ "min": 0, "max": 1, "score": 1 },
{ "min": 2, "max": 5, "score": 3 },
{ "min": 6, "max": 100, "score": 5 }
]
}
}
Notes:
- Intended only for numeric field questions in v1
- Reads
answer_value.value - Matches the value against configured ranges
- Returns the score for the matching range
Constraints
scoring_schemashould be a JSON object- Required top-level keys:
targetbucketstrategyconfig
- Optional top-level key:
condition
targetshould reference a valid question keybucketis a string label for score groupingstrategymust be one of the supported v1 strategiesconfigmust match the selected strategycondition, if present, should use the same condition shape as rules- Scoring is backend-calculated
- Scoring rules contribute points only; they do not change survey behaviour
Notes
- Keep scoring simpler than normal rules
- Reuse the current question families and answer payload shapes
- Avoid arbitrary formulas in v1
- Avoid frontend-calculated scoring in v1
- Keep evaluation deterministic and easy to debug
Summary
Create question request
{
"question_key": "...",
"question_schema": {}
}
Create submission request
{
"is_anonymous": false,
"started_at": null,
"submitted_at": null,
"answers": [],
"metadata": {}
}
Create rule request
{
"rule_key": "...",
"rule_schema": {}
}
Create scoring rule request
{
"scoring_key": "...",
"scoring_schema": {
"target": "...",
"bucket": "...",
"condition": null,
"strategy": "...",
"config": {}
}
}
This is the current v1 contract.
Questions, answers, rules, and scoring are now defined at the structure level.
Scoring strategy details are intentionally kept simple for backend evaluation.