feat(spec)!: typed expression slots fix their dialect on the envelope arm and refuse a blank string (#15028, #15035) - #16001
Conversation
… arm and refuse a blank string `CronExpressionInputSchema` / `TemplateExpressionInputSchema` narrow their envelope arm to their own dialect literal and refuse a blank (empty or whitespace-only) bare string, each with ONE `invalid_union` at the slot whose message names the dialect and the fix. No cron syntax is judged at parse time; `croner` judges it where a schedule is wired; no grammar is restated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…not typecheck under safeExtend, and the parsed value must stay assignable to the input type Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…for the typed-slot narrowing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…ng guard) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
📓 Docs Drift Check7 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 7f7ca0124c630857090ee7f24eaa61a3a013af83 && git checkout 7f7ca0124c630857090ee7f24eaa61a3a013af83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7db8f4fd268a86a08c62ae4894cf7417720f8c9 b2b216ea3820d15cbff1f161720f1f6fc6a2ca6a && git checkout -B drift-repro f7db8f4fd268a86a08c62ae4894cf7417720f8c9 && git merge --no-ff b2b216ea3820d15cbff1f161720f1f6fc6a2ca6a
node scripts/docs-audit/affected-docs.mjs --json f7db8f4fd268a86a08c62ae4894cf7417720f8c9 |
|
CI note from the dispatching seat — the one red check on Generated by Claude Code |
Fixes #15028
Fixes #15035
Two-card fold, one branch, one PR — the implementation lap after the census (domain:spec seat,
session_01M59rPZZFzqhfMUPFqqZTkf; implementation-lap claims5552964754on #15028 and5552964864on #15035). Clause-②: yes — an accept-set narrowing on twelve published authorable keys, measured migration cost zero. Theneeds:contract-reviewcarrier is the seat's to hang and clear; this PR does not touch it.The seat's position, executed as written
Quoted from comment
5552964624on #15035, its "Position" paragraph:Position (iii), as recorded in the changeset and on the D7
cron-declared-unwiredrow: no cron syntax is judged at parse time;cronerjudges it where a schedule is wired; no grammar is restated in spec.Measured cost (census comment
5552934813on #15035, re-run on this branch)git showcopies of the BASEa2051fa4eschema and the HEAD schema, a key-name sweep that is a deliberate superset of the census, objectui read first-hand at the pina472b07): cron — 240 positions, 53 distinct values, 53 green today, 51 green narrowed; template — 107 positions, 84 distinct values, 83 green today, 83 green narrowed. The two cron values the narrowed schema refuses are{ dialect: 'cel', source: 'now()' }(packages/runtime/src/job-schedule.test.ts:78,app-plugin.jobs.test.ts:127— handed raw totoBoundaryJobSchedule/AppPlugin, never parsed by the schema; both files pass 10/10 on this branch; the third position is this PR's own changeset example) and' '(job-schedule.test.ts:87, the deliberate raw negative fixture for the boundary's own blank check). The one template value red on both sides istitleFormat: ''inpackages/lint/src/validate-record-title.test.ts:89, refused by.min(1)before this PR exactly as after. No real author is refused; the stop condition did not trigger.What changes (
packages/spec/src/shared/expression.zod.ts)CronExpressionInputSchema=z.union([bare non-blank string → { dialect: 'cron', source }, ExpressionSchema.safeExtend({ dialect: z.literal('cron') })]);TemplateExpressionInputSchemalikewise fortemplate. The literal (not a.refine) was chosen by measurement: it is the only spelling under which the input TYPE (CronExpressionInput/TemplateExpressionInput), the published JSON Schema (const: 'cron') and the generated reference pages all declare the one dialect — twelve reference pages moved fromEnum<'cel' | 'cron' | 'template'>to'cron'/'template'on the typed keys..min(1)(which does not trim) becomes.refine(source => source.trim().length > 0)— theEvaluatedExpressionSchemaprecedent's rule and shape;'',' 'and'\t\n'are refused on all twelve slots.invalid_union; both arms here abort on a foreign value (the string arm is a pipe, the envelope arm's literal aborts), so every refusal is ONEinvalid_unionat the slot, and the union's own error map carries the sentence —TYPED_EXPRESSION_SOURCE_REQUIRED[dialect]for a string input,TYPED_EXPRESSION_DIALECT_ONLY[dialect]for everything else (both exported, a record keyed byTypedExpressionDialect). The.refinespelling would have surfacedcustomatdialectbut left the type, JSON Schema and docs advertising every dialect on a typed slot — declared ≠ enforced the other way round.ExpressionSchema's neither-source-nor-astrefusal still surfaces with its own message; anast-only same-dialect envelope still parses on a typed slot (it persists, it is not an evaluated slot);'not a cron'still normalizes — the deliberate non-verdict, pinned.ExpressionInputSchema,ExpressionSchemaand the twelve sites are untouched.lint/src/validate-expressions.ts:374is untouched (M5).Refusal-issue shapes (through
z.object({ slot })and throughObjectStackDefinitionSchema){ dialect: 'cel', source: 'now()' }invalid_unionjobs.0.schedule.expression)dialect: 'cron'only: an envelope naming another dialect would validate and then have nothing to schedule. Write'0 9 * * 1-5'or{ dialect: 'cron', source: '0 9 * * 1-5' }."{ dialect: 'template', … },{ dialect: 'js', … },42,{ source: 'x' }invalid_union''/' 'invalid_union{ dialect: 'cron', source }, and a blank one would normalize to an envelope with nothing to schedule. Write'0 9 * * 1-5'or …; no cron syntax is judged here —cronerrefuses an invalid pattern where a schedule is wired."{ dialect: 'cron' }customsourceorast" (unchanged)Template slots mirror this with the
templatesentences (connectors.0.syncConfig.schedulewith atemplateenvelope andobjects.0.titleFormatwith acronenvelope are pinned through the stack).M2, stated: a second sentence, the same rule
EVALUATED_EXPRESSION_SOURCE_REQUIREDprescribes{ dialect: 'cel', source: '…' }— the one envelope a typed slot now refuses — so it cannot be reused verbatim on a cron slot. Its shape (z.string().refine(trim), one message for both blank spellings, the.trim()notion of blank) is mirrored exactly; only the words are the typed slot's own.Files
packages/spec/src/shared/expression.zod.ts— the two typed unions, the two exported sentence records,TypedExpressionDialect, the module and per-schema docblocks.packages/spec/src/shared/typed-expression-envelope-dialect.test.ts(new, 35 pins) — per dialect: accept bare / same-dialect envelope / ast-only; refuse the two foreign envelopes,js, non-envelopes, three blank spellings, each asserting code + path + message and that it is the only issue; the kept neither-source-nor-ast refusal; the non-verdict; the sentences' first sentence names the dialect; the input type narrows (@ts-expect-error, compiled bytsconfig.test.json); throughObjectStackDefinitionSchema— control green with bare strings in all three stack-reachable slots, then the three foreign-envelope refusals and a blank at the named paths, and'not a cron'green.packages/spec/src/ai/knowledge-source.test.ts— PR feat(spec): typeKnowledgeRefreshPolicy.cronwithCronExpressionInputSchema— the describe promises what the parse enforces (#14825) #15029's pin file. The''pin flips load-bearing (wasinvalid_union+ "Invalid input"; nowinvalid_union+ the cron source-required sentence, and a whitespace case beside it); thejspin gains its message and acelsibling. The deliberate non-verdict pin ('not a cron','0 0 3 * * *','@daily') is untouched and green. The attribution sentences in that file are spec/ai:KnowledgeRefreshPolicy.cron's docblock, shipped describe and test comments attribute the cron syntax verdict to@objectstack/formula's cron-engine "when the expression is evaluated" — nothing evaluates that slot, and that engine has no caller outside formula #15867's and are untouched.packages/qa/dogfood/test/expression-conformance.ledger.ts— thecron-declared-unwirednote's last sentence said the envelope arm accepts any declared dialect; it now records the pinned state and position (iii). Text only; no row added..changeset/typed-expression-envelope-dialect.md—@objectstack/specminor, launch-window**BREAKING**banner,adr-0087: not-required (no-migration-prescription)with the measured-cost argument, position (iii) verbatim. Gate:check-adr-0087-registration✓ ([BREAKING+bang] not-required (no-migration-prescription)),check-changeset-no-major✓.check:generated --fix(only the three it proved stale):api-surface/shared.json(+2 consts, +1 type),export-origins/shared.json, and 12 reference pages. M3 measured:authorable-surface/, its.base.jsonanchor,authorable-defaults/andjson-schema.manifest/did not move (emptygit statusaftergen:schema).Verification (all under
scripts/pm/os-verify-lock.sh, slotissue-15028, three sibling devs on the box)pnpm --filter @objectstack/spec build✓ (dts 34/34);pnpm --filter @objectstack/spec test→ 477 files, 12,824 tests passed; spectsc --noEmit✓....@objectstack/spec= consumers):typecheckran and echoed for@objectstack/runtime,rest,lint,service-automation,service-job(Scope: 5 of 80, eachtsc --noEmit→ Done) ✓ after building their closures (turbo, 64 tasks, 6m33s). Targeted consumer tests: dogfoodexpression-conformance.test.ts4/4; runtimejob-schedule.test.ts+app-plugin.jobs.test.ts10/10; lintvalidate-record-title.test.ts+validate-expressions.test.ts292/292; service-automationassignment-value-envelope.test.ts40/40.ExpressionSchema(marker on disk:grep -c1, literal count 0), exactly the five predicted pins went red — thecelandtemplateenvelope pins, thejobs.0.schedule.expressionandconnectors.0.syncConfig.schedulestack pins, and the newcelcase inknowledge-source.test.ts— 5 failed / 40 passed; every blank-string,jsand template-side pin stayed green. Restore proven bygit checkout HEAD -- path: blob hashbbccf2c3…equal both legs,git diff HEADempty, porcelain empty. The pins import./expression.zod.js(source), so no dist was involved and none was left mutated; spec was rebuilt afterwards anyway because the mtime-reading gates refuse a dist older than src.node scripts/pm/dispatch-gates.mjs --commandsderived 95 families from this change set (the derivation warned it read a tree 10 commits behindorigin/main);--ranreconciliation: 95 derived, 95 run, 0 UNRUN. Union run atea0b6fcb1; the only later commit (b2b216ea3) changed one string in the D7 ledger note, and the gate that reads it (check:doc-authoring, which had gone red on issue ids in string prose) and the D7 test were re-run on those bytes ✓. Verdicts:check:generated"All 15 generated artifacts are up to date"; all 38node scripts/*gates 0; allpnpm check:*gates 0 exceptcheck:dual-build-cjs-loads— exit 3, its own line "⛔ This is NOT a pass: nothing was measured" (needs every package's dist;knowledge-ragflow,service-cluster-redis,service-knowledgeunbuilt here) — NOT MEASURED locally, CI's.check:skill-examples✓ after theclient-reactclosure came from the turbo cache.**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minus itsignores;--format jsonover the diff's lintable files counted 4 files, 0 errors, 0 warnings; invariance:eslint.config.mjs:328states noparserOptions.projectand no typed rules, so this diff cannot move any untouched file's verdict. The repo-levelpnpm lintis CI's.check:nul-bytes✓; no control bytes in the edited files.Sequencing and adjacent cards (no closing keyword on any of these)
#15954 (the nine unread typed keys) is the maintainer's and is sequenced after this PR; #15867 / #15945 / #15947 are triage's. #15027 remains closed (its D7 rows are what this PR's ledger sentence updates); #14825 and PR #15029 are the landed history this PR's pins extend, not addressed here.
Generated by Claude Code