diff --git a/content/docs/references/api/endpoint.mdx b/content/docs/references/api/endpoint.mdx index 7718002bc9..a467ba9b79 100644 --- a/content/docs/references/api/endpoint.mdx +++ b/content/docs/references/api/endpoint.mdx @@ -35,8 +35,8 @@ const result = ApiEndpointSchema.parse(data); | **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP Method | | **summary** | `string` | optional | | | **description** | `string` | optional | | -| **type** | `Enum<'flow' \| 'script' \| 'object_operation' \| 'proxy'>` | ✅ | Implementation type | -| **target** | `string` | ✅ | Target Flow ID, Script Name, or Proxy URL | +| **type** | `Enum<'flow' \| 'script' \| 'object_operation' \| 'proxy'>` | ✅ | Implementation type — only 'object_operation' and 'flow' EXECUTE in 17.x. 'script' and 'proxy' stay in the frozen vocabulary (#5040) and are rejected at publish, not parsed and ignored: express script logic as a flow whose script node runs your registered function, and an outbound call as a flow using a declared connector | +| **target** | `string` | ✅ | Target Flow ID or Script Name or Proxy URL, per `type` — but only the Flow ID is reachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'` (an `object_operation` endpoint is addressed by `objectParams.object` / `.operation`; neither the publish gate nor the executor reads `target` for that type) | | **objectParams** | `{ object?: string; operation?: Enum<'find' \| 'get' \| 'create' \| 'update' \| 'delete'> }` | optional | For object_operation type | | **inputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Request Body to Internal Params | | **outputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Internal Result to Response Body | @@ -62,7 +62,7 @@ const result = ApiEndpointSchema.parse(data); | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field/path | | **target** | `string` | ✅ | Target field/path | -| **transform** | `string` | optional | Transformation function name | +| **transform** | `string` | optional | Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (#5040 E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object) | --- diff --git a/content/docs/references/shared/expression.mdx b/content/docs/references/shared/expression.mdx index 2cd762d0d7..0546252581 100644 --- a/content/docs/references/shared/expression.mdx +++ b/content/docs/references/shared/expression.mdx @@ -20,9 +20,16 @@ envelope. | dialect | engine | use | |:---|:---|:---| -| `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values | -| `js` | sandboxed L2 hook bodies (`isolated-vm` / `quickjs`) | mapping, hook bodies | -| `cron` | `cron-parser` | job schedules | +| `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values | +| `cron` | `cron-parser` | job schedules | +| `template` | `{{var}}` interpolation at evaluate time (same variable scope as CEL) | notification subjects/bodies, `titleFormat`, prompt templates | + +Those three are the whole list — it is exactly the `ExpressionDialect` enum +below. Procedural JavaScript is **not** a dialect: it is the L2 authoring +surface, the sandboxed, capability-gated `ScriptBody { language: 'js' }` in +hook/action bodies. A `js` row stood in this table long after the dialect was +retired in #3278 (ADR-0058 addendum); `ExpressionSchema` rejects +`dialect: 'js'`. SQL fragments (analytics joins, partial indexes) are intentionally **not** routed through this schema — they stay driver-native because their security diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 1a69466e28..dd7ef857b1 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -68,7 +68,7 @@ One question decides the class: **who writes this schema's input?** | **wire** | Another machine: server responses, connector payloads, runtime envelopes, persisted runtime state | stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash | | **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) | | **no door** | **Nobody — nothing parses it.** The shape is exported and typed, but no schema declares a carrier key for it, so it is unreachable from every metadata-type root and from `defineStack`, and nothing calls `.parse()` on it outside its own test. Added at 批 13, when the first run of files resolved its `(p)` this way | **out of this ratchet's scope.** `.strict()` is a property of a PARSE; with no parse it enforces nothing and only makes a dead slot look load-bearing — *"a precisely-validated dead slot is the more convincing lie"* (#4583). The live question is ADR-0049 enforce-or-remove — retire the vocabulary or give it a carrier — so a row here points at an issue, never at a batch (#4988, #5015) | -| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``); 批 17 then found the same shape at scale — all 29 sites of `ui/component.zod.ts`, behind `PageComponentSchema.properties`, making this the largest class in `ui/` | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | +| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``); 批 17 then found the same shape at scale — all 29 sites of `ui/component.zod.ts`, behind `PageComponentSchema.properties`, which made it the largest class in `ui/` **at the time**. ⚠️ **Both exemplars have since had their parse wired and LEFT the class** (#5020 / #5068 — their strip rows carry the flips), so this bucket's current population is **ZERO**: `…counts.md` reads `no gate — carrier live, no parse | 0` globally and in all five directory subtotals. Read the exemplars as the shape's definition, not as a live inventory — there is no un-wired `no gate` site anywhere in the tree today. The verdict stays in the vocabulary regardless: an empty class is not a defect, it is a word waiting for the next site that measures this way (#5249 established exactly that when it ADDED `covered` rather than rounding an unlike shape onto a wrong-action verdict) | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | | **covered** | **An author — but never through THIS site.** A module-private shape FRAGMENT with no carrier key and no `.parse()` of its own, whose keys reach authors only after being copied into consumers that each gate them. The copy must be a `...X.shape` SPREAD, because a spread lands the keys in a fresh `z.object` whose posture is its own — `.extend()` / `.merge()` / `.omit()` INHERIT the base's posture, which makes the base a real door and puts it back in `authorable` (finding 16, and `view.zod.ts`'s `FormFieldBaseSchema` one directory over). Added at #5249 on `ui/app.zod.ts`'s `BaseNavItemSchema` | **out of this ratchet's scope, and the follow-up is NOTHING.** Same absent parse, so closing it enforces nothing — and unlike `no door` the vocabulary is fully ALIVE and fully GATED, at every consumer, so retirement would delete keys those consumers still accept and check. This is the one verdict that prescribes no next step, which is exactly why it needed its own word: a row here is DONE, not queued | A fourth answer to "who writes this input" is **nobody**, and it is only diff --git a/packages/runtime/src/api-mapping.ts b/packages/runtime/src/api-mapping.ts index cfaee722f5..2a72e54c79 100644 --- a/packages/runtime/src/api-mapping.ts +++ b/packages/runtime/src/api-mapping.ts @@ -22,10 +22,12 @@ * | `outputMapping` | *Map Internal Result to Response Body* | * | `ApiMapping.source` | *Source field/path* | * | `ApiMapping.target` | *Target field/path* | - * | `ApiMapping.transform` | *Transformation function name* | + * | `ApiMapping.transform` | *Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (#5040 E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object)* | * * Five short sentences, and everything below is the MINIMAL faithful reading of - * them. Where the text is silent this module takes the least expressive option + * them — `transform`'s now says out loud, at the point of authoring, what this + * module and the E7 publish gate have always answered at rejection time (#6065). + * Where the text is silent this module takes the least expressive option * available and says so here, because the alternative — inventing expression * power (a template language, JSONPath, wildcards, conditionals) — would put a * dialect in the runtime that no contract declares and no publish gate can diff --git a/packages/spec/src/api/discovery-environment-subset.pin.test.ts b/packages/spec/src/api/discovery-environment-subset.pin.test.ts new file mode 100644 index 0000000000..dd5a2507c8 --- /dev/null +++ b/packages/spec/src/api/discovery-environment-subset.pin.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5676] `DiscoverySchema.environment` ⊂ `EnvironmentTypeSchema`. + * + * One concept — "which kind of environment is this" — is declared by two enums + * in this package: + * + * | declaration | members | + * |:---|:---| + * | `DiscoveryEnvironmentSchema` (`api/discovery.zod.ts`) | `production` `sandbox` `development` | + * | `EnvironmentTypeSchema` (`cloud/environment.zod.ts`) | those three + `test` `staging` `preview` `trial` | + * + * Keeping both is the ruled outcome, not a defect: discovery answers the coarse + * question ("am I talking to production?") on a machine-readable surface whose + * consumers `switch` over three values, so widening it would be a breaking + * change to a RESPONSE enum. #4828 introduced the lossy fold that makes the two + * co-exist (`resolveDiscoveryEnvironment`: `staging` → `sandbox`, `test` → + * `development`), and the maintainer's 2026-08-05 ruling requires every producer + * to land inside the three. + * + * What was missing is the thing that makes "subset" a FACT rather than a comment: + * nothing referenced one enum from the other, so a rename or a removal on the + * seven-member side would leave the three-member side silently claiming a + * membership it no longer has. The prose cross-references now run both ways + * (`discovery.zod.ts`'s `.describe()` since #4828; `environment.zod.ts`'s JSDoc + * since this pin) — and prose is unassertable, which is what this file is for. + * + * ⛔ Scope: this pins the RELATION only. It deliberately does not pin either + * enum's exact membership — `EnvironmentTypeSchema` is free to grow a new + * bucket, and a change-detector here would just tax that. What must never + * happen silently is the three drifting OUT of the seven. + * + * Every assertion carries an anti-vacuity guard, because the failure mode of a + * subset test is passing on an empty left-hand side. + */ + +import { describe, it, expect } from 'vitest'; + +import { EnvironmentTypeSchema } from '../cloud/environment.zod'; + +import { DiscoveryEnvironmentSchema } from './discovery.zod'; + +/** `.options` through the `lazySchema` Proxy — read once, asserted below. */ +const discoveryMembers = DiscoveryEnvironmentSchema.options as readonly string[]; +const environmentMembers = EnvironmentTypeSchema.options as readonly string[]; + +describe('[#5676] DiscoveryEnvironment ⊂ EnvironmentType', () => { + it('reads a non-empty membership off both enums (anti-vacuity)', () => { + // Without this, every `every()` below passes against a broken import. + expect(Array.isArray(discoveryMembers)).toBe(true); + expect(Array.isArray(environmentMembers)).toBe(true); + expect(discoveryMembers.length).toBeGreaterThan(0); + expect(environmentMembers.length).toBeGreaterThan(discoveryMembers.length); + }); + + it('declares every discovery environment as an EnvironmentType member', () => { + const missing = discoveryMembers.filter(m => !environmentMembers.includes(m)); + expect( + missing, + `${missing.join(', ')} is advertised by DiscoverySchema.environment but is no longer an ` + + 'EnvironmentTypeSchema member. The two describe one concept and discovery is the coarse ' + + 'view of it (#5676) — if a bucket was renamed on the cloud side, rename it here and in ' + + "`NODE_ENV_TO_DISCOVERY_ENVIRONMENT`'s values too, or the fold points at a dead value.", + ).toEqual([]); + }); + + it('PARSES every discovery environment as an EnvironmentType (not just string equality)', () => { + // The arrays could agree while the schemas disagree — a refinement, a + // transform, a branded type. Judge the schema, not its `.options` list. + for (const member of discoveryMembers) { + expect(EnvironmentTypeSchema.safeParse(member).success, member).toBe(true); + } + }); + + it('is a STRICT subset — the extra EnvironmentType buckets are rejected by discovery', () => { + // The negative control. Without it the test above would still pass if the + // two enums had been collapsed into one, which is the outcome #4828's + // ruling declined (widening a response enum breaks 3-value consumers). + const extras = environmentMembers.filter(m => !discoveryMembers.includes(m)); + expect(extras.length, 'no extra buckets left — did the two enums get collapsed?') + .toBeGreaterThan(0); + for (const member of extras) { + expect(DiscoveryEnvironmentSchema.safeParse(member).success, member).toBe(false); + } + }); +}); diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index 81f0490afd..6d34592d88 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -12,7 +12,7 @@ import { lazySchema } from '../shared/lazy-schema'; export const ApiMappingSchema = lazySchema(() => z.object({ source: z.string().describe('Source field/path'), target: z.string().describe('Target field/path'), - transform: z.string().optional().describe('Transformation function name'), + transform: z.string().optional().describe('Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (#5040 E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object)'), })); /** @@ -71,8 +71,8 @@ export const ApiEndpointSchema = z.object({ description: z.string().optional(), /** Execution Logic */ - type: z.enum(['flow', 'script', 'object_operation', 'proxy']).describe('Implementation type'), - target: z.string().describe('Target Flow ID, Script Name, or Proxy URL'), + type: z.enum(['flow', 'script', 'object_operation', 'proxy']).describe("Implementation type — only 'object_operation' and 'flow' EXECUTE in 17.x. 'script' and 'proxy' stay in the frozen vocabulary (#5040) and are rejected at publish, not parsed and ignored: express script logic as a flow whose script node runs your registered function, and an outbound call as a flow using a declared connector"), + target: z.string().describe("Target Flow ID or Script Name or Proxy URL, per `type` — but only the Flow ID is reachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'` (an `object_operation` endpoint is addressed by `objectParams.object` / `.operation`; neither the publish gate nor the executor reads `target` for that type)"), /** Logic Config */ objectParams: z.object({ diff --git a/packages/spec/src/cloud/environment.zod.ts b/packages/spec/src/cloud/environment.zod.ts index bd6c3cf285..9557aa9d0e 100644 --- a/packages/spec/src/cloud/environment.zod.ts +++ b/packages/spec/src/cloud/environment.zod.ts @@ -43,6 +43,18 @@ import { lazySchema } from '../shared/lazy-schema'; * as a dedicated column. It remains in the protocol as a typed advisory used * by Studio badges, provisioning policies and SDK helpers; deployments that * need to persist it should write it into `metadata.env_type`. + * + * ⚠️ **This is NOT the enum a discovery response advertises.** + * `DiscoverySchema.environment` (`api/discovery.zod.ts`) is a deliberately + * coarser THREE-member enum — `production` / `sandbox` / `development` — that + * answers "am I talking to production?", not "which environment is this". The + * three are a strict subset of the seven here, and `resolveDiscoveryEnvironment` + * folds the other four onto them (`staging` → `sandbox`, `test` → `development`, + * #4828). So a `staging` value that is first-class on this taxonomy is REJECTED + * by `DiscoveryEnvironmentSchema`; do not carry a value from here onto a + * discovery response without going through that resolver. The subset relation is + * pinned in `api/discovery-environment-subset.pin.test.ts` so neither enum can + * drift out of it silently (#5676). */ export const EnvironmentTypeSchema = lazySchema(() => z .enum(['production', 'sandbox', 'development', 'test', 'staging', 'preview', 'trial']) diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index 09ae194d36..4cb0cbfc65 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -419,7 +419,7 @@ describe('HookContextSchema', () => { const context = HookContextSchema.parse({ object: 'account', event: 'beforeInsert', - input: { doc: { name: 'Test Account' } }, + input: { data: { name: 'Test Account' } }, ql: {}, }); @@ -460,7 +460,7 @@ describe('HookContextSchema', () => { object: 'account', event: 'beforeInsert', input: { - doc: { + data: { name: 'New Account', industry: 'Technology', }, @@ -471,7 +471,7 @@ describe('HookContextSchema', () => { // `input` is `z.record(z.string(), z.unknown())` by contract — the payload // shape varies per event — so a parsed read is narrowed at the read site. - expect((context.input.doc as { name: string }).name).toBe('New Account'); + expect((context.input.data as { name: string }).name).toBe('New Account'); }); it('should accept update input', () => { @@ -480,14 +480,14 @@ describe('HookContextSchema', () => { event: 'beforeUpdate', input: { id: '123', - doc: { status: 'active' }, + data: { status: 'active' }, options: {}, }, ql: {}, }); expect(context.input.id).toBe('123'); - expect((context.input.doc as { status: string }).status).toBe('active'); + expect((context.input.data as { status: string }).status).toBe('active'); }); it('should accept delete input', () => { @@ -667,7 +667,7 @@ describe('HookContextSchema', () => { object: 'account', event: 'beforeInsert', input: { - doc: { + data: { name: 'New Account', industry: 'Technology', status: 'active', @@ -701,7 +701,7 @@ describe('HookContextSchema', () => { event: 'afterUpdate', input: { id: '123', - doc: { status: 'active' }, + data: { status: 'active' }, options: {}, }, result: { @@ -745,7 +745,7 @@ describe('Integration Tests', () => { object: 'account', event: 'beforeInsert', input: { - doc: { name: 'Test Account' }, + data: { name: 'Test Account' }, }, session: { userId: 'user_123', @@ -758,7 +758,7 @@ describe('Integration Tests', () => { object: 'account', event: 'afterInsert', input: { - doc: { name: 'Test Account' }, + data: { name: 'Test Account' }, }, result: { id: '123', diff --git a/packages/spec/src/shared/expression-dialect-docs.pin.test.ts b/packages/spec/src/shared/expression-dialect-docs.pin.test.ts new file mode 100644 index 0000000000..9dc7198f9b --- /dev/null +++ b/packages/spec/src/shared/expression-dialect-docs.pin.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#6085] The `## Dialects` table in `expression.zod.ts`'s module TSDoc lists + * exactly the members of `ExpressionDialect` — no more, no fewer. + * + * That table is not a comment. `build-docs.ts` publishes the module doc block + * verbatim as the opening prose of `content/docs/references/shared/expression.mdx`, + * so it is the authoring surface an author (often an AI author, ADR-0033) reads + * to learn which dialects exist. It drifted in BOTH directions at once and + * stayed that way for majors: + * + * - it carried a `js` row, complete with an engine (`isolated-vm` / `quickjs`) + * and a use ("mapping, hook bodies"), for a dialect retired at #3278 + * (ADR-0058 addendum) that `ExpressionSchema` rejects on parse — the + * Prime Directive #10 shape, advertising a capability the runtime refuses; + * - it omitted `template`, a real member with its own author helper + * (`TemplateExpressionInputSchema`, `tmpl`), so the one dialect an author + * reaches for on notification bodies and prompt templates was undocumented. + * + * Twelve lines below the table, the enum and the comment beside it had the + * retirement right the whole time. Nothing compared the two, which is exactly + * the gap this pin closes: `check:docs` re-renders the page FROM this table, so + * it verifies the artifact matches the source and is green on a source that + * lies. + * + * ⛔ Scope: the relation, not the wording. Rewording a `use` cell or renaming an + * engine is free; adding or dropping a ROW without the enum agreeing is not. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { ExpressionDialect } from './expression.zod'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const SOURCE = path.resolve(HERE, 'expression.zod.ts'); + +/** + * The dialect names in the first column of the `## Dialects` table. + * + * Read out of the raw source rather than out of the generated `.mdx`, because + * the source is what a reader of the file sees AND what the generator copies — + * one read covers both surfaces, and it cannot go green because a regen was + * forgotten. + */ +function dialectRowsInDocTable(): string[] { + const source = fs.readFileSync(SOURCE, 'utf8'); + const section = source.slice(source.indexOf('## Dialects')); + const row = /^\s*\*\s*\|\s*`([a-z_]+)`\s*\|/; + + // Walk the section rather than slicing it: the header is followed by a blank + // doc line and a `|:---|` separator, so any "up to the first blank line" cut + // lands before the rows. Collect the run of body rows and stop when it ends. + const names: string[] = []; + for (const line of section.split('\n')) { + const match = row.exec(line); + if (match) { names.push(match[1]); continue; } + if (names.length > 0) break; // the table's body ended + } + return names; +} + +describe('[#6085] expression.zod.ts dialect table === ExpressionDialect', () => { + const rows = dialectRowsInDocTable(); + + it('finds the table at all (anti-vacuity)', () => { + // Every assertion below compares two lists; an empty left side would make + // them all pass the day someone reformats the block or moves the section. + expect(rows.length, 'no `| `dialect` |` rows parsed out of the `## Dialects` table') + .toBeGreaterThan(0); + expect(ExpressionDialect.options.length).toBeGreaterThan(0); + }); + + it('documents exactly the enum members, in the enum-agnostic sense of a set', () => { + expect([...rows].sort()).toEqual([...ExpressionDialect.options].sort()); + }); + + it('never re-advertises `js`, retired at #3278', () => { + // The specific regression this pin was written for. `js` is not an + // expression dialect at all — procedural JavaScript is the L2 authoring + // surface (`ScriptBody { language: 'js' }`), so a row here would send an + // author to a value `ExpressionSchema` rejects. + expect(rows).not.toContain('js'); + expect(ExpressionDialect.safeParse('js').success).toBe(false); + }); + + it('documents `template`, which has an author helper and is easy to omit', () => { + expect(rows).toContain('template'); + expect(ExpressionDialect.safeParse('template').success).toBe(true); + }); +}); diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index c8be5ba2ed..a97dbe3f0a 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -18,9 +18,16 @@ import { z } from 'zod'; * * | dialect | engine | use | * |:---|:---|:---| - * | `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values | - * | `js` | sandboxed L2 hook bodies (`isolated-vm` / `quickjs`) | mapping, hook bodies | - * | `cron` | `cron-parser` | job schedules | + * | `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values | + * | `cron` | `cron-parser` | job schedules | + * | `template` | `{{var}}` interpolation at evaluate time (same variable scope as CEL) | notification subjects/bodies, `titleFormat`, prompt templates | + * + * Those three are the whole list — it is exactly the `ExpressionDialect` enum + * below. Procedural JavaScript is **not** a dialect: it is the L2 authoring + * surface, the sandboxed, capability-gated `ScriptBody { language: 'js' }` in + * hook/action bodies. A `js` row stood in this table long after the dialect was + * retired in #3278 (ADR-0058 addendum); `ExpressionSchema` rejects + * `dialect: 'js'`. * * SQL fragments (analytics joins, partial indexes) are intentionally **not** * routed through this schema — they stay driver-native because their security diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 299a0465ad..e209be8120 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -393,8 +393,13 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ * - **skills**: Reusable capability bundles ("topics" in Salesforce * parlance) — THE extension primitive. Each skill groups related tools, * declares its agent surface affinity (`'ask' | 'build' | 'both'`, - * ADR-0063 §3 — checked by lint, enforced at load), trigger phrases for - * intent matching, and trigger conditions for context-aware activation. + * ADR-0063 §3 — checked by lint, enforced at load) and `triggerConditions` + * (an AND of context field/operator/value) for context-aware activation. + * Activation is that intersected with the agent's own skill allowlist — + * there is no phrase list: `triggerPhrases` was retired in #3896 because + * phrases were never matched against the user's message, and the key is now + * a `retiredKey()` tombstone that rejects on parse. Natural-language intent + * belongs in `description` / `instructions`, where the LLM reads it. * - **tools**: OPTIONAL refinement layer, never required (ADR-0109). The * default third-party path declares no tool records: a skill's `tools[]` * names a platform-registered tool (`PLATFORM_PROVIDED_TOOL_NAMES`) or a diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index 729c42ea67..025ed9c533 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -10,13 +10,6 @@ import { strictObject } from '../shared/strict-object'; // closed; two are deliberately left open with the reason recorded, because // closing them would gate nothing. // -// ⚠️ Nothing above this block may be a JSDoc block: `build-docs.ts`'s -// `getFileDescription()` publishes the module's FIRST doc block as the -// reference page's description (#3746 trap 1). Hence `//`. Note you cannot -// safely spell that token out here either — see the longer note in -// `theme.zod.ts`, where quoting it inside a `//` line silently emptied the -// published page description. -// // CLOSED (real door, three measurements, 2026-08-03): // `ChartConfigSchema`, `ChartAxisSchema`, `ChartSeriesSchema`, // `ChartAnnotationSchema`, `ChartInteractionSchema`. diff --git a/packages/spec/src/ui/theme.zod.ts b/packages/spec/src/ui/theme.zod.ts index a3954aa8d1..06c9f34416 100644 --- a/packages/spec/src/ui/theme.zod.ts +++ b/packages/spec/src/ui/theme.zod.ts @@ -8,19 +8,6 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; // MEASURED before anything was tightened, not assumed. Read this before adding // a key, and before "finishing" any sibling file by analogy. // -// ⚠️ Nothing above this block may be a JSDoc block: `build-docs.ts`'s -// `getFileDescription()` publishes the module's FIRST doc block as the -// reference page's description, so a doc-comment header here would replace the -// public page's text with an internal note (#3746 trap 1). Hence `//`. -// -// And do not spell that hazard out with the literal two-star opener, either — -// `getFileDescription()` matches it with a bare regex over the raw source, so -// even INSIDE a `//` line it reads as the file's first doc block. The first -// draft of this very warning quoted the token, matched as an empty description, -// and deleted "Color Palette Schema / Defines brand colors and their variants" -// from the published page. The caution about the trap sprang the trap; caught -// by `check:docs`, which is exactly what it is for. -// // THE DOOR (three measurements, 2026-08-03, each with controls in the run): // // 1. CARRIER KEY — `stack.zod.ts` declares `themes: z.array(ThemeSchema)`,