feat(spec)!: an evaluated expression slot requires a non-blank source — EvaluatedExpressionSchema, composed by the assignment value envelope (#15430) - #15810
Conversation
… — 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
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
…on its discriminant before reading error Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
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 asourcethat is blank after trimming — by ONE rule with one message.ExpressionSchemaitself (the persistence contract) is not narrowed;evaluateCondition's empty-predicate arm is not touched. The remaining half — the structuraledge.conditionenvelope, 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: anast-only edge condition answers a silentfalse). #15572 (closed) and #15662 (open) own the predicate path's silent-falsebehaviour change; neither is addressed here. That is why the first line isPart ofand 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 exportsEvaluatedExpressionSchema(+EvaluatedExpression,EvaluatedExpressionParsedper ADR-0122) andEVALUATED_EXPRESSION_SOURCE_REQUIRED. The evaluated sibling isExpressionSchema.safeExtend({ source: z.string(...).refine(non-blank) }):sourcerequired and non-blank after trimming (.trim().length > 0— the engine's own notion of blank, H3). The docblock names the dependency (the CEL engine evaluatessource, M9.1 canonical form;cel-engine.tsevaluaterefuses AST-only input and its own note revisits AST execution at the persistence cut-over) and the one-edit flip when AST evaluation lands.ExpressionSchemais byte-identical.packages/spec/src/automation/builtin-node-config.zod.ts—AssignmentExpressionValueSchemacomposesEvaluatedExpressionSchemainstead ofExpressionSchema(still narrowed todialect: 'cel'); docblocks updated. Nothing else in the file moves.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).@objectstack/specminor, 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; throughAssignmentConfigSchemait is prefixed byASSIGNMENT_VALUE_ENVELOPE_REFUSALand the path isassignments.NAME.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.refinesibling runs even when a property already failed, so it reportssource: ''TWICE (too_small+ the refinement) and{ dialect: 'cel' }TWICE (the source-or-ast rule + the refinement); an abortinginvalid_typeon the property skips the object's refinements. The property override yields exactly one issue, this message, atsource, for all four spellings (ast-only →invalid_type; blank / empty →custom), and narrows the TYPE too (source: string— anast-only envelope is a compile error, pinned with a live@ts-expect-errorin a file proved present in the tsc program via--listFiles).safeExtendkeepsExpressionSchema'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/main8e500f2FLOW_NODE_EXPRESSION_PATHS)ast-only beforesourcebeforeassignment.assignments.*valueAssignmentExpressionValueSchemaviaAssignmentValueSchema.superRefine(builtin-node-config.zod.ts)source"min(1)passes' ',validateExpressiontrims to "not authored", run faultsUnexpected token: EOFassignments.NAME.sourcescreen.fields[].visibleWhenpredicatestringon the screen descriptor (no Zod)predicateSlotRefusal, #15572)decision.conditions[].expressionpredicateDecisionConditionSchema.expression: z.string()(schemaless-node-config.zod.ts)loop.collection,map.collectionflow-templatez.union([z.string(), z.array(z.unknown())])So the rule lands on exactly ONE schema. H1 is falsified in one respect worth recording: the ledger's
predicateroles are bare-string slots, so the envelope-typed evaluated predicate is OUTSIDE the ledger —FlowEdgeSchema.condition: ExpressionInputSchema.optional()(flow.zod.ts), plusstart.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.ts—MALFORMEDgains both spellings (so the located-registration, registration-AND-evaluator, and legacy-array-form properties cover them); theast-only pin now asserts the shape door's refusal (ASSIGNMENT_VALUE_ENVELOPE_REFUSAL+ the evaluated sentence atassignments.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 throughExpressionEngine.evaluatedirectly (the only path that bypasses the schema —evaluateValueEnveloperuns the schema first, so metadata cannot reach it); the whitespace-only pin flips from "registers then faults" to "refused at registration, located atsource, never the run's parse fault".lint/src/validate-expressions.test.ts— the:3758pin flips to a locatederrorfinding (measured: the pass sees it through the object-levelAssignmentValueSchema.safeParseit already ran; nothing in the pass changed); anast-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" toassignments.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, atsource) and directly on the schema, withExpressionSchemaas the control.decisioncondition accepts a CEL envelope that neither validator can see — a malformed one evaluates tofalseSILENTLY 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 withcode/path/message, one-message property, the engine's.trim()notion, accepted envelope unchanged incl.astbesidesource,ExpressionSchemastill accepts both — the control, type-level narrowing).H5 — Zone-2 census, with a lit control
Scanner (
census/scan.mjs): everydialect: celenvelope literal in TS/JS/JSON (balanced-brace object,sourceread at that nesting level, JS shorthandsourcecounted as present) and YAML (inline flow maps + block-style sibling keys) underpackages/**andexamples/**, non-test files,node_modules/distexcluded; comment lines and.mdprose reported apart. Lit control: a scratch root with three planted files (a.tsast-only,b.yamlblock-style' '+ inline"",c.json"") beside three good envelopes → 4 hits, the 3 good ones not flagged. Census: 2943 files, 84celenvelopes 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.tsintrospectScope), 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 mutateddistcarriesABLATION_15430(3 files) and lacks the unique pairingsource.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED(a first--absentpreflight on the baresource.trim().length > 0marker 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 (theempty sourcepins included, because the mutation replaced the wholesourceoverride,min(1)with it); no off-target red. Restore:git checkout HEAD -- path,git diff HEADempty, blobb18088cd90fc46bb58fabcea620464905a0d86beequal toHEAD:…expression.zod.ts; spec rebuilt, preflight: unique marker present (exit 0),ABLATION_15430absent (exit 0).Consumer readings — head
23b288141(prefix direction...@objectstack/spec= downstream; closures built through the verify lock first)turbo ls --affectedagainst the base: 75 packages (spec is the root of the graph); the named minimum set ran locally, the rest is CI's.vitest runon the two edited test files;typecheck(tsc + scripts + test layer, both edited test files in the program per--listFiles);check:generatedvitest run --maxWorkers=2(whole package)--project unitcli 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 pipeAll 67 green at the final tree. Two were real reds fixed in-branch:
check:spec-parsed-alias(ADR-0122 —EvaluatedExpressionParsedadded, then regenerated:1517 bare z.input aliases, 825 pinned isomorphic, 692 paired with an XParsed. OK) andcheck-adr-0087-registration --base origin/main(my changeset'sFROM → TOlabel read as a migration prescription againstno-migration-prescription; nothing is renamed, so the label was reworded — now1 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-loadsneeds the whole repo built (78 packages withoutdist) 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-namedcheck:api-surface/authorable-surface/docs/export-origins/strictness-ledger/liveness/variant-docs/yaml-examples/skill-refs/llms-txtare all in the 67. Not run locally (CI's):pnpm lintrepo-wide.Deviations from the dispatch, stated
Part of #15430, not a closing keyword: theedge.conditionhalf is spec/automation:FlowEdgeSchema.conditionstill accepts an envelope the engine cannot evaluate (ast-only, whitespace-onlysource) — the evaluated-slot rule of #15430 has not reached the edge condition #15807 (a sub-issue of the card), so a merge must not close the card..refine— measured reason above; same export, same composition, same one-edit flip.EvaluatedExpressionParsedis an extra public type (ADR-0122 gate).flow.zod.ts,cel-engine.ts,validate.ts,service-automation/src/engine.ts,flow-node-expression-paths.ts,migrations/registry.ts— untouched, as required.Generated by Claude Code