Skip to content

Commit e08c8b0

Browse files
os-billclaude
andauthored
docs(spec,formula): state the Expression contract in the present tense — drop the M9.1 / M9.2 phase language (#18557)
Part of #17849 Clause-②: no The spec-seat half of #17323, ruled **B** by the director seat (comment `5644710461`, decision batch #123 item 1, 2026-09-12; maintainer verbatim 「同意」). The M9.1 / M9.2 phase language is dropped from the citation sites in `packages/spec` and `packages/formula`, and the contract each of them describes is restated in the present tense. ⛔ No accept-set change: no key is added, renamed, re-typed or retired, and every envelope that parsed before parses byte-identically. The docs half (PR #17851, `Part of #17323`) merged on 2026-09-12T13:17:02Z, so `content/docs/data-modeling/formulas.mdx` and the two pending changesets already carry the new wording. This PR is the other half the ruling's item 4 assigns to this seat — `packages/formula` included, which the domain table calls `domain:engine` and the ruling puts here verbatim. ## What the text says now - `source` is the canonical persisted form — it is what the engine evaluates. - `ast` is accepted beside it as an optional opaque structured value (each engine validates its own shape), and it carries no promise of becoming required. - A slot whose value the engine RUNS requires `source`; `EvaluatedExpressionSchema` is where that is spelled out. | file | `M9\.[12]` hits at `79a046f` | after | |:--|--:|--:| | `packages/spec/src/shared/expression.zod.ts` | 7 | 0 | | `packages/formula/src/cel-engine.ts` | 1 | 0 | | `packages/formula/src/normalize.ts` | 1 | 0 | Lit control for the same probe: `ExpressionSchema` in `expression.zod.ts` reads 19, so the zero is a reading and not a broken path. `cel-engine.ts`'s AST-only arm also carried an `M9.7` promise ("revisit AST-only execution in M9.7 when we cut the spec persistence over") **inside the same sentence** as its `M9.1` hit. It goes with the sentence rather than being left as a phase promise standing beside a de-phased one; it is named here rather than smuggled. ## The verbatim-quote constraint, and the proof it holds Coordination note `5645945565`: the two pending changesets quote the exported constant `EVALUATED_EXPRESSION_SOURCE_REQUIRED` VERBATIM inside a blockquote, and the docs half already edited them to read 「(the canonical persisted form)」. The constant loses exactly the four words `of phase M9.1` and nothing else, so the quote stays a quote. Proved mechanically rather than by eye — the constant's runtime value was reassembled from the source and compared with each changeset's blockquote, joined: ``` .changeset/flow-edge-condition-evaluated-slot.md ==> BYTE-IDENTICAL to the constant .changeset/blank-node-condition-refused-at-registration.md ==> BYTE-IDENTICAL to the constant ``` No consumer parses that sentence: every call site imports the constant by name, and the message assertions in `packages/lint`, `packages/services/service-automation` and `packages/spec` read substrings that never contained the phase id — the non-blank-source clause and the cannot-evaluate-ast clause. ## ⚠️ Declared deviation — the `packages/spec/CHANGELOG.md` erratum is NOT in this PR The card's work items and the ruling's item 2 also name an erratum line under the already-published `## 17.4.0` entry that promised M9.2 (3 hits at `:1096`, `:1103`, `:1119`). It is **deliberately not carried here**, because AGENTS.md's Documentation Guardrails row for `packages/*/CHANGELOG.md` is categorical about the packaging: > ❌ Never edit in a code PR … Factual error in a released entry → **amend that entry in a dedicated docs-only PR**, ⛔ never an erratum in a later entry and never a rider on code changes This PR is a code PR: it moves a published string and owes a `patch` changeset. The ruling names 「the #17026 shape」 for the erratum, and that shape — read off the card #17026 and PR #17896 that executed it — **is** a dedicated docs-only PR with `skip-changeset`, whose executing seat quoted this same AGENTS.md row verbatim as its authorization chain. So the ruling and the guardrail agree; only the card's file-surface bundling puts them in one PR. ⇒ The erratum is owed as a **separate docs-only PR**, and #17849 stays open for it — which is why this body reads `Part of #17849` and not the closing form the dispatch asked for. The residual after this PR, measured on this branch: ``` $ git grep -lE 'M9\.[12]' -- packages packages/spec/CHANGELOG.md # 3 hits — the erratum's target, owed as a docs-only PR ``` Nothing under `content/` remains; the docs half cleared it. ## Verification Everything below is a real local run, exit codes landed to disk before reading (⛔ never through a pipe). | run | verdict | |:--|:--| | `pnpm --filter @objectstack/spec --filter @objectstack/formula build` | `VERDICT command-exit 0` (shared verify lock) | | `pnpm --filter @objectstack/spec check:generated` | exit 0 — ✓ all 15 generated artifacts up to date (nothing to regenerate: no `.describe()` moved) | | `pnpm --filter @objectstack/spec --filter @objectstack/formula typecheck` | exit 0 | | `pnpm --filter @objectstack/spec --filter @objectstack/formula test` | exit 0 — spec 483 files / 13773 tests, formula 29 files / 856 tests | | `pnpm lint` (`eslint . --no-inline-config`, whole repo) | exit 0 | | derived gate families (`dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`) | 78 derived · 76 run green · 2 NOT MEASURED | `--ran` reconciliation with exit codes recorded per family: `78 derived famil(ies) accounted for — 76 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. The two are `check:dual-build-cjs-loads` and `check:lean-entry-closure`, both exit **3 = PREREQUISITE NOT MET** (they read the `dist/` of ~80 packages this tree never built) — ⛔ neither a pass nor a failure; CI's `Build Core` is where they run. Two families needed a prerequisite before they could answer, and both are green once given it: `check:doc-formula-expressions` (exit 3 until `@objectstack/lint` was built, then exit 0) and `check-plugin-teardown-shape --self-test` (exit 1 on this shallow clone until its pinned fixture commit was fetched, then exit 0 / 47 cases). ## Acceptance notes ⚠️ **One gate reds on this tree and it is NOT this diff** — `pnpm check:cross-package-test-inputs` (exit 1), flagging `@objectstack/cli descends a directory tree from packages/spec/dist/`, rooted in `packages/cli/test/init-created-files-summary.e2e.test.ts`. Ablated to the cause on a **pristine `origin/main` worktree with zero diff**: ``` node scripts/check-cross-package-test-inputs.mjs -> exit 0 (OK: 29 package(s) … 13 walked root(s) judged) mkdir -p packages/spec/dist && (same command) -> exit 1 (the identical failure text) rm -rf packages/spec/dist && (same command) -> exit 0 (restored; tree clean) ``` ⇒ the verdict turns on whether the gitignored `packages/spec/dist` exists, not on any source change — and building `packages/spec` is exactly what AGENTS.md requires before the spec artifact gates run. CI cannot see it: `Lint & Repo Gates` installs and never builds, so the gate is green there. Filed as a finding for the dispatching seat rather than fixed here — `packages/cli` and `scripts/check-cross-package-test-inputs.mjs` are both outside this card's file surface. Noted, not filed: nothing else. The three source files were read whole and carry no other stale phase promise; the remaining `M9.x` mentions in the tree (`M9.9b` in `default-value-shape.ts`, `action.zod.ts`, `objectql/engine.ts`; `M9.5` / `M9.7` in `objectql` and the docs page) are ROADMAP section references outside the ruling's seven citation sites, and the ruling names only the M9.1 / M9.2 phase promise. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 340b6dc commit e08c8b0

4 files changed

Lines changed: 69 additions & 19 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the Expression contract is stated in the present tense — the M9.1 / M9.2 phase language is dropped (#17849)
6+
7+
Clause-②: no
8+
9+
No accept-set change. `ExpressionSchema` still accepts `source` OR `ast`, every
10+
evaluated slot still requires a non-blank `source`, and no key is added, renamed
11+
or retired. What moves is the text six citation sites carried.
12+
13+
Those docblocks promised a two-phase roadmap — "Phase 1 (M9.1): `source` is the
14+
canonical persisted form … Phase 2 (M9.2+): `ast` becomes required in build
15+
output" — that no ADR ever chartered, and the refusal sentence an author reads
16+
carried the phase id inside it. #17323 ruled the promise removed: `ast` stays an
17+
accepted optional structured value with no promise of becoming required. The
18+
contract is now written as it actually is:
19+
20+
- `source` is the canonical persisted form — it is what the engine evaluates;
21+
- `ast` is accepted beside it as an optional opaque structured value, and
22+
carries no promise of becoming required;
23+
- a slot whose value the engine RUNS requires `source`, which is what
24+
`EvaluatedExpressionSchema` spells out.
25+
26+
**The one published string that moves** is `EVALUATED_EXPRESSION_SOURCE_REQUIRED`,
27+
the sentence an author reads when an evaluated slot refuses a non-evaluable
28+
envelope. It loses four words and nothing else:
29+
30+
> … the expression engine evaluates `source` (the canonical persisted form of
31+
> phase M9.1) and cannot evaluate `ast` alone …
32+
33+
now reads
34+
35+
> … the expression engine evaluates `source` (the canonical persisted form) and
36+
> cannot evaluate `ast` alone …
37+
38+
Nothing parses that sentence for its content: every consumer imports the
39+
constant by name, and the two pending changesets that quote it verbatim
40+
(`flow-edge-condition-evaluated-slot`,
41+
`blank-node-condition-refused-at-registration`) already carry the new wording,
42+
so the quote stays a quote.
43+
44+
The `packages/formula` half of the same ruling — `cel-engine.ts`'s AST-only arm
45+
and `normalize.ts`'s header — is comment-only and publishes nothing from that
46+
package (`@objectstack/formula` ships `dist` alone), so it is not graded here.

packages/formula/src/cel-engine.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,9 +1715,10 @@ export const celEngine: DialectEngine = {
17151715
const source = expr.source;
17161716
if (typeof source !== 'string' || source.length === 0) {
17171717
// AST-only inputs: cel-js does not currently expose a public API to
1718-
// re-execute a parsed AST without re-serializing. We persist `source`
1719-
// as the canonical form during M9.1 and revisit AST-only execution in
1720-
// M9.7 when we cut the spec persistence over.
1718+
// re-execute a parsed AST without re-serializing. `source` is the
1719+
// canonical persisted form and is what this engine evaluates, so an
1720+
// envelope carrying only `ast` has no evaluable form here and is
1721+
// reported as such rather than run.
17211722
return {
17221723
ok: false,
17231724
error: { kind: 'parse', message: 'AST-only evaluation not yet supported; persist `source`' },

packages/formula/src/normalize.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
*
77
* 1. String shorthand input is replaced by `{ dialect: 'cel', source }`.
88
* 2. The persisted envelope carries an `ast` field produced by the dialect
9-
* engine (M9.2 deliverable). Source is retained for round-trip / debug.
9+
* engine, beside the canonical `source` the engine evaluates. `ast` is an
10+
* optional opaque structured value; it never replaces `source`.
1011
*
1112
* Spec layer cannot do step 2 because it must remain dependency-free; this
1213
* package owns the engine import and therefore the AST step.

packages/spec/src/shared/expression.zod.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,14 @@ export type ExpressionMeta = z.input<typeof ExpressionMetaSchema>;
7777
/**
7878
* Canonical Expression envelope.
7979
*
80-
* Phase 1 (M9.1): `source` is the canonical persisted form. `ast` is reserved
81-
* and accepted as opaque structured value — `objectstack compile` will fill it
82-
* in M9.2 with the engine's parsed AST so the artifact carries an AST-only
83-
* representation.
80+
* `source` is the canonical persisted form: it is what the expression engine
81+
* evaluates. `ast` is accepted beside it as an optional opaque structured
82+
* value — `objectstack compile` fills it with the engine's parsed AST, and
83+
* each engine validates its own shape — and it carries no promise of becoming
84+
* required.
8485
*
85-
* Phase 2 (M9.2+): `ast` becomes required in build output; `source` is kept
86-
* only for round-trip / debug.
86+
* A slot whose value the engine RUNS requires `source`; the envelope that
87+
* spells that out is {@link EvaluatedExpressionSchema}.
8788
*/
8889
export const ExpressionSchema = z.object({
8990
/** Which engine evaluates `source` / `ast`. */
@@ -111,7 +112,7 @@ export type Expression = z.input<typeof ExpressionSchema>;
111112
*/
112113
export const EVALUATED_EXPRESSION_SOURCE_REQUIRED =
113114
'An expression in an evaluated slot needs a non-blank `source`: the expression engine evaluates `source` '
114-
+ '(the canonical persisted form of phase M9.1) and cannot evaluate `ast` alone, so an envelope carrying only '
115+
+ '(the canonical persisted form) and cannot evaluate `ast` alone, so an envelope carrying only '
115116
+ '`ast`, or a `source` that is blank after trimming, would validate and register and then fault at run time. '
116117
+ 'Write `{ dialect: \'cel\', source: \'…\' }`.';
117118

@@ -134,11 +135,12 @@ export const EVALUATED_EXPRESSION_SOURCE_REQUIRED =
134135
* parses it untrimmed and faults).
135136
*
136137
* `ExpressionSchema` itself is NOT narrowed: it is the persistence contract,
137-
* and its docblock declares that `ast` becomes required in build output at
138-
* phase M9.2. When AST-only evaluation lands, this schema is the one place to
139-
* revisit — relax `source` and require "`source` or `ast`, whichever the
140-
* engine evaluates" — and every evaluated slot composes it, so that flip is
141-
* one edit rather than a per-slot unwinding.
138+
* and its docblock declares `ast` an optional opaque structured value that
139+
* carries no promise of becoming required. If AST-only evaluation is ever
140+
* chartered, this schema is the one place to revisit — relax `source` and
141+
* require "`source` or `ast`, whichever the engine evaluates" — and every
142+
* evaluated slot composes it, so that flip is one edit rather than a per-slot
143+
* unwinding.
142144
*
143145
* Spelled as a property override rather than an object-level `.refine`, for a
144146
* measured reason: Zod runs an object's refinements even after a property has
@@ -160,7 +162,7 @@ export const EVALUATED_EXPRESSION_SOURCE_REQUIRED =
160162
export const EvaluatedExpressionSchema = ExpressionSchema.safeExtend({
161163
/**
162164
* Surface syntax — required and non-blank in an evaluated slot: it is what
163-
* the engine evaluates (M9.1), and `ast` alone cannot be run.
165+
* the engine evaluates, and `ast` alone cannot be run.
164166
*/
165167
source: z.string({ error: () => EVALUATED_EXPRESSION_SOURCE_REQUIRED })
166168
.refine((source) => source.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }),
@@ -420,8 +422,8 @@ export function expression(source: string, dialect: ExpressionDialect = 'cel', m
420422
*
421423
* Each helper produces an {@link Expression} envelope with `dialect: 'cel'`
422424
* and the rendered template string as `source`. The CLI `objectstack compile`
423-
* step (M9.2) parses these into ASTs at build time so the persisted artifact
424-
* is dialect-AST only.
425+
* step parses these into ASTs at build time, so the persisted envelope carries
426+
* `ast` beside the canonical `source`.
425427
*/
426428
function renderTemplate(strings: TemplateStringsArray, values: readonly unknown[]): string {
427429
if (values.length === 0) return strings[0] ?? '';

0 commit comments

Comments
 (0)