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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions content/docs/references/api/endpoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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) |


---
Expand Down
13 changes: 10 additions & 3 deletions content/docs/references/shared/expression.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<ObjectChart aggregate={…}>`); 批 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` (`<ObjectChart aggregate={…}>`); 批 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
Expand Down
6 changes: 4 additions & 2 deletions packages/runtime/src/api-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions packages/spec/src/api/discovery-environment-subset.pin.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
6 changes: 3 additions & 3 deletions packages/spec/src/api/endpoint.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
}));

/**
Expand Down Expand Up @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions packages/spec/src/cloud/environment.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
18 changes: 9 additions & 9 deletions packages/spec/src/data/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
});

Expand Down Expand Up @@ -460,7 +460,7 @@ describe('HookContextSchema', () => {
object: 'account',
event: 'beforeInsert',
input: {
doc: {
data: {
name: 'New Account',
industry: 'Technology',
},
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -667,7 +667,7 @@ describe('HookContextSchema', () => {
object: 'account',
event: 'beforeInsert',
input: {
doc: {
data: {
name: 'New Account',
industry: 'Technology',
status: 'active',
Expand Down Expand Up @@ -701,7 +701,7 @@ describe('HookContextSchema', () => {
event: 'afterUpdate',
input: {
id: '123',
doc: { status: 'active' },
data: { status: 'active' },
options: {},
},
result: {
Expand Down Expand Up @@ -745,7 +745,7 @@ describe('Integration Tests', () => {
object: 'account',
event: 'beforeInsert',
input: {
doc: { name: 'Test Account' },
data: { name: 'Test Account' },
},
session: {
userId: 'user_123',
Expand All @@ -758,7 +758,7 @@ describe('Integration Tests', () => {
object: 'account',
event: 'afterInsert',
input: {
doc: { name: 'Test Account' },
data: { name: 'Test Account' },
},
result: {
id: '123',
Expand Down
Loading
Loading