Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/email-template-materializer-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/platform-objects": minor
"@objectstack/plugin-email": minor
"@objectstack/objectql": minor
"@objectstack/spec": patch
---

feat(email): declared email templates reach the mail service (#4509)

Authoring an `email_template` was a silent no-op. `EmailService.sendTemplate`
resolves `(name, locale)` against **`sys_email_template` rows**, and the only
writers of those rows were the built-in auth templates plus a code-constructed
`EmailServicePluginOptions.templates` that no bootstrapper ever passed. Every
door an author can actually use — a stack's `emailTemplates:`, an
`*.email-template.ts` file, Studio's metadata-admin list, `PUT /meta` — parked
items in a metadata store nothing read back. So an admin could "fix" the
password-reset email in Studio, get a success toast, and watch users keep
receiving the built-in copy: ADR-0078 false compliance on **authentication
mail**. This is the shape #3461 had for webhooks, closed the same way (ADR-0049
enforce-or-remove, route: enforce).

**`bootstrapDeclaredEmailTemplates`** now materializes declared templates into
`sys_email_template` at boot. Each item is validated through
`EmailTemplateDefinitionSchema.parse()` — the spec schema finally has a real
consumer, defaults and all — and projected with `mapTemplateToRow`, which is the
**same** mapping the built-in seeder uses, extracted and shared so the two doors
cannot drift apart. A malformed template warns and is skipped rather than
crashing boot.

**Runtime writes take effect immediately.** Unlike `webhook`, `email_template`
is `allowRuntimeCreate: true`, so a boot-only bridge would have left a Studio
save inert until the next restart — the same bug, half-fixed. The plugin also
subscribes to `email_template` metadata changes and re-materializes the single
changed item; withdrawing a template deactivates its rows (across locales)
rather than deleting them.

**Three breaks sat on this path, not one**, and closing any two of them would
still have shipped a template that never sent:

- `@objectstack/objectql` never registered a manifest's `emailTemplates:` into
the metadata registry at all — the key was simply missing from the generic
ingestion list, so the bridge's own source was empty.
- The built-in seeder left `managed_by` at the column's `'admin'` default, which
made platform templates masquerade as admin-authored. Since the bridge refuses
to overwrite admin rows, a built-in would have permanently outranked the
template an app declared. Built-ins now stamp `managed_by: 'platform'`.
- Nothing materialized declared metadata into rows.

**Seed-not-clobber** mirrors `sys_webhook` (#3489) and `sys_sharing_rule`
(#2909): `sys_email_template` gains `managed_by` / `customized`. Declared
templates re-seed every boot as `managed_by: 'package'`; a row an admin created
(`admin`) or edited (`customized`, stamped by a `beforeUpdate` hook) is never
overwritten, so reworded transactional mail survives redeploys. This is a
separate axis from `is_system`, which keeps its existing meaning for built-ins.

The `email_template` liveness ledger flips from 13 dead properties to fully
live, with an ADR-0054 runtime proof bound on `subject`
(`email-template-materialization`): it boots a real stack, authors a template
that overrides a built-in auth template, and asserts the **authored** wording is
what reaches the transport.
40 changes: 40 additions & 0 deletions .changeset/job-runtime-create-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/spec": major
---

feat(spec)!: `job` is a code artifact — runtime creation and org overrides are withdrawn (#4509)

A `job` metadata item created at runtime could never be scheduled. `JobSchema.handler`
names a function in the **compiled bundle's function table** — the schema says so
("must match a key in `defineStack({ functions })`") and the scheduler is built that
way: `AppPlugin` sources jobs from `bundle.jobs` alone and resolves each handler
through `collectBundleFunctions(bundle)`, skipping any job whose handler is not in
that table. Yet the type was registered `allowRuntimeCreate: true` (and
`allowOrgOverride: true`), so a job authored in Studio or through `PUT /meta` parsed,
saved, reported success — and never ran.

Unlike the sibling disconnects closed 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. Under ADR-0049
enforce-or-remove, the honest move is to close the door:

- `allowRuntimeCreate: false` — no "create job" in Studio or via `PUT /meta`.
- `allowOrgOverride: false` — no per-org job fork, which was unreachable for the same
reason.

**`job` remains a first-class authorable type.** `*.job.ts` / `*.job.yml` /
`*.job.json` files and `defineStack({ jobs })` are the supported doors, and they are
fully enforced — every schedule shape, `retryPolicy`, `timeout` and `enabled` reach
the scheduler. The kind stays in the metadata registry because its file loader is
genuinely consumed (ADR-0088 admission test).

**If you were creating jobs at runtime:** move the definition into your stack
(`defineStack({ jobs, functions })`) so the handler resolves against a real function.
Rows already in `sys_metadata` are left untouched — they were never scheduled, so
nothing changes behaviorally; `migrateStoredMetadata` now reports them `skipped`, the
same way it does for `agent`.

Re-opening the type means constraining `handler` to something a runtime writer can
name — an already-registered flow, or a named and separately governed function — and
building the bridge to `IJobService.schedule`. Flipping the flag without that work
just restores the silent no-op.
55 changes: 55 additions & 0 deletions .changeset/validation-kind-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@objectstack/spec": major
"@objectstack/metadata-core": major
"@objectstack/metadata-protocol": minor
"@objectstack/platform-objects": minor
---

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 that expected 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[]` array, on insert and on every matched update row.

So a rule created through the standalone door — a `*.validation.ts` file, 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 at all.

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 closed in this batch, it could not be bridged into
one — the shape has nowhere to name its object.

**The rule vocabulary is untouched.** `ValidationRuleSchema` and all six
variants are unchanged and fully live; the engine's evaluation path is not
modified by this change. It is the *kind* that was inert, not the schema. The
liveness ledger keeps governing it through the gate's `SPEC_ONLY_SCHEMAS`
override (alongside `webhook` and `query`), because an ungoverned live schema is
exactly how the next drift would hide.

**Migration.** Move the rule into the owning object's `validations:` array — the
rule body is identical, same schema, same six variants:

```ts
// before — a standalone *.validation.ts, which never ran
export default defineValidation({ name: 'amount_positive', type: 'script', … })

// after — on the object, where rules are evaluated
ObjectSchema.create({
name: 'invoice',
validations: [{ name: 'amount_positive', type: 'script', … }],
})
```

Removed: the registry entry (and its `*.validation.ts` / `*.validation.yml`
patterns), the `MetadataTypeSchema` member, the metadata-core lockstep enum
member, the schema-map entry, the create seed, Studio's Validations nav item and
its hand-crafted form, and the dangling reference-tracker row. Standalone rows
already in `sys_metadata` are left alone — they were never evaluated, so nothing
changes behaviorally.
4 changes: 2 additions & 2 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ Metadata query with filtering, sorting, and pagination

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **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 |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
Expand Down Expand Up @@ -363,7 +363,7 @@ Metadata query with filtering, sorting, and pagination

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **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 |
| **name** | `string` | ✅ | Item name (snake_case) |
| **data** | `Record<string, any>` | ✅ | Metadata payload |
| **namespace** | `string` | optional | Optional namespace |
Expand Down
7 changes: 3 additions & 4 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **event** | `Enum<'metadata.registered' \| 'metadata.updated' \| 'metadata.unregistered' \| 'metadata.validated' \| 'metadata.deployed' \| 'metadata.overlay.applied' \| 'metadata.overlay.removed' \| 'metadata.imported' \| 'metadata.exported'>` | ✅ | Event type |
| **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 |
| **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 |
| **name** | `string` | ✅ | Metadata item name |
| **namespace** | `string` | optional | Namespace |
| **packageId** | `string` | optional | Owning package ID |
Expand Down Expand Up @@ -183,7 +183,7 @@ const result = MetadataBulkRegisterRequest.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **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 |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
Expand Down Expand Up @@ -218,7 +218,6 @@ const result = MetadataBulkRegisterRequest.parse(data);

* `object`
* `field`
* `validation`
* `hook`
* `seed`
* `mapping`
Expand Down Expand Up @@ -252,7 +251,7 @@ const result = MetadataBulkRegisterRequest.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **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 |
| **label** | `string` | ✅ | Display label for the metadata type |
| **description** | `string` | optional | Description of the metadata type |
| **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type |
Expand Down
45 changes: 45 additions & 0 deletions docs/adr/0088-metadata-kind-admission-and-retirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,48 @@ The cached remote-schema snapshot of a federated datasource (ADR-0062) has a rea
- `OPS_FILE_SUFFIX_REGEX` drops the four suffixes: `*.trigger.ts` / `*.router.ts` / `*.function.ts` / `*.service.ts` are no longer valid OPS metadata file names.
- 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.
- ADR-0005 / ADR-0010 prose tables no longer list the retired kinds.

## Addendum (2026-08): `validation` retired — the admission test's first clause

`validation` was registered as a kind with `allowRuntimeCreate: true`, a
`*.validation.ts` loader, and a Studio form. On the admission test above it
nonetheless fails the **first** clause — independent lifecycle — and the failure
is not cosmetic:

- **No independent lifecycle.** A rule only means something against an object,
and the only shape the engine evaluates is `object.validations[]`
(`evaluateValidationRules` on insert and on every matched update row).
- **No way to bind.** `ValidationRuleSchema` carries no `object` / `objectName`
key, and all six variants are `strictObject`, so an author could not supply
one either — the parse would reject it. There was no merge step, and the only
code that expected such a key was a reference-tracker row scanning a field
that could never exist.

So the standalone door led nowhere: an item authored through it — including a
`state_machine` rule, which ADR-0020 explicitly routes through this same
vocabulary — saved cleanly, reported success, and intercepted no write. That is
the ADR-0049 false-compliance shape, on a surface authors reasonably expect to
gate their data.

The kind is removed (registry entry, `MetadataTypeSchema` member, metadata-core
lockstep enum, schema map entry, Studio nav item and hand-crafted form, create
seed, and the dangling reference row). `ValidationRuleSchema` itself is
**unchanged and fully live** — it is the kind that was inert, not the
vocabulary. The liveness ledger keeps governing the schema through the gate's
`SPEC_ONLY_SCHEMAS` override, alongside `webhook` and `query`, precisely because
an ungoverned live schema is how the next drift would hide.

Note the contrast with the sibling disconnects closed in the same batch (#4509).
`email_template` had a real feature with missing wiring, so enforce-or-remove
resolved it by **enforcing** — a materializer bridge. `validation` had a shape
that could not carry the feature at all, so it resolves by **removing**. The
test is not "is this dead?" but "can this be made to work as declared?".

- `MetadataTypeSchema` and `DEFAULT_METADATA_TYPE_REGISTRY` shrink 26 → 25.
- `*.validation.ts` / `*.validation.yml` are no longer metadata file patterns.
(`OPS_FILE_SUFFIX_REGEX` never listed them — no change there.)
- Persisted standalone `sys_metadata` rows are left alone. They were never
evaluated, so nothing changes behaviorally; `migrateStoredMetadata` declines
them like any unregistered type.
- **Migration for authors:** move the rule into the object's `validations:`
array. The rule body is unchanged — same schema, same six variants.
17 changes: 6 additions & 11 deletions examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,19 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
object: {
status: 'demonstrated',
files: ['src/data/objects/index.ts', 'src/data/objects/field-zoo.object.ts'],
notes:
'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.',
},
field: {
status: 'demonstrated',
files: ['src/data/objects/field-zoo.object.ts'],
notes:
'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.',
},
validation: {
status: 'demonstrated',
files: [
'src/data/objects/account.object.ts',
'src/data/objects/task.object.ts',
'src/data/objects/project.object.ts',
'src/data/objects/invoice.object.ts',
],
notes:
'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.',
},
// `validation` was retired as a KIND in #4509 (ADR-0088) — the coverage test
// fails on any entry the registry no longer knows. The rules themselves are
// unchanged and still demonstrated; that coverage moved onto `object`, which
// is where they are authored.
hook: { status: 'demonstrated', files: ['src/data/hooks/index.ts'] },
seed: { status: 'demonstrated', files: ['src/data/seed/index.ts'] },
mapping: {
Expand Down
Loading
Loading