Skip to content

Commit 367a39b

Browse files
committed
docs(spec): 7-item description-surface truth sweep (#6085 #6081 #6161 #6137 #6065 #5676 #5886)
First bundled-trivia sweep: seven same-class corrections to spec description surfaces that lied or drifted. No behaviour changes — every item is either truth-restoration or the execution of an existing ruling. - #6085 `shared/expression.zod.ts` dialect table: drop the retired `js` row (#3278 / ADR-0058 addendum — `ExpressionSchema` rejects it), add the real member `template`, and state that the table IS the enum. New pin `expression-dialect-docs.pin.test.ts` compares the two so neither can drift. - #6081 `stack.zod.ts` skills JSDoc: stop selling `triggerPhrases` (retired at #3896, now a `retiredKey()` tombstone) — point at `triggerConditions` plus `description`/`instructions`, which is #3896's own prescription. - #6161 strictness-ledger classification table: the `no gate` row still defined the class with two exemplars that have since had their parse wired and left it (#5020 / #5068). Record that the bucket's population is zero tree-wide, and that the verdict stays in the vocabulary regardless. - #6137 `ui/theme.zod.ts` / `ui/chart.zod.ts`: delete the two "nothing above this block may be a JSDoc block" warnings. #5059 made both hazards impossible — block selection now requires column 0, the header zone and no immediately following declaration, and a `//` line can no longer match. - #6065 (direction B) `api/endpoint.zod.ts` ×3 `.describe()`: `type`, `target` and `ApiMapping.transform` stop advertising what the publish gate rejects whole-key. The frozen vocabulary (#5040) is unchanged; the copy now says at authoring time what the gate says at rejection time. The verbatim mirror table in `packages/runtime/src/api-mapping.ts` is updated in the same pass. - #5676 `cloud/environment.zod.ts`: back-reference to the coarser 3-member `DiscoverySchema.environment` (the forward reference landed with #4828), plus `discovery-environment-subset.pin.test.ts` pinning the subset relation. No contract change — neither enum's membership moves. - #5886 `data/hook.test.ts`: re-spell the `input: { doc: … }` fixtures to the engine's real shape (`data`), the spec half of #5671. The old contract table was corrected at #5273; these fixtures still taught the disproved spelling. `content/docs/references/{shared/expression,api/endpoint}.mdx` regenerated — `check:generated` proved exactly those two stale and `--fix` regenerated only them. Fixes #6085 Fixes #6081 Fixes #6161 Fixes #6137 Fixes #6065 Fixes #5676 Fixes #5886 Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY
1 parent 01faeb1 commit 367a39b

13 files changed

Lines changed: 246 additions & 46 deletions

File tree

content/docs/references/api/endpoint.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ const result = ApiEndpointSchema.parse(data);
3636
| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` || HTTP Method |
3737
| **summary** | `string` | optional | |
3838
| **description** | `string` | optional | |
39-
| **type** | `Enum<'flow' \| 'script' \| 'object_operation' \| 'proxy'>` || Implementation type |
40-
| **target** | `string` || Target Flow ID, Script Name, or Proxy URL |
39+
| **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 |
40+
| **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) |
4141
| **objectParams** | `{ object?: string; operation?: Enum<'find' \| 'get' \| 'create' \| 'update' \| 'delete'> }` | optional | For object_operation type |
4242
| **inputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Request Body to Internal Params |
4343
| **outputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Internal Result to Response Body |
@@ -63,7 +63,7 @@ const result = ApiEndpointSchema.parse(data);
6363
| :--- | :--- | :--- | :--- |
6464
| **source** | `string` || Source field/path |
6565
| **target** | `string` || Target field/path |
66-
| **transform** | `string` | optional | Transformation function name |
66+
| **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) |
6767

6868

6969
---

content/docs/references/shared/expression.mdx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,23 @@ envelope.
2727

2828
|:---|:---|:---|
2929

30-
| `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values |
30+
| `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values |
3131

32-
| `js` | sandboxed L2 hook bodies (`isolated-vm` / `quickjs`) | mapping, hook bodies |
32+
| `cron` | `cron-parser` | job schedules |
3333

34-
| `cron` | `cron-parser` | job schedules |
34+
| `template` | `\{\{var\}\}` interpolation at evaluate time (same variable scope as CEL) | notification subjects/bodies, `titleFormat`, prompt templates |
35+
36+
Those three are the whole list — it is exactly the `ExpressionDialect` enum
37+
38+
below. Procedural JavaScript is **not** a dialect: it is the L2 authoring
39+
40+
surface, the sandboxed, capability-gated `ScriptBody \{ language: 'js' \}` in
41+
42+
hook/action bodies. A `js` row stood in this table long after the dialect was
43+
44+
retired in #3278 (ADR-0058 addendum); `ExpressionSchema` rejects
45+
46+
`dialect: 'js'`.
3547

3648
SQL fragments (analytics joins, partial indexes) are intentionally **not**
3749

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ One question decides the class: **who writes this schema's input?**
6868
| **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 |
6969
| **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) |
7070
| **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) |
71-
| **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 |
71+
| **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 |
7272
| **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 |
7373

7474
A fourth answer to "who writes this input" is **nobody**, and it is only

packages/runtime/src/api-mapping.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
* | `outputMapping` | *Map Internal Result to Response Body* |
2323
* | `ApiMapping.source` | *Source field/path* |
2424
* | `ApiMapping.target` | *Target field/path* |
25-
* | `ApiMapping.transform` | *Transformation function name* |
25+
* | `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)* |
2626
*
2727
* Five short sentences, and everything below is the MINIMAL faithful reading of
28-
* them. Where the text is silent this module takes the least expressive option
28+
* them — `transform`'s now says out loud, at the point of authoring, what this
29+
* module and the E7 publish gate have always answered at rejection time (#6065).
30+
* Where the text is silent this module takes the least expressive option
2931
* available and says so here, because the alternative — inventing expression
3032
* power (a template language, JSONPath, wildcards, conditionals) — would put a
3133
* dialect in the runtime that no contract declares and no publish gate can
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#5676] `DiscoverySchema.environment` ⊂ `EnvironmentTypeSchema`.
5+
*
6+
* One concept — "which kind of environment is this" — is declared by two enums
7+
* in this package:
8+
*
9+
* | declaration | members |
10+
* |:---|:---|
11+
* | `DiscoveryEnvironmentSchema` (`api/discovery.zod.ts`) | `production` `sandbox` `development` |
12+
* | `EnvironmentTypeSchema` (`cloud/environment.zod.ts`) | those three + `test` `staging` `preview` `trial` |
13+
*
14+
* Keeping both is the ruled outcome, not a defect: discovery answers the coarse
15+
* question ("am I talking to production?") on a machine-readable surface whose
16+
* consumers `switch` over three values, so widening it would be a breaking
17+
* change to a RESPONSE enum. #4828 introduced the lossy fold that makes the two
18+
* co-exist (`resolveDiscoveryEnvironment`: `staging` → `sandbox`, `test` →
19+
* `development`), and the maintainer's 2026-08-05 ruling requires every producer
20+
* to land inside the three.
21+
*
22+
* What was missing is the thing that makes "subset" a FACT rather than a comment:
23+
* nothing referenced one enum from the other, so a rename or a removal on the
24+
* seven-member side would leave the three-member side silently claiming a
25+
* membership it no longer has. The prose cross-references now run both ways
26+
* (`discovery.zod.ts`'s `.describe()` since #4828; `environment.zod.ts`'s JSDoc
27+
* since this pin) — and prose is unassertable, which is what this file is for.
28+
*
29+
* ⛔ Scope: this pins the RELATION only. It deliberately does not pin either
30+
* enum's exact membership — `EnvironmentTypeSchema` is free to grow a new
31+
* bucket, and a change-detector here would just tax that. What must never
32+
* happen silently is the three drifting OUT of the seven.
33+
*
34+
* Every assertion carries an anti-vacuity guard, because the failure mode of a
35+
* subset test is passing on an empty left-hand side.
36+
*/
37+
38+
import { describe, it, expect } from 'vitest';
39+
40+
import { EnvironmentTypeSchema } from '../cloud/environment.zod';
41+
42+
import { DiscoveryEnvironmentSchema } from './discovery.zod';
43+
44+
/** `.options` through the `lazySchema` Proxy — read once, asserted below. */
45+
const discoveryMembers = DiscoveryEnvironmentSchema.options as readonly string[];
46+
const environmentMembers = EnvironmentTypeSchema.options as readonly string[];
47+
48+
describe('[#5676] DiscoveryEnvironment ⊂ EnvironmentType', () => {
49+
it('reads a non-empty membership off both enums (anti-vacuity)', () => {
50+
// Without this, every `every()` below passes against a broken import.
51+
expect(Array.isArray(discoveryMembers)).toBe(true);
52+
expect(Array.isArray(environmentMembers)).toBe(true);
53+
expect(discoveryMembers.length).toBeGreaterThan(0);
54+
expect(environmentMembers.length).toBeGreaterThan(discoveryMembers.length);
55+
});
56+
57+
it('declares every discovery environment as an EnvironmentType member', () => {
58+
const missing = discoveryMembers.filter(m => !environmentMembers.includes(m));
59+
expect(
60+
missing,
61+
`${missing.join(', ')} is advertised by DiscoverySchema.environment but is no longer an `
62+
+ 'EnvironmentTypeSchema member. The two describe one concept and discovery is the coarse '
63+
+ 'view of it (#5676) — if a bucket was renamed on the cloud side, rename it here and in '
64+
+ "`NODE_ENV_TO_DISCOVERY_ENVIRONMENT`'s values too, or the fold points at a dead value.",
65+
).toEqual([]);
66+
});
67+
68+
it('PARSES every discovery environment as an EnvironmentType (not just string equality)', () => {
69+
// The arrays could agree while the schemas disagree — a refinement, a
70+
// transform, a branded type. Judge the schema, not its `.options` list.
71+
for (const member of discoveryMembers) {
72+
expect(EnvironmentTypeSchema.safeParse(member).success, member).toBe(true);
73+
}
74+
});
75+
76+
it('is a STRICT subset — the extra EnvironmentType buckets are rejected by discovery', () => {
77+
// The negative control. Without it the test above would still pass if the
78+
// two enums had been collapsed into one, which is the outcome #4828's
79+
// ruling declined (widening a response enum breaks 3-value consumers).
80+
const extras = environmentMembers.filter(m => !discoveryMembers.includes(m));
81+
expect(extras.length, 'no extra buckets left — did the two enums get collapsed?')
82+
.toBeGreaterThan(0);
83+
for (const member of extras) {
84+
expect(DiscoveryEnvironmentSchema.safeParse(member).success, member).toBe(false);
85+
}
86+
});
87+
});

packages/spec/src/api/endpoint.zod.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { lazySchema } from '../shared/lazy-schema';
1212
export const ApiMappingSchema = lazySchema(() => z.object({
1313
source: z.string().describe('Source field/path'),
1414
target: z.string().describe('Target field/path'),
15-
transform: z.string().optional().describe('Transformation function name'),
15+
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)'),
1616
}));
1717

1818
/**
@@ -71,8 +71,8 @@ export const ApiEndpointSchema = z.object({
7171
description: z.string().optional(),
7272

7373
/** Execution Logic */
74-
type: z.enum(['flow', 'script', 'object_operation', 'proxy']).describe('Implementation type'),
75-
target: z.string().describe('Target Flow ID, Script Name, or Proxy URL'),
74+
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"),
75+
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)"),
7676

7777
/** Logic Config */
7878
objectParams: z.object({

packages/spec/src/cloud/environment.zod.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ import { lazySchema } from '../shared/lazy-schema';
4343
* as a dedicated column. It remains in the protocol as a typed advisory used
4444
* by Studio badges, provisioning policies and SDK helpers; deployments that
4545
* need to persist it should write it into `metadata.env_type`.
46+
*
47+
* ⚠️ **This is NOT the enum a discovery response advertises.**
48+
* `DiscoverySchema.environment` (`api/discovery.zod.ts`) is a deliberately
49+
* coarser THREE-member enum — `production` / `sandbox` / `development` — that
50+
* answers "am I talking to production?", not "which environment is this". The
51+
* three are a strict subset of the seven here, and `resolveDiscoveryEnvironment`
52+
* folds the other four onto them (`staging` → `sandbox`, `test` → `development`,
53+
* #4828). So a `staging` value that is first-class on this taxonomy is REJECTED
54+
* by `DiscoveryEnvironmentSchema`; do not carry a value from here onto a
55+
* discovery response without going through that resolver. The subset relation is
56+
* pinned in `api/discovery-environment-subset.pin.test.ts` so neither enum can
57+
* drift out of it silently (#5676).
4658
*/
4759
export const EnvironmentTypeSchema = lazySchema(() => z
4860
.enum(['production', 'sandbox', 'development', 'test', 'staging', 'preview', 'trial'])

packages/spec/src/data/hook.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ describe('HookContextSchema', () => {
419419
const context = HookContextSchema.parse({
420420
object: 'account',
421421
event: 'beforeInsert',
422-
input: { doc: { name: 'Test Account' } },
422+
input: { data: { name: 'Test Account' } },
423423
ql: {},
424424
});
425425

@@ -460,7 +460,7 @@ describe('HookContextSchema', () => {
460460
object: 'account',
461461
event: 'beforeInsert',
462462
input: {
463-
doc: {
463+
data: {
464464
name: 'New Account',
465465
industry: 'Technology',
466466
},
@@ -471,7 +471,7 @@ describe('HookContextSchema', () => {
471471

472472
// `input` is `z.record(z.string(), z.unknown())` by contract — the payload
473473
// shape varies per event — so a parsed read is narrowed at the read site.
474-
expect((context.input.doc as { name: string }).name).toBe('New Account');
474+
expect((context.input.data as { name: string }).name).toBe('New Account');
475475
});
476476

477477
it('should accept update input', () => {
@@ -480,14 +480,14 @@ describe('HookContextSchema', () => {
480480
event: 'beforeUpdate',
481481
input: {
482482
id: '123',
483-
doc: { status: 'active' },
483+
data: { status: 'active' },
484484
options: {},
485485
},
486486
ql: {},
487487
});
488488

489489
expect(context.input.id).toBe('123');
490-
expect((context.input.doc as { status: string }).status).toBe('active');
490+
expect((context.input.data as { status: string }).status).toBe('active');
491491
});
492492

493493
it('should accept delete input', () => {
@@ -667,7 +667,7 @@ describe('HookContextSchema', () => {
667667
object: 'account',
668668
event: 'beforeInsert',
669669
input: {
670-
doc: {
670+
data: {
671671
name: 'New Account',
672672
industry: 'Technology',
673673
status: 'active',
@@ -701,7 +701,7 @@ describe('HookContextSchema', () => {
701701
event: 'afterUpdate',
702702
input: {
703703
id: '123',
704-
doc: { status: 'active' },
704+
data: { status: 'active' },
705705
options: {},
706706
},
707707
result: {
@@ -745,7 +745,7 @@ describe('Integration Tests', () => {
745745
object: 'account',
746746
event: 'beforeInsert',
747747
input: {
748-
doc: { name: 'Test Account' },
748+
data: { name: 'Test Account' },
749749
},
750750
session: {
751751
userId: 'user_123',
@@ -758,7 +758,7 @@ describe('Integration Tests', () => {
758758
object: 'account',
759759
event: 'afterInsert',
760760
input: {
761-
doc: { name: 'Test Account' },
761+
data: { name: 'Test Account' },
762762
},
763763
result: {
764764
id: '123',

0 commit comments

Comments
 (0)