Repository navigation
feat(spec,service-automation)!: refuse the date macros in flow value slots with their CEL string form (#19939 pass 3) - #22635
Conversation
… string form
The value-slot judge keeps no token now: `{TODAY()}` / `{TODAY() +- N}` /
`{NOW()}` / `{NOW() +- N}` are refused, naming `isoDate(today())`,
`isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())` and
`isoDatetime(addDays(now(), N))`, with the fractional, variable and
unreadable offset edges named. Where an envelope is literal data (a string
nested in an object or list literal, or a legacy assignment shape) the
remedy names the whole-value CEL literal or the canonical map instead of an
envelope at that position. The text-slot remedy and the step-18 D3 entries
follow.
Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…and the literal-position remedies through the engine The live parity pin runs the 17.x interpolator and the envelope each date-macro refusal names over the same instants (DST, month end, year end, leap day) and compares what reaches the store; the fraction, variable and unreadable-offset edges are pinned as the refusal names them. The literal-data positions (legacy assignment shapes, a string nested in a literal) are pinned through the engine: the old envelope remedy stores the object, the new one writes the template's value. Docblocks and the README stop saying the macros are kept. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…and the double-brace hint names the envelope in a value slot
`flow-double-brace-interpolation` no longer prescribes `{record.title}` in a
value slot, where the judge refuses it: it names the CEL envelope the judge
writes for the holes the author meant. Both value-slot hints ask the judge
at the string's own position, so a nested or legacy-shape string gets the
spelling that evaluates there. The flows docs restate the date macros as
refused, with their CEL string forms and edges.
Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…ue-slot descriptions Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…ervice-automation major; lint patch) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…udge prints, then names it Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…ed tree Main moved the reference while this branch changed two value-slot descriptions; the os-regen driver kept one side, so it is regenerated from the merged sources (gen:schema, gen:docs). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
…ars, so it holds for an unbound variable A refused path named a guard on its last key, has(source.id) ? source.id : null, for "one that may be absent". has() evaluates everything but its last selection, so where the variable itself was never bound (an isInput variable the caller left out, with no default) that guard failed the run with Unknown variable: source. The judge now prints a chain off vars, which holds only the bound variables: has(vars.source) && has(vars.source.id) ? vars.source.id : null. flow-bare-dollar-reference's value-slot hint carries the judge's words, so it is fixed with it. Pinned through the engine (value-slot-template-grammar.test.ts: an unbound root, a null root, an absent key, a bound value, and a full run with an unsupplied isInput variable against the last-key control), in the judge's own test, and on the lint hint (four judges, 0 refusals). The D3 entry, the flows doc row and the changeset name the chain. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
📓 Docs Drift CheckThis PR changes 3 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 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 2bb1f3b241c770f1f495d3cce52b04b169ef8d83 && git checkout 2bb1f3b241c770f1f495d3cce52b04b169ef8d83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f368b7e980d3df201620098be965523b50d6df19 3c97f71067e95a79b91f3008353b1b5660e98586 && git checkout -B drift-repro f368b7e980d3df201620098be965523b50d6df19 && git merge --no-ff 3c97f71067e95a79b91f3008353b1b5660e98586
node scripts/docs-audit/affected-docs.mjs --json f368b7e980d3df201620098be965523b50d6df19
|
Contract reviewServed-tier: Inputs: card #19939 (body and all 27 comments, the pass-3 claim ① Derived judgmentsThe refusal — accept set, The CEL string forms. RIGHT, checked against The parity pin. RIGHT, and it proves what the claim asks. The kept-spelling leak ( Literal-data positions, option A judge-side ( The The text-slot judge. RIGHT, forced. Its date-macro remedy named
Descriptions, docs, README. RIGHT. The two Public surface. RIGHT — nothing new reaches an entry. The diff adds exactly two Census. Consistent. No ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Part of #19939
Clause-②: no (narrowing)
Pass 3 of #19939, under ruling D on #11182 (maintainer 「11182 D 其他同意」): the date macros
{TODAY()}/{TODAY() ± N}/{NOW()}/{NOW() ± N}leave the flow value slots, refused with their CEL string form. With them, a value slot reads no single-brace token at all. The pass also carries the three carries named in the claim (6067854904,6092547738, and the kept-spelling leak from6092730938), plus seat 3's pointer6094071127on thehas()guard. #19939 stays open for the six rider positions, so this PR usesPart of.This branch was built by two dev runs. The first stopped before opening a PR. This run merged
main(d748ae80af) throughos-regen-merge.sh, added the guard fix, and re-ran every reading below on the final head.What lands
The refusal (
@objectstack/spec).valueSlotTemplateRefusalskeeps no token kind. A date macro, alone or beside other text and tokens, is refused atobjectstack validate, atregisterFlowand by the executor, naming its CEL string form (celDateMacroin the package-internal token module):'{TODAY()}'isoDate(today())'{TODAY() + N}','{TODAY() - N}'isoDate(daysFromNow(N)),isoDate(daysAgo(N))'{NOW()}'isoDatetime(now())'{NOW() + N}','{NOW() - N}'isoDatetime(addDays(now(), N))withNsigned;daysFromNowwould land on midnight'{TODAY() + days}'isoDate(addDays(today(), days))The edges the remedy names (carry
6067854904item 2), each said in the refusal itself:addDaystruncates the offset. Going back, they land a day apart once the day of the month passes the offset.daysAgo(1.5)/daysFromNow(1.5)are refused at build.isoDaterefuses.{TODAY() + 3d}). The template added 0 days, so the remedy writesisoDate(today())and says why.The kept-spelling leak (
6092730938).'Due {TODAY()} by {$User.Id}'was kept whole while the macros were kept. It is refused now with one concatenation,'Due ' + isoDate(today()) + ' by ' + current_user.id, and a sentence per token (the run user's guard included).Where an envelope is literal data, the remedy names none there (carry
6092547738item 1). At an element of the legacyassignmentsarray, a key of the bare legacy config, and a string inside an object or list literal, the executor reads an envelope as data. The judge used to prescribe one at that position, and the run then stored the envelope object. Chosen: judge those positions as non-evaluating. The refusal there names what does evaluate:{'note': 'for ' + name}), with mixed values each wrapped indyn(…);assignmentsmap.Why not make the executor evaluate envelopes there: an envelope-shaped object at those positions is data today, pinned by
the legacy shapes are untouchedand the CRUD nested-envelope pin. Evaluating it would change what an existing flow writes. It would also need the spec ledger to declare those positions as value slots, a contract change out of proportion to a remedy text. The judge-side fix is text only, consistent with how the executor reads the positions, and pinned through the engine: each named spelling, read off the refusal and put back, writes the template's value, and the envelope the old remedy named is stored as an object (controls).flow-double-brace-interpolation's value-slot hint (carry6092547738item 2). In a value slot it named{record.title}, which the judge refuses. It now reads the author's intent: each{{ path }}hole becomes the single-brace token it means, and the hint carries the envelope the judge writes for that string at the string's own position (pass 2's probe,valueSlotRefusalAt). Elsewhere the hint is unchanged.flow-bare-dollar-reference's value-slot hint also asks the judge at the position now, so a nested or legacy-shape string gets the spelling that evaluates there. The two #1315 false-positive guards are re-titled; their assertions are unchanged.The
has()guard holds for an absent variable (seat 3's pointer6094071127). For a path that may be absent, the refusal named a guard on the last key,has(source.id) ? source.id : null.has()evaluates everything but its last selection, so where the variable itself was never bound (anisInputvariable the caller left out, with no default) that guard failed the run,Unknown variable: source. Measured throughAutomationEngineat0593be8782:has(vars.source.id)fails too (No such key: source). The judge now prints a chain offvars, which holds only the bound variables:has(vars.source) && has(vars.source.id) ? vars.source.id : null. That answersnullfor an unbound variable, an absent key or intermediate key, anullvariable and a non-map variable, and the value when bound.flow-bare-dollar-reference's hint carries the judge's words, so it is fixed at the producer.Texts that said the macros were kept, or "no string form yet" (carry
6067854904item 3):content/docs/automation/flows.mdx(FROM → TO rows, the literal-data paragraph, the dialect table, the failure modes, the guard row), the judge's and the token module's docblocks,builtin/template.ts's docblock, thevalidate-expressions.fields-value-slot.test.tscomment, the publishedservice-automationREADME Expressions section, and the two value-slot.describe()strings with the regeneratedbuiltin-node-config.mdx.skills/**is not touched (#22585).The ledger. The step-18 D3 entry
flow-value-slot-template-dialect-refusedis amended: its surface, replacement, reason and acceptance criteria name the macros, their forms and edges, the literal-data positions and the guard.flow-text-slot-single-brace-refusedis amended where its replacement computed a date through the value-slot spelling this change refuses.registry.tsis regenerated (gen:migration-registry); it is byte-identical to the generator's output after the merge ofmain. No D2 conversion and no new entry.Census. No date macro sits in a value slot in
examples/**or inpackages/**source at3c97f71067. The remaining hits are filter positions, which keep the dialect, and docs. This repository's own test fixtures and docs that used one are migrated. Cross-repo note: pass 1's census measured 15 date-macro value-slot sites in hotcrm atc529de2(8{NOW()}, 3{TODAY()}, 3{TODAY() + N}, 1{TODAY() + var}). They migrate there; hotcrm is not edited.Changeset.
.changeset/19939-flow-value-slot-date-macros-refused.md:@objectstack/specmajor,@objectstack/service-automationmajor,@objectstack/lintpatch (pass 1 and pass 2's levels, on thenextline), with FROM → TO rows and the ADR-0087already-registereddisposition.Clause-②: no (narrowing)holds on measurement. The newcelDateMacro/CelDateMacroexports stay in the package-internalflow-template-token.ts, which no entry re-exports (spec'sexportshas no wildcard subpath). Noapi-surface/orexport-origins/snapshot moved, andcheck:api-surface/check:export-originsexit 0.Surface crossings
The claim lists the judge, the token module,
builtin/template.ts, the parity pin,lint-flow-patterns.ts, the.describe()strings, the step-18 value-slot entry andregistry.ts,flows.mdx, the README, examples and one changeset. These files are outside that list. Each one would have stated something false after this change, or pinned a spelling it refuses:service-automation/src/builtin/crud-nodes.ts,logic-nodes.ts: docblock comments only. They said the date macros were kept and reachedinterpolate().spec/src/automation/flow-text-slot-template.ts(and its test), with the step-18 entry18.flow-text-slot-single-brace-refused.ts: the text-slot remedy for a date macro namedassignments: { v: '{TODAY() + 7}' }, a spelling this change refuses. It now names the CEL envelope.spec/src/automation/builtin-node-config.test.ts, and inservice-automation,logic-nodes.test.ts,value-slot-template-grammar.test.tsandassignment-value-envelope.test.ts(the literal-position engine pins).value-slot-template-grammar.test.tsalso hosts the engine pin of the guard.No governed surface is touched (
skills/**,.claude/**,docs/adr/**,AGENTS.md,CLAUDE.md).Tests and gates (head
3c97f71067)3c97f71067is the final head. It holds the merge ofmainatd748ae80af(5027462a6a), the regenerated reference (31f20e025e), the guard fix (0593be8782), then the merge ofmainate22315238f. Both merges went throughos-regen-merge.sh.registry.tsregenerates byte-identical after each, and every entry id onmain's registry is present. The build is spec plus the dependency closures ofservice-automationandlint(29 turbo tasks, exit 0), rebuilt after each merge.All readings below were taken at
3c97f71067. The same set was green at0593be8782before the second merge.@objectstack/speclocal project: 642 files, 19164 passed and 1 todo. Repo project: 54 files, 915 passed.@objectstack/service-automation: 181 files, 2316 passed.@objectstack/lint: 134 files, 6130 passed.check:test-typecheckincluded).dispatch-gates --commands, re-derived on the actual diff, gives 117 commands: the 114 at dispatch plus speccheck:generated,check:quick-reference-countsandcheck:swallow-census-controls. All 117 exit 0, anddispatch-gates --ranreports 117 derived, 117 run, 0 NOT-MEASURED and 0 UNRUN.eslint . --no-inline-configovereslint.config.mjs.--format jsonover the 20 changed lintable files reports 20 linted, 0 ignored, 0 errors and 0 warnings.parserOptions.project, no typed rules, as its own note says), so this diff cannot move a verdict on a file it does not touch. The fullpnpm lintis CI's.mainmoved tof368b7e980after these readings. Its six new commits share no path with this PR, andregistry.tsis not among them. The PR's CI runs on the merge ref.Ablations
Five ablations, one per behaviour: the order's four, plus the guard. Each went through
scripts/ablation-replace.mjsin WRAP mode under the verify lock, from the committed state0593be8782. Each mutation is verified on disk (anchor x1 → x0, blob changed). Each restore is proven blob == HEAD with an emptygit diff HEAD.The suites under
service-automationandlintresolve@objectstack/specthrough its builtdist/(no source alias). So A2, A3 and A5 rebuilt spec on the mutated source. Each confirmed withablation-dist-preflight.mjsthat the marker reacheddist/before reading a result. After the restore, each rebuilt spec again and confirmed with--absentthat the marker leftdist/, withgit status --porcelainempty.flow-value-slot-template.test.ts(src) goes red: 23 failed, 94 passed. Every date-macro refusal, the edges, the kept-spelling leak and the doors' contract pins fail. Restored to blobe403d51fd5cc.{NOW() ± N}'s remedy is mutated to land on midnight,isoDatetime(addDays(today(), N)). The live parity pin incrud-fields-value-envelope.test.tsevaluates the spelling the judge prints and goes red on the written bytes: 2 failed, 57 passed ({NOW() + 2},{NOW() - 1}). Example:expected '2026-03-10T00:00:00.000Z' to be '2026-03-10T09:30:00.000Z'. Restored to blob5ca227356df7.literalPositionLeadanswers nothing, which puts back the envelope at the position. The judge test (src) goes red: 5 failed, 112 passed. The engine pins go red: 8 failed, 97 passed, acrossassignment-value-envelope.test.tsandcrud-fields-value-envelope.test.ts. The controls stay green: the envelope at those positions is stored as an object, and the top-level slot keeps its envelope remedy. Restored to blobe403d51fd5cc.Use {var}hint at every position.lint-flow-patterns.test.ts(src) goes red: 3 failed, 203 passed, exactly the three value-slot double-brace pins. Both controls stay green. Restored to blob6366250123ba.has(vars.source.id) ? …. The judge test goes red: 5 failed, 112 passed. The engine pins invalue-slot-template-grammar.test.tsgo red: 5 failed, 70 passed, each on evaluation, for example{source.id}: … No such key: source. The single-segment{x}row stays green, as it should. The lint hint pin goes red: 1 failed, 205 passed. Restored to blobe403d51fd5cc.Acceptance notes
Nothing here is filed. Each item is for the seat:
19939-flow-value-slot-template-dialect-refused.mdlists the date macros as accepted and prints the last-key guard. Pass 2's19939-flow-value-slot-run-user-refused.mdlists the date macros as still accepted. [v18] flow text slots: read ADR-0032 §3's{{ }}delimiter instead of single-brace{token}(notify title/message and the other flow string slots), converting only what renders the same #22110's22110-flow-text-slot-double-brace.mdcomputes a date through the value-slot spelling. This PR's changeset says it supersedes those lines. Correcting them in place ischeck:empty-changeset's DELIBERATE CORRECTION path, which release(changesets): four merged pending changesets state behaviour that later PRs on the same v18 train changed; correct them in place through check-empty-changeset's DELIBERATE CORRECTION path before the release #22586 carries.recordwith norecordvariable. In a flow CEL expression,recordreads the variables map itself when the run binds norecordvariable:buildScopebinds the scope'srecordargument, andcelScopepassesvarsthere. Sohas(record.assignee) ? record.assignee : nullreturns a variable namedassignee("u9" over{ assignee: 'u9' }). Measured throughevaluateValueEnvelopeat0593be8782; no public door was measured, so it is not filed. The judge's chain guard readsvars.recordand answersnullthere, as the template did.packages/lint/src/validate-field-consumers.ts:335(fields: { added_date: '{NOW()}' }, an illustration of a write key) andpackages/spec/src/automation/flow-node-expression-paths.test.ts:130(a{token}string "keeps its 17.x meaning", stale since pass 1). The generatedspec-changes.jsonanddocs/protocol-upgrade-guide.mdregenerate at release; no regeneration is owed here.skills/objectstack-automation/SKILL.mdstill teaches the date macros as kept (Tier H, skills(objectstack-automation): SKILL.md still teaches{$User.<path>}as a kept value-slot spelling and the text-slot run-user remedy through it, which #19939 pass 2 (PR #22563) refuses #22585).{var}template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939 stands.lint-flow-patterns.test.ts'svalueFlowfixture in hunks it measured as disjoint from this PR's. Whichever lands later mergesmain.Clause-②. The claim line readsno; the dispatch prescribesno (narrowing), the same value with its direction arm stated.Generated by Claude Code