diff --git a/.changeset/email-template-materializer-bridge.md b/.changeset/email-template-materializer-bridge.md new file mode 100644 index 0000000000..a8f329b4de --- /dev/null +++ b/.changeset/email-template-materializer-bridge.md @@ -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. diff --git a/.changeset/job-runtime-create-closed.md b/.changeset/job-runtime-create-closed.md new file mode 100644 index 0000000000..11d36fd7fa --- /dev/null +++ b/.changeset/job-runtime-create-closed.md @@ -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. diff --git a/.changeset/validation-kind-retired.md b/.changeset/validation-kind-retired.md new file mode 100644 index 0000000000..bfab463b53 --- /dev/null +++ b/.changeset/validation-kind-retired.md @@ -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. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index df19cac6f4..21182b5916 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -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 | @@ -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` | ✅ | Metadata payload | | **namespace** | `string` | optional | Optional namespace | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 84e932554f..b7604e5ea9 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -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 | @@ -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 | @@ -218,7 +218,6 @@ const result = MetadataBulkRegisterRequest.parse(data); * `object` * `field` -* `validation` * `hook` * `seed` * `mapping` @@ -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 | diff --git a/docs/adr/0088-metadata-kind-admission-and-retirement.md b/docs/adr/0088-metadata-kind-admission-and-retirement.md index 6865755408..cd60c1901c 100644 --- a/docs/adr/0088-metadata-kind-admission-and-retirement.md +++ b/docs/adr/0088-metadata-kind-admission-and-retirement.md @@ -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. diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index d0fb7a656f..81e6071574 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -54,6 +54,8 @@ export const KIND_COVERAGE: Record = { 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', @@ -61,17 +63,10 @@ export const KIND_COVERAGE: Record = { 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: { diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 4997ccaa25..ec70d3e388 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -284,9 +284,14 @@ describe('lintLivenessProperties', () => { expect(perms!.hint).toMatch(/per item|Per-item/i); }); - // email_template: the WHOLE authoring surface is disconnected from - // sendTemplate (webhook shape) — one per-artifact warn carried on `name`. - it('warns once per email_template artifact via name (#4488)', () => { + // email_template used to carry a per-artifact warn on `name`: the WHOLE + // authoring surface was disconnected from sendTemplate (the webhook shape). + // #4509 built the materializer bridge, so authoring is no longer a no-op and + // a well-formed template must warn about NOTHING. The type stays in + // TYPE_COLLECTIONS — a listed type with zero warns is the resolved state + // (webhook sits there the same way), and keeping it means a future + // regression that re-deadens a prop starts warning again on its own. + it('does not warn on a well-formed email_template — the bridge closed it (#4509)', () => { const findings = lintLivenessProperties({ emailTemplates: [{ name: 'crm.welcome', @@ -295,9 +300,7 @@ describe('lintLivenessProperties', () => { bodyHtml: '

Welcome

', }], }); - const hit = findings.find((f) => f.message.includes('`name`')); - expect(hit).toBeDefined(); - expect(hit!.hint).toMatch(/sys_email_template/); + expect(findings).toEqual([]); }); // translation.validationMessages: pointed at by #3778's own migration table, diff --git a/packages/metadata-core/src/types.ts b/packages/metadata-core/src/types.ts index 09f646c6d7..eae2a56ec6 100644 --- a/packages/metadata-core/src/types.ts +++ b/packages/metadata-core/src/types.ts @@ -19,7 +19,7 @@ import { z } from 'zod'; export const MetadataTypeSchema = z.enum([ 'object', 'field', - 'validation', + // ADR-0088 (#4509): no `validation` kind — rules are inline `object.validations[]`. 'hook', 'mapping', 'view', diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a3fb503d90..6c2db0d5f4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -261,74 +261,13 @@ const HAND_CRAFTED_SCHEMAS: Record> = { required: ['name', 'label', 'type'], additionalProperties: true, }, - // Validation rules live inside `object.validations[]`. The canonical - // ValidationRuleSchema is a discriminated union of 6 variants; the - // generic SchemaForm renderer treats unions as opaque JSON, so we - // ship a *flat* form-friendly schema covering the common base - // properties plus every variant-specific field as optional. Save-time - // validation is unaffected — the union schema is still authoritative - // at write time. - validation: { - type: 'object', - properties: { - // --- Base fields (all variants) --- - name: { type: 'string', description: 'Unique rule name (snake_case)' }, - label: { type: 'string' }, - description: { type: 'string' }, - type: { - type: 'string', - enum: [ - 'script', - 'state_machine', - 'format', - 'cross_field', - 'json_schema', - 'conditional', - ], - default: 'script', - description: 'Validation variant', - }, - active: { type: 'boolean', default: true }, - events: { - type: 'array', - items: { type: 'string', enum: ['insert', 'update'] }, - default: ['insert', 'update'], - }, - priority: { type: 'number', default: 100, minimum: 0, maximum: 9999 }, - severity: { - type: 'string', - enum: ['error', 'warning', 'info'], - default: 'error', - }, - message: { type: 'string' }, - tags: { type: 'array', items: { type: 'string' } }, - // --- Variant-specific (all optional, gated by `type`) --- - condition: { - type: 'string', - description: 'CEL predicate (type=script). True ⇒ validation fails.', - }, - fields: { - type: 'array', - items: { type: 'string' }, - description: 'Fields (type=cross_field).', - }, - field: { type: 'string', description: 'Single field (type=state_machine / format).' }, - transitions: { - type: 'object', - additionalProperties: { type: 'array', items: { type: 'string' } }, - description: 'Map { OldState: [AllowedNewStates] } (type=state_machine).', - }, - regex: { type: 'string', description: 'Regex (type=format).' }, - format: { - type: 'string', - enum: ['email', 'url', 'phone', 'json'], - description: 'Built-in format (type=format).', - }, - when: { type: 'string', description: 'Outer condition (type=conditional).' }, - }, - required: ['name', 'type', 'message'], - additionalProperties: true, - }, + // ADR-0088 (#4509): the `validation` kind is retired, so its hand-crafted + // form goes with it. Rules are authored inside `object.validations[]` and + // edited on the object; there is no standalone validation editor to render + // a schema for. (The form was flat by necessity — ValidationRuleSchema is a + // 6-variant discriminated union the generic SchemaForm treats as opaque — + // and it had no field for the object being validated, which is precisely + // the gap that retired the kind: a rule saved here bound to nothing.) }; /** @@ -1374,7 +1313,11 @@ const REFERENCE_PATHS: Record { // (artifact-free) names succeed. Tested separately below. // // 2. Types with `allowRuntimeCreate: false` — after ADR-0088 retired the - // router/function/service placeholder kinds, `agent` (platform-owned, - // ADR-0063) is the remaining member — blocked for ANY write in - // project-kernel mode. + // router/function/service placeholder kinds, the members are `agent` + // (platform-owned, ADR-0063) and `job` (a code artifact: its `handler` + // names a function in the compiled bundle's function table, so a + // runtime-created job could never be scheduled — #4509) — blocked for + // ANY write in project-kernel mode. // // NOTE: `datasource` moved to cohort #1 with the ADR-0015 Addendum // (runtime-UI-creatable datasources). Brand-new runtime datasources @@ -186,6 +188,11 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { reason: 'agents are platform-owned (ADR-0063); per-org agent forks are withdrawn', item: { name: 'my_agent', label: 'My Agent' }, }, + { + type: 'job', + reason: 'jobs are code artifacts (#4509): `handler` resolves only through the compiled bundle function table, so a runtime-created job could never be scheduled', + item: { name: 'nightly_sync', label: 'Nightly Sync', schedule: '0 2 * * *', handler: 'syncAll' }, + }, ]; for (const { type, reason, item } of deniedTypeWide) { @@ -212,15 +219,9 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { describe('runtime-creatable (allowOrgOverride:false, allowRuntimeCreate:true) — brand-new items succeed', () => { const runtimeCreatable: Array<{ type: string; item: any }> = [ { type: 'trigger', item: { name: 'on_insert', object: 'case', event: 'beforeInsert' } }, - { - type: 'validation', - item: { - name: 'require_name', - type: 'script', - message: 'Name required', - condition: 'record.name == null', - }, - }, + // `validation` left this list with the kind (#4509, ADR-0088): it is + // no longer registered, so "runtime-creatable" no longer describes + // it. The reintroduction guard below is what holds the line now. { type: 'hook', item: { name: 'before_save', object: 'case', events: ['beforeInsert'] } }, { type: 'hooks', item: { name: 'before_save', object: 'case', events: ['beforeInsert'] } }, // plural // object/field reverted to allowOrgOverride:false on 2026-05-29 — @@ -318,7 +319,8 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { // Execution/wiring-layer types must NOT be in the set. // Accepting them as overlays would corrupt runtime semantics. // (trigger/router/function/service were retired outright by - // ADR-0088 — the asserts double as reintroduction guards.) + // ADR-0088, and `validation` by #4509 under the same ADR — the + // asserts double as reintroduction guards.) expect(allowedFromRegistry.has('trigger')).toBe(false); expect(allowedFromRegistry.has('validation')).toBe(false); expect(allowedFromRegistry.has('hook')).toBe(false); diff --git a/packages/objectql/src/protocol-meta-types-rich.test.ts b/packages/objectql/src/protocol-meta-types-rich.test.ts index 5720233d9d..b7215e443c 100644 --- a/packages/objectql/src/protocol-meta-types-rich.test.ts +++ b/packages/objectql/src/protocol-meta-types-rich.test.ts @@ -33,10 +33,11 @@ describe('ObjectStackProtocolImplementation - getMetaTypes rich response', () => registry.registerItem('flow', { name: 'crm.onboard', steps: [] }, 'name'); // Register control types so getMetaTypes() includes them in `entries` // (getMetaTypes only returns types present in getRegisteredTypes()). - // `hook`/`validation`/`external_catalog` are registry-default + // `hook`/`seed`/`external_catalog` are registry-default // `allowOrgOverride: false` — used by the OS_METADATA_WRITABLE tests. + // (`validation` used to stand here; #4509 retired the kind.) registry.registerItem('hook', { name: 'audit_stamp' }, 'name'); - registry.registerItem('validation', { name: 'amount_positive' }, 'name'); + registry.registerItem('seed', { name: 'demo_rows' }, 'name'); registry.registerItem('external_catalog', { name: 'warehouse_snapshot' }, 'name'); mockEngine = { @@ -92,10 +93,11 @@ describe('ObjectStackProtocolImplementation - getMetaTypes rich response', () => }); it('honours OS_METADATA_WRITABLE to elevate allowOrgOverride', async () => { - // Use `hook` and `validation` — both are registry-default + // Use `hook` and `seed` — both are registry-default // `allowOrgOverride: false` (ADR-0088 retired the former code-only - // placeholder kinds this test used to lean on). - process.env.OS_METADATA_WRITABLE = 'hook,validation'; + // placeholder kinds this test used to lean on, and #4509 retired + // `validation`, which stood here until then). + process.env.OS_METADATA_WRITABLE = 'hook,seed'; ObjectStackProtocolImplementation.resetEnvWritableCache(); const result: any = await protocol.getMetaTypes(); @@ -116,8 +118,9 @@ describe('ObjectStackProtocolImplementation - getMetaTypes rich response', () => const scoped = new ObjectStackProtocolImplementation(mockEngine, undefined, 'env_alpha'); mockEngine.findOne.mockResolvedValue(null); - // Without env var: `agent` writes blocked — the one remaining - // `allowRuntimeCreate: false` kind (platform-owned, ADR-0063). Since + // Without env var: `agent` writes blocked — one of the two + // `allowRuntimeCreate: false` kinds (platform-owned, ADR-0063; the + // other is `job`, a code artifact — #4509). Since // the test registry has no artifact at this name, the protocol // returns `not_creatable` (the precise reason); for artifact-backed // names the code would be `not_overridable`. Both indicate the gate diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index d3d524feb2..480ea7c92e 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1314,7 +1314,10 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(result.success).toBe(true); }); - it('accepts brand-new trigger and validation (allowRuntimeCreate:true)', async () => { + // `validation` used to ride along here; #4509 retired the kind + // (ADR-0088), so `seed` — still registry-default allowRuntimeCreate — + // stands in as the second case. + it('accepts brand-new trigger and seed (allowRuntimeCreate:true)', async () => { mockEngine.findOne.mockResolvedValue(null); const triggerResult = await scoped.saveMetaItem({ @@ -1323,20 +1326,15 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { item: { name: 'my_trigger', object: 'case', event: 'beforeInsert' }, organizationId: 'org_alpha', }); - const validationResult = await scoped.saveMetaItem({ - type: 'validation', - name: 'my_validation', - item: { - name: 'my_validation', - type: 'script', - message: 'Amount must be positive', - condition: 'record.amount < 0', - }, + const seedResult = await scoped.saveMetaItem({ + type: 'seed', + name: 'my_seed', + item: { object: 'case', records: [] }, organizationId: 'org_alpha', }); expect(triggerResult.success).toBe(true); - expect(validationResult.success).toBe(true); + expect(seedResult.success).toBe(true); }); it('rejects brand-new agent with not_creatable (allowRuntimeCreate:false)', async () => { diff --git a/packages/platform-objects/src/apps/studio.app.ts b/packages/platform-objects/src/apps/studio.app.ts index 08bfd8fc32..079b7fc16e 100644 --- a/packages/platform-objects/src/apps/studio.app.ts +++ b/packages/platform-objects/src/apps/studio.app.ts @@ -130,14 +130,11 @@ export const STUDIO_APP: AppInput = { params: { type: 'object', package: '{active_package}' }, icon: 'box', }, - { - id: 'nav_validations', - type: 'component', - label: 'Validations', - componentRef: 'metadata:resource', - params: { type: 'validation', package: '{active_package}' }, - icon: 'check-square', - }, + // `nav_validations` removed (#4509): the `validation` kind is retired + // (ADR-0088), so this list had nothing to list. Validation rules are + // authored on the object as `validations:` and edited there — a + // standalone rule never bound to an object, so every rule created + // through this nav item saved cleanly and intercepted no write. ], }, { diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 1c80a8c336..a907eb829d 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -444,9 +444,6 @@ export const enMetadataForms: NonNullable = { } } }, - validation: { - label: "Validation Rule" - }, hook: { label: "Hook", sections: { diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 8598288d7f..32e52f10bf 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -2150,6 +2150,19 @@ export const enObjects: NonNullable = { label: "Variables (JSON)", help: "JSON array of {name,type,required,description}" }, + managed_by: { + label: "Managed By", + help: "Record provenance: platform = framework built-in / package = app/package-declared (boot-seeded from declared email_template metadata) / admin = created in Studio.", + options: { + platform: "platform", + package: "package", + admin: "admin" + } + }, + customized: { + label: "Customized", + help: "Set when an admin edits a package-declared template; boot seeding will no longer overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows." + }, created_at: { label: "Created At" }, diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 1a113fd1ff..214ed621c7 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -122,7 +122,6 @@ export const en: TranslationData = { nav_packages: { label: 'Packages' }, group_data_model: { label: 'Data Model' }, nav_objects: { label: 'Objects' }, - nav_validations: { label: 'Validations' }, group_ux: { label: 'User Experience' }, nav_apps: { label: 'Apps' }, nav_views: { label: 'Views' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 5b7712bdfc..36d649cae8 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -444,9 +444,6 @@ export const esESMetadataForms: NonNullable = } } }, - validation: { - label: "Regla de validación" - }, hook: { label: "Gancho", sections: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index fe10171352..6d200672ed 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -2150,6 +2150,19 @@ export const esESObjects: NonNullable = { label: "Variables (JSON)", help: "Matriz JSON de {name,type,required,description}." }, + managed_by: { + label: "Managed By", + help: "Record provenance: platform = framework built-in / package = app/package-declared (boot-seeded from declared email_template metadata) / admin = created in Studio.", + options: { + platform: "platform", + package: "package", + admin: "admin" + } + }, + customized: { + label: "Customized", + help: "Set when an admin edits a package-declared template; boot seeding will no longer overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows." + }, created_at: { label: "Creado el" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index accec65e09..504b9b0163 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -98,7 +98,6 @@ export const esES: TranslationData = { nav_packages: { label: 'Paquetes' }, group_data_model: { label: 'Modelo de datos' }, nav_objects: { label: 'Objetos' }, - nav_validations: { label: 'Validaciones' }, group_ux: { label: 'Experiencia de usuario' }, nav_apps: { label: 'Aplicaciones' }, nav_views: { label: 'Vistas' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index f43215c40c..3b57001a82 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -444,9 +444,6 @@ export const jaJPMetadataForms: NonNullable = } } }, - validation: { - label: "検証ルール" - }, hook: { label: "フック", sections: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index d9cf363c93..76ab4b657b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -2150,6 +2150,19 @@ export const jaJPObjects: NonNullable = { label: "変数(JSON)", help: "{name,type,required,description} の JSON 配列" }, + managed_by: { + label: "Managed By", + help: "Record provenance: platform = framework built-in / package = app/package-declared (boot-seeded from declared email_template metadata) / admin = created in Studio.", + options: { + platform: "platform", + package: "package", + admin: "admin" + } + }, + customized: { + label: "Customized", + help: "Set when an admin edits a package-declared template; boot seeding will no longer overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows." + }, created_at: { label: "作成日時" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 2d884cea13..70c8162c54 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -98,7 +98,6 @@ export const jaJP: TranslationData = { nav_packages: { label: 'パッケージ' }, group_data_model: { label: 'データモデル' }, nav_objects: { label: 'オブジェクト' }, - nav_validations: { label: 'バリデーション' }, group_ux: { label: 'ユーザー体験' }, nav_apps: { label: 'アプリ' }, nav_views: { label: 'ビュー' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 94999027f2..405fd721b8 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -444,9 +444,6 @@ export const zhCNMetadataForms: NonNullable = } } }, - validation: { - label: "验证规则" - }, hook: { label: "钩子", sections: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 3c5b507286..828e6fd71e 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -2150,6 +2150,19 @@ export const zhCNObjects: NonNullable = { label: "变量(JSON)", help: "形如 {name,type,required,description} 的 JSON 数组" }, + managed_by: { + label: "Managed By", + help: "Record provenance: platform = framework built-in / package = app/package-declared (boot-seeded from declared email_template metadata) / admin = created in Studio.", + options: { + platform: "platform", + package: "package", + admin: "admin" + } + }, + customized: { + label: "Customized", + help: "Set when an admin edits a package-declared template; boot seeding will no longer overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows." + }, created_at: { label: "创建时间" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index d344d64ac3..c9a87e5702 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -101,7 +101,6 @@ export const zhCN: TranslationData = { nav_packages: { label: '软件包' }, group_data_model: { label: '数据模型' }, nav_objects: { label: '对象' }, - nav_validations: { label: '校验规则' }, group_ux: { label: '用户体验' }, nav_apps: { label: '应用' }, nav_views: { label: '视图' }, diff --git a/packages/platform-objects/src/audit/sys-email-template.object.ts b/packages/platform-objects/src/audit/sys-email-template.object.ts index 3b70534de1..83f991ba7b 100644 --- a/packages/platform-objects/src/audit/sys-email-template.object.ts +++ b/packages/platform-objects/src/audit/sys-email-template.object.ts @@ -145,6 +145,40 @@ export const SysEmailTemplate = ObjectSchema.create({ group: 'Lifecycle', }), + // ── Provenance (#4509 — record-authoritative seed-not-clobber) ── + // Mirrors sys_webhook (#3461) / sys_sharing_rule (#2909). `is_system` + // remains the built-in-auth-template axis; these two track the DECLARED + // metadata door: bootstrapDeclaredEmailTemplates seeds `package` rows and + // re-seeds them every boot, while an admin's edit stamps `customized` and + // freezes the row. Both are `readonly` — the engine strips them from + // non-system payloads, and only the seeder / stamp hook (isSystem) write + // them. Deliberately NOT a write gate: editing a template in Studio is a + // first-class admin action, it just has to be remembered. + managed_by: Field.select( + ['platform', 'package', 'admin'], + { + label: 'Managed By', + required: false, + readonly: true, + defaultValue: 'admin', + description: + 'Record provenance: platform = framework built-in / package = app/package-declared ' + + '(boot-seeded from declared email_template metadata) / admin = created in Studio.', + group: 'System', + }, + ), + + customized: Field.boolean({ + label: 'Customized', + required: false, + readonly: true, + defaultValue: false, + description: + 'Set when an admin edits a package-declared template; boot seeding will no longer ' + + 'overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows.', + group: 'System', + }), + created_at: Field.datetime({ label: 'Created At', required: true, diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts new file mode 100644 index 0000000000..fd75060ec1 --- /dev/null +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts @@ -0,0 +1,350 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapDeclaredEmailTemplates — the ingestion bridge that closes #4509 (1). + * + * Verifies that declared `email_template` metadata is materialized into the + * `sys_email_template` rows `sendTemplate` actually reads — keyed by + * `(name, locale)`, idempotently, without clobbering admin edits — and that a + * withdrawn template stops being sent. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + bootstrapDeclaredEmailTemplates, + upsertDeclaredEmailTemplate, + deactivateDeclaredEmailTemplate, + mapTemplateToRow, +} from './bootstrap-declared-email-templates.js'; +import { bindEmailTemplateProvenanceStamp } from './email-template-provenance.js'; + +// --------------------------------------------------------------------------- +// Fakes — mirrors the ObjectQL surface the bridge and the stamp hook touch. +// --------------------------------------------------------------------------- + +interface HookEntry { + event: string; + handler: (ctx: any) => any; + object?: string; + packageId?: string; +} + +class FakeEngine { + rows: Record = {}; + private hooks: HookEntry[] = []; + private declared: Record = {}; + + constructor(seed?: { rows?: Record; declared?: Record }) { + if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows)); + if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared)); + } + + get _registry() { + return { + listItems: (type: string) => (this.declared[type] ?? []).map((content) => ({ content })), + }; + } + + private matches(row: any, cond?: Record): boolean { + if (!cond) return true; + return Object.entries(cond).every(([k, v]) => row[k] === v); + } + + async find(name: string, q?: any): Promise { + const all = this.rows[name] ?? []; + const cond = q?.filter ?? q?.where; + const out = all.filter((r) => this.matches(r, cond)); + return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; + } + async insert(name: string, data: any): Promise { + const arr = (this.rows[name] = this.rows[name] ?? []); + arr.push({ ...data }); + return data; + } + async update(name: string, data: any, opts?: any): Promise { + const id = data?.id ?? opts?.where?.id; + const ctx = { input: { id, data }, session: opts?.context }; + for (const h of this.hooks) { + if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) { + await h.handler(ctx); + } + } + const arr = this.rows[name] ?? []; + const cond = opts?.where ?? (id ? { id } : undefined); + for (const r of arr) { + if (this.matches(r, cond)) Object.assign(r, data); + } + return { affected: 0 }; + } + + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void { + this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); + } + unregisterHooksByPackage(packageId: string): number { + const before = this.hooks.length; + this.hooks = this.hooks.filter((h) => h.packageId !== packageId); + return before - this.hooks.length; + } +} + +const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] }; +const TABLE = 'sys_email_template'; + +function declaredTemplate(over: Record = {}): any { + return { + name: 'auth.password_reset', + label: 'Password Reset', + category: 'auth', + subject: 'Reset your password, {{user.name}}', + bodyHtml: '

Click here

', + variables: [{ name: 'url', type: 'string', required: true }], + ...over, + }; +} + +function rowsOf(engine: FakeEngine): any[] { + return engine.rows[TABLE] ?? []; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('bootstrapDeclaredEmailTemplates', () => { + it('materializes a declared template into a sys_email_template row with the shared mapping', async () => { + const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + expect(result).toEqual({ seeded: 1, skipped: 0 }); + expect(rowsOf(engine)).toHaveLength(1); + const row = rowsOf(engine)[0]; + // Spec camelCase → row snake_case, and schema defaults applied. + expect(row.name).toBe('auth.password_reset'); + expect(row.body_html).toBe('

Click here

'); + expect(row.locale).toBe('en-US'); + expect(row.active).toBe(true); + expect(row.is_system).toBe(false); + expect(JSON.parse(row.variables_json)).toEqual([ + { name: 'url', type: 'string', required: true }, + ]); + expect(row.managed_by).toBe('package'); + expect(row.customized).toBe(false); + }); + + it('keys rows by (name, locale) — the same template in two locales is two rows', async () => { + const engine = new FakeEngine({ + declared: { + email_template: [ + declaredTemplate({ locale: 'en-US' }), + declaredTemplate({ locale: 'zh-CN', subject: '重置密码' }), + ], + }, + }); + + await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + expect(rowsOf(engine)).toHaveLength(2); + expect(rowsOf(engine).map((r) => r.locale).sort()).toEqual(['en-US', 'zh-CN']); + }); + + it('is idempotent — re-seeding updates in place rather than duplicating', async () => { + const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); + + await bootstrapDeclaredEmailTemplates(engine as any, undefined); + await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + expect(rowsOf(engine)).toHaveLength(1); + }); + + it('propagates an edited declaration to a pristine seeded row', async () => { + const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); + await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + (engine as any).declared = { + email_template: [declaredTemplate({ subject: 'New subject' })], + }; + await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + expect(rowsOf(engine)).toHaveLength(1); + expect(rowsOf(engine)[0].subject).toBe('New subject'); + }); + + it('never clobbers a row an admin has customized (the seed-not-clobber contract)', async () => { + const engine = new FakeEngine({ declared: { email_template: [declaredTemplate()] } }); + await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + // Admin edits the seeded row through a normal (non-system) write; the + // provenance stamp freezes it. + bindEmailTemplateProvenanceStamp(engine as any); + const seeded = rowsOf(engine)[0]; + await engine.update(TABLE, { id: seeded.id, subject: 'Admin wording' }, { context: ADMIN_CTX }); + expect(rowsOf(engine)[0].customized).toBe(true); + + (engine as any).declared = { + email_template: [declaredTemplate({ subject: 'Redeploy wording' })], + }; + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + expect(result).toEqual({ seeded: 0, skipped: 1 }); + expect(rowsOf(engine)[0].subject).toBe('Admin wording'); + }); + + it('skips an admin-authored row of the same (name, locale) with a warning', async () => { + const engine = new FakeEngine({ + rows: { + [TABLE]: [{ + id: 'etpl_admin', + name: 'auth.password_reset', + locale: 'en-US', + subject: 'Admin original', + managed_by: 'admin', + }], + }, + declared: { email_template: [declaredTemplate()] }, + }); + const warn = vi.fn(); + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); + + expect(result).toEqual({ seeded: 0, skipped: 1 }); + expect(rowsOf(engine)[0].subject).toBe('Admin original'); + expect(warn).toHaveBeenCalled(); + }); + + it('adopts a pristine pre-provenance row so future boots recognize it', async () => { + const engine = new FakeEngine({ + rows: { + [TABLE]: [{ + id: 'etpl_legacy', + name: 'auth.password_reset', + locale: 'en-US', + subject: 'Legacy', + }], + }, + declared: { email_template: [declaredTemplate()] }, + }); + + await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + expect(rowsOf(engine)).toHaveLength(1); + expect(rowsOf(engine)[0].managed_by).toBe('package'); + expect(rowsOf(engine)[0].subject).toBe('Reset your password, {{user.name}}'); + }); + + it('skips an invalid declaration without aborting the rest of the batch', async () => { + const engine = new FakeEngine({ + declared: { + email_template: [ + { name: 'broken' }, // no subject / bodyHtml / label + declaredTemplate({ name: 'ops.digest', category: 'notification' }), + ], + }, + }); + const warn = vi.fn(); + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); + + expect(result).toEqual({ seeded: 1, skipped: 1 }); + expect(rowsOf(engine).map((r) => r.name)).toEqual(['ops.digest']); + expect(warn).toHaveBeenCalled(); + }); + + it('no-ops when nothing is declared', async () => { + const engine = new FakeEngine(); + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + expect(result).toEqual({ seeded: 0, skipped: 0 }); + expect(rowsOf(engine)).toHaveLength(0); + }); + + it('falls back to the metadata service when the registry is empty', async () => { + const engine = new FakeEngine(); + const metadataService = { list: () => [{ content: declaredTemplate() }] }; + + const result = await bootstrapDeclaredEmailTemplates(engine as any, metadataService); + + expect(result.seeded).toBe(1); + expect(rowsOf(engine)[0].name).toBe('auth.password_reset'); + }); +}); + +describe('upsertDeclaredEmailTemplate (the live runtime-write path)', () => { + it('materializes a single item — a Studio save takes effect without a restart', async () => { + const engine = new FakeEngine(); + + const written = await upsertDeclaredEmailTemplate( + engine as any, + declaredTemplate({ subject: 'Saved in Studio' }), + ); + + expect(written).toBe(true); + expect(rowsOf(engine)[0].subject).toBe('Saved in Studio'); + }); + + it('rejects a malformed item by throwing, so the caller can warn', async () => { + const engine = new FakeEngine(); + + await expect(upsertDeclaredEmailTemplate(engine as any, { name: 'broken' })) + .rejects.toThrow(); + expect(rowsOf(engine)).toHaveLength(0); + }); +}); + +describe('deactivateDeclaredEmailTemplate (withdrawal)', () => { + it('deactivates every locale of a withdrawn template without deleting rows', async () => { + const engine = new FakeEngine({ + declared: { + email_template: [ + declaredTemplate({ locale: 'en-US' }), + declaredTemplate({ locale: 'zh-CN' }), + ], + }, + }); + await bootstrapDeclaredEmailTemplates(engine as any, undefined); + + const count = await deactivateDeclaredEmailTemplate(engine as any, 'auth.password_reset'); + + expect(count).toBe(2); + expect(rowsOf(engine)).toHaveLength(2); + expect(rowsOf(engine).every((r) => r.active === false)).toBe(true); + }); + + it('leaves admin-authored and customized rows alone', async () => { + const engine = new FakeEngine({ + rows: { + [TABLE]: [ + { id: 'a', name: 'ops.digest', locale: 'en-US', active: true, managed_by: 'admin' }, + { id: 'b', name: 'ops.digest', locale: 'zh-CN', active: true, managed_by: 'package', customized: true }, + ], + }, + }); + + const count = await deactivateDeclaredEmailTemplate(engine as any, 'ops.digest'); + + expect(count).toBe(0); + expect(rowsOf(engine).every((r) => r.active === true)).toBe(true); + }); +}); + +describe('mapTemplateToRow', () => { + it('omits optional columns rather than nulling them, so a re-seed never blanks a field', () => { + const row = mapTemplateToRow({ + name: 'ops.digest', + label: 'Digest', + category: 'notification', + locale: 'en-US', + subject: 'Digest', + bodyHtml: '

hi

', + variables: [], + active: true, + isSystem: false, + } as any); + + expect(row).not.toHaveProperty('body_text'); + expect(row).not.toHaveProperty('reply_to'); + expect(row).not.toHaveProperty('from_address'); + expect(row).not.toHaveProperty('variables_json'); + }); +}); diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts new file mode 100644 index 0000000000..abe362b2cf --- /dev/null +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapDeclaredEmailTemplates — materialize declared `email_template` + * metadata into `sys_email_template` rows so `sendTemplate` can actually see + * them (closes #4509 item 1; same disconnect class as webhook #3461). + * + * ## The disconnect this closes + * `EmailService.sendTemplate` resolves templates through a {@link TemplateLoader} + * that reads `sys_email_template` ROWS. Until now the only writers of those rows + * were the built-in auth templates and `EmailServicePluginOptions.templates` — + * a code-only door that no bootstrapper ever passed. Meanwhile the whole + * authoring surface (stack `emailTemplates:`, `*.email-template.ts`, Studio's + * metadata-admin list, `PUT /meta`) decomposed into `email_template` METADATA + * that nothing read back. An admin could "fix" the password-reset mail in + * Studio, get a success toast, and watch users keep receiving the built-in copy + * — ADR-0078 false compliance on authentication email. This seeder is the + * missing ingestion path. + * + * ## Shape translation (authoring → runtime row) + * Unlike webhooks, there is no shape divergence to reconcile: the metadata + * authoring shape and the seeding input are the SAME spec type + * ({@link EmailTemplateDefinition}), so the mapping is exactly the one the + * built-in seeder already uses — {@link mapTemplateToRow}, shared by both doors + * so they can never drift apart. + * + * Each item is validated through `EmailTemplateDefinitionSchema.parse()` first — + * this gives the spec schema a real consumer (defaults for `locale` / `category` + * / `active` / `isSystem` get applied) and rejects malformed authoring with a + * warning instead of crashing boot. + * + * ## Seed-not-clobber (mirrors sys_webhook #3461, sys_sharing_rule #2909) + * `sys_email_template` is admin-editable (`managedBy: 'config'`). Declared + * templates ship with the app/package, so they seed with `managed_by: 'package'` + * provenance and re-seed on every boot — but a row an admin created + * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by + * {@link bindEmailTemplateProvenanceStamp}) is never overwritten. An admin's + * reworded transactional mail survives redeploys. + * + * Note this is a DIFFERENT axis from `is_system`, which the built-in auth + * template seeder uses and which stays exactly as it was. + * + * ## Runtime writes + * `email_template` is `allowRuntimeCreate: true` (unlike `webhook`), so a + * boot-only bridge would leave a Studio save inert until the next restart — the + * same bug, half-fixed. {@link upsertDeclaredEmailTemplate} is exported for the + * live `metadata.subscribe('email_template', …)` path in EmailServicePlugin. + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { + EmailTemplateDefinitionSchema, + type EmailTemplateDefinition, +} from '@objectstack/spec/system'; + +/** System write context — the boot seeder is not an admin authoring action. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** Default backing object; overridable for tests. */ +export const EMAIL_TEMPLATE_OBJECT = 'sys_email_template'; + +interface Logger { + info?: (msg: string, meta?: unknown) => void; + warn?: (msg: string, meta?: unknown) => void; +} + +/** + * Translate a validated {@link EmailTemplateDefinition} into + * `sys_email_template` column values. + * + * Shared by BOTH doors — the built-in/options seeder (`EmailServicePlugin`) + * and the declared-metadata bridge — so the authoring shape has exactly one + * runtime projection. Optional keys are omitted rather than nulled so a + * re-seed never blanks a column the author left unset. + */ +export function mapTemplateToRow(tpl: EmailTemplateDefinition): Record { + return { + name: tpl.name, + label: tpl.label, + category: tpl.category, + locale: tpl.locale, + subject: tpl.subject, + body_html: tpl.bodyHtml, + ...(tpl.bodyText ? { body_text: tpl.bodyText } : {}), + ...(tpl.fromOverride?.address ? { + from_address: tpl.fromOverride.address, + ...(tpl.fromOverride.name ? { from_name: tpl.fromOverride.name } : {}), + } : {}), + ...(tpl.replyTo ? { reply_to: tpl.replyTo } : {}), + active: tpl.active, + is_system: tpl.isSystem, + ...(tpl.description ? { description: tpl.description } : {}), + ...(tpl.variables?.length ? { variables_json: JSON.stringify(tpl.variables) } : {}), + }; +} + +/** Random id with a stable prefix — mirrors the webhook seeder. */ +function uid(prefix: string): string { + const g: any = globalThis as any; + if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`; + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Read declared `email_template` items from the ObjectQL registry (where the + * manifest decomposition parks `stack.emailTemplates`), falling back to the + * metadata service. Items may be wrapped as `{ content }` — unwrap to the raw + * authoring object. + */ +function readDeclared(engine: any, metadataService: any, type: string): any[] { + try { + const reg = engine?._registry; + if (reg?.listItems) { + const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + if (items.length > 0) return items; + } + } catch { + /* fall through to metadata service */ + } + try { + const listed = metadataService?.list?.(type); + const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []); + return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : []; + } catch { + return []; + } +} + +export interface BootstrapDeclaredEmailTemplatesResult { + seeded: number; + skipped: number; +} + +/** + * Materialize ONE declared template into `sys_email_template`, honouring + * seed-not-clobber. Shared by the boot sweep and the live subscribe path. + * + * @returns `true` when the row was written, `false` when deliberately skipped. + * @throws when the raw item fails schema validation, or the write itself fails + * — callers decide whether that warns or propagates. + */ +export async function upsertDeclaredEmailTemplate( + engine: IDataEngine, + raw: unknown, + object = EMAIL_TEMPLATE_OBJECT, + logger?: Logger, +): Promise { + const tpl: EmailTemplateDefinition = EmailTemplateDefinitionSchema.parse(raw); + const now = new Date().toISOString(); + + const existing = await (engine as any).find(object, { + where: { name: tpl.name, locale: tpl.locale }, + limit: 1, + context: SYSTEM_CTX, + }); + const row: any = Array.isArray(existing) ? existing[0] : (existing as any)?.data?.[0]; + + if (row?.id) { + // Admin owns a same-named row, or has edited this seeded one — never + // clobber. A reworded transactional mail must survive redeploys. + if (row.managed_by === 'admin') { + logger?.warn?.('[email] declared template collides with an admin-authored row — seed skipped', { + name: tpl.name, + locale: tpl.locale, + }); + return false; + } + if (row.customized === true) return false; + await (engine as any).update(object, { + id: row.id, + ...mapTemplateToRow(tpl), + // Adopt pristine/legacy (pre-provenance) rows so future boots recognize + // them as package-managed. + managed_by: 'package', + updated_at: now, + }, { context: SYSTEM_CTX }); + return true; + } + + await (engine as any).insert(object, { + id: uid('etpl'), + ...mapTemplateToRow(tpl), + managed_by: 'package', + customized: false, + created_at: now, + updated_at: now, + }, { context: SYSTEM_CTX }); + return true; +} + +/** + * Deactivate the rows a deleted `email_template` metadata item materialized. + * + * Delete events carry only `(type, name)` — no locale — while a template is + * keyed `(name, locale)`, so the sweep is by name across locales. Rows are + * DEACTIVATED rather than deleted: withdrawing an authored template should + * stop it being sent, not destroy a row an admin may have hand-tuned or a + * history an operator may want to read. Admin-authored and `customized` rows + * are left strictly alone. + */ +export async function deactivateDeclaredEmailTemplate( + engine: IDataEngine, + name: string, + object = EMAIL_TEMPLATE_OBJECT, + logger?: Logger, +): Promise { + const found = await (engine as any).find(object, { + where: { name }, + context: SYSTEM_CTX, + }); + const rows: any[] = Array.isArray(found) ? found : ((found as any)?.data ?? []); + let deactivated = 0; + for (const row of rows) { + if (!row?.id) continue; + if (row.managed_by !== 'package') continue; + if (row.customized === true) continue; + if (row.active === false) continue; + await (engine as any).update(object, { + id: row.id, + active: false, + updated_at: new Date().toISOString(), + }, { context: SYSTEM_CTX }); + deactivated += 1; + } + if (deactivated > 0) { + logger?.info?.('[email] declared template withdrawn — rows deactivated', { name, deactivated }); + } + return deactivated; +} + +/** + * Materialize every declared email template into `sys_email_template`. + * Idempotent and safe to run on every boot. + */ +export async function bootstrapDeclaredEmailTemplates( + engine: IDataEngine, + metadataService: any, + logger?: Logger, + object = EMAIL_TEMPLATE_OBJECT, +): Promise { + const declared = readDeclared(engine, metadataService, 'email_template'); + if (declared.length === 0) return { seeded: 0, skipped: 0 }; + + let seeded = 0; + let skipped = 0; + + for (const raw of declared) { + try { + const written = await upsertDeclaredEmailTemplate(engine, raw, object, logger); + if (written) seeded += 1; + else skipped += 1; + } catch (err: any) { + // A malformed template warns and is skipped, never crashing boot — the + // rest of the batch still materializes. + logger?.warn?.('[email] declared email template failed to materialize — skipped', { + name: (raw as any)?.name, + error: err?.message ?? String(err), + }); + skipped += 1; + } + } + + logger?.info?.('[email] declared email templates materialized into sys_email_template', { + seeded, + skipped, + total: declared.length, + }); + return { seeded, skipped }; +} diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 35b749dae5..1bacc01716 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -5,12 +5,23 @@ import type { IDataEngine } from '@objectstack/spec/contracts'; import type { IEmailTransport, EmailAddress, + IMetadataService, } from '@objectstack/spec/contracts'; import { SysEmail, SysEmailTemplate } from '@objectstack/platform-objects/audit'; import { EmailService, LogTransport, type EmailPersistence, type TemplateLoader, type EmailTemplateRow } from './email-service.js'; import { makeTransport } from './transports/index.js'; import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; import type { EmailTemplateDefinition as EmailTemplate } from '@objectstack/spec/system'; +import { + bootstrapDeclaredEmailTemplates, + upsertDeclaredEmailTemplate, + deactivateDeclaredEmailTemplate, + mapTemplateToRow, +} from './bootstrap-declared-email-templates.js'; +import { + bindEmailTemplateProvenanceStamp, + unbindEmailTemplateProvenanceStamp, +} from './email-template-provenance.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; @@ -68,6 +79,10 @@ export class EmailServicePlugin implements Plugin { private readonly options: EmailServicePluginOptions; private service?: EmailService; + /** Engine carrying the template provenance hook — unbound in dispose(). */ + private boundEngine?: IDataEngine; + /** Live `email_template` metadata subscription — detached in dispose(). */ + private unsubscribeTemplates?: () => void; constructor(options: EmailServicePluginOptions = {}) { this.options = options; @@ -369,9 +384,91 @@ export class EmailServicePlugin implements Plugin { } ctx.logger.info(`EmailServicePlugin: seeded ${all.length} template row(s)`); } + + // ── DECLARED email_template METADATA → sys_email_template (#4509) ── + // The second door: everything authored as `email_template` metadata + // (stack `emailTemplates:`, `*.email-template.ts`, Studio, PUT /meta) + // materializes into the rows sendTemplate actually reads. Gated on the + // DATA ENGINE alone — materializing rows is a pure write, so it must not + // sit behind transport/settings availability. (The webhook bridge learned + // this the hard way: gated behind its dispatch prerequisites, a + // realtime-less deployment silently materialized nothing — the very + // no-op class this closes, #3461.) + await this.bootDeclaredTemplates(ctx, engine); }); } + /** + * [#4509] Materialize declared `email_template` metadata, bind the provenance + * stamp, and keep the rows live for runtime authoring. + * + * `email_template` is `allowRuntimeCreate: true` (unlike `webhook`), so a + * boot-only sweep would leave a Studio save inert until the next restart — + * the same bug, half-fixed. The subscription re-materializes the single + * changed item; `MetadataManager.register` notifies watchers only AFTER the + * write has landed, so re-reading on the event cannot race the data. + */ + private async bootDeclaredTemplates(ctx: PluginContext, engine: IDataEngine): Promise { + // Bind the provenance stamp so an admin edit freezes a seeded row. + this.boundEngine = engine; + try { bindEmailTemplateProvenanceStamp(engine as any, ctx.logger as any); } + catch (err: any) { + ctx.logger.warn('EmailServicePlugin: template provenance stamp not bound: ' + (err?.message ?? err)); + } + + let metadataService: IMetadataService | undefined; + try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } + + try { + await bootstrapDeclaredEmailTemplates(engine, metadataService, ctx.logger as any); + } catch (err: any) { + ctx.logger.warn( + 'EmailServicePlugin: declared email-template bootstrap failed (built-in templates still serve): ' + + (err?.message ?? err), + ); + } + + // Live path — Studio saves / PUT /meta land as `added`/`changed` events. + if (typeof metadataService?.subscribe !== 'function') return; + try { + this.unsubscribeTemplates = metadataService.subscribe('email_template', (event: any) => { + void (async () => { + try { + const kind = event?.type; + if (kind === 'deleted' || kind === 'unlink') { + // Delete events carry no locale — deactivate by name, and only + // rows this bridge owns. + await deactivateDeclaredEmailTemplate(engine, String(event?.name ?? ''), undefined, ctx.logger as any); + return; + } + const raw = event?.data ?? (event?.name + ? await metadataService.get?.('email_template', event.name) + : undefined); + if (!raw) return; + await upsertDeclaredEmailTemplate(engine, (raw as any)?.content ?? raw, undefined, ctx.logger as any); + ctx.logger.info(`EmailServicePlugin: email template '${event?.name}' materialized from a runtime write`); + } catch (err: any) { + ctx.logger.warn( + `EmailServicePlugin: runtime email-template sync failed for '${event?.name}': ${err?.message ?? err}`, + ); + } + })(); + }); + ctx.logger.info('EmailServicePlugin: subscribed to email_template metadata changes'); + } catch (err: any) { + ctx.logger.warn('EmailServicePlugin: email_template subscription failed: ' + (err?.message ?? err)); + } + } + + async dispose(): Promise { + try { this.unsubscribeTemplates?.(); } catch { /* best effort */ } + this.unsubscribeTemplates = undefined; + if (this.boundEngine) { + try { unbindEmailTemplateProvenanceStamp(this.boundEngine as any); } catch { /* best effort */ } + this.boundEngine = undefined; + } + } + /** * Translate the `mail` settings namespace snapshot into a transport * and `defaultFrom`, then hot-swap them on the running EmailService. @@ -428,25 +525,22 @@ export class EmailServicePlugin implements Plugin { } } + /** + * Seed a built-in / options-supplied template. Provenance axis here is + * `is_system` — see {@link bootstrapDeclaredEmailTemplates} for the DECLARED + * metadata door, which uses `managed_by`/`customized` and shares this exact + * row mapping via {@link mapTemplateToRow}. + * + * New rows are stamped `managed_by: 'platform'` — NOT left to the column's + * `'admin'` default. The default exists for rows an admin creates through the + * data door, and the declared-metadata bridge refuses to overwrite `admin` + * rows; an unstamped built-in would therefore masquerade as admin-authored + * and permanently outrank a template the app actually declared. That is the + * #4509 failure exactly (an authored password-reset mail losing to the + * built-in copy), and the ADR-0054 proof pins it. + */ private async upsertTemplate(engine: IDataEngine, tpl: EmailTemplate): Promise { - const row = { - name: tpl.name, - label: tpl.label, - category: tpl.category, - locale: tpl.locale, - subject: tpl.subject, - body_html: tpl.bodyHtml, - ...(tpl.bodyText ? { body_text: tpl.bodyText } : {}), - ...(tpl.fromOverride?.address ? { - from_address: tpl.fromOverride.address, - ...(tpl.fromOverride.name ? { from_name: tpl.fromOverride.name } : {}), - } : {}), - ...(tpl.replyTo ? { reply_to: tpl.replyTo } : {}), - active: tpl.active, - is_system: tpl.isSystem, - ...(tpl.description ? { description: tpl.description } : {}), - ...(tpl.variables?.length ? { variables_json: JSON.stringify(tpl.variables) } : {}), - }; + const row = mapTemplateToRow(tpl); const existing = await (engine as any).find('sys_email_template', { where: { name: tpl.name, locale: tpl.locale }, limit: 1, @@ -457,11 +551,18 @@ export class EmailServicePlugin implements Plugin { // Only re-seed if the existing row is system-managed (is_system=true); // never overwrite a tenant-customised row. if (existingRow.is_system === false) return; + // A row the declared bridge already owns keeps its `package` provenance — + // re-stamping it `platform` would hand the built-in door a veto it does + // not have. await (engine as any).update('sys_email_template', { id: existingRow.id, ...row }, { context: SYSTEM_CTX, }); } else { - await (engine as any).insert('sys_email_template', row, { + await (engine as any).insert('sys_email_template', { + ...row, + managed_by: 'platform', + customized: false, + }, { context: SYSTEM_CTX, }); } diff --git a/packages/plugins/plugin-email/src/email-template-provenance.ts b/packages/plugins/plugin-email/src/email-template-provenance.ts new file mode 100644 index 0000000000..eacbf98d47 --- /dev/null +++ b/packages/plugins/plugin-email/src/email-template-provenance.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4509] Provenance stamp for `sys_email_template`. + * + * `sys_email_template` is RECORD-AUTHORITATIVE: a declared email template is a + * boot seed ({@link bootstrapDeclaredEmailTemplates}), and the row — including + * any admin rewording of a transactional mail — is the authority. The seeder + * skips rows marked `customized`, so this hook is the half that DETECTS the + * admin edit: any non-system update touching a `package`/`platform`-seeded row + * stamps `customized: true` onto the payload. + * + * Why a data hook (and not a write gate or the REST layer), verbatim to the + * sys_webhook / sys_sharing_rule rationale (#3461, #2909 T1): + * - admins edit templates through several doors (Studio metadata-admin, the + * generic data door, scripts) — an engine hook covers them all; + * - there is deliberately NO write gate here: templates are a first-class + * admin authoring surface, so edits are allowed — they just have to be + * remembered; + * - both provenance columns are `readonly`, and the engine's readonly strip + * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run + * — so a caller can never forge/clear `customized`, while this hook's stamp + * survives. + * + * Known boundary: multi-row updates (no single `input.id`) are not stamped — + * every template-editing UI path updates by id. + */ + +interface MinimalEngine { + find(object: string, opts?: any): Promise; + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void; + unregisterHooksByPackage(packageId: string): number; +} + +interface MinimalLogger { + info?: (msg: string, meta?: Record) => void; + warn?: (msg: string, meta?: Record) => void; +} + +export const EMAIL_TEMPLATE_PROVENANCE_PACKAGE = 'plugin-email:template-provenance'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +export function bindEmailTemplateProvenanceStamp( + engine: MinimalEngine, + logger?: MinimalLogger, + object = 'sys_email_template', +): void { + if (typeof engine?.registerHook !== 'function') return; + // Re-binding on a re-boot must not stack duplicate hooks. + if (typeof engine.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(EMAIL_TEMPLATE_PROVENANCE_PACKAGE); + } + engine.registerHook( + 'beforeUpdate', + async (ctx: any) => { + // Seeder / boot reconcilers write with isSystem — the package door, not + // an admin customization. + if ((ctx?.session as any)?.isSystem) return; + const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id; + if (!id) return; // multi-row update — see boundary note above + const data = ctx?.input?.data; + if (!data || typeof data !== 'object') return; + try { + // `previous` is not resolved before beforeUpdate hooks run — read the + // current row ourselves (system ctx: this is a provenance check, not + // an authorization decision). + const rows = await engine.find(object, { + where: { id }, + fields: ['id', 'managed_by', 'customized'], + limit: 1, + context: SYSTEM_CTX, + }); + const row = Array.isArray(rows) ? rows[0] : undefined; + if (!row) return; + if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) { + (data as any).customized = true; + } + } catch (err: any) { + logger?.warn?.('[email] template provenance stamp failed (edit proceeds unstamped)', { + id, + error: err?.message, + }); + } + }, + { object, packageId: EMAIL_TEMPLATE_PROVENANCE_PACKAGE, priority: 150 }, + ); + logger?.info?.('[email] template provenance stamp hook bound'); +} + +export function unbindEmailTemplateProvenanceStamp(engine: MinimalEngine): void { + if (typeof engine?.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(EMAIL_TEMPLATE_PROVENANCE_PACKAGE); + } +} diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index ceca890dbd..a5044e55ec 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -22,6 +22,19 @@ export { type PostmarkTransportOptions, type MakeTransportOptions, } from './transports/index.js'; +export { + bootstrapDeclaredEmailTemplates, + upsertDeclaredEmailTemplate, + deactivateDeclaredEmailTemplate, + mapTemplateToRow, + EMAIL_TEMPLATE_OBJECT, + type BootstrapDeclaredEmailTemplatesResult, +} from './bootstrap-declared-email-templates.js'; +export { + bindEmailTemplateProvenanceStamp, + unbindEmailTemplateProvenanceStamp, + EMAIL_TEMPLATE_PROVENANCE_PACKAGE, +} from './email-template-provenance.js'; export { AUTH_PASSWORD_RESET_TEMPLATE, AUTH_VERIFY_EMAIL_TEMPLATE, diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 5f6937ce84..3a48f1dbe4 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -18,6 +18,7 @@ "@objectstack/objectql": "workspace:*", "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-auth": "workspace:*", + "@objectstack/plugin-email": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", "@objectstack/plugin-webhooks": "workspace:*", diff --git a/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts b/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts new file mode 100644 index 0000000000..8443002ec2 --- /dev/null +++ b/packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// EMAIL TEMPLATE MATERIALIZATION proof (ADR-0054), exercised end-to-end through +// the real in-process stack. +// +// @proof: email-template-materialization +// ADR-0054 runtime proof for the email-template-materialization high-risk class. +// Referenced by the liveness ledger entry `email_template.subject` +// (packages/spec/liveness/email_template.json); the spec liveness gate fails if +// this tag is removed. See proof-registry.mts. +// +// An email_template prop being `live` means the materializer + renderer READ it +// — necessary but not sufficient. This boots the real stack with the email +// plugin, authors a template via the app config's `emailTemplates:` array, and +// asserts BOTH ends of the graph that #4509 found disconnected: +// 1. the authored metadata materializes into a `sys_email_template` row with +// the spec→runtime remap applied (`bodyHtml`→`body_html`); +// 2. `EmailService.sendTemplate` then renders THAT template — not the built-in +// auth copy it overrides. This is the ADR-0078 false-compliance case the +// issue named: an admin "fixes" the password-reset mail, gets a success +// toast, and users keep receiving the built-in version. +// +// It also pins the #3461 integration lesson the bridge inherits: materialization +// is gated on the data engine ALONE, so a boot with no transport configured +// (LogTransport) must still materialize — a bridge re-gated behind delivery +// prerequisites would materialize nothing and this proof would fail. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { EmailServicePlugin } from '@objectstack/plugin-email'; +import { emailTemplateFixtureStack } from './fixtures/email-template-materialization-fixture.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +/** + * Capturing transport — `sendTemplate` returns a delivery result, not the + * rendered message, so the only way to prove WHICH template was rendered is to + * look at what reached the wire. + */ +const sent: Array<{ subject?: string; html?: string }> = []; +const captureTransport = { + async send(message: { subject?: string; html?: string }) { + sent.push({ subject: message.subject, html: message.html }); + return { messageId: `captured-${sent.length}` }; + }, +}; + +describe('objectstack verify EMAIL TEMPLATE: authored template materializes and renders (#email-template-materialization)', () => { + let stack: VerifyStack; + + beforeAll(async () => { + // A capturing transport stands in for delivery. The bridge must run on the + // data engine alone, independent of what (if anything) delivers. + stack = await bootStack(emailTemplateFixtureStack, { + extraPlugins: [new EmailServicePlugin({ + transport: captureTransport as never, + defaultFrom: { address: 'no-reply@example.test', name: 'ET Fixture' }, + })], + }); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('materializes the stack-authored template into a sys_email_template row (bodyHtml→body_html)', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const engine = (await stack.kernel.getServiceAsync('objectql')) as any; + const rows = await engine.find('sys_email_template', { + filter: { name: 'auth.password_reset', locale: 'en-US' }, + context: SYSTEM_CTX, + }); + + expect(rows, 'authored template was NOT materialized into a sys_email_template row').toHaveLength(1); + const row = rows[0]; + + // The remap that is the whole point of the bridge, and the authored wording + // beating the built-in seed. + expect(row.body_html, 'spec `bodyHtml` did not remap to runtime `body_html`') + .toContain('Authored body'); + expect(row.subject, 'the built-in auth template overwrote the authored one') + .toBe('Authored reset for {{user.name}}'); + + // Boot-seeded provenance (seed-not-clobber): a package row, not admin. + expect(row.managed_by).toBe('package'); + }); + + it('renders the authored template through sendTemplate — the disconnect #4509 closed', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const email = (await stack.kernel.getServiceAsync('email')) as any; + + const result = await email.sendTemplate({ + to: 'someone@example.com', + template: 'auth.password_reset', + data: { url: 'https://example.test/reset', user: { name: 'Ada' } }, + }); + + expect(result.status, `sendTemplate failed: ${result.error ?? ''}`).not.toBe('failed'); + // The rendered message proves the authored row — not the built-in copy — + // reached the execution point, with placeholders interpolated. + const delivered = sent.at(-1); + expect(delivered?.subject, 'the built-in copy was rendered instead of the authored template') + .toBe('Authored reset for Ada'); + expect(delivered?.html).toContain('Authored body'); + }); +}); diff --git a/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts b/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts new file mode 100644 index 0000000000..4e263b99a1 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Email-template materialization fixture — the deterministic ADR-0054 proof for +// the `email-template-materialization` high-risk class. +// +// An `email_template` prop is `live` in the ledger because a stack-authored +// `emailTemplates:` entry is materialized into the `sys_email_template` row that +// `sendTemplate` reads (#4509) — but "materialized" is not "reaches the send +// path". The authored value crosses manifest-decomposition → the ObjectQL +// registry (type `email_template`) → `bootstrapDeclaredEmailTemplates` → +// `engine.insert('sys_email_template')` → the plugin's `TemplateLoader` → +// `EmailService.sendTemplate`, and the break can live in any seam — the whole +// disconnect this closes was exactly such a break, with an authoring surface on +// one side and the renderer on the other. +// +// The template deliberately overrides a BUILT-IN auth template name +// (`auth.password_reset`): that is the case #4509 called out as ADR-0078 false +// compliance — an admin "fixes" the password-reset mail and users keep receiving +// the built-in copy. The proof asserts the authored wording actually wins. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { defineEmailTemplateDefinition } from '@objectstack/spec/system'; + +/** One trivial object — a stack needs a data surface to boot against. */ +export const EtNote = ObjectSchema.create({ + name: 'et_note', + // [ADR-0090 D1] grandfather stamp: the gate under test is email-template + // materialization, not owner-sharing — keep RLS out of the way. + sharingModel: 'public_read_write', + label: 'ET Note', + pluralLabel: 'ET Notes', + fields: { + name: Field.text({ label: 'Name', required: true }), + }, +}); + +/** + * The stack-authored override of the built-in password-reset mail. `bodyHtml` + * (spec) must land as `body_html` (runtime col) and the authored subject must + * beat the built-in seed — the outcomes the proof asserts. + */ +export const etPasswordReset = defineEmailTemplateDefinition({ + name: 'auth.password_reset', + label: 'ET Password Reset', + category: 'auth', + subject: 'Authored reset for {{user.name}}', + bodyHtml: '

Authored body: reset

', + variables: [{ name: 'url', type: 'string', required: true }], +}); + +export const emailTemplateFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.email_template_fixture', + namespace: 'et', + version: '0.0.0', + type: 'app', + name: 'Email Template Materialization Fixture', + description: 'Single-object app that authors one email template to prove stack `emailTemplates:` entries materialize into the sys_email_template rows sendTemplate reads (ADR-0054, #4509).', + }, + objects: [EtNote], + emailTemplates: [etPasswordReset], +}); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index c7a751e0c8..929ee9e667 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -511,12 +511,12 @@ for t, v in r['types'].items(): | app | 45 | – | 14 | – | seeded 2026-08-01 (#4488). Dead 14 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (the landing IS the first nav item; root landing follows `isDefault` routing) + the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` (nothing evaluates them, while the per-ITEM siblings are enforced server- and client-side — the audit's most important app finding, both authorWarn'd) + `areas.order`/`description` + selector `includeAll` (deliberately ignored: selectors are mandatory-scope; an "All" would leak system metadata) and `placement`. Nav walk covers the union's `object` variant; other variants hand-verified live except the `actionDef` dispatch gap (renders, but no shipped shell passes `onAction`) — #4509 | | book | 13 | – | 2 | – | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant can never match (DocSchema declares no `tags`) | | doc | 7 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type | -| email_template | 8 | – | 13 | – | seeded 2026-08-01 (#4488). **Every authorable property is dead** — the 8 live are the ADR-0010 framework overlay fields the gate auto-classifies. Webhook's OLD shape: `sendTemplate` reads `sys_email_template` ROWS, whose only writers are the built-in auth templates + code-constructed plugin options; every authoring door (stack `emailTemplates:`, `*.email-template.ts`, Studio metadata-admin, PUT /meta) lands items nothing reads back. An admin who "fixes" the password-reset mail in Studio changes nothing — false compliance on AUTH mail. One per-artifact authorWarn on `name`; `upsertTemplate`'s field map is the future bridge's mapping table — #4509 | -| job | 6 | – | 3 | – | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). Type-level gap recorded: `allowRuntimeCreate: true` but no path schedules a runtime-authored job item — #4509 | +| email_template | 21 | 0 | 0 | 0 | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) | +| job | 13 | 0 | 3 | 0 | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) | | mapping | 7 | – | 3 | – | seeded 2026-08-01 (#4488). The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. Dead 3 = `extractQuery` (authorWarn — "for export only" promises an export path that does not exist) + `errorPolicy`/`batchSize` (dead but UNWARNABLE: their schema defaults materialize at compile, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule) | | seed | 5 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | | translation | 10 | – | 1 | – | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn): nothing resolves it, and #3778's own legacy-key migration table steers `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape | -| validation | 8 | – | 3 | – | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys (transitions/initialStates/regex/schema/when/then/…) verified via the evaluator's own tests. Type-level gap: a STANDALONE `validation` item binds to no object and reaches no write path — #4509 | +| validation | 15 | 0 | 3 | 0 | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index 81452f5fdc..e7c3f985d3 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -1,6 +1,6 @@ { "type": "app", - "_note": "AppSchema — the navigation shell, the densest hand-authored surface on the platform. Consumers: the REST read layer's filterAppForUser (packages/rest/src/rest-server.ts:1796-1847 — the SERVER-side authority for app/nav permission + capability gating and ADR-0045 hidden-app visibility), the spec i18n translateApp (i18n-resolver.ts:472), and objectui's shell (@940ba24: app-shell AppSidebar/ConsoleLayout/ContextSelectors, layout NavigationRenderer, console RootLandingRedirect). The #4001/#4142 app step already retired seven dead keys as retiredKey tombstones — they stay in the walked shape, so their rows stay here (tombstone rule, orphans.mts). WALK BOUNDARY (#3095 union rule): `navigation` drills into the union's FIRST member (the `object` variant + base keys); the other variants' payload keys sit outside the walk and were verified by hand — dashboardName (NavigationRenderer.tsx:433), pageName (:435-442), url/target (:462), reportName (:460), componentRef (:464,:644), group `expanded` (:856) all live. ONE GAP found there, recorded not hidden: an `action` item renders and gates like any other, but its click dispatches through a host-supplied `onAction` prop that NO shipped shell passes — `actionDef.actionName` currently reaches no dispatcher (#4509). Also note filterAppForUser strips only the TOP-LEVEL `navigation` tree; `areas` trees rely on the client-side per-item gates. Seeded 2026-08-01 (#4488).", + "_note": "AppSchema — the navigation shell, the densest hand-authored surface on the platform. Consumers: the REST read layer's filterAppForUser (packages/rest/src/rest-server.ts:1796-1847 — the SERVER-side authority for app/nav permission + capability gating and ADR-0045 hidden-app visibility), the spec i18n translateApp (i18n-resolver.ts:472), and objectui's shell (@940ba24: app-shell AppSidebar/ConsoleLayout/ContextSelectors, layout NavigationRenderer, console RootLandingRedirect). The #4001/#4142 app step already retired seven dead keys as retiredKey tombstones — they stay in the walked shape, so their rows stay here (tombstone rule, orphans.mts). WALK BOUNDARY (#3095 union rule): `navigation` drills into the union's FIRST member (the `object` variant + base keys); the other variants' payload keys sit outside the walk and were verified by hand — dashboardName (NavigationRenderer.tsx:433), pageName (:435-442), url/target (:462), reportName (:460), componentRef (:464,:644), group `expanded` (:856) all live. The one GAP found there is now CLOSED (#4509, objectui @e8bec83): an `action` item's click dispatches through a host-supplied `onAction` prop that no shipped shell passed, so `actionDef.actionName` reached no dispatcher and every such item dead-clicked. objectui's `useNavActionDispatch` (objectui: packages/app-shell/src/hooks/useNavActionDispatch.ts) resolves the name against `action` metadata and dispatches through the console action runtime, and UnifiedSidebar passes it (objectui: packages/app-shell/src/layout/UnifiedSidebar.tsx:473). A shell that still passes no handler now HIDES action items rather than rendering them dead (objectui: packages/layout/src/NavigationRenderer.tsx:971) — the renderer stops manufacturing the trap. Also note filterAppForUser strips only the TOP-LEVEL `navigation` tree; `areas` trees rely on the client-side per-item gates. Seeded 2026-08-01 (#4488).", "props": { "name": { "status": "live", @@ -113,9 +113,9 @@ }, "type": { "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:905-960 (branch dispatch), :397-471 (href resolution per variant)", - "note": "the discriminant. Variant payload keys outside this walk are covered in the type note — all live except the `actionDef` dispatch gap." + "verifiedAt": "2026-08-02", + "evidence": "objectui @e8bec83: packages/layout/src/NavigationRenderer.tsx:905-1020 (branch dispatch), :397-471 (href resolution per variant), :963-971 (action branch — hidden when the host passes no dispatcher)", + "note": "the discriminant. Variant payload keys outside this walk are covered in the type note — all live, the `actionDef` dispatch gap included since #4509 wired it to the console action runtime." }, "objectName": { "status": "live", diff --git a/packages/spec/liveness/email_template.json b/packages/spec/liveness/email_template.json index fe8734bb53..e81289aa64 100644 --- a/packages/spec/liveness/email_template.json +++ b/packages/spec/liveness/email_template.json @@ -1,73 +1,85 @@ { "type": "email_template", - "_note": "EmailTemplateDefinitionSchema. THE ENTIRE AUTHORING SURFACE IS DEAD — the webhook (#3461) disconnect shape, verified 2026-08-01 by closing the graph from both ends. Enforcement end: IEmailService.sendTemplate resolves (name, locale) against sys_email_template ROWS and honors active/variables/fromOverride/replyTo from the ROW (packages/plugins/plugin-email/src/email-service.ts:404-465). Writer end: the ONLY writers of sys_email_template are the built-in auth templates plus code-constructed EmailServicePluginOptions.templates, both via upsertTemplate (packages/plugins/plugin-email/src/email-plugin.ts:358-371, :431-468) — and no bootstrapper passes `templates` (the CLI serve composition omits it, packages/cli/src/commands/serve.ts:2165). Authoring end: every door an author can use — stack `emailTemplates:` (ingested as metadata items, metadata/src/plugin.ts:89), `*.email-template.ts` files, Studio (whose nav points at the metadata-admin list, platform-objects/src/apps/studio.app.ts:331), PUT /meta — lands items in the metadata store that NOTHING reads back; the package importer even excludes emailTemplates explicitly (packages/runtime/src/domains/packages.ts:569-571). So an admin who 'fixes' the password-reset email in Studio sees it saved and users keep receiving the builtin — ADR-0078 false compliance on AUTH mail. Enforce-or-remove tracked in #4509; upsertTemplate's field mapping (email-plugin.ts:432-449) is the future materializer's mapping table, exactly as the sys_webhook column map was for webhooks (#3489 closed that one). Per the webhook precedent, ONE per-artifact authorWarn is carried on `name` rather than one per property. `protection`/_lock*/_provenance are framework overlay fields, auto-live.", + "_note": "EmailTemplateDefinitionSchema. THE WHOLE SURFACE WENT LIVE with the #4509 materializer bridge — this file previously recorded the webhook (#3461) disconnect shape, all 13 props dead, and it is kept as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING. What closed it: `bootstrapDeclaredEmailTemplates` (packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts) validates each declared item through `EmailTemplateDefinitionSchema.parse()` (:148 — the spec schema finally has a real consumer, defaults and all) and materializes it into the `sys_email_template` ROW that the execution point reads, via `mapTemplateToRow` (:76) — the SAME mapping the built-in seeder uses (email-plugin.ts:533), shared deliberately so the two doors cannot drift. The execution end is unchanged: `IEmailService.sendTemplate` resolves (name, locale) with an en-US fallback (email-service.ts:402-416) and honors active/variables/fromOverride/replyTo off the row. THREE breaks had to close, not one: the engine never registered authored `emailTemplates:` into the registry at all (objectql/src/engine.ts metadataArrayKeys — the key was simply missing, so the bridge's source was empty); built-in seeds left `managed_by` at the column default 'admin', which made them masquerade as admin-authored and permanently outrank a declared template (email-plugin.ts:533 now stamps 'platform'); and only then did the bridge's write land. The ADR-0054 proof pins all three by asserting the AUTHORED wording is what sendTemplate actually renders. Runtime authoring is covered too: email_template is allowRuntimeCreate:true (unlike webhook), so a boot-only sweep would leave a Studio save inert until restart — the plugin also subscribes to `email_template` metadata changes and re-materializes the single changed item (email-plugin.ts:428-455). Seed-not-clobber mirrors sys_webhook (#3489): declared rows seed as `managed_by:'package'` and re-seed every boot, but an admin-authored ('admin') or admin-edited ('customized', stamped by email-template-provenance.ts) row is never overwritten — a reworded transactional mail survives redeploys. Nothing carries an authorWarn any more: authoring is no longer a no-op. `protection`/_lock*/_provenance are framework overlay fields, auto-live.", "props": { "name": { - "status": "dead", - "verifiedAt": "2026-08-01", - "authorWarn": true, - "authorHint": "Authoring an `email_template` metadata item does NOT register it with the mail service: sendTemplate reads sys_email_template rows, and nothing materializes metadata items into that table (see the type note). Until the bridge exists, outbound-mail templates are the built-in auth set plus code-supplied EmailServicePluginOptions.templates — a template authored here saves cleanly and is never used.", - "note": "Would-be row mapping: `name` (the sendTemplate lookup key, email-service.ts:404)." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:78 (row mapping); packages/plugins/plugin-email/src/email-service.ts:411 (the sendTemplate lookup key)", + "note": "The (name, locale) resolution key at both ends of the bridge." }, "label": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `label` (email-plugin.ts:434). Warn carried on `name` — one heads-up per artifact, not per prop." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:79", + "note": "Materialized into the `label` column — the Studio row title (sys_email_template nameField)." }, "category": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `category` (email-plugin.ts:435); a Studio filter facet even on the live rows, never behavior." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:80", + "note": "Materialized into `category`; a Studio filter facet on the row, never send behavior." }, "locale": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `locale` (email-plugin.ts:436) — on live rows this IS enforced ((name, locale) resolution with en-US fallback, email-service.ts:410-416), which is what makes the inert metadata copy so misleading." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:81 (row mapping, and half the upsert key); packages/plugins/plugin-email/src/email-service.ts:411-416 ((name, locale) resolution with en-US fallback)", + "note": "Load-bearing in both directions: the same template in two locales materializes as two rows, and the recipient's locale picks between them." }, "subject": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `subject` (email-plugin.ts:437); rendered with {{path}} holes by renderTemplate on live rows (email-service.ts:444)." + "status": "live", + "verifiedAt": "2026-08-02", + "proof": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts#email-template-materialization", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:82 (row mapping); packages/plugins/plugin-email/src/email-service.ts:444 (rendered with {{path}} holes by renderTemplate)", + "note": "ADR-0054 high-risk class (email-template-materialization): the authored subject is the representative check for the whole authoring→send pipeline, which crosses manifest-decomposition → the ObjectQL registry → the materializer → the sys_email_template row → the TemplateLoader → sendTemplate. Exactly the multi-layer seam that was broken here, and in three places at once. The proof authors a stack `emailTemplates:` entry overriding a BUILT-IN auth template and asserts the authored wording is what reaches the transport — the ADR-0078 false-compliance case #4509 named (an admin 'fixes' the password-reset mail and users keep receiving the built-in copy)." }, "bodyHtml": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `body_html` (email-plugin.ts:438)." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:83 (bodyHtml→body_html); packages/plugins/plugin-email/src/email-service.ts:445", + "note": "The camelCase→snake_case remap the proof asserts alongside the subject." }, "bodyText": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `body_text` (email-plugin.ts:439); live rows auto-derive text from HTML when absent (htmlToText)." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:84 (bodyText→body_text); packages/plugins/plugin-email/src/email-service.ts:446-448", + "note": "Omitted from the row rather than nulled when unset, so a re-seed never blanks it; the send path then auto-derives text from HTML (htmlToText)." }, "variables": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `variables_json` (email-plugin.ts:448); on live rows `required` variables fail sends fast (requireVars, email-service.ts:427-431). All four child keys share this verdict — not drilled." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:91 (variables→variables_json); packages/plugins/plugin-email/src/email-service.ts:427-431 (requireVars fails the send fast on a missing required var)", + "note": "All four child keys share this verdict — not drilled. `required` is a real runtime gate, not a hint." }, "fromOverride": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `from_address`/`from_name` (email-plugin.ts:440-443). Both child keys share this verdict." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:85-88 (fromOverride.address/.name → from_address/from_name); packages/plugins/plugin-email/src/email-service.ts:450-453", + "note": "Both child keys share this verdict." }, "replyTo": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `reply_to` (email-plugin.ts:444); honored on live rows (email-service.ts:463-464)." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:89 (replyTo→reply_to); packages/plugins/plugin-email/src/email-service.ts:139", + "note": "Honored on the outbound message when the caller passes no explicit replyTo." }, "active": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `active` (email-plugin.ts:445); on live rows `active: false` makes sendTemplate return TEMPLATE_INACTIVE (email-service.ts:418). default(true) boolean — could not carry authorWarn even if we wanted one (the lint cannot tell author-set from schema default)." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:90; packages/plugins/plugin-email/src/email-service.ts:418-419 (active:false → TEMPLATE_INACTIVE)", + "note": "Also the withdrawal mechanism: deleting a declared template deactivates its rows rather than destroying them (bootstrap-declared-email-templates.ts:201)." }, "isSystem": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `is_system` (email-plugin.ts:446); on live rows it gates re-seeding (a tenant-customised row is never overwritten, email-plugin.ts:459)." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:91 (isSystem→is_system); packages/plugins/plugin-email/src/email-plugin.ts:544 (gates built-in re-seeding)", + "note": "The BUILT-IN seeder's provenance axis, distinct from the declared bridge's managed_by/customized pair — a declared template lands is_system:false, which is what stops the built-in auth seed reclaiming the row on the next boot." }, "description": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "would-be row mapping: `description` (email-plugin.ts:447); docs-shaped even on live rows." + "status": "live", + "verifiedAt": "2026-08-02", + "evidence": "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:92", + "note": "Materialized into `description`; docs-shaped — read by humans in Studio, never by the send path." } } } diff --git a/packages/spec/liveness/job.json b/packages/spec/liveness/job.json index 36375f97de..af7e60d4cb 100644 --- a/packages/spec/liveness/job.json +++ b/packages/spec/liveness/job.json @@ -1,58 +1,58 @@ { "type": "job", - "_note": "JobSchema. The file-authored path is healthy: `defineStack({ jobs })` → app-plugin kernel:ready → IJobService.schedule (packages/runtime/src/app-plugin.ts:766-802) → the service-job adapters honor every schedule shape (packages/services/service-job/src/cron-job-adapter.ts:71-88) and runWithPolicy enforces retryPolicy/timeout (#3494 — these used to be parsed-but-ignored). `retryPolicy` here is the ENFORCED spelling ({maxRetries, backoffMs, backoffMultiplier}); do not confuse it with the datasource `retryPolicy`, which is dead and spells its delay differently. TYPE-LEVEL GAP, recorded not hidden: `job` is registered `allowRuntimeCreate: true` (metadata-plugin.zod.ts:640) but ONLY the compiled bundle's `jobs` reach the scheduler — no code path schedules a runtime-authored `job` metadata item (a Studio-created job saves cleanly and never runs; its `handler` could not even resolve, since the function map lives in the bundle). Same disconnect class as webhook (#3461) — tracked in #4509. Seeded 2026-08-01.", + "_note": "JobSchema. The file-authored path is healthy: `defineStack({ jobs })` → app-plugin kernel:ready → IJobService.schedule (packages/runtime/src/app-plugin.ts:790-855) → the service-job adapters honor every schedule shape (packages/services/service-job/src/cron-job-adapter.ts:71-88) and runWithPolicy enforces retryPolicy/timeout (#3494 — these used to be parsed-but-ignored). `retryPolicy` here is the ENFORCED spelling ({maxRetries, backoffMs, backoffMultiplier}); do not confuse it with the datasource `retryPolicy`, which is dead and spells its delay differently. TYPE-LEVEL GAP CLOSED 2026-08-02 (#4509) by CLOSING THE DOOR, not building a bridge: `job` was registered `allowRuntimeCreate: true` while only the compiled bundle's `jobs` ever reached the scheduler, so a Studio-created job saved cleanly and never ran. Unlike the webhook (#3461) and email_template (#4509 item 1) disconnects, this one could not be bridged: `handler` names a function in the compiled bundle's function table (`collectBundleFunctions`, app-plugin.ts:812), which a runtime writer does not have and cannot name — the missing piece is a handler-binding design, not an ingestion path. So `allowRuntimeCreate` AND `allowOrgOverride` are now both false (metadata-plugin.zod.ts, with the rationale block), leaving `*.job.ts` / `defineStack({ jobs })` as the supported doors. The kind stays registered: its file loader is genuinely consumed, so it still passes the ADR-0088 admission test. Evidence lines restamped 2026-08-02 — the seeded set pointed at app-plugin.ts:767-791, which had drifted ~25 lines (the gate resolves paths, not line numbers, so nothing failed; this is the rot mode `verifiedAt` exists to catch). Seeded 2026-08-01.", "props": { "id": { "status": "dead", - "verifiedAt": "2026-08-01", + "verifiedAt": "2026-08-02", "authorWarn": true, - "authorHint": "Delete it — `name` is the job's identity everywhere: the scheduling key (app-plugin.ts:784), the sys_job row key (db-job-adapter upserts by `name` and mints its own row id), and the JobExecution.jobId stamp. Nothing reads `id`, so two jobs differing only in `id` are the same job.", + "authorHint": "Delete it — `name` is the job's identity everywhere: the scheduling key (app-plugin.ts:833), the sys_job row key (db-job-adapter upserts by `name` and mints its own row id), and the JobExecution.jobId stamp. Nothing reads `id`, so two jobs differing only in `id` are the same job.", "note": "The describe() text ('defaults to `name` when omitted') implies an identity override that does not exist." }, "name": { "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "packages/runtime/src/app-plugin.ts:767, packages/runtime/src/app-plugin.ts:784", + "verifiedAt": "2026-08-02", + "evidence": "packages/runtime/src/app-plugin.ts:815, packages/runtime/src/app-plugin.ts:833", "note": "scheduling identity; a job without one is skipped loudly." }, "label": { "status": "dead", - "verifiedAt": "2026-08-01", + "verifiedAt": "2026-08-02", "note": "display metadata; no runtime consumer (sys_job stores name/schedule only). Docs-shaped annotation, deliberately KEPT and not authorWarn'd — the hook.label/description precedent, exempt from enforce-or-remove (ADR-0033)." }, "description": { "status": "dead", - "verifiedAt": "2026-08-01", + "verifiedAt": "2026-08-02", "note": "same as `label`: docs-shaped, deliberately kept, no warning." }, "schedule": { "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "packages/runtime/src/app-plugin.ts:786, packages/services/service-job/src/cron-job-adapter.ts:71-88, packages/services/service-job/src/db-job-adapter.ts:83", + "verifiedAt": "2026-08-02", + "evidence": "packages/runtime/src/app-plugin.ts:834, packages/services/service-job/src/cron-job-adapter.ts:71-88, packages/services/service-job/src/db-job-adapter.ts:83", "note": "all three variants enforced: cron `expression` + per-job `timezone` (cron-job-adapter.ts:76-77), interval `intervalMs` (:82), once `at` (:87); the db adapter persists the shape onto sys_job (db-job-adapter.ts:233-245). WALK BOUNDARY: a discriminated union — the gate classifies it as one property; the per-variant keys are covered by the adapter evidence above, not by ledger rows." }, "handler": { "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "packages/runtime/src/app-plugin.ts:776", - "note": "resolved against the bundle's function map; a missing handler skips the job with a warning rather than scheduling a no-op." + "verifiedAt": "2026-08-02", + "evidence": "packages/runtime/src/app-plugin.ts:824-830", + "note": "resolved against the bundle's function map (`collectBundleFunctions`, app-plugin.ts:812); a missing handler skips the job with a warning rather than scheduling a no-op. This resolution is ALSO why the type is closed to runtime creation (#4509): the function table is a bundle artifact, so a handler string authored at runtime has nothing to resolve against." }, "retryPolicy": { "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "packages/runtime/src/app-plugin.ts:791, packages/services/service-job/src/run-with-policy.ts:58-65", + "verifiedAt": "2026-08-02", + "evidence": "packages/runtime/src/app-plugin.ts:838-841, packages/services/service-job/src/run-with-policy.ts:58-65", "note": "maxRetries/backoffMs/backoffMultiplier all drive the exponential-backoff retry loop (delay = backoffMs * multiplier^(retry-1)). Enforced since #3494. This is the `retryPolicy` the datasource ledger warns about confusing with its dead namesake." }, "timeout": { "status": "live", - "verifiedAt": "2026-08-01", + "verifiedAt": "2026-08-02", "evidence": "packages/services/service-job/src/run-with-policy.ts:25-33", "note": "per-attempt limit; an over-limit run records execution status 'timeout' (JobTimeoutError). The in-flight handler is abandoned, not cancelled — as documented." }, "enabled": { "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "packages/runtime/src/app-plugin.ts:772", + "verifiedAt": "2026-08-02", + "evidence": "packages/runtime/src/app-plugin.ts:820", "note": "`enabled: false` skips scheduling entirely at registration — genuinely enforced, unlike the retired flow.active/tool.active." } } diff --git a/packages/spec/liveness/validation.json b/packages/spec/liveness/validation.json index ff5707b73e..f282bcc1f1 100644 --- a/packages/spec/liveness/validation.json +++ b/packages/spec/liveness/validation.json @@ -1,6 +1,6 @@ { "type": "validation", - "_note": "ValidationRuleSchema — the ADR-0020 carrier, where a wrong verdict is expensive, so the call graph was closed with extra care. The walked shape is the discriminated union's FIRST object member (the base keys + `script`'s type/condition — the #3095 union rule); per-variant keys (state_machine's field/transitions/initialStates, format's regex/format, json_schema's schema, conditional's when/then/otherwise, cross_field's fields) sit OUTSIDE the walk — an explicit blind spot recorded here (the union analog of the z.record rule), governed by the evaluator's own tests, not ledger rows. Consumer: the engine write path calls evaluateValidationRules on insert and on every matched update row (packages/objectql/src/engine.ts:3703, :4017, :4085) with rules from the OBJECT's embedded `validations` array (+ object_extension merge, engine.ts:1559). The evaluator provably honors every execution-control key — the zod header's prose claiming it 'only reads type/condition/field/events/severity/message' is STALE (it predates enforcement of active/priority) and should not be trusted over the ledger. TYPE-LEVEL GAP, recorded not hidden: a STANDALONE `validation` metadata item (file `*.validation.ts` or Studio — allowRuntimeCreate: true, metadata-plugin.zod.ts:602) never reaches any object's write path — the schema has no object-binding key, no merge code exists, and only the reference-tracker even expects one (metadata-protocol/src/protocol.ts:1306). A state machine authored through that door saves cleanly and gates nothing. The per-prop verdicts below are for rules where rules actually live (`object.validations` — the same schema instance); the standalone-door disconnect is tracked in #4509. Seeded 2026-08-01.", + "_note": "ValidationRuleSchema — the ADR-0020 carrier, where a wrong verdict is expensive, so the call graph was closed with extra care. GOVERNED VIA THE GATE'S SPEC-ONLY OVERRIDE (SPEC_ONLY_SCHEMAS) since #4509: `validation` is no longer a registered metadata KIND, but the rule vocabulary is entirely live, so the ledger must keep governing the schema — being off the registry is exactly the state in which a drift can survive unnoticed (the same reason `webhook` and `query` sit there). The walked shape is the discriminated union's FIRST object member (the base keys + `script`'s type/condition — the #3095 union rule); per-variant keys (state_machine's field/transitions/initialStates, format's regex/format, json_schema's schema, conditional's when/then/otherwise, cross_field's fields) sit OUTSIDE the walk — an explicit blind spot recorded here (the union analog of the z.record rule), governed by the evaluator's own tests, not ledger rows. Consumer: the engine write path calls evaluateValidationRules on insert and on every matched update row (packages/objectql/src/engine.ts:3931, :4248, :4321) with rules from the OBJECT's embedded `validations` array (+ object_extension merge, engine.ts:1572). The evaluator provably honors every execution-control key — the zod header's prose claiming it 'only reads type/condition/field/events/severity/message' is STALE (it predates enforcement of active/priority) and should not be trusted over the ledger. TYPE-LEVEL GAP CLOSED 2026-08-02 (#4509) by RETIRING THE KIND (ADR-0088), not by building a bridge: a STANDALONE `validation` item (file `*.validation.ts` or Studio) never reached any object's write path, because the schema has no object-binding key and — every variant being `.strict()` — an author could not add one; no merge code existed, and only the reference tracker even expected one (a row that scanned a key the schema would have stripped; both are now gone). A state machine authored through that door saved cleanly and gated nothing. The kind failed the ADR-0088 admission test on its first clause — no independent lifecycle: a rule only means something against an object. Rules are authored where they have always been evaluated, as `validations:` on the object, and the per-prop verdicts below describe exactly that path (the same schema instance). Evidence lines restamped 2026-08-02 — the seeded engine.ts refs had drifted ~220 lines. Seeded 2026-08-01.", "props": { "name": { "status": "live", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 59699beb9d..2c90db1e76 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -55,6 +55,7 @@ import { dirname, join, resolve } from 'node:path'; import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../../src/kernel/metadata-type-schemas'; import { WebhookSchema } from '../../src/automation/webhook.zod'; import { QuerySchema } from '../../src/data/query.zod'; +import { ValidationRuleSchema } from '../../src/data/validation.zod'; import { BOUND_PROOF_PATHS, HIGH_RISK_CLASSES, @@ -135,9 +136,18 @@ const PENDING_GOVERNANCE: Record = { // what authors write into metadata files while nothing governed what callers // write into a query. Walking `QuerySchema` here closes that class; // `query.json` carries the request-surface verdicts. +// +// `validation` stopped being a metadata type in #4509 (ADR-0088 retirement): +// a standalone rule had no object-binding key, so an item authored as its own +// artifact bound to nothing and intercepted no write. The RULE VOCABULARY is +// entirely live — the engine evaluates `object.validations[]` on every insert +// and update — so the ledger must keep governing `ValidationRuleSchema`; it is +// the kind, not the schema, that went away. Governing it here is what stops the +// retirement from quietly un-governing a live surface. const SPEC_ONLY_SCHEMAS: Record = { webhook: WebhookSchema, query: QuerySchema, + validation: ValidationRuleSchema, }; // ADR-0010 provenance/lock overlay fields — system-stamped, on every type; auto-live. diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 2d63492a9a..aaea21a0b5 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -170,6 +170,20 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ // the proof boots WITHOUT realtime to pin exactly that. ledgerBindings: [{ type: 'webhook', path: 'object' }], }, + { + id: 'email-template-materialization', + label: 'Email template materialization', + summary: + 'a stack-authored email template is materialized into the sys_email_template row sendTemplate actually reads (#4509) — the authored value crosses manifest-decomposition → the ObjectQL registry (type `email_template`) → the bridge → engine.insert → the TemplateLoader → the renderer, and THREE independent breaks sat on that path at once (the engine never registered `emailTemplates:` into the registry; built-in seeds defaulted to `managed_by: admin` and outranked declared templates; nothing materialized). An admin "fixing" the password-reset mail in Studio saved cleanly and users kept receiving the built-in copy — ADR-0078 false compliance on AUTH mail.', + proofId: 'email-template-materialization', + proofRef: 'packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts#email-template-materialization', + bound: true, + // `subject` is the representative prop for the whole authoring→send + // pipeline: its `live` status is only true because the bridge runs AND the + // authored row outranks the built-in seed, so the proof overrides a + // built-in auth template and asserts the authored wording reaches the wire. + ledgerBindings: [{ type: 'email_template', path: 'subject' }], + }, { id: 'form-widget', label: 'Form layout / section / widget', diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts index fcab4d093b..82ef49e716 100644 --- a/packages/spec/scripts/liveness/proof-registry.test.ts +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -113,6 +113,11 @@ describe('registry invariants', () => { 'position/delegatable', 'object/lifecycle', 'webhook/object', + // Bound 2026-08-02 (#4509) — the second materializer bridge. Authored + // email templates now reach sendTemplate, and `subject` is the + // representative prop for a pipeline that had THREE independent breaks + // on it, so its `live` status is exactly what the proof gates. + 'email_template/subject', // Bound when the 13 orphan `@proof:` tags were registered — the five // classes that had an authorable property whose `live` status they // actually gate (the other eight record a blockedReason instead). @@ -153,6 +158,7 @@ describe('real proof wiring resolves', () => { object: 'packages/spec/liveness/object.json', position: 'packages/spec/liveness/position.json', webhook: 'packages/spec/liveness/webhook.json', + email_template: 'packages/spec/liveness/email_template.json', }; function ledgerEntry(type: string, path: string): any { diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index 6c9eb80858..a3e93d4d23 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -33,7 +33,9 @@ describe('metadata create seeds validate against their spec schemas', () => { it('sanity: seeds the core Studio-designer types', () => { const seeded = new Set(listMetadataCreateSeedTypes()); - for (const t of ['dashboard', 'action', 'page', 'view', 'flow', 'validation', 'hook', 'dataset', 'object']) { + // `validation` left this list with the kind (#4509, ADR-0088) — rules are + // authored inside an object's `validations:`, never created standalone. + for (const t of ['dashboard', 'action', 'page', 'view', 'flow', 'hook', 'dataset', 'object']) { expect(seeded.has(t), `core type '${t}' has no create seed`).toBe(true); } }); diff --git a/packages/spec/src/kernel/metadata-create-seeds.ts b/packages/spec/src/kernel/metadata-create-seeds.ts index d0cf40f939..1ebbdfbe22 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.ts @@ -73,17 +73,9 @@ const BUILTIN_METADATA_CREATE_SEEDS: Partial> = { nodes: [], edges: [], }, - validation: { - name: 'new_validation', - label: 'New Validation', - message: 'This record is invalid.', - type: 'script', - active: true, - events: ['insert', 'update'], - priority: 10, - severity: 'error', - condition: 'false', - }, + // ADR-0088 (#4509): no `validation` seed — the kind is retired, so there is + // no standalone "create validation" flow to seed. Rules are added to an + // object's `validations:` array. hook: { name: 'new_hook', label: 'New Hook', diff --git a/packages/spec/src/kernel/metadata-plugin.test.ts b/packages/spec/src/kernel/metadata-plugin.test.ts index 65dcb00b9c..94f8ea0e03 100644 --- a/packages/spec/src/kernel/metadata-plugin.test.ts +++ b/packages/spec/src/kernel/metadata-plugin.test.ts @@ -18,10 +18,11 @@ describe('MetadataPluginProtocol', () => { describe('MetadataTypeSchema', () => { it('should accept all built-in metadata types', () => { const types = [ - 'object', 'field', 'validation', 'hook', 'seed', 'mapping', + 'object', 'field', 'hook', 'seed', 'mapping', 'view', 'page', 'dashboard', 'app', 'action', 'report', 'flow', // ADR-0020: `workflow` retired as a metadata type - // ADR-0088: `trigger`/`router`/`function`/`service` retired as kinds + // ADR-0088: `trigger`/`router`/`function`/`service` retired as kinds, + // then `validation` (#4509) — rules are inline `object.validations[]` 'datasource', 'external_catalog', 'translation', 'permission', 'position', 'agent', 'tool', 'skill', @@ -42,6 +43,15 @@ describe('MetadataPluginProtocol', () => { expect(() => MetadataTypeSchema.parse('approval')).toThrow(); }); + it('should reject `validation` as a metadata type (#4509: rules are inline object.validations[])', () => { + // ADR-0088 retirement. A standalone rule had no object-binding key — + // ValidationRuleSchema carries none and every variant is strict — so an + // item authored as its own artifact bound to nothing and intercepted no + // write. The rule VOCABULARY is untouched and fully live via the object. + expect(() => MetadataTypeSchema.parse('validation')).toThrow(); + expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'validation')).toBeUndefined(); + }); + it('registers `seed` as a runtime-draftable data type applied on publish', () => { // accepted by the type enum expect(MetadataTypeSchema.parse('seed')).toBe('seed'); diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index c60cde9699..3934cef4f8 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -76,7 +76,13 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ // ADR-0088: there is no `trigger` metadata type — sync data-layer logic is a // `hook` (24 lifecycle events); async automation is a `record_change` flow. // (The `triggers` capability token in `requires:` is a different namespace.) - 'validation', // Validation rules (ValidationSchema) + // ADR-0088 (#4509): there is no `validation` metadata type — validation rules + // are authored INLINE as `object.validations[]`, which is the only shape the + // engine evaluates. A standalone rule had no way to say what it validated + // (`ValidationRuleSchema` carries no object-binding key and every variant is + // strict), so an item authored here bound to nothing and intercepted no write + // — including `state_machine` rules, which ADR-0020 routes through this same + // inline shape. `ValidationRuleSchema` itself is unchanged and fully live. 'hook', // Data hooks (HookSchema) 'seed', // Seed/fixture data — runtime-draftable; publishing applies it (SeedSchema) 'mapping', // Import/export field mappings (MappingSchema) — consumed by POST /data/:object/import via mappingName (#2611); promoted to a kind per the ADR-0088 admission test once the consumer landed @@ -599,7 +605,13 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [ // are not modifiable per-org beyond layout/label; custom objects are full). { type: 'object', label: 'Object', filePatterns: ['**/*.object.ts', '**/*.object.yml', '**/*.object.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 10, domain: 'data' }, { type: 'field', label: 'Field', filePatterns: ['**/*.field.ts', '**/*.field.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 20, domain: 'data' }, - { type: 'validation', label: 'Validation Rule', filePatterns: ['**/*.validation.ts', '**/*.validation.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 30, domain: 'data' }, + // ADR-0088 (#4509) — the `validation` kind is RETIRED. It failed the + // admission test on its first clause: no independent lifecycle. A rule only + // means anything against an object, and the only shape the engine evaluates + // is `object.validations[]`; the standalone schema had no binding key to + // name its object with (and, being `.strict()` in all six variants, could not + // be given one by an author). Its `filePatterns` and `allowRuntimeCreate` + // retire with it. Author rules as `validations:` on the object. { type: 'hook', label: 'Hook', filePatterns: ['**/*.hook.ts', '**/*.hook.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 30, domain: 'data' }, // `seed`: fixture / initialization data (SeedSchema = object + records + mode + // externalId). Runtime-draftable so the AI (and any author) can stage seed @@ -637,7 +649,41 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [ // ADR-0020: there is no `workflow` metadata type — record state machines are // a `state_machine` validation rule on the object, not a standalone artifact. { type: 'flow', label: 'Flow', filePatterns: ['**/*.flow.ts', '**/*.flow.yml', '**/*.flow.json'], supportsOverlay: false, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: true, loadOrder: 80, domain: 'automation' }, - { type: 'job', label: 'Background Job', filePatterns: ['**/*.job.ts', '**/*.job.yml', '**/*.job.json'], supportsOverlay: false, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 80, domain: 'automation' }, + // `job`: A JOB IS A CODE ARTIFACT, and the flags now say so (#4509). + // + // `JobSchema.handler` is the name of a function in the compiled bundle's + // function table — the schema says as much ("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 (packages/runtime/src/app-plugin.ts). + // + // So a job created at runtime, or overlaid per-org, could never be scheduled + // — not "is not yet scheduled", but CANNOT BE: its handler names a function + // that exists only inside a bundle the runtime writer never had. Both doors + // led to metadata that parses, saves, and never runs. Under ADR-0049 + // enforce-or-remove that is not a state to leave standing, and there is + // nothing here to enforce: the missing piece is not a bridge but a + // handler-binding design. + // + // Hence allowRuntimeCreate:false (no "create job" in Studio / PUT /meta) and + // allowOrgOverride:false (no per-org job fork). Unlike `agent`, which is + // closed to third-party AUTHORING entirely (ADR-0063 §2), `job` stays a + // first-class authorable type: `*.job.ts` and `defineStack({ jobs })` are the + // supported doors, and they work — the file loader is genuinely consumed, so + // the kind still passes the ADR-0088 admission test and stays registered. + // + // Consequence that looks like a bug and is not: `migrateStoredMetadata` + // reports runtime-authored `job` rows `skipped` (no governed write path), the + // same way it does for `agent`. Rows already in `sys_metadata` are left + // alone; they were never scheduled, so nothing changes behaviorally. + // + // Re-opening this type means designing a handler a runtime writer can + // actually name — e.g. constraining `handler` to an already-registered flow + // or a named, separately-governed function — and then building the bridge + // from job metadata to `IJobService.schedule`. Opening the flag without that + // work just restores the silent no-op. + { type: 'job', label: 'Background Job', filePatterns: ['**/*.job.ts', '**/*.job.yml', '**/*.job.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 80, domain: 'automation' }, // System Protocol // `datasource`: runtime-creatable (ADR-0015 Addendum) — the Studio wizard diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index 2987d11600..73092d3b1a 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -307,7 +307,11 @@ describe('#4001 — registered-type closure is derived, not tallied', () => { it('reports the campaign number so a reader never has to count', () => { const closed = types.filter((t) => !STILL_STRIP.has(t)); expect(closed.length + STILL_STRIP.size).toBe(types.length); - expect(closed.length).toBe(24); - expect(types.length).toBe(25); + // 25 → 24 on 2026-08-02: `validation` left the registry entirely (#4509, + // ADR-0088 retirement), taking an already-closed schema with it. The + // campaign's closure ratio is unchanged — one fewer registered type, not + // one fewer closed one. + expect(closed.length).toBe(23); + expect(types.length).toBe(24); }); }); diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 6d3005cd7e..57c65a15c2 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -19,12 +19,11 @@ * * The map intentionally only contains types that meaningfully round-trip * through the runtime metadata API. (The former code-only placeholder kinds - * `function`/`service`/`router` — and `trigger` — were retired from the - * registry entirely by ADR-0088.) + * `function`/`service`/`router` — and `trigger`, then `validation` — were + * retired from the registry entirely by ADR-0088.) * - * `validation` exposes the discriminated union - * over all built-in rule variants. Custom plugin types can extend this - * registry at runtime via `registerMetadataTypeSchema()`. + * Custom plugin types can extend this registry at runtime via + * `registerMetadataTypeSchema()`. */ import type { z } from 'zod'; @@ -32,7 +31,6 @@ import type { z } from 'zod'; import { FieldSchema } from '../data/field.zod'; import { ObjectSchema } from '../data/object.zod'; import { HookSchema } from '../data/hook.zod'; -import { ValidationRuleSchema } from '../data/validation.zod'; import { DatasourceSchema } from '../data/datasource.zod'; import { SeedSchema } from '../data/seed.zod'; import { MappingSchema } from '../data/mapping.zod'; @@ -66,15 +64,25 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from './metadata-plugin.zod'; /** * Built-in mapping from metadata type identifier → its canonical Zod - * schema. Types omitted here have no runtime-editable form (and are - * marked `allowRuntimeCreate: false` in `DEFAULT_METADATA_TYPE_REGISTRY`). + * schema. A type omitted here has no runtime-editable form. + * + * The converse does NOT hold: presence here is about schema RESOLUTION + * (validation, diagnostics, generated docs), not about the runtime-create + * door. `agent` (ADR-0063 §2) and `job` (#4509) are both listed and both carry + * `allowRuntimeCreate: false` in `DEFAULT_METADATA_TYPE_REGISTRY` — they are + * authored in code and still need their schema resolvable. That registry is + * the authority on who may write at runtime; this map only says what shape a + * given type has. */ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = { // Data Protocol object: ObjectSchema, field: FieldSchema, hook: HookSchema, - validation: ValidationRuleSchema, + // ADR-0088 (#4509): no `validation` entry — the kind is retired. Rules are + // authored inline as `object.validations[]`, where `ObjectSchema` already + // carries `ValidationRuleSchema`, so the shape stays resolvable through its + // owning object. seed: SeedSchema, // fixture/init data; runtime-draftable, applied on publish mapping: MappingSchema as unknown as z.ZodType, // #2611: reusable import mapping; runtime-creatable so the wizard can save one diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7f6b44014..487b1f7134 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1638,6 +1638,9 @@ importers: '@objectstack/plugin-auth': specifier: workspace:* version: link:../../plugins/plugin-auth + '@objectstack/plugin-email': + specifier: workspace:* + version: link:../../plugins/plugin-email '@objectstack/plugin-security': specifier: workspace:* version: link:../../plugins/plugin-security