Skip to content

Commit ce92674

Browse files
os-zhuangclaude
andauthored
fix(#4509): three auth-gate disconnects close — email_template bridges, job and validation close their doors (#4558)
* feat(email): materialize declared email_template metadata into sys_email_template (#4509) Authoring an `email_template` was a silent no-op. `sendTemplate` resolves `(name, locale)` against sys_email_template ROWS, whose only writers were the built-in auth templates plus a code-constructed `EmailServicePluginOptions. templates` that no bootstrapper passed — while every door an author can use (stack `emailTemplates:`, `*.email-template.ts`, Studio, PUT /meta) parked items in a metadata store nothing read back. An admin could "fix" the password-reset mail in Studio and users kept receiving the built-in copy: ADR-0078 false compliance on AUTH mail. Webhook #3461's shape, closed the same way (ADR-0049 enforce-or-remove, route: enforce). `bootstrapDeclaredEmailTemplates` materializes declared items at boot, validating each through EmailTemplateDefinitionSchema.parse() and projecting with `mapTemplateToRow` — the SAME mapping the built-in seeder uses, extracted and shared so the two doors cannot drift. Because email_template is allowRuntimeCreate:true (unlike webhook), the plugin also subscribes to metadata changes and re-materializes the changed item, so a Studio save takes effect without a restart; a withdrawn template deactivates its rows rather than losing them. Three breaks sat on this path, and closing any two would still have shipped a template that never sent: - objectql never registered a manifest's `emailTemplates:` into the registry at all — the key was missing from the generic ingestion list, so the bridge's own source was empty. - Built-in seeds left `managed_by` at the column's 'admin' default, masquerading as admin-authored; since the bridge refuses to overwrite admin rows, a built-in would permanently outrank the template an app declared. Built-ins now stamp 'platform'. - Nothing materialized declared metadata into rows. Seed-not-clobber mirrors sys_webhook (#3489): sys_email_template gains managed_by/customized, declared rows re-seed as 'package', and an admin-created or admin-edited row is never overwritten. Separate axis from `is_system`, which keeps its meaning for built-ins. Ledger email_template flips 13 dead → fully live, with an ADR-0054 proof bound on `subject`: it boots a real stack, authors a template overriding a built-in auth template, and asserts the AUTHORED wording reaches the transport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu * feat(spec)!: close the job runtime-create gate — a job is a code artifact (#4509) A `job` created at runtime could never be scheduled. `JobSchema.handler` names a function in the compiled bundle's function table — the schema says so, and the scheduler is built that way: AppPlugin sources jobs from `bundle.jobs` alone and resolves handlers through `collectBundleFunctions(bundle)`. Yet the type was registered allowRuntimeCreate:true (and allowOrgOverride:true), so a job authored in Studio or via PUT /meta parsed, saved, reported success, and never ran. Unlike the sibling disconnects in this batch, this one cannot be bridged: the runtime writer does not have the bundle and cannot name a function inside it. The missing piece is a handler-binding design, not an ingestion path. Per ADR-0049 enforce-or-remove the honest move is to close the door — both flags now false, with a rationale block modeled on the `agent` entry stating what re-opening would require. `job` stays a first-class authorable type: `*.job.ts` and `defineStack({ jobs })` are the supported doors and are fully enforced, so the kind still passes the ADR-0088 admission test and stays registered. Also restamps job.json's evidence lines, which pointed at app-plugin.ts:767-791 and had drifted ~25 lines — the gate resolves paths, not line numbers, which is exactly the rot `verifiedAt` exists to catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu * chore(i18n): regenerate object bundles for the sys_email_template provenance columns (#4509) Follow-up to the materializer bridge: `managed_by` / `customized` on sys_email_template are new translatable field labels, so the nine locale bundles had drifted from the schema. `node scripts/check-i18n-bundles.mjs --write` (merge mode — no existing translation overwritten). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu * 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 * chore(liveness): record the nav action dispatch gap as closed (#4509) app.json's type note carried the one gap the AppSchema navigation walk found by hand: an `action` item rendered and gated like any other while its click went to an `onAction` prop no shipped shell passed. objectui @e8bec83 wires it — the sidebar resolves the action name against metadata and dispatches through the console action runtime, and a shell that still passes no handler now hides action items instead of rendering them dead. Re-pins the objectui evidence (the old pin @940ba24 predates the fix) and dates the row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu * fix(email): type the metadata slot lookup so the ratchet holds (#4509) CI's slot-lookup ratchet caught the bridge's `ctx.getService('metadata')`: email-plugin.ts is grandfathered for its three existing erasures, and a new untyped lookup pushed it to four. Pass the slot's contract type (`IMetadataService`) as #4251 asks — which also gives the subscribe/get calls a real type instead of `any`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dafb390 commit ce92674

51 files changed

Lines changed: 1583 additions & 262 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-email": minor
4+
"@objectstack/objectql": minor
5+
"@objectstack/spec": patch
6+
---
7+
8+
feat(email): declared email templates reach the mail service (#4509)
9+
10+
Authoring an `email_template` was a silent no-op. `EmailService.sendTemplate`
11+
resolves `(name, locale)` against **`sys_email_template` rows**, and the only
12+
writers of those rows were the built-in auth templates plus a code-constructed
13+
`EmailServicePluginOptions.templates` that no bootstrapper ever passed. Every
14+
door an author can actually use — a stack's `emailTemplates:`, an
15+
`*.email-template.ts` file, Studio's metadata-admin list, `PUT /meta` — parked
16+
items in a metadata store nothing read back. So an admin could "fix" the
17+
password-reset email in Studio, get a success toast, and watch users keep
18+
receiving the built-in copy: ADR-0078 false compliance on **authentication
19+
mail**. This is the shape #3461 had for webhooks, closed the same way (ADR-0049
20+
enforce-or-remove, route: enforce).
21+
22+
**`bootstrapDeclaredEmailTemplates`** now materializes declared templates into
23+
`sys_email_template` at boot. Each item is validated through
24+
`EmailTemplateDefinitionSchema.parse()` — the spec schema finally has a real
25+
consumer, defaults and all — and projected with `mapTemplateToRow`, which is the
26+
**same** mapping the built-in seeder uses, extracted and shared so the two doors
27+
cannot drift apart. A malformed template warns and is skipped rather than
28+
crashing boot.
29+
30+
**Runtime writes take effect immediately.** Unlike `webhook`, `email_template`
31+
is `allowRuntimeCreate: true`, so a boot-only bridge would have left a Studio
32+
save inert until the next restart — the same bug, half-fixed. The plugin also
33+
subscribes to `email_template` metadata changes and re-materializes the single
34+
changed item; withdrawing a template deactivates its rows (across locales)
35+
rather than deleting them.
36+
37+
**Three breaks sat on this path, not one**, and closing any two of them would
38+
still have shipped a template that never sent:
39+
40+
- `@objectstack/objectql` never registered a manifest's `emailTemplates:` into
41+
the metadata registry at all — the key was simply missing from the generic
42+
ingestion list, so the bridge's own source was empty.
43+
- The built-in seeder left `managed_by` at the column's `'admin'` default, which
44+
made platform templates masquerade as admin-authored. Since the bridge refuses
45+
to overwrite admin rows, a built-in would have permanently outranked the
46+
template an app declared. Built-ins now stamp `managed_by: 'platform'`.
47+
- Nothing materialized declared metadata into rows.
48+
49+
**Seed-not-clobber** mirrors `sys_webhook` (#3489) and `sys_sharing_rule`
50+
(#2909): `sys_email_template` gains `managed_by` / `customized`. Declared
51+
templates re-seed every boot as `managed_by: 'package'`; a row an admin created
52+
(`admin`) or edited (`customized`, stamped by a `beforeUpdate` hook) is never
53+
overwritten, so reworded transactional mail survives redeploys. This is a
54+
separate axis from `is_system`, which keeps its existing meaning for built-ins.
55+
56+
The `email_template` liveness ledger flips from 13 dead properties to fully
57+
live, with an ADR-0054 runtime proof bound on `subject`
58+
(`email-template-materialization`): it boots a real stack, authors a template
59+
that overrides a built-in auth template, and asserts the **authored** wording is
60+
what reaches the transport.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: `job` is a code artifact — runtime creation and org overrides are withdrawn (#4509)
6+
7+
A `job` metadata item created at runtime could never be scheduled. `JobSchema.handler`
8+
names a function in the **compiled bundle's function table** — the schema says so
9+
("must match a key in `defineStack({ functions })`") and the scheduler is built that
10+
way: `AppPlugin` sources jobs from `bundle.jobs` alone and resolves each handler
11+
through `collectBundleFunctions(bundle)`, skipping any job whose handler is not in
12+
that table. Yet the type was registered `allowRuntimeCreate: true` (and
13+
`allowOrgOverride: true`), so a job authored in Studio or through `PUT /meta` parsed,
14+
saved, reported success — and never ran.
15+
16+
Unlike the sibling disconnects closed in this batch, this one **cannot be bridged**.
17+
The runtime writer does not have the bundle and cannot name a function inside it; the
18+
missing piece is a handler-binding design, not an ingestion path. Under ADR-0049
19+
enforce-or-remove, the honest move is to close the door:
20+
21+
- `allowRuntimeCreate: false` — no "create job" in Studio or via `PUT /meta`.
22+
- `allowOrgOverride: false` — no per-org job fork, which was unreachable for the same
23+
reason.
24+
25+
**`job` remains a first-class authorable type.** `*.job.ts` / `*.job.yml` /
26+
`*.job.json` files and `defineStack({ jobs })` are the supported doors, and they are
27+
fully enforced — every schedule shape, `retryPolicy`, `timeout` and `enabled` reach
28+
the scheduler. The kind stays in the metadata registry because its file loader is
29+
genuinely consumed (ADR-0088 admission test).
30+
31+
**If you were creating jobs at runtime:** move the definition into your stack
32+
(`defineStack({ jobs, functions })`) so the handler resolves against a real function.
33+
Rows already in `sys_metadata` are left untouched — they were never scheduled, so
34+
nothing changes behaviorally; `migrateStoredMetadata` now reports them `skipped`, the
35+
same way it does for `agent`.
36+
37+
Re-opening the type means constraining `handler` to something a runtime writer can
38+
name — an already-registered flow, or a named and separately governed function — and
39+
building the bridge to `IJobService.schedule`. Flipping the flag without that work
40+
just restores the silent no-op.
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: {

0 commit comments

Comments
 (0)