Skip to content

Commit 4cb3254

Browse files
committed
feat(spec)!: retire the standalone validation metadata kind (#4509, ADR-0088)
A validation rule authored as its own artifact bound to nothing and gated no write. ValidationRuleSchema carries no object-binding key — no `object`, no `objectName` — and all six variants are strictObject, so an author could not supply one either. No merge step existed; the only code expecting such a key was a reference-tracker row scanning a field the schema would have stripped. Meanwhile the engine evaluates exactly one shape: the object's own `validations[]`, on insert and on every matched update row. So a rule created through the standalone door (*.validation.ts, or Studio's Validations list) parsed, saved, reported success, and intercepted nothing — including a state_machine rule, which ADR-0020 routes through this same vocabulary. An author could believe they had locked down record state transitions and have changed nothing. Under ADR-0088 the kind fails the admission test on its first clause: a rule has no independent lifecycle, because it only means something against an object. And unlike the sibling disconnects in this batch it could not be bridged into one — the shape has nowhere to name its object. Enforce-or-remove picks ENFORCE where the feature exists and only wiring is missing (email_template), REMOVE where the shape cannot carry the feature. The rule VOCABULARY is untouched: ValidationRuleSchema and all six variants are unchanged and fully live, and the engine's evaluation path is not modified. The ledger keeps governing the schema through the gate's SPEC_ONLY_SCHEMAS override (alongside webhook and query) — an ungoverned live schema is how the next drift would hide. Removed: registry entry (with its file patterns), MetadataTypeSchema member, metadata-core lockstep enum member, schema-map entry, create seed, Studio nav item + hand-crafted form + its four locale strings, and the dangling reference-tracker row. Stored standalone rows are left alone — never evaluated, so nothing changes behaviorally. Also restamps validation.json's engine evidence lines (drifted ~220 lines) and adds the ADR-0088 addendum recording the retirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
1 parent 34f82df commit 4cb3254

28 files changed

Lines changed: 208 additions & 164 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/metadata-core": major
4+
"@objectstack/metadata-protocol": minor
5+
"@objectstack/platform-objects": minor
6+
---
7+
8+
feat(spec)!: retire the standalone `validation` metadata kind (#4509, ADR-0088)
9+
10+
A validation rule authored as its own artifact bound to nothing and gated no
11+
write. `ValidationRuleSchema` carries **no object-binding key** — no `object`,
12+
no `objectName` — and all six variants are `strictObject`, so an author could
13+
not supply one either. No merge step existed. The only code that expected such a
14+
key was a reference-tracker row scanning a field the schema would have stripped.
15+
Meanwhile the engine evaluates exactly one shape: the object's own
16+
`validations[]` array, on insert and on every matched update row.
17+
18+
So a rule created through the standalone door — a `*.validation.ts` file, or
19+
Studio's Validations list — parsed, saved, reported success, and intercepted
20+
nothing. Including a `state_machine` rule, which ADR-0020 routes through this
21+
same vocabulary: an author could believe they had locked down record state
22+
transitions and have changed nothing at all.
23+
24+
Under ADR-0088 the kind fails the admission test on its first clause: a rule has
25+
no independent lifecycle, because it only means something against an object. And
26+
unlike the sibling disconnects closed in this batch, it could not be bridged into
27+
one — the shape has nowhere to name its object.
28+
29+
**The rule vocabulary is untouched.** `ValidationRuleSchema` and all six
30+
variants are unchanged and fully live; the engine's evaluation path is not
31+
modified by this change. It is the *kind* that was inert, not the schema. The
32+
liveness ledger keeps governing it through the gate's `SPEC_ONLY_SCHEMAS`
33+
override (alongside `webhook` and `query`), because an ungoverned live schema is
34+
exactly how the next drift would hide.
35+
36+
**Migration.** Move the rule into the owning object's `validations:` array — the
37+
rule body is identical, same schema, same six variants:
38+
39+
```ts
40+
// before — a standalone *.validation.ts, which never ran
41+
export default defineValidation({ name: 'amount_positive', type: 'script', … })
42+
43+
// after — on the object, where rules are evaluated
44+
ObjectSchema.create({
45+
name: 'invoice',
46+
validations: [{ name: 'amount_positive', type: 'script', … }],
47+
})
48+
```
49+
50+
Removed: the registry entry (and its `*.validation.ts` / `*.validation.yml`
51+
patterns), the `MetadataTypeSchema` member, the metadata-core lockstep enum
52+
member, the schema-map entry, the create seed, Studio's Validations nav item and
53+
its hand-crafted form, and the dangling reference-tracker row. Standalone rows
54+
already in `sys_metadata` are left alone — they were never evaluated, so nothing
55+
changes behaviorally.

content/docs/references/api/metadata.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ Metadata query with filtering, sorting, and pagination
328328

329329
| Property | Type | Required | Description |
330330
| :--- | :--- | :--- | :--- |
331-
| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
331+
| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
332332
| **namespaces** | `string[]` | optional | Filter by namespaces |
333333
| **packageId** | `string` | optional | Filter by owning package |
334334
| **search** | `string` | optional | Full-text search query |
@@ -363,7 +363,7 @@ Metadata query with filtering, sorting, and pagination
363363

364364
| Property | Type | Required | Description |
365365
| :--- | :--- | :--- | :--- |
366-
| **type** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type |
366+
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type |
367367
| **name** | `string` || Item name (snake_case) |
368368
| **data** | `Record<string, any>` || Metadata payload |
369369
| **namespace** | `string` | optional | Optional namespace |

content/docs/references/kernel/metadata-plugin.mdx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
130130
| Property | Type | Required | Description |
131131
| :--- | :--- | :--- | :--- |
132132
| **event** | `Enum<'metadata.registered' \| 'metadata.updated' \| 'metadata.unregistered' \| 'metadata.validated' \| 'metadata.deployed' \| 'metadata.overlay.applied' \| 'metadata.overlay.removed' \| 'metadata.imported' \| 'metadata.exported'>` || Event type |
133-
| **metadataType** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type |
133+
| **metadataType** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type |
134134
| **name** | `string` || Metadata item name |
135135
| **namespace** | `string` | optional | Namespace |
136136
| **packageId** | `string` | optional | Owning package ID |
@@ -183,7 +183,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
183183

184184
| Property | Type | Required | Description |
185185
| :--- | :--- | :--- | :--- |
186-
| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
186+
| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
187187
| **namespaces** | `string[]` | optional | Filter by namespaces |
188188
| **packageId** | `string` | optional | Filter by owning package |
189189
| **search** | `string` | optional | Full-text search query |
@@ -218,7 +218,6 @@ const result = MetadataBulkRegisterRequest.parse(data);
218218

219219
* `object`
220220
* `field`
221-
* `validation`
222221
* `hook`
223222
* `seed`
224223
* `mapping`
@@ -252,7 +251,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
252251

253252
| Property | Type | Required | Description |
254253
| :--- | :--- | :--- | :--- |
255-
| **type** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type identifier |
254+
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type identifier |
256255
| **label** | `string` || Display label for the metadata type |
257256
| **description** | `string` | optional | Description of the metadata type |
258257
| **filePatterns** | `string[]` || Glob patterns to discover files of this type |

docs/adr/0088-metadata-kind-admission-and-retirement.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,48 @@ The cached remote-schema snapshot of a federated datasource (ADR-0062) has a rea
5454
- `OPS_FILE_SUFFIX_REGEX` drops the four suffixes: `*.trigger.ts` / `*.router.ts` / `*.function.ts` / `*.service.ts` are no longer valid OPS metadata file names.
5555
- The showcase's registry-driven `KIND_COVERAGE` shrinks in lockstep (its coverage test enforces exact registry membership); the four waivers disappear and `external_catalog`'s waiver becomes a permanent, documented exclusion.
5656
- ADR-0005 / ADR-0010 prose tables no longer list the retired kinds.
57+
58+
## Addendum (2026-08): `validation` retired — the admission test's first clause
59+
60+
`validation` was registered as a kind with `allowRuntimeCreate: true`, a
61+
`*.validation.ts` loader, and a Studio form. On the admission test above it
62+
nonetheless fails the **first** clause — independent lifecycle — and the failure
63+
is not cosmetic:
64+
65+
- **No independent lifecycle.** A rule only means something against an object,
66+
and the only shape the engine evaluates is `object.validations[]`
67+
(`evaluateValidationRules` on insert and on every matched update row).
68+
- **No way to bind.** `ValidationRuleSchema` carries no `object` / `objectName`
69+
key, and all six variants are `strictObject`, so an author could not supply
70+
one either — the parse would reject it. There was no merge step, and the only
71+
code that expected such a key was a reference-tracker row scanning a field
72+
that could never exist.
73+
74+
So the standalone door led nowhere: an item authored through it — including a
75+
`state_machine` rule, which ADR-0020 explicitly routes through this same
76+
vocabulary — saved cleanly, reported success, and intercepted no write. That is
77+
the ADR-0049 false-compliance shape, on a surface authors reasonably expect to
78+
gate their data.
79+
80+
The kind is removed (registry entry, `MetadataTypeSchema` member, metadata-core
81+
lockstep enum, schema map entry, Studio nav item and hand-crafted form, create
82+
seed, and the dangling reference row). `ValidationRuleSchema` itself is
83+
**unchanged and fully live** — it is the kind that was inert, not the
84+
vocabulary. The liveness ledger keeps governing the schema through the gate's
85+
`SPEC_ONLY_SCHEMAS` override, alongside `webhook` and `query`, precisely because
86+
an ungoverned live schema is how the next drift would hide.
87+
88+
Note the contrast with the sibling disconnects closed in the same batch (#4509).
89+
`email_template` had a real feature with missing wiring, so enforce-or-remove
90+
resolved it by **enforcing** — a materializer bridge. `validation` had a shape
91+
that could not carry the feature at all, so it resolves by **removing**. The
92+
test is not "is this dead?" but "can this be made to work as declared?".
93+
94+
- `MetadataTypeSchema` and `DEFAULT_METADATA_TYPE_REGISTRY` shrink 26 → 25.
95+
- `*.validation.ts` / `*.validation.yml` are no longer metadata file patterns.
96+
(`OPS_FILE_SUFFIX_REGEX` never listed them — no change there.)
97+
- Persisted standalone `sys_metadata` rows are left alone. They were never
98+
evaluated, so nothing changes behaviorally; `migrateStoredMetadata` declines
99+
them like any unregistered type.
100+
- **Migration for authors:** move the rule into the object's `validations:`
101+
array. The rule body is unchanged — same schema, same six variants.

examples/app-showcase/src/coverage.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,24 +54,19 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
5454
object: {
5555
status: 'demonstrated',
5656
files: ['src/data/objects/index.ts', 'src/data/objects/field-zoo.object.ts'],
57+
notes:
58+
'Also carries the validation-rule surface: rules are authored inline via object `validations` (the `validation` KIND was retired in #4509 — ADR-0088 — because a standalone rule had no way to name the object it validated). Every declared rule type is write-path enforced (rule-validator dispatches all of state_machine/script/cross_field/format/json_schema/conditional — ADR-0020 "no silent no-ops", closing the #1475 gap) and each is demonstrated: state_machine (task/project), script+cross_field (project), format/json_schema/conditional (account). Field-level requiredWhen/readonlyWhen are likewise enforced and demonstrated on invoice.',
5759
},
5860
field: {
5961
status: 'demonstrated',
6062
files: ['src/data/objects/field-zoo.object.ts'],
6163
notes:
6264
'FieldSchema is authored inline on objects (the stack DSL has no standalone `fields` collection); field-zoo exhausts every field type — see the variant-level test.',
6365
},
64-
validation: {
65-
status: 'demonstrated',
66-
files: [
67-
'src/data/objects/account.object.ts',
68-
'src/data/objects/task.object.ts',
69-
'src/data/objects/project.object.ts',
70-
'src/data/objects/invoice.object.ts',
71-
],
72-
notes:
73-
'Authored inline via object `validations`. Every declared rule type is now write-path enforced (rule-validator dispatches all of state_machine/script/cross_field/format/json_schema/conditional — ADR-0020 "no silent no-ops", closing the #1475 gap) and each is demonstrated: state_machine (task/project), script+cross_field (project), format/json_schema/conditional (account). Field-level requiredWhen/readonlyWhen are likewise enforced and demonstrated on invoice.',
74-
},
66+
// `validation` was retired as a KIND in #4509 (ADR-0088) — the coverage test
67+
// fails on any entry the registry no longer knows. The rules themselves are
68+
// unchanged and still demonstrated; that coverage moved onto `object`, which
69+
// is where they are authored.
7570
hook: { status: 'demonstrated', files: ['src/data/hooks/index.ts'] },
7671
seed: { status: 'demonstrated', files: ['src/data/seed/index.ts'] },
7772
mapping: {

packages/metadata-core/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { z } from 'zod';
1919
export const MetadataTypeSchema = z.enum([
2020
'object',
2121
'field',
22-
'validation',
22+
// ADR-0088 (#4509): no `validation` kind — rules are inline `object.validations[]`.
2323
'hook',
2424
'mapping',
2525
'view',

packages/metadata-protocol/src/protocol.ts

Lines changed: 12 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -261,74 +261,13 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
261261
required: ['name', 'label', 'type'],
262262
additionalProperties: true,
263263
},
264-
// Validation rules live inside `object.validations[]`. The canonical
265-
// ValidationRuleSchema is a discriminated union of 6 variants; the
266-
// generic SchemaForm renderer treats unions as opaque JSON, so we
267-
// ship a *flat* form-friendly schema covering the common base
268-
// properties plus every variant-specific field as optional. Save-time
269-
// validation is unaffected — the union schema is still authoritative
270-
// at write time.
271-
validation: {
272-
type: 'object',
273-
properties: {
274-
// --- Base fields (all variants) ---
275-
name: { type: 'string', description: 'Unique rule name (snake_case)' },
276-
label: { type: 'string' },
277-
description: { type: 'string' },
278-
type: {
279-
type: 'string',
280-
enum: [
281-
'script',
282-
'state_machine',
283-
'format',
284-
'cross_field',
285-
'json_schema',
286-
'conditional',
287-
],
288-
default: 'script',
289-
description: 'Validation variant',
290-
},
291-
active: { type: 'boolean', default: true },
292-
events: {
293-
type: 'array',
294-
items: { type: 'string', enum: ['insert', 'update'] },
295-
default: ['insert', 'update'],
296-
},
297-
priority: { type: 'number', default: 100, minimum: 0, maximum: 9999 },
298-
severity: {
299-
type: 'string',
300-
enum: ['error', 'warning', 'info'],
301-
default: 'error',
302-
},
303-
message: { type: 'string' },
304-
tags: { type: 'array', items: { type: 'string' } },
305-
// --- Variant-specific (all optional, gated by `type`) ---
306-
condition: {
307-
type: 'string',
308-
description: 'CEL predicate (type=script). True ⇒ validation fails.',
309-
},
310-
fields: {
311-
type: 'array',
312-
items: { type: 'string' },
313-
description: 'Fields (type=cross_field).',
314-
},
315-
field: { type: 'string', description: 'Single field (type=state_machine / format).' },
316-
transitions: {
317-
type: 'object',
318-
additionalProperties: { type: 'array', items: { type: 'string' } },
319-
description: 'Map { OldState: [AllowedNewStates] } (type=state_machine).',
320-
},
321-
regex: { type: 'string', description: 'Regex (type=format).' },
322-
format: {
323-
type: 'string',
324-
enum: ['email', 'url', 'phone', 'json'],
325-
description: 'Built-in format (type=format).',
326-
},
327-
when: { type: 'string', description: 'Outer condition (type=conditional).' },
328-
},
329-
required: ['name', 'type', 'message'],
330-
additionalProperties: true,
331-
},
264+
// ADR-0088 (#4509): the `validation` kind is retired, so its hand-crafted
265+
// form goes with it. Rules are authored inside `object.validations[]` and
266+
// edited on the object; there is no standalone validation editor to render
267+
// a schema for. (The form was flat by necessity — ValidationRuleSchema is a
268+
// 6-variant discriminated union the generic SchemaForm treats as opaque —
269+
// and it had no field for the object being validated, which is precisely
270+
// the gap that retired the kind: a rule saved here bound to nothing.)
332271
};
333272

334273
/**
@@ -1374,7 +1313,11 @@ const REFERENCE_PATHS: Record<string, Array<{ fromType: string; paths: string[];
13741313
{ fromType: 'page', paths: ['object', 'objectName'], kind: 'page' },
13751314
{ fromType: 'report', paths: ['object', 'objectName'], kind: 'report' },
13761315
{ fromType: 'action', paths: ['object', 'objectName'], kind: 'action' },
1377-
{ fromType: 'validation', paths: ['object', 'objectName'], kind: 'validation' },
1316+
// fromType 'validation' removed (#4509): it scanned `object`/`objectName`
1317+
// on a schema that has neither — every variant is strict, so parse would
1318+
// have stripped such a key anyway — and the kind is now retired
1319+
// (ADR-0088). Rules travel with their object, so a rule's dependency on
1320+
// that object needs no row: deleting the object takes them with it.
13781321
{ fromType: 'hook', paths: ['object', 'objectName'], kind: 'hook' },
13791322
{ fromType: 'object', paths: ['fields[].referenceTo', 'fields{}.referenceTo', 'fields{}.reference'], kind: 'field reference' },
13801323
],

0 commit comments

Comments
 (0)