Skip to content

feat(spec)!: typed expression slots fix their dialect on the envelope arm and refuse a blank string (#15028, #15035) - #16001

Draft
os-project-manager wants to merge 4 commits into
mainfrom
claude/issue-15028-typed-expression-envelope-dialect
Draft

feat(spec)!: typed expression slots fix their dialect on the envelope arm and refuse a blank string (#15028, #15035)#16001
os-project-manager wants to merge 4 commits into
mainfrom
claude/issue-15028-typed-expression-envelope-dialect

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #15028
Fixes #15035

Two-card fold, one branch, one PR — the implementation lap after the census (domain:spec seat, session_01M59rPZZFzqhfMUPFqqZTkf; implementation-lap claims 5552964754 on #15028 and 5552964864 on #15035). Clause-②: yes — an accept-set narrowing on twelve published authorable keys, measured migration cost zero. The needs:contract-review carrier is the seat's to hang and clear; this PR does not touch it.

The seat's position, executed as written

Quoted from comment 5552964624 on #15035, its "Position" paragraph:

  1. (i) Narrow the envelope arm on the typed schemasCronExpressionInputSchema accepts a bare string or a { dialect: 'cron', source } envelope only; TemplateExpressionInputSchema likewise for template. Measured cost: zero author migrations, zero pin flips. Plus the whitespace correction: the bare-string arm refuses a blank/whitespace-only string on both typed schemas, the way EvaluatedExpressionSchema does. Both are accept-set narrowings on 12 published authorable keys ⇒ Clause-②: yes, in-seat contract review, carriers on the PR and both cards.
  2. (iii) Record the parse-time position and close [finding] The shared cron dialect judges no cron syntax at parse time — after #14825 all 9 CronExpressionInputSchema sites accept 'not a cron' green; the verdict is the formula cron-engine's at evaluate time #15035 — no cron syntax is judged at parse time; croner judges it where a schedule is wired; the describes ([finding] KnowledgeSourceSchema.cron is documented as a 5-field cron expression but typed z.string() — the spec's own CronExpressionInputSchema is not used, so 'not a cron' parses green #14825, docs(spec): the dialect table's cron row names what fires a schedule — cron-parser is not in the product #15877) already say so. The PR records this in its changeset and, where the ADR-0049 / D7 ledger has a row for the shared dialect, on that row. Not (ii): a restated grammar would be a third source of truth in a repo whose two existing ones already disagree, and a cronEngine-sourced one would green-light patterns the scheduler refuses — a lenient verdict in strict clothing.

Position (iii), as recorded in the changeset and on the D7 cron-declared-unwired row: no cron syntax is judged at parse time; croner judges it where a schedule is wired; no grammar is restated in spec.

Measured cost (census comment 5552934813 on #15035, re-run on this branch)

  • Census: 32 cron and 14 template author values across this repo, the examples, the docs, the skills and the objectui pin — every one a bare string or a same-dialect envelope; narrowing the envelope arm refuses zero real authors.
  • Re-run on this branch (git show copies of the BASE a2051fa4e schema and the HEAD schema, a key-name sweep that is a deliberate superset of the census, objectui read first-hand at the pin a472b07): 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 to toBoundaryJobSchedule / 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 is titleFormat: '' in packages/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)

  • Envelope arm narrowed to the literal. CronExpressionInputSchema = z.union([bare non-blank string → { dialect: 'cron', source }, ExpressionSchema.safeExtend({ dialect: z.literal('cron') })]); TemplateExpressionInputSchema likewise for template. 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 from Enum<'cel' | 'cron' | 'template'> to 'cron' / 'template' on the typed keys.
  • Bare-string arm refuses blank. .min(1) (which does not trim) becomes .refine(source => source.trim().length > 0) — the EvaluatedExpressionSchema precedent's rule and shape; '', ' ' and '\t\n' are refused on all twelve slots.
  • The refusal issue is named, not bare. Measured on zod 4.4.3: a union reports the one arm that did not abort, else 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 ONE invalid_union at 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 by TypedExpressionDialect). The .refine spelling would have surfaced custom at dialect but left the type, JSON Schema and docs advertising every dialect on a typed slot — declared ≠ enforced the other way round.
  • Kept: ExpressionSchema's neither-source-nor-ast refusal still surfaces with its own message; an ast-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, ExpressionSchema and the twelve sites are untouched. lint/src/validate-expressions.ts:374 is untouched (M5).

Refusal-issue shapes (through z.object({ slot }) and through ObjectStackDefinitionSchema)

input on a cron slot code path message
{ dialect: 'cel', source: 'now()' } invalid_union the slot (jobs.0.schedule.expression) "A cron-typed slot accepts a bare cron string or an envelope declaring 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 the slot the same dialect-only sentence
'' / ' ' invalid_union the slot "A cron-typed slot needs a non-blank cron expression: a bare string is shorthand for { 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 — croner refuses an invalid pattern where a schedule is wired."
{ dialect: 'cron' } custom the slot "Expression requires at least one of source or ast" (unchanged)

Template slots mirror this with the template sentences (connectors.0.syncConfig.schedule with a template envelope and objects.0.titleFormat with a cron envelope are pinned through the stack).

M2, stated: a second sentence, the same rule

EVALUATED_EXPRESSION_SOURCE_REQUIRED prescribes { 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 by tsconfig.test.json); through ObjectStackDefinitionSchema — 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): type KnowledgeRefreshPolicy.cron with CronExpressionInputSchema — the describe promises what the parse enforces (#14825) #15029's pin file. The '' pin flips load-bearing (was invalid_union + "Invalid input"; now invalid_union + the cron source-required sentence, and a whitespace case beside it); the js pin gains its message and a cel sibling. 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 — the cron-declared-unwired note'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/spec minor, 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 ✓.
  • Regenerated by 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.json anchor, authorable-defaults/ and json-schema.manifest/ did not move (empty git status after gen:schema).

Verification (all under scripts/pm/os-verify-lock.sh, slot issue-15028, three sibling devs on the box)

  • pnpm --filter @objectstack/spec build ✓ (dts 34/34); pnpm --filter @objectstack/spec test477 files, 12,824 tests passed; spec tsc --noEmit ✓.
  • Downstream direction (...@objectstack/spec = consumers): typecheck ran and echoed for @objectstack/runtime, rest, lint, service-automation, service-job (Scope: 5 of 80, each tsc --noEmit → Done) ✓ after building their closures (turbo, 64 tasks, 6m33s). Targeted consumer tests: dogfood expression-conformance.test.ts 4/4; runtime job-schedule.test.ts + app-plugin.jobs.test.ts 10/10; lint validate-record-title.test.ts + validate-expressions.test.ts 292/292; service-automation assignment-value-envelope.test.ts 40/40.
  • Reverse verification, direction stated before running: with the cron literal arm ablated back to the unrestricted ExpressionSchema (marker on disk: grep -c 1, literal count 0), exactly the five predicted pins went red — the cel and template envelope pins, the jobs.0.schedule.expression and connectors.0.syncConfig.schedule stack pins, and the new cel case in knowledge-source.test.ts — 5 failed / 40 passed; every blank-string, js and template-side pin stayed green. Restore proven by git checkout HEAD -- path: blob hash bbccf2c3… equal both legs, git diff HEAD empty, 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.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands derived 95 families from this change set (the derivation warned it read a tree 10 commits behind origin/main); --ran reconciliation: 95 derived, 95 run, 0 UNRUN. Union run at ea0b6fcb1; 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 38 node scripts/* gates 0; all pnpm check:* gates 0 except check: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-knowledge unbuilt here) — NOT MEASURED locally, CI's. check:skill-examples ✓ after the client-react closure came from the turbo cache.
  • ESLint, narrowed and proven: population is the config's **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus its ignores; --format json over the diff's lintable files counted 4 files, 0 errors, 0 warnings; invariance: eslint.config.mjs:328 states no parserOptions.project and no typed rules, so this diff cannot move any untouched file's verdict. The repo-level pnpm lint is 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

… 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
@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/shared.json, packages/spec/export-origins/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
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/shared.json, packages/spec/export-origins/shared.json) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 f7db8f4fd268a86a08c62ae4894cf7417720f8c9packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7f7ca0124c630857090ee7f24eaa61a3a013af83 — the merge of head b2b216ea3820d15cbff1f161720f1f6fc6a2ca6a into base f7db8f4fd268a86a08c62ae4894cf7417720f8c9, 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 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

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

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

CI note from the dispatching seat — the one red check on b2b216ea3 is not this PR's. Lint & Repo Gates fails at the step Merge-driver wiring gate (pnpm check:merge-driver), which has been red on main itself since ~17:22Z in every lane: the check-regen-pending.mjs --self-test fixture runs pnpm -s in an unpinned directory and Corepack resolves pnpm latest = 12.3.4, whose CLI rejects -s. Anchor cards #15990 / #15992 / #15994; the one-file fix is PR #16002 (ready, auto-merge armed, its own run green on that step). Every other check on this head is green. This PR needs no change for it: once #16002 lands, the branch merges main by tooling (os-regen-merge.sh) so CI re-runs, and the seat re-cycles the contract-review carriers at the new head and flips to ready + auto-merge.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment