Skip to content

feat(spec)!: an evaluated expression slot requires a non-blank sourceEvaluatedExpressionSchema, composed by the assignment value envelope (#15430) - #15810

Merged
os-project-manager merged 5 commits into
mainfrom
claude/issue-15430-evaluated-expression-slot-requires-source
Sep 5, 2026
Merged

feat(spec)!: an evaluated expression slot requires a non-blank sourceEvaluatedExpressionSchema, composed by the assignment value envelope (#15430)#15810
os-project-manager merged 5 commits into
mainfrom
claude/issue-15430-evaluated-expression-slot-requires-source

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Part of #15430

The half this PR executes: every schema that types an EVALUATED expression slot in the flow-node expression ledger refuses, at authoring, an envelope the engine cannot evaluate — no source, or a source that is blank after trimming — by ONE rule with one message. ExpressionSchema itself (the persistence contract) is not narrowed; evaluateCondition's empty-predicate arm is not touched. The remaining half — the structural edge.condition envelope, which is not a ledger slot and whose file was held by #15716 at dispatch time and is now shared with in-flight #15792 — is split out as sub-issue #15807 (measured by #15662's seat in #15430's comment 5550509137: an ast-only edge condition answers a silent false). #15572 (closed) and #15662 (open) own the predicate path's silent-false behaviour change; neither is addressed here. That is why the first line is Part of and not a closing keyword: merging this must not close the card while #15807 is open.

What changes

  • packages/spec/src/shared/expression.zod.ts — new exports EvaluatedExpressionSchema (+ EvaluatedExpression, EvaluatedExpressionParsed per ADR-0122) and EVALUATED_EXPRESSION_SOURCE_REQUIRED. The evaluated sibling is ExpressionSchema.safeExtend({ source: z.string(...).refine(non-blank) }): source required and non-blank after trimming (.trim().length > 0 — the engine's own notion of blank, H3). The docblock names the dependency (the CEL engine evaluates source, M9.1 canonical form; cel-engine.ts evaluate refuses AST-only input and its own note revisits AST execution at the persistence cut-over) and the one-edit flip when AST evaluation lands. ExpressionSchema is byte-identical.
  • packages/spec/src/automation/builtin-node-config.zod.tsAssignmentExpressionValueSchema composes EvaluatedExpressionSchema instead of ExpressionSchema (still narrowed to dialect: 'cel'); docblocks updated. Nothing else in the file moves.
  • Regenerated (check:generated --fix, never hand-edited): api-surface/{root,shared}.json, export-origins/{root,shared}.json, declaration-map/shared.json, authorable-surface/shared.json, json-schema.manifest/shared.json, content/docs/references/{index,shared/expression,automation/builtin-node-config}.mdx — exactly the new-export delta (3 names + 4 authorable keys; schema count 1589 → 1590).
  • Changeset @objectstack/spec minor, BREAKING banner, adr-0087: not-required (no-migration-prescription) (census below is zero; no key renamed; the registry file is held by PR feat(spec)!: duration-shaped number keys carry their unit in the key name — no-baseline gate + seven ADR-0087 renames (timeoutMs, ttlSeconds/ttlMs, *TimeoutSeconds) #15626 and is untouched).

The refusal, as rendered (one message for both spellings, at the value's source; through AssignmentConfigSchema it is prefixed by ASSIGNMENT_VALUE_ENVELOPE_REFUSAL and the path is assignments.NAME.source):

An expression in an evaluated slot needs a non-blank source: the expression engine evaluates source (the canonical persisted form of phase M9.1) and cannot evaluate ast alone, so an envelope carrying only ast, or a source that is blank after trimming, would validate and register and then fault at run time. Write { dialect: 'cel', source: '…' }.

Why a property override, not an object-level .refine (H2 — measured, Zod 4.4.3)

Probe on the spec package's own zod (zod-probe.mjs, run before writing the code): an object-level .refine sibling runs even when a property already failed, so it reports source: '' TWICE (too_small + the refinement) and { dialect: 'cel' } TWICE (the source-or-ast rule + the refinement); an aborting invalid_type on the property skips the object's refinements. The property override yields exactly one issue, this message, at source, for all four spellings (ast-only → invalid_type; blank / empty → custom), and narrows the TYPE too (source: string — an ast-only envelope is a compile error, pinned with a live @ts-expect-error in a file proved present in the tsc program via --listFiles). safeExtend keeps ExpressionSchema's own rules (a dialect outside the enum is still refused there). Still one exported spelling composed by the slot, so the M9.2 flip is one edit.

H1 — the ledger's evaluated slots, measured on origin/main 8e500f2

slot (FLOW_NODE_EXPRESSION_PATHS) role schema typing it today refuses ast-only before refuses blank source before after
assignment.assignments.* value AssignmentExpressionValueSchema via AssignmentValueSchema.superRefine (builtin-node-config.zod.ts) NO — validated, registered, faulted at run time with the engine's "persist source" NO — min(1) passes ' ', validateExpression trims to "not authored", run faults Unexpected token: EOF refused at authoring, one issue at assignments.NAME.source
screen.fields[].visibleWhen predicate JSON-Schema string on the screen descriptor (no Zod) every envelope refused wholesale (predicateSlotRefusal, #15572) a blank bare string is "not authored" (resolver skips it) — #15572/#15662 territory unchanged (not envelope-typed; nothing to compose)
decision.conditions[].expression predicate DecisionConditionSchema.expression: z.string() (schemaless-node-config.zod.ts) every envelope refused wholesale (same) same unchanged
loop.collection, map.collection flow-template z.union([z.string(), z.array(z.unknown())]) n/a — not evaluated by the expression engine n/a unchanged

So the rule lands on exactly ONE schema. H1 is falsified in one respect worth recording: the ledger's predicate roles are bare-string slots, so the envelope-typed evaluated predicate is OUTSIDE the ledger — FlowEdgeSchema.condition: ExpressionInputSchema.optional() (flow.zod.ts), plus start.config.condition, which has no Zod schema at all (open record). Both are #15807's. No ledger slot is typed by a schema shared with a persistence-only use.

H4 — old-stance pin sweep (repo-wide, one lap)

git grep -n "ast-only\|AST-only\|whitespace-only\|source: ' '\|source: ' '" origin/main -- 'packages/**/*.test.ts' — 44 hits; positive control: both named pins (assignment-value-envelope.test.ts:273, validate-expressions.test.ts:3758) appear. Flipped in this PR:

  • service-automation/src/builtin/assignment-value-envelope.test.tsMALFORMED gains both spellings (so the located-registration, registration-AND-evaluator, and legacy-array-form properties cover them); the ast-only pin now asserts the shape door's refusal (ASSIGNMENT_VALUE_ENVELOPE_REFUSAL + the evaluated sentence at assignments.digest) and that the engine's "AST-only evaluation not yet supported" text is NOT in the message; the engine's own arm is kept as a defence-in-depth pin through ExpressionEngine.evaluate directly (the only path that bypasses the schema — evaluateValueEnvelope runs the schema first, so metadata cannot reach it); the whitespace-only pin flips from "registers then faults" to "refused at registration, located at source, never the run's parse fault".
  • lint/src/validate-expressions.test.ts — the :3758 pin flips to a located error finding (measured: the pass sees it through the object-level AssignmentValueSchema.safeParse it already ran; nothing in the pass changed); an ast-only row joins the located-findings table. :2175 (bare STRING predicates, whitespace-only included) is untouched and green.
  • spec/src/automation/builtin-node-config.test.ts — the { dialect: 'cel' } row moves from the envelope path + "Expression requires at least one of" to assignments.digest.source + the evaluated sentence (same for the "wherever it sits in the map" pin); a new describe block pins both spellings through the node config (ONE issue, custom, at source) and directly on the schema, with ExpressionSchema as the control.
  • The other 40 hits: service-automation: a decision condition accepts a CEL envelope that neither validator can see — a malformed one evaluates to false SILENTLY at run time and takes the wrong branch #15572 predicate-slot pins (envelopes refused wholesale — unchanged by this rule), the resolver's own pins, cron / job-schedule / form-root-policy / rest / driver pins about other surfaces. None is an old-stance pin on an evaluated envelope slot.

New spec pins: packages/spec/src/shared/evaluated-expression.test.ts (8 tests: both spellings with code/path/message, one-message property, the engine's .trim() notion, accepted envelope unchanged incl. ast beside source, ExpressionSchema still accepts both — the control, type-level narrowing).

H5 — Zone-2 census, with a lit control

Scanner (census/scan.mjs): every dialect: cel envelope literal in TS/JS/JSON (balanced-brace object, source read at that nesting level, JS shorthand source counted as present) and YAML (inline flow maps + block-style sibling keys) under packages/** and examples/**, non-test files, node_modules/dist excluded; comment lines and .md prose reported apart. Lit control: a scratch root with three planted files (a.ts ast-only, b.yaml block-style ' ' + inline "", c.json "") beside three good envelopes → 4 hits, the 3 good ones not flagged. Census: 2943 files, 84 cel envelopes seen, 20 flags — 19 are comment/CHANGELOG prose (e.g. the docblocks that say { dialect: 'cel' } is the malformed shape), 1 is a TypeScript return-type declaration (formula/src/validate.ts introspectScope), 0 authored instances of either spelling. In-tree control with --include-tests: 143 flags, including the known test pins in the two consumer files. ⇒ the changeset carries a release note, not a migration sentence; no other lane's files were touched.

Ablation (both legs rebuilt, both proved)

Mutation: source: z.string(...).refine(non-blank)source: z.string().optional() // ABLATION_15430 (trap-restore on absolute paths). Proof on disk by anchored counts: anchor 1 → 0, inject 0 → 1. Spec rebuilt; the mutated dist carries ABLATION_15430 (3 files) and lacks the unique pairing source.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED (a first --absent preflight on the bare source.trim().length > 0 marker exited 1 because that fragment is non-unique — 36 dist files, e.g. ui/ — so the unique pairing was used instead). Direction: RED, as predicted — spec 11 red / 49 green, lint pin file 3 red / 270 green, service-automation pin file 8 red / 32 green: every red is a pin on this rule (the empty source pins included, because the mutation replaced the whole source override, min(1) with it); no off-target red. Restore: git checkout HEAD -- path, git diff HEAD empty, blob b18088cd90fc46bb58fabcea620464905a0d86be equal to HEAD:…expression.zod.ts; spec rebuilt, preflight: unique marker present (exit 0), ABLATION_15430 absent (exit 0).

Consumer readings — head 23b288141 (prefix direction ...@objectstack/spec = downstream; closures built through the verify lock first)

turbo ls --affected against the base: 75 packages (spec is the root of the graph); the named minimum set ran locally, the rest is CI's.

package command reading
spec vitest run on the two edited test files; typecheck (tsc + scripts + test layer, both edited test files in the program per --listFiles); check:generated 60/60 · exit 0 · "All 15 generated artifacts are up to date"
lint vitest run --maxWorkers=2 (whole package) 96 files, 3037 passed, 5 skipped · VERDICT command-exit 0
service-automation whole package 109 files, 1310 passed · VERDICT command-exit 0
formula whole package 28 files, 800 passed · VERDICT command-exit 0
core whole package 49 files, 1190 passed · VERDICT command-exit 0
runtime whole package 226 files, 3243 passed · VERDICT command-exit 0
cli --project unit 175 files: 2328 + 13 passed, 6 expected fail, 13 skipped · exit 0 (one file first refused as "packages/cli is not built" — prerequisite, re-run green after cli build); integration tier declared to CI (no diff on its files)

Gates — derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs, no paths, at 5eb7c57), 67 commands, each exit captured before any pipe

All 67 green at the final tree. Two were real reds fixed in-branch: check:spec-parsed-alias (ADR-0122 — EvaluatedExpressionParsed added, then regenerated: 1517 bare z.input aliases, 825 pinned isomorphic, 692 paired with an XParsed. OK) and check-adr-0087-registration --base origin/main (my changeset's FROM → TO label read as a migration prescription against no-migration-prescription; nothing is renamed, so the label was reworded — now 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition). Five first answered exit 3 "PREREQUISITE NOT MET" (lint / client-react / cli not built — NOT MEASURED, not red) and were re-run green after the closure builds: check:doc-formula-expressions, check:doc-security-posture, check:docs-transcript-drift, check:skill-examples; check:dual-build-cjs-loads needs the whole repo built (78 packages without dist) and is declared to CI. Also green: check:nul-bytes (7614 files) and the control-byte self-scan of every edited file. Beyond the derivation's list: the dispatch-named check:api-surface / authorable-surface / docs / export-origins / strictness-ledger / liveness / variant-docs / yaml-examples / skill-refs / llms-txt are all in the 67. Not run locally (CI's): pnpm lint repo-wide.

Deviations from the dispatch, stated


Generated by Claude Code

… — EvaluatedExpressionSchema, composed by the assignment value envelope

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…and docs for EvaluatedExpressionSchema

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…alias (ADR-0122) and regenerate

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…is renamed, the refusal carries the prescription

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 8 changed file(s) yielded no anchor (packages/spec/api-surface/root.json, packages/spec/api-surface/shared.json, packages/spec/authorable-surface/shared.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 8 changed file(s) yielded no anchor (packages/spec/api-surface/root.json, packages/spec/api-surface/shared.json, packages/spec/authorable-surface/shared.json, …) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 54bb2f125ffb130cececd20fcd0dd69b57aa8600packageMentionDocs.

Which tree this was computed on

This run read content/docs from 52fcedee8297015ff369e289101792667ddf3fd5 — the merge of head db817a3a66e3d83d0616d54d605ef3a7c8cca1ea into base 54bb2f125ffb130cececd20fcd0dd69b57aa8600, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 52fcedee8297015ff369e289101792667ddf3fd5 && git checkout 52fcedee8297015ff369e289101792667ddf3fd5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 54bb2f125ffb130cececd20fcd0dd69b57aa8600 db817a3a66e3d83d0616d54d605ef3a7c8cca1ea && git checkout -B drift-repro 54bb2f125ffb130cececd20fcd0dd69b57aa8600 && git merge --no-ff db817a3a66e3d83d0616d54d605ef3a7c8cca1ea

node scripts/docs-audit/affected-docs.mjs --json 54bb2f125ffb130cececd20fcd0dd69b57aa8600

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…on its discriminant before reading error

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants