10 KiB
Validation
This document is the frontend guide for the validation system.
The important idea: reusable validation is built from rules and sets.
The frontend creates and manages those. When a set is applied to a table field,
the server resolves it into the existing FieldValidation shape, and the form
runtime continues to work through the normal table-validation flow.
Ownership
flowchart LR
Core[validation-core<br/>validation meaning<br/>rule/set merge rules]
Server[server<br/>stores rules/sets<br/>applies sets<br/>enforces writes]
Common[common/proto<br/>gRPC contract]
Client[client/frontend<br/>rule/set UI<br/>calls gRPC]
Canvas[canvas<br/>field editing<br/>mask display<br/>local feedback]
Server --> Core
Canvas --> Core
Client --> Common
Server --> Common
Client --> Canvas
server stores simple serializable settings. validation-core owns how those
settings combine. canvas uses resolved field validation to guide editing.
Terms
| Term | Meaning |
|---|---|
FieldValidation |
Existing per-column validation config from common/proto/table_validation.proto. This is what forms/canvas already consume. |
ValidationRule |
One named reusable fragment, for example digits-only, phone-length, or required. Stored by the server as a FieldValidation fragment with no meaningful dataKey. |
ValidationSet |
Ordered collection of rule names, for example phone = [required, phone-length, digits-only, phone-mask]. |
| Applied validation | A resolved snapshot of a set written to table_validation_rules for a concrete (table, dataKey). |
| Snapshot | Applying a set copies the resolved config to a field. Later edits to the set do not automatically update fields that were already applied. |
What Backend Enforces
Backend write validation enforces only server-relevant parts:
| FieldValidation part | Backend | Canvas/frontend |
|---|---|---|
required |
Yes | Yes |
limits |
Yes | Yes |
pattern |
Yes | Yes |
allowed_values |
Yes | Yes |
mask |
Partly: raw value length/literals | Yes: display/editing mask |
formatter |
No | Yes |
external_validation_enabled |
No | Yes/UI hint |
mask is visual metadata, but the backend still uses it to reject incorrectly
submitted raw values. Example: if the mask is (###) ###-####, the backend
expects the stored value to be raw digits, not (123) 456-7890.
Main User Flow
sequenceDiagram
participant UI as Frontend UI
participant API as TableValidationService
participant DB as Server DB
participant Form as Existing Form Runtime
UI->>API: UpsertValidationRule(required)
UI->>API: UpsertValidationRule(digits-only)
UI->>API: UpsertValidationRule(phone-length)
UI->>API: UpsertValidationSet(phone: [required, phone-length, digits-only])
UI->>API: ApplyValidationSet(profile, table, dataKey, phone)
API->>DB: write resolved FieldValidation snapshot
Form->>API: GetTableValidation(profile, table)
API->>Form: resolved FieldValidation for dataKey
After ApplyValidationSet, the existing form code does not need to know that a
set was used. It receives normal FieldValidation.
API
All APIs live on TableValidationService.
Rules
Create or update one reusable rule:
UpsertValidationRule(UpsertValidationRuleRequest)
Request shape:
profileName: string
rule:
name: string
description: optional string
validation: FieldValidation
Frontend rules:
rule.nameis required and unique inside a profile.rule.validation.dataKeyis ignored by the server.- A rule should usually configure one logical fragment.
- Examples:
required,phone-length,digits-only,phone-mask.
List rules:
ListValidationRules({ profileName })
Delete rule:
DeleteValidationRule({ profileName, name })
Deleting a rule removes it from future reusable composition. Already applied field snapshots are not changed.
Sets
Create or update one reusable set:
UpsertValidationSet(UpsertValidationSetRequest)
Request shape:
profileName: string
set:
name: string
description: optional string
ruleNames: repeated string
Frontend rules:
set.nameis required and unique inside a profile.ruleNamesmust contain at least one rule.ruleNamesare ordered.- Every rule name must already exist.
- Duplicate rule names in the same set are rejected.
- Conflicting singleton fragments are rejected.
Singleton fragments are:
limits
allowed_values
mask
formatter
That means a set cannot currently contain two rules that both define limits.
Pattern rules are additive: multiple rules with pattern are merged into one
combined pattern.
List sets:
ListValidationSets({ profileName })
Response includes each set plus resolvedValidation, so the frontend can show
what the set expands to.
Delete set:
DeleteValidationSet({ profileName, name })
Deleting a set does not change already applied fields.
Apply Set To Field
Apply a reusable set to one field:
ApplyValidationSet(ApplyValidationSetRequest)
Request shape:
profileName: string
tableName: string
dataKey: string
setName: string
Server behavior:
- Loads the set.
- Loads its ordered rules.
- Resolves/merges them through
validation-core. - Validates that
dataKeyexists in the table definition. - Writes the resolved config into existing
table_validation_rules.
This is a snapshot. If the user later edits the phone set, fields that already
used phone keep their old resolved config until the set is applied again.
FieldValidation Guide
Rules and direct field validation both use FieldValidation.
Required
required: true
Backend rejects missing or empty values.
Limits
limits:
min: 10
max: 10
warnAt: optional
countMode: CHARS | BYTES | DISPLAY_WIDTH
Backend enforces min and max. warnAt is mainly UI feedback.
Pattern
Pattern rules validate characters at positions.
Example digits-only:
pattern:
rules:
- position:
kind: PATTERN_POSITION_FROM
start: 0
constraint:
kind: CHARACTER_CONSTRAINT_NUMERIC
Useful constraints:
CHARACTER_CONSTRAINT_ALPHABETIC
CHARACTER_CONSTRAINT_NUMERIC
CHARACTER_CONSTRAINT_ALPHANUMERIC
CHARACTER_CONSTRAINT_EXACT
CHARACTER_CONSTRAINT_ONE_OF
CHARACTER_CONSTRAINT_REGEX
Pattern fragments from multiple rules are merged.
Allowed Values
allowed_values:
values: ["open", "closed"]
allow_empty: false
case_insensitive: true
Backend rejects values not in the list.
Mask
mask:
pattern: "(###) ###-####"
input_char: "#"
template_char: "_"
Canvas uses this for display/editing. Backend expects raw values without mask literals.
Formatter
formatter:
type: "PhoneFormatter"
options: []
description: optional
Formatter is resolved client-side. Backend stores it but does not execute it.
External Validation
external_validation_enabled: true
This is a frontend/UI hint. Backend stores it but does not perform external validation.
Recommended Frontend Screens
Rule List
Show all rules for a profile.
Actions:
create rule
edit rule
delete rule
preview rule config
Rule Editor
Build a ValidationRuleDefinition.
Recommended UI:
name
description
required toggle
limits section
pattern section
allowed values section
mask section
formatter section
external validation toggle
For v1, encourage one fragment per rule. Example: create phone-length and
digits-only separately, instead of one huge rule.
Set List
Show all sets for a profile.
Use ListValidationSets, because it returns resolvedValidation.
Actions:
create set
edit set
delete set
preview resolved validation
Set Editor
Build a ValidationSetDefinition.
Recommended UI:
name
description
ordered rule picker
resolved preview
When rule ordering changes, call UpsertValidationSet and then refresh
ListValidationSets.
Apply Set
On the table/field validation screen, add:
Apply validation set
Flow:
- Load sets with
ListValidationSets. - User selects a set.
- Call
ApplyValidationSet(profileName, tableName, dataKey, setName). - Refresh
GetTableValidation(profileName, tableName).
The field should now behave exactly like a directly configured field validation.
Example: Phone
Create rule required:
validation:
required: true
Create rule phone-length:
validation:
limits:
min: 10
max: 10
countMode: CHARS
Create rule digits-only:
validation:
pattern:
rules:
- position:
kind: PATTERN_POSITION_FROM
start: 0
constraint:
kind: CHARACTER_CONSTRAINT_NUMERIC
Create rule phone-mask:
validation:
mask:
pattern: "(###) ###-####"
input_char: "#"
Create set phone:
ruleNames:
- required
- phone-length
- digits-only
- phone-mask
Apply set:
profileName: "default"
tableName: "customers"
dataKey: "customer_phone"
setName: "phone"
Then refresh:
GetTableValidation(default, customers)
The response contains a normal FieldValidation for customer_phone.
Important UX Notes
- Applying a set is not a live link.
- Editing a rule or set does not mutate fields where it was already applied.
- To update a field after set changes, apply the set again.
- If a set has conflicting singleton rules, the server rejects it.
- For now, the system does not store field metadata like
sourceSetNameon applied fields. The field only stores the resolved validation snapshot.
Files
Core model:
validation-core/src/set.rs
validation-core/src/config.rs
Wire contract:
common/proto/table_validation.proto
Server implementation:
server/src/table_validation/get/service.rs
server/src/table_validation/post/repo.rs
server/src/table_validation/config.rs
Storage:
server/migrations/20260506170000_create_validation_rules_and_sets.sql