Skip to content

Commit 6a53d3d

Browse files
JeroenDeDauwclaude
andcommitted
Rewrite validation-codes.md at the API-consumer altitude
Follows-up to #1082 A full text pass over the validation-codes reference for its actual reader: an API consumer deciding what a code means, whether their write fails, and what to do next. Everything that derived or justified the contract instead of stating it is gone: - Emitter-class names and validator-loop narration (SubjectValidator, StatementListBuilder, ...). Entries now use schema type names (`text`, `dateTime`) instead of PHP class names. - The relation-target resolution anatomy (revision slots, subject-to-page index, graph staleness, #1022): read consistency is a system-wide property homed in operations/maintenance.md, not something to restate per validation code. - Design-rationale asides and roadmap notes ("would be redundant noise", the 404-asymmetry reconciliation note). - The "Known limitations" inventory of which class performs which check — it duplicated the entries. - RedHerb's `invalid-color` entry: extension codes belong to the extension's docs (step 5 of the plugin-author section now says exactly that); RedHerb stays as the worked-example pointer. Blocking semantics now live in one "Blocking and enforcement" section instead of an intro bullet plus per-entry restatements; only the three non-blocking codes carry a marker, and per-entry propertyName/valuePartIndex lines that restated defaults are gone. Completeness fixes found on the way: `min-length`, `max-length`, `invalid-date`, and `unregistered-type` are emitted but were undocumented (the last was even referenced by name), and `min-value`/`max-value` are also emitted by `date` properties, which the emitter list omitted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0fee6bb commit 6a53d3d

1 file changed

Lines changed: 94 additions & 165 deletions

File tree

docs/api/validation-codes.md

Lines changed: 94 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,17 @@ order: 4
44
---
55
# Validation Codes
66

7-
Constraint violations returned by NeoWiki's backend validation API use stable `code` strings.
8-
This document is the authoritative reference for NeoWiki's PHP backend validation.
7+
Constraint violations returned by NeoWiki's validation API use stable `code` strings. This document is
8+
the reference for those codes.
99

1010
Violations are returned by:
1111

1212
- `POST /neowiki/v0/subject/validate` — dry-run validation of a proposed create-shape body.
1313
- `POST /neowiki/v0/subject/{subjectId}/validate` — dry-run validation of a proposed update-shape
1414
body against an existing Subject's Schema.
1515
- `POST /neowiki/v0/subject` and `PUT /neowiki/v0/subject/{subjectId}` — the write endpoints include
16-
the resulting `violations` array in their `201`/`200` success body. By default
17-
(`$wgNeoWikiEnforceValidation = false`) the write always persists. When an admin enables
18-
enforcement (`$wgNeoWikiEnforceValidation = true`), a write that introduces *new* blocking
19-
violations is rejected with `422 Unprocessable Entity` and an `{ status, message, violations }`
20-
body; `violations` carries the full proposed list, not just the newly-introduced subset.
21-
Pre-existing violations (already present on the stored Subject under the current Schema) and
22-
non-blocking codes such as [`schema-not-found`](#schema-not-found) never block, so editing an
23-
already-invalid Subject stays possible. See [ADR 21](../adr/021-add-backend-validation.md).
16+
the resulting `violations` array in their `201`/`200` success body. Whether a write with violations
17+
is rejected is covered under [Blocking and enforcement](#blocking-and-enforcement).
2418

2519
The `/validate` endpoints return `200 OK` with a `{violations: [...]}` body whenever the request is
2620
well-formed; violations in the body do not change the HTTP status. `400` is reserved for malformed
@@ -42,218 +36,151 @@ Each violation in the response has this shape:
4236
- `propertyName` is the property name as a string, or `null` for Subject-level violations.
4337
- `code` is one of the stable strings documented below.
4438
- `args` is always present; `[]` when there is nothing to interpolate.
45-
- `valuePartIndex` is the zero-based index into the offending Value's parts. The key is **omitted
46-
from the JSON** when not applicable (Subject-level violations or property-level violations that
47-
do not point at a single part).
39+
- `valuePartIndex` is the zero-based index of the offending part of a multi-part value. Only the
40+
codes that document it set it; the key is omitted from the JSON otherwise.
41+
42+
## Blocking and enforcement
43+
44+
Three codes are non-blocking warnings: [`unregistered-type`](#unregistered-type),
45+
[`schema-not-found`](#schema-not-found), and [`relation-target-not-found`](#relation-target-not-found).
46+
They are marked "Non-blocking" below and never cause a write to be rejected. Every other code blocks.
47+
48+
Blocking matters only when an admin enables enforcement (`$wgNeoWikiEnforceValidation`; off by
49+
default, so every write persists). Under enforcement, a write that introduces *new* blocking
50+
violations is rejected with `422 Unprocessable Entity` and an `{ status, message, violations }` body,
51+
where `violations` carries the full proposed list, not just the newly-introduced ones. Violations
52+
already present on the stored Subject never block, so an already-invalid Subject stays editable. See
53+
[ADR 21](../adr/021-add-backend-validation.md) and
54+
[ADR 26](../adr/026-validation-severity-levels.md).
4855

4956
## Code reference
5057

5158
### `required`
5259

53-
Emitted by `SubjectValidator` (when a required property has no Statement on the Subject) and
54-
by every built-in PropertyType plus `ColorType` (RedHerb) (when a Statement is present but its
55-
Value is content-empty).
60+
A property declared `required: true` has no usable value. Fires when the Subject body has no
61+
Statement for the property, and when a Statement is present but its value is empty for its type:
62+
only whitespace (`text`, `date`, `dateTime`), no parts (`url`, `select`), no targets (`relation`),
63+
or no value at all (`number`, `boolean`).
64+
5665
`args`: `[]`.
57-
`valuePartIndex`: never set.
58-
`propertyName`: attached by `SubjectValidator` (either directly when the schema-iteration loop
59-
emits, or via `withPropertyName()` from the per-type loop).
60-
61-
Fires in two cases for any property declared `required: true`:
62-
63-
1. **No Statement at all** — the Subject body did not include the property. `SubjectValidator`
64-
iterates the Schema's properties after the per-statement loop and emits `required` for any
65-
required property without a corresponding Statement. This also covers the case where the
66-
client sent the property with an empty Value (e.g. `value: []`), because
67-
`StatementListBuilder` drops empty-Value Statements before the validator runs, leaving the
68-
schema-iteration loop to surface it.
69-
2. **Statement present but Value is content-empty** — emitted by the PropertyType validator.
70-
What counts as "content-empty" depends on the type:
71-
- `TextType` — no non-whitespace string in the `StringValue` parts.
72-
- `UrlType`, `SelectType`, `ColorType``StringValue` has zero parts after deserialization.
73-
- `NumberType` — the value is not a `NumberValue`.
74-
- `RelationType` — the value is not a `RelationValue`, or has zero relations.
75-
- `DateTimeType` — the value is not a `StringValue`, has zero parts, or its first part is
76-
empty/whitespace.
7766

7867
### `label-required`
7968

80-
Emitted by `SubjectValidator` only.
81-
`args`: `[]`.
82-
`valuePartIndex`: never set.
83-
`propertyName`: always `null` (Subject-level).
69+
The Subject's label is empty or whitespace-only. Subject-level: `propertyName` is `null`.
8470

85-
The Subject's label is empty or whitespace-only.
71+
`args`: `[]`.
8672

8773
### `type-mismatch`
8874

89-
Emitted by `SubjectValidator` only.
90-
`args`: `[writerType, currentType]` — the type recorded on the Statement when it was written
91-
and the type the Schema currently declares for the property.
92-
`valuePartIndex`: never set.
93-
`propertyName`: the affected property.
75+
The type recorded on the Statement when it was written (ADR 11) no longer matches the type the
76+
Schema currently declares for the property — for example, a Statement written while the property was
77+
a `url`, after the Schema changed the property to `number`. When this fires, it is the only
78+
violation reported for that property: per-type checks and `required` are suppressed.
9479

95-
The Statement's writer's-schema type (the `propertyType` recorded when the Statement was
96-
written, per ADR 11) no longer matches the Schema's current type for that property. For example,
97-
a property was originally declared as `url` and a Statement was written carrying a `StringValue`;
98-
the Schema was later edited to make that property a `number`. The Statement is now invalid under
99-
the current Schema (ADR 12 names this case explicitly).
100-
101-
`SubjectValidator` skips per-type validation when this fires — the per-type's `instanceof`
102-
PropertyDefinition guard would no-op against the wrong-typed definition anyway, and the more
103-
specific `type-mismatch` code is what a consumer needs to react.
104-
105-
If a property is `required` AND its Statement has a type mismatch, only `type-mismatch` fires
106-
(the Statement is present, just under the wrong type). `required` would be redundant noise.
80+
`args`: `[writerType, currentType]`.
10781

10882
### `invalid-url`
10983

110-
Emitted by `UrlType`.
111-
`args`: `[]`.
112-
`valuePartIndex`: index of the offending URL within the multi-part `StringValue`.
113-
`propertyName`: attached by `SubjectValidator`.
84+
On `url` properties. A non-empty value does not match the allowed URL pattern. The pattern accepts
85+
`http://` and `https://` schemes (or no scheme), domain-like hosts, IPv4 literals, `localhost`,
86+
port, path, query, and fragment. It rejects other schemes (`ftp://`, `file://`), spaces, and
87+
disallowed characters.
11488

115-
A non-empty trimmed string in the `StringValue` does not match the allowed URL pattern. The
116-
pattern accepts `http://` and `https://` schemes (or no scheme), domain-like hosts, IPv4
117-
literals, `localhost`, port, path, query, and fragment. It rejects other schemes (`ftp://`,
118-
`file://`), spaces, and disallowed characters.
89+
`args`: `[]`. `valuePartIndex`: the offending part.
11990

12091
### `unique`
12192

122-
Emitted by `TextType` and `UrlType`.
123-
`args`: `[]`.
124-
`valuePartIndex`: never set.
125-
`propertyName`: attached by `SubjectValidator`.
93+
On `text` and `url` properties with `uniqueItems` enabled: the value contains duplicate parts.
12694

127-
The Property's `uniqueItems` constraint is enabled and the `StringValue` contains duplicate parts.
95+
`args`: `[]`.
12896

129-
### `min-value`
97+
### `min-length` / `max-length`
13098

131-
Emitted by `NumberType` and `DateTimeType`.
132-
`args`: `[minimum]`. For `NumberType` this is a `number`; for `DateTimeType` this is the ISO 8601
133-
string declared on the Property.
134-
`valuePartIndex`: never set.
99+
On `text` properties. A part's trimmed length is below `minLength` or above `maxLength`. Empty
100+
parts are not length-checked.
135101

136-
The value is strictly below the Property's inclusive minimum.
102+
`args`: `[minLength]` / `[maxLength]`. `valuePartIndex`: the offending part.
137103

138-
### `max-value`
104+
### `min-value` / `max-value`
139105

140-
Emitted by `NumberType` and `DateTimeType`.
141-
`args`: `[maximum]`. Same shape as `min-value`.
142-
`valuePartIndex`: never set.
106+
On `number`, `date`, and `dateTime` properties. The value is below the property's inclusive
107+
`minimum` or above its inclusive `maximum`.
143108

144-
The value is strictly above the Property's inclusive maximum.
109+
`args`: `[minimum]` / `[maximum]` — a number for `number` properties, the declared ISO 8601 string
110+
for `date` and `dateTime`.
145111

146112
### `invalid-option`
147113

148-
Emitted by `SelectType` and RedHerb's `ColorType` (when `allowedColors` is non-empty).
149-
`args`: `[offendingPart]`.
150-
`valuePartIndex`: index of the offending part within the `StringValue`.
114+
On `select` properties. A part is not in the property's `options` allow-list.
151115

152-
A part is not in the Property's allow-list (`options` for SelectType, `allowedColors` for
153-
ColorType).
116+
`args`: `[offendingPart]`. `valuePartIndex`: the offending part.
154117

155118
### `single-value-only`
156119

157-
Emitted by `SelectType` and `RelationType`.
158-
`args`: `[]`.
159-
`valuePartIndex`: never set (Subject-property-level, not part-level).
120+
On single-valued (`multiple: false`) `select` and `relation` properties: more than one part
121+
(`select`) or relation target (`relation`) was supplied.
160122

161-
The Property's `multiple` flag is false and more than one value was supplied — more than one part for
162-
`SelectType`, or more than one relation target for `RelationType`.
123+
`args`: `[]`.
163124

164125
### `invalid-datetime`
165126

166-
Emitted by `DateTimeType`.
127+
On `dateTime` properties. The value is not a strict ISO 8601 / `xsd:dateTime` string with an
128+
explicit timezone offset or `Z`. Includes calendar-overflow cases like `2025-02-30T00:00:00Z`,
129+
partial dates (`2025`, `2025-06`, `2025-06-15`), and missing offsets.
130+
167131
`args`: `[]`.
168-
`valuePartIndex`: never set (DateTime is single-valued).
169132

170-
The non-empty first part of the `StringValue` is not a strict ISO 8601 / xsd:dateTime string
171-
with an explicit timezone offset or `Z`. Includes calendar-overflow cases like
172-
`2025-02-30T00:00:00Z`, partial dates (`2025`, `2025-06`, `2025-06-15`), and missing offsets.
133+
### `invalid-date`
134+
135+
On `date` properties. The value is not a strict ISO 8601 calendar date (`YYYY-MM-DD`). Time or
136+
timezone components and calendar overflows like `2025-02-30` are rejected.
173137

174-
### `invalid-color`
138+
`args`: `[]`.
139+
140+
### `unregistered-type`
175141

176-
Emitted by RedHerb's `ColorType`.
177-
`args`: `[offendingPart]`.
178-
`valuePartIndex`: index of the offending part within the `StringValue`.
142+
Non-blocking. The property's type has no registered PropertyType — typically the extension providing
143+
the type is disabled. The value cannot be interpreted, so it is preserved verbatim and no other
144+
checks run for the property. A required property of an unregistered type reports this code instead
145+
of `required`, so the Subject stays saveable.
179146

180-
A part does not match the 6-digit hex-color pattern (`/^#[0-9a-fA-F]{6}$/`).
147+
`args`: `[propertyType]`.
181148

182149
### `schema-not-found`
183150

184-
Emitted by `ValidateSubjectUpdateApi` and by the Subject write endpoints
185-
(`POST /subject`, `PUT /subject/{id}`) via `ProposedSubjectValidator`.
186-
`args`: `[schemaName]`.
187-
`valuePartIndex`: never set.
188-
`propertyName`: always `null` (Subject-level).
151+
Non-blocking. The Subject's Schema cannot be loaded — usually deleted or renamed since the Subject
152+
was created, or the Subject was created or imported referencing a Schema that does not (yet) exist.
153+
The write proceeds and reports the violation; creating or renaming the Schema page resolves it.
154+
Subject-level: `propertyName` is `null`.
189155

190-
The Subject's Schema cannot be loaded — usually because the Schema page was deleted or renamed
191-
since the Subject was created, or because a Subject is created/imported referencing a Schema that
192-
does not (yet) exist. On the write endpoints it is non-blocking: the write proceeds and the
193-
(unvalidatable) Subject stays editable per ADR 21, but the response reports `schema-not-found`
194-
rather than an empty (and misleading "valid") violation list. The caller can resolve it by
195-
creating or renaming the Schema page.
156+
Returned by the update dry-run and both write endpoints. The create dry-run
157+
(`POST /subject/validate`) instead returns `404`, because there the Schema is the addressed
158+
resource.
196159

197-
Note the create dry-run endpoint (`POST /subject/validate`) instead returns `404`
198-
(`SchemaNotFoundException`) for a missing Schema, because there it is the addressed resource;
199-
the update dry-run (`POST /subject/{id}/validate`) and both write endpoints surface the violation.
200-
Reconciling that asymmetry is left to the enforcement tier (ADR 21).
160+
`args`: `[schemaName]`.
201161

202162
### `relation-target-schema-mismatch`
203163

204-
Emitted by `SubjectValidator`.
205-
`args`: `[expectedSchema, actualSchema]` — the target Schema the relation Property declares
206-
(`targetSchema`), and the Schema the resolved target Subject actually uses.
207-
`valuePartIndex`: index of the offending relation target within the `RelationValue`.
208-
`propertyName`: the relation property.
164+
On `relation` properties. The relation targets a Subject that exists but whose own Schema is not the
165+
property's declared `targetSchema`. A target that cannot be resolved is reported as
166+
[`relation-target-not-found`](#relation-target-not-found) instead. The subject editor filters
167+
relation candidates by target Schema, so this normally only occurs on API writes such as imports.
209168

210-
A relation targets a Subject that exists but whose own Schema is not the Property's declared
211-
`targetSchema`. The Schema compared is the target's own stored writer's-schema, read from its revision
212-
slot rather than from a graph node property. Reaching that slot still resolves the target ID through
213-
the subject-to-page index, which lives only in the graph projection, so this check only runs for
214-
targets the graph currently knows about. This is a blocking `error` (ADR 26): it is rejected at write
215-
time when `$wgNeoWikiEnforceValidation` is enabled. The subject editor's picker filters candidates by
216-
target Schema, so this is normally only reachable through the API — for example a bulk import.
169+
`args`: `[expectedSchema, actualSchema]`. `valuePartIndex`: the offending target.
217170

218171
### `relation-target-not-found`
219172

220-
Emitted by `SubjectValidator`.
221-
`args`: `[targetId]` — the target Subject ID that did not resolve.
222-
`valuePartIndex`: index of the offending relation target within the `RelationValue`.
223-
`propertyName`: the relation property.
224-
225-
A relation targets a Subject ID that does not resolve to any existing Subject. It is a non-blocking
226-
`warning` (ADR 26), joining [`schema-not-found`](#schema-not-found) and `unregistered-type` in the
227-
hardcoded non-blocking set, so it never rejects a write even under enforcement. This is deliberate:
228-
pointing at a not-yet-created Subject is wiki-native red-link behavior, and an import may legitimately
229-
mint the target later.
230-
231-
Resolution goes through the subject-to-page index, which lives only in the graph projection, so a
232-
target that exists in revision slots but is missing from the graph is reported as not found. An
233-
import populates the slots without updating the projection
234-
([#1022](https://github.com/ProfessionalWiki/NeoWiki/issues/1022)), so imported targets warn until
235-
[the graph is rebuilt](../operations/maintenance.md#rebuilding-the-graph). Being non-blocking, this
236-
misreport never costs a write.
173+
Non-blocking. On `relation` properties. The relation targets a Subject ID that does not resolve to
174+
any existing Subject. Deliberately a warning: pointing at a not-yet-created Subject is wiki-native
175+
red-link behavior, and an import may legitimately mint the target later.
237176

238-
## Known limitations (Foundation round)
177+
`args`: `[targetId]`. `valuePartIndex`: the offending target.
239178

240-
The PHP `SubjectValidator` performs these Subject-level checks:
179+
## Out-of-schema Statements
241180

242-
- **`required`** — PHP iterates the Schema's properties to catch absent-required cases.
243-
- **`type-mismatch`** — PHP compares the Statement's writer's-schema type against the Schema's
244-
current type and surfaces drift per ADR 11 / ADR 12.
245-
- **[`relation-target-schema-mismatch`](#relation-target-schema-mismatch)** and
246-
**[`relation-target-not-found`](#relation-target-not-found)** — documented above; both need a
247-
Subject lookup, which `PropertyType::validate()` has no access to.
248-
249-
### Deliberate behavior, not a gap
250-
251-
- **Out-of-schema Statements are silently skipped.** A Statement whose property is not declared
252-
on the current Schema is ignored — no violation. This is schema-drift tolerance: a property
253-
may have been removed from the Schema while Subjects still carry old Statements. ADR 8 says
254-
one Schema per Subject, not that every Statement on that Subject must reference an extant
255-
Property. If surfacing these as violations becomes useful (e.g. for migration UIs), it can be
256-
added as a separate code without changing the current behavior.
181+
A Statement whose property is not declared on the current Schema is ignored — no violation. This is
182+
schema-drift tolerance: a property may have been removed from the Schema while Subjects still carry
183+
old Statements.
257184

258185
## Adding a new validation code
259186

@@ -268,5 +195,7 @@ violation code:
268195
convention as the codes above.
269196
4. If the violation points at a specific part of a multi-part Value, set `valuePartIndex` to
270197
that part's zero-based index.
271-
5. Document your new code in this file (or your extension's documentation if you want it to be
272-
discoverable independently).
198+
5. Document your new code in your extension's documentation.
199+
200+
RedHerb's `ColorType` (`tests/RedHerb/src/ColorType.php`) is a worked example, including its own
201+
`invalid-color` code.

0 commit comments

Comments
 (0)