Skip to content

Latest commit

 

History

History
415 lines (341 loc) · 18.9 KB

File metadata and controls

415 lines (341 loc) · 18.9 KB

Scenario check: schema / payload / UI three-way correspondence

Purpose: use one real scenario to check backwards whether the design in design.md is self-consistent. Method: write the payload the user submits first, then the schema that can validate it, then the UI, then compare all three item by item. Wherever they fail to line up is a gap in the design — the last section lists the 8 found this time.

1. The scenario

A corporate event registration form, built by the organizer in a form builder and filled in by attendees. This scenario was chosen because it naturally covers secrets, arrays of objects (containing secrets), cross-field validation, conditional display, and toggle groups — exactly the newest and least-tested parts of the design.

Requirements:

  • Contact details; at least one of email or phone
  • A student ticket requires a student ID upload
  • The departure date must be later than the arrival date
  • Accommodation details only when accommodation is needed; otherwise the whole group does not exist
  • 0–5 companions, each with a name, a meal choice, and a national ID number (personal data, never read back); minors need a guardian
  • The same national ID may not register twice
  • A tax ID is only needed for a company invoice
  • Account creation: password + confirm password

2. Payload (what the user submits)

{
  "values": {
    "full_name": "Mei-Ling Chen",
    "email": "meiling@example.com",
    "phone": "+886912345678",
    "company": "Brobridge",

    "ticket_type": "standard",
    "arrive_at": "2026-11-12",
    "leave_at": "2026-11-14",
    "dietary": ["no-beef", "no-alcohol"],

    "need_accommodation": true,
    "room_type": "twin",
    "nights": 2,
    "special_request": "A high floor would be appreciated",

    "companions": [
      { "$id": "c_8f2a", "name": "Xiao-Ming Wang", "meal": "vegetarian",
        "is_child": false, "room_share": true },
      { "$id": "c_3b71", "name": "Xiao-Hua Wang", "meal": "normal",
        "is_child": true, "guardian": "Xiao-Ming Wang", "room_share": true }
    ],

    "invoice_type": "company",
    "tax_id": "12345678",
    "coupon": "EARLYBIRD"
  },
  "secrets": {
    "password": "hunter2hunter2",
    "password_confirm": "hunter2hunter2",
    "companions.c_8f2a.id_number": "A123456789",
    "companions.c_3b71.id_number": "A234567890"
  }
}

Four things worth noticing, each a direct consequence of the design:

Observation Why
student_id is absent from the payload The ticket type is not student → the field is invisible → not drawn, not sent, not validated, not stored
No group key appears anywhere in the payload Groups affect the UI and validation, never the shape of values. Regrouping does not touch stored data
need_accommodation is an ordinary flat value A toggle field is a real field, a sibling of the group it controls
Every companions entry has a $id The entry contains a secret, so $id is part of the secret key and must be generated by the client

3. Schema

{
  "schema_version": 1,
  "groups": [
    {
      "key": "applicant", "label": "Applicant", "mode": "always",
      "valid_when": [
        { "when": { "any_of": [
            { "field": "email", "is_empty": false },
            { "field": "phone", "is_empty": false } ] },
          "code": "contact_required", "reason": "provide at least one of email or phone" } ],
      "fields": [
        { "name": "full_name", "type": "string", "label": "Full name", "required": true,
          "max_length": 40 },
        { "name": "email", "type": "string", "format": "email", "label": "Email" },
        { "name": "phone", "type": "string", "format": "phone", "label": "Mobile" },
        { "name": "company", "type": "string", "label": "Organization" }
      ]
    },

    {
      "key": "attendance", "label": "Attendance", "mode": "always",
      "fields": [
        { "name": "ticket_type", "type": "enum", "label": "Ticket", "required": true,
          "options": [
            { "value": "standard", "label": "Standard" },
            { "value": "vip",      "label": "VIP" },
            { "value": "student",  "label": "Student" } ] },

        { "name": "student_id", "type": "file", "label": "Student ID",
          "visible_when":  { "field": "ticket_type", "equals": "student" },
          "required_when": { "field": "ticket_type", "equals": "student" },
          "metadata": { "accept": ["image/*", "application/pdf"], "max_size": 5242880 } },

        { "name": "arrive_at", "type": "datetime", "format": "date",
          "label": "Arrival", "required": true },

        { "name": "leave_at", "type": "datetime", "format": "date",
          "label": "Departure", "required": true,
          "valid_when": [
            { "when": { "field": "leave_at", "gt_field": "arrive_at" },
              "code": "leave_before_arrive",
              "reason": "departure must be later than arrival" } ] },

        { "name": "dietary", "type": "list", "label": "Dietary restrictions",
          "items": { "type": "string", "max_length": 20 }, "max_items": 8 }
      ]
    },

    {
      "key": "accommodation", "label": "Accommodation", "mode": "toggleable",
      "toggle": { "field": "need_accommodation", "label": "Book a room for me",
                  "default": false },
      "fields": [
        { "name": "room_type", "type": "enum", "label": "Room type", "required": true,
          "options": [
            { "value": "single", "label": "Single" },
            { "value": "twin",   "label": "Twin" } ] },
        { "name": "nights", "type": "integer", "label": "Nights",
          "required": true, "min": 1, "max": 7 },
        { "name": "special_request", "type": "string", "format": "textarea",
          "label": "Special requests", "max_length": 200 }
      ]
    },

    {
      "key": "companions_group", "label": "Companions", "mode": "always",
      "fields": [
        { "name": "companions", "type": "object_list", "label": "Companions",
          "max_items": 5, "unique": ["id_number"],
          "item_label": "{name}", "layout": "card",
          "fields": [
            { "name": "name", "type": "string", "label": "Name", "required": true },

            { "name": "id_number", "type": "secret", "label": "National ID",
              "required": true, "pattern": "^[A-Z][12][0-9]{8}$" },

            { "name": "meal", "type": "enum", "label": "Meal", "default": "normal",
              "options": [
                { "value": "normal",     "label": "Standard" },
                { "value": "vegetarian", "label": "Vegetarian" } ] },

            { "name": "is_child", "type": "boolean", "label": "Under 18",
              "default": false },

            { "name": "guardian", "type": "string", "label": "Guardian's name",
              "visible_when":  { "field": "is_child", "is_true": true },
              "required_when": { "field": "is_child", "is_true": true } },

            { "name": "room_share", "type": "boolean", "label": "Shares a room",
              "visible_when": { "field": "$.need_accommodation", "is_true": true } }
          ] }
      ]
    },

    {
      "key": "payment", "label": "Payment and invoice", "mode": "always",
      "fields": [
        { "name": "amount", "type": "decimal", "label": "Amount due",
          "readonly": true, "min": 0 },

        { "name": "invoice_type", "type": "enum", "label": "Invoice type",
          "required": true, "default": "personal",
          "options": [
            { "value": "personal", "label": "Personal" },
            { "value": "company",  "label": "Company" } ] },

        { "name": "tax_id", "type": "string", "label": "Tax ID",
          "pattern": "^[0-9]{8}$",
          "visible_when":  { "field": "invoice_type", "equals": "company" },
          "required_when": { "field": "invoice_type", "equals": "company" } },

        { "name": "coupon", "type": "string", "label": "Coupon code" }
      ]
    },

    {
      "key": "account", "label": "Create an account", "mode": "collapsible",
      "default_collapsed": false,
      "fields": [
        { "name": "password", "type": "secret", "label": "Password",
          "required": true, "min_length": 8 },

        { "name": "password_confirm", "type": "secret", "label": "Confirm password",
          "required_when": { "not": { "field": "password", "is_empty": true } },
          "transient": true,
          "valid_when": [
            { "when": { "field": "password_confirm", "equals_field": "password" },
              "code": "password_mismatch",
              "reason": "the two passwords do not match" } ] }
      ]
    }
  ]
}

4. The UI

+- Applicant --------------------------------------+   group.mode = always
| Full name *     [Mei-Ling Chen                 ] |
| Email           [meiling@example.com           ] |
| Mobile          [+886912345678                 ] |
| Organization    [Brobridge                     ] |
| ! provide at least one of email or phone         |  <- group.valid_when lands here
+--------------------------------------------------+

+- Attendance -------------------------------------+
| Ticket *        (o) Standard ( ) VIP ( ) Student |   enum -> radio (few options)
|                                                  |
| ... the Student ID field does not appear ...     |   visible_when does not hold
|                                                  |
| Arrival *       [2026-11-12  v]                  |   datetime + format:date
| Departure *     [2026-11-14  v]                  |
| Dietary         [no-beef x] [no-alcohol x] [ + ] |   list -> chips input
+--------------------------------------------------+

+- Accommodation ------------- [ o] Book a room ---+   toggle drawn in the header
| Room type *     ( ) Single  (o) Twin             |   <- body drawn only when on
| Nights *        [  2 ] ^v                        |
| Special         +-----------------------------+  |
| requests        | A high floor would be       |  |   format:textarea
|                 | appreciated                 |  |
|                 +-----------------------------+  |
+--------------------------------------------------+

+- Companions -------------------------------------+   object_list, layout:card
| +- Xiao-Ming Wang ------------------------ [x] + |   item_label = "{name}"
| | Name *          [Xiao-Ming Wang            ] | |
| | National ID *   [**********                ] | |   secret -> masked
| | Meal            [Vegetarian          v]      | |
| | Under 18        [ ] no                       | |
| | ... guardian does not appear ...             | |   same-level visible_when
| | Shares a room   [x]                          | |   $.need_accommodation
| +----------------------------------------------+ |
| +- Xiao-Hua Wang ------------------------- [x] + |
| | Name *          [Xiao-Hua Wang             ] | |
| | National ID *   [**********                ] | |
| | Meal            [Standard            v]      | |
| | Under 18        [x] yes                      | |
| | Guardian *      [Xiao-Ming Wang            ] | |   <- appears once it holds
| | Shares a room   [x]                          | |
| +----------------------------------------------+ |
| [ + Add a companion ]                  2 / 5     |   max_items = 5
+--------------------------------------------------+

+- Payment and invoice ----------------------------+
| Amount due      NT$ 6,000                        |   readonly -> text, not an input
| Invoice type *  ( ) Personal   (o) Company       |
| Tax ID *        [12345678                      ] |   <- appears once it holds
| Coupon code     [EARLYBIRD                     ] |
+--------------------------------------------------+

+- Create an account --------------------------[v]-+   collapsible
| Password *        [**************              ] |
| Confirm password *[**************              ] |
+--------------------------------------------------+

5. The correspondence

5.1 Structure

Schema Payload UI
Schema the whole value document the whole form
Group absent one section (heading + border)
Field one key in values[name] one input widget
Field.type = secret secrets[name] masked input
Field.type = object_list values[name] = an array add/removable cards or table
a Field inside an object_list one key of an array element one input inside the card
type = secret inside an entry secrets["<list>.<$id>.<name>"] masked input inside the card
Group.toggle.field values[<toggle.field>] a switch in the group header

Groups not appearing in the payload is the key property: the value namespace is flat (object_list opens a nested one), so when the organizer drags a field from group A to group B in the builder, stored data is untouched. The cost is that field names must be globally unique; a group is not a scope.

5.2 Type → widget

type / format UI
string single-line input
string + textarea multi-line
integer / number numeric input with a stepper
decimal numeric input with thousands separators; plain text when readonly
boolean checkbox (standalone) / switch (as a group toggle)
enum radio for ≤4 options, select beyond that; multiple for multi-select
secret masked input; an "already set" placeholder once it has a value
datetime + date date picker
file upload area; metadata.accept feeds the file picker
list chips input
map key/value list (unused in this scenario)
object_list layout: card → stacked cards; table → table rows

5.3 Validation → feedback

Schema Error payload UI
required {path:"full_name", code:"required"} under that input
pattern {path:"tax_id", code:"pattern"} under that input
field valid_when {path:"leave_at", code:"leave_before_arrive"} under Departure
group valid_when {path:"applicant", group:"applicant", code:"contact_required"} at the bottom of the group
a field inside an entry {path:"companions[1].guardian", item_path:"companions.c_3b71", code:"required"} inside the second card
unique {path:"companions", code:"duplicate"} at the list level
max_items {path:"companions", code:"max_items"} disable the "add" button

path uses the index (companions[1]) so the client can find the right card; item_path uses the $id path so it still finds the right one after the list is reordered, and so it can be matched directly against secrets_set. Both are provided.

6. The 8 gaps this stress test found

Every one of these surfaced only because the three sides failed to line up. All have been folded back into design.md.

6.1 Which group does toggle.field belong to?

The original design said it is "a real field, present in values", and that Compile should check it "exists and is boolean" — but where it exists was undefined. Requiring it to appear in some group's fields would make the UI draw it twice (once as the header switch, once as a field in the body), and the payload could not say which group it belongs to.

Fix: toggle implicitly defines the field; it need not — and may not — be declared again in any fields. Its type is fixed to boolean, and label and default live inside toggle. What Compile checks is that it does not collide with another field name.

6.2 No way to express "always read-only"

Field had Required bool and RequiredWhen, but read-only had only ReadOnlyWhen. "The amount due is always read-only" could only be forced through an always-true condition. Fix: add ReadOnly bool, symmetric with Required.

6.3 "Confirm password" ends up stored in secrets

password_confirm exists for validation and should never reach the storage layer — but it is a secret type, so Validate leaves it in the secrets container and the caller has to remember to delete it. "The caller has to remember" is a design defect, the same class of problem the secrets container exists to solve.

Fix: add Transient bool. It is removed from the returned document after validation passes (it still exists during validation, or equals_field would have nothing to compare).

6.4 An object_list holding secrets needs a client-generated $id

The original design said "if not supplied, Validate fills one in". But the payload's secret key is companions.<$id>.id_numberthe client must know the id before it can write that key, so filling it in on the server is too late.

Fix: for an object_list whose entries contain a secret, every entry in the payload must carry a $id; missing it is a missing_item_id error. Only entries without secrets get one filled in by Validate.

6.5 Where a readonly field's value comes from at creation time

amount is read-only, so the client does not send it. At first creation current is empty, so Apply has no old value to keep.

Fix: state explicitly that a readonly field's value has exactly two sources — Default, or the caller writing it in after Validate. The library does not do computed fields (there is no expression language). This is a boundary, and it belongs in the documentation rather than being something users run into.

6.6 required duplicates a meaning on containers

If required on a list / object_list means "at least one entry", it says the same thing as min_items. Fix: containers do not use required, only min_items; Compile rejects required on a container outright rather than quietly picking one.

6.7 Comparison operators need to be type-aware

leave_at gt_field arrive_at compares dates; max gte_field min compares numbers. The original design never said how gt_field compares. Fix: comparison follows the field's Typedatetime along the time axis, number / integer / decimal numerically, string lexicographically, and every other type rejects ordering operators (Compile errors).

6.8 unique on a secret field leaks the value

unique: ["id_number"] is a real requirement here (the same ID may not register twice), and id_number is a secret. Fix: allow it, but a unique error must not contain the duplicated value — only path and item_path. This needs explicit handling in the implementation, or the default "value X is duplicated" message becomes a leak.

7. What this scenario does not cover

Recorded honestly, so nobody assumes the design has been fully verified:

  • The map, number, and json types
  • The ^. (one level out) scope prefix — this scenario has only one level of object_list, so only $. (root) is exercised
  • Nested object_list
  • label_i18n (this scenario assumes a single locale)
  • The Apply patch path — this document only verifies first creation. Only "editing existing data" stresses secret-empty-string retention, $id alignment, and secrets staying aligned after an entry is deleted → picked up by example-pipeline.md, which found a design bug there that would have caused data loss.