Skip to content

feat(spec,service-automation)!: refuse the date macros in flow value slots with their CEL string form (#19939 pass 3) - #22635

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-19939-pass3-date-macros
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-19939-pass3-date-macros

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 from 6092730938), plus seat 3's pointer 6094071127 on the has() guard. #19939 stays open for the six rider positions, so this PR uses Part of.

This branch was built by two dev runs. The first stopped before opening a PR. This run merged main (d748ae80af) through os-regen-merge.sh, added the guard fix, and re-ran every reading below on the final head.

What lands

The refusal (@objectstack/spec). valueSlotTemplateRefusals keeps no token kind. A date macro, alone or beside other text and tokens, is refused at objectstack validate, at registerFlow and by the executor, naming its CEL string form (celDateMacro in the package-internal token module):

you wrote the refusal names
'{TODAY()}' isoDate(today())
'{TODAY() + N}', '{TODAY() - N}' isoDate(daysFromNow(N)), isoDate(daysAgo(N))
'{NOW()}' isoDatetime(now())
'{NOW() + N}', '{NOW() - N}' isoDatetime(addDays(now(), N)) with N signed; daysFromNow would land on midnight
'{TODAY() + days}' isoDate(addDays(today(), days))

The edges the remedy names (carry 6067854904 item 2), each said in the refusal itself:

  • A fractional offset. The template added the offset to the day of the month and truncated the sum, while addDays truncates 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.
  • A variable offset. The template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number. CEL reads the path, an absent variable fails the run, and a value that is not a number is an invalid date that isoDate refuses.
  • An offset that is neither a number nor a variable name ({TODAY() + 3d}). The template added 0 days, so the remedy writes isoDate(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 6092547738 item 1). At an element of the legacy assignments array, 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:

  • the whole value built as one envelope, a CEL map or list literal holding the string's CEL spelling at its place ({'note': 'for ' + name}), with mixed values each wrapped in dyn(…);
  • or, for a legacy shape, the node's assignments moved into the canonical assignments map.

Why not make the executor evaluate envelopes there: an envelope-shaped object at those positions is data today, pinned by the legacy shapes are untouched and 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 (carry 6092547738 item 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 pointer 6094071127). 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 (an isInput variable the caller left out, with no default) that guard failed the run, Unknown variable: source. Measured through AutomationEngine at 0593be8782: has(vars.source.id) fails too (No such key: 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. That answers null for an unbound variable, an absent key or intermediate key, a null variable 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 6067854904 item 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, the validate-expressions.fields-value-slot.test.ts comment, the published service-automation README Expressions section, and the two value-slot .describe() strings with the regenerated builtin-node-config.mdx. skills/** is not touched (#22585).

The ledger. The step-18 D3 entry flow-value-slot-template-dialect-refused is 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-refused is amended where its replacement computed a date through the value-slot spelling this change refuses. registry.ts is regenerated (gen:migration-registry); it is byte-identical to the generator's output after the merge of main. No D2 conversion and no new entry.

Census. No date macro sits in a value slot in examples/** or in packages/** source at 3c97f71067. 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 at c529de2 (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/spec major, @objectstack/service-automation major, @objectstack/lint patch (pass 1 and pass 2's levels, on the next line), with FROM → TO rows and the ADR-0087 already-registered disposition. Clause-②: no (narrowing) holds on measurement. The new celDateMacro / CelDateMacro exports stay in the package-internal flow-template-token.ts, which no entry re-exports (spec's exports has no wildcard subpath). No api-surface/ or export-origins/ snapshot moved, and check:api-surface / check:export-origins exit 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 and registry.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 reached interpolate().
  • spec/src/automation/flow-text-slot-template.ts (and its test), with the step-18 entry 18.flow-text-slot-single-brace-refused.ts: the text-slot remedy for a date macro named assignments: { v: '{TODAY() + 7}' }, a spelling this change refuses. It now names the CEL envelope.
  • Tests that pinned the kept spelling: spec/src/automation/builtin-node-config.test.ts, and in service-automation, logic-nodes.test.ts, value-slot-template-grammar.test.ts and assignment-value-envelope.test.ts (the literal-position engine pins). value-slot-template-grammar.test.ts also 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)

3c97f71067 is the final head. It holds the merge of main at d748ae80af (5027462a6a), the regenerated reference (31f20e025e), the guard fix (0593be8782), then the merge of main at e22315238f. Both merges went through os-regen-merge.sh. registry.ts regenerates byte-identical after each, and every entry id on main's registry is present. The build is spec plus the dependency closures of service-automation and lint (29 turbo tasks, exit 0), rebuilt after each merge.

All readings below were taken at 3c97f71067. The same set was green at 0593be8782 before the second merge.

  • @objectstack/spec local 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.
  • Typecheck exit 0 for spec, lint and service-automation (each package's script, check:test-typecheck included).
  • Gates: dispatch-gates --commands, re-derived on the actual diff, gives 117 commands: the 114 at dispatch plus spec check:generated, check:quick-reference-counts and check:swallow-census-controls. All 117 exit 0, and dispatch-gates --ran reports 117 derived, 117 run, 0 NOT-MEASURED and 0 UNRUN.
  • Repo lint, narrowed and proven:
    • ① The population is eslint . --no-inline-config over eslint.config.mjs.
    • ② --format json over the 20 changed lintable files reports 20 linted, 0 ignored, 0 errors and 0 warnings.
    • ③ That config enables no type-aware linting (no 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 full pnpm lint is CI's.
  • main moved to f368b7e980 after these readings. Its six new commits share no path with this PR, and registry.ts is 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.mjs in WRAP mode under the verify lock, from the committed state 0593be8782. Each mutation is verified on disk (anchor x1 → x0, blob changed). Each restore is proven blob == HEAD with an empty git diff HEAD.

The suites under service-automation and lint resolve @objectstack/spec through its built dist/ (no source alias). So A2, A3 and A5 rebuilt spec on the mutated source. Each confirmed with ablation-dist-preflight.mjs that the marker reached dist/ before reading a result. After the restore, each rebuilt spec again and confirmed with --absent that the marker left dist/, with git status --porcelain empty.

  • A1, the refusal. The judge skips a string carrying a date macro again (the old kept kind). 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 blob e403d51fd5cc.
  • A2, the parity. {NOW() ± N}'s remedy is mutated to land on midnight, isoDatetime(addDays(today(), N)). The live parity pin in crud-fields-value-envelope.test.ts evaluates 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 blob 5ca227356df7.
  • A3, the literal-data positions. literalPositionLead answers 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, across assignment-value-envelope.test.ts and crud-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 blob e403d51fd5cc.
  • A4, the double-brace hint. The old 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 blob 6366250123ba.
  • A5, the guard. The guard is put back on the last step alone, has(vars.source.id) ? …. The judge test goes red: 5 failed, 112 passed. The engine pins in value-slot-template-grammar.test.ts go 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 blob e403d51fd5cc.

Acceptance notes

Nothing here is filed. Each item is for the seat:


Generated by Claude Code

claude added 11 commits October 10, 2026 03:19
… 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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/service-automation, @objectstack/spec, touching 37 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/services/service-automation/src/builtin/crud-nodes.ts, packages/services/service-automation/src/builtin/template.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via FlowValueSlotSchema (symbol, a top-level const object), daysAgo (literal, a string literal in celDateMacro), daysFromNow (literal, a string literal in celDateMacro), isoDate (literal, a string literal in celDateMacro; a string literal in dateMacroRemedy), isoDatetime (literal, a string literal in celDateMacro; a string literal in dateMacroRemedy))
  • content/docs/data-modeling/formulas.mdx (via daysAgo (literal, a string literal in celDateMacro), daysFromNow (literal, a string literal in celDateMacro), isoDate (literal, a string literal in celDateMacro; a string literal in dateMacroRemedy), isoDatetime (literal, a string literal in celDateMacro; a string literal in dateMacroRemedy))
  • content/docs/data-modeling/seed-data.mdx (via daysAgo (literal, a string literal in celDateMacro), daysFromNow (literal, a string literal in celDateMacro))
  • content/docs/protocol/objectql/schema.mdx (via daysAgo (literal, a string literal in celDateMacro), daysFromNow (literal, a string literal in celDateMacro))
  • content/docs/protocol/objectql/types.mdx (via daysFromNow (literal, a string literal in celDateMacro))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via daysFromNow (literal, a string literal in celDateMacro))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/services/service-automation/src/builtin/crud-nodes.ts, packages/services/service-automation/src/builtin/template.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 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 f368b7e980d3df201620098be965523b50d6df19 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2bb1f3b241c770f1f495d3cce52b04b169ef8d83 — the merge of head 3c97f71067e95a79b91f3008353b1b5660e98586 into base f368b7e980d3df201620098be965523b50d6df19, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f368b7e980d3df201620098be965523b50d6df19 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3c97f71067e95a79b91f3008353b1b5660e98586
Local-runs: none

Inputs: card #19939 (body and all 27 comments, the pass-3 claim 6093076978, the carries 6067854904 / 6092547738 / 6092730938, seat 3's pointer 6094071127, the pass-2 landing 6093042228, the dev report 6095185806), ruling D on #11182 (5805777944), PR #22635 (body, 24 files, the net diff from merge base e22315238f: +1351 / −271, patch-id 51dd1d8bb044), the check-runs on the head, and main as read. Nothing was built, run or re-run; every code reading below is of the head or of origin/main through git show / git grep.

① Derived judgments

The refusal — accept set, @objectstack/spec. RIGHT. valueSlotTemplateRefusals drops KEPT_KINDS; the only early return left on a string is tokens.length === 0, so a value slot keeps NO single-brace token. The doors are wired at the head and read, not run: the Zod contracts (celValueSlotSchema → valueSlotTemplateRefusals, builtin-node-config.zod.ts:359; the AssignmentConfigSchema catchall with envelopeIsLiteral, :1106), objectstack validate (validate-expressions.ts:2376), registerFlow (engine.ts:4497 → validateFlowExpressions → :10808), the executors (CRUD through parseNodeConfig / FlowValueSlotSchema; assignment at logic-nodes.ts:164). Pinned at each: every value-slot contract refuses {TODAY() + 7} and accepts its envelope; AssignmentConfigSchema.safeParse({ assignments: { due: '{TODAY() + 7}' } }) is false; registration pins in logic-nodes.test.ts and crud-fields-value-envelope.test.ts name the node, the path and the form; the validate pin in validate-expressions.fields-value-slot.test.ts (expression-invalid, error, located) carries three macro rows and the mixed-token row. Alone, with ± N, with a variable or junk offset, beside text, beside another token — all refused (judge pins at eleven spellings plus the edges and the leak). Ablation A1 (judge keeps the kind again → 23 red) is reported, not re-run.

The CEL string forms. RIGHT, checked against main's builtin/template.ts resolveToken (UTC-only since #14852: setUTCDate(getUTCDate() + sign * offset), then toISOString() for NOW() and .slice(0, 10) for TODAY()) and against main's packages/formula/src/stdlib.ts, which this PR does not touch: isoDate is toISOString().slice(0, 10), isoDatetime is toISOString(), addDays(d, n) is setUTCDate(getUTCDate() + Math.trunc(Number(n))), daysFromNow(int) / daysAgo(int) are the calendar day ± n, today() is calendarDayUtc(now(), tz) with tz defaulting to 'UTC'. celDateMacro mirrors resolveToken byte for byte: the same DATE_MACRO regex, Number(offset) first, else a variable path, else nothing; a zero offset is skipped as if (offset) skips it; a signed literal after the sign composes as the template composes it ({TODAY() + -3} → daysAgo(3)). So {TODAY()} → isoDate(today()); {TODAY() ± N} → isoDate(daysFromNow(N)) / isoDate(daysAgo(N)) (the date part of calendar day ± N, which is what the template's .slice(0, 10) wrote); {NOW()} → isoDatetime(now()); {NOW() ± N} → isoDatetime(addDays(now(), ±N)), right because daysFromNow starts from UTC midnight and the template kept the time of day (ablation A2 reports the pin catching exactly the midnight form); {TODAY() + days} → isoDate(addDays(today(), days)). The three edges are named right: a fraction (the template truncated the SUM inside setUTCDate, addDays truncates the OFFSET; daysFromNow(int) refuses a double at build); a variable offset (the template did variables.has(name) on the whole text, no dotted walk, then Number(v) || 0; CEL reads the path, faults on an absent variable, and a non-number makes an invalid Date that isoTimestampText throws on); neither ({TODAY() + 3d} → 0 days, remedy isoDate(today()) and says so). The text-with-holes form puts the macro's source in the concatenation ('Due ' + isoDate(daysFromNow(7)) + ' for ' + name), a string, so + is well typed.

The parity pin. RIGHT, and it proves what the claim asks. crud-fields-value-envelope.test.ts runs interpolateString and the envelope the judge PRINTS (remedyOf reads the source off the refusal, then asserts it equals the table's spelling — the table only names which spelling) through the engine and a real ObjectQL store, with Date frozen at five instants (a spring-forward and a fall-back day, the last millisecond of a month, a year end, a leap day), over thirteen spellings: the six whole-number forms, three variable offsets, the fraction, the junk offset, text with holes and the leak. The edges are pinned as named: -1.5 lands a day apart on the 8th and agrees on the 1st; daysAgo(1.5) refused at registration; an absent or non-numeric days fails the run with the source and writes nothing. One reading for the seat, not a defect: parity is proven on the UTC reference calendar. today() is the reference-timezone day (ADR-0053 D1) and the flow engine passes no timezone — celScope returns { extra, record } and timezone appears nowhere in engine.ts or builtin/ — so isoDate(today()) is the UTC day the template wrote. The remedy's "on the UTC calendar" is true of the engine as wired; if a flow run ever carries a reference timezone, that sentence and the near-midnight day would need re-reading.

The kept-spelling leak (6092730938). RIGHT. 'Due {TODAY()} by {$User.Id}' is refused with one concatenation and one sentence per token (the run user's guard included); pinned in the judge, at the validate door and in the parity pin.

Literal-data positions, option A judge-side (6092547738 item 1). RIGHT, and inside the carry. The carry says the envelope remedy "has held since pass 1 for every {…} token there" and asks for one of two routes "with a pin through the engine". The dev took the judge-side route, said why (an envelope there is data today, pinned by the legacy shapes are untouched and the CRUD nested-envelope pin; evaluating it would change what an existing flow writes and needs a ledger declaration), and pinned it through the engine: assignment-value-envelope.test.ts (both legacy shapes → the canonical map writes 'Hello Ada'; the nested map literal writes the template's object; the mixed map evaluates only with dyn(…); controls store the envelope object) and crud-fields-value-envelope.test.ts (the nested CRUD string). Naming it for every token is not a widening: literalPositionLead is positional (a legacy shape, or a non-empty path inside the slot value), the per-token remedy is unchanged, and the top-level declared slot keeps the envelope remedy (control pinned). The text is right: literalAlong builds the map/list literal along the path with CEL string keys and list indices ([x], {'meta': {'note': 'for ' + name}}); the dyn(…) sentence matches the pinned registration refusal of the un-wrapped mixed map; the lint hints inherit it by asking the judge at the position (valueSlotSpellingAt).

The has() guard (6094071127). RIGHT. guardOf now prints has(vars.a) && has(vars.a.b) && … ? vars.a.b : null. The semantics claim holds: has(e.f) evaluates e and tests f, so has(source.id) evaluates the unbound source; vars is a plain map celScope builds from the bound variables alone (it iterates variables, then binds vars and current_user over the spread), so has(vars.source) is a key test that cannot fault, and && stops at the first false. Pinned through the engine with an unsupplied isInput variable and no default (value-slot-template-grammar.test.ts: execute(…, { params: {} }) → { who: null }; control has(source.id) ? … → Unknown variable: source), and at the unit level for unbound, null, absent-key and bound over four shapes. The lint hint carries the judge's words (pinned not to contain has(source.id)), the D3 entry's acceptance criteria carry the chain, and the old celHeadReadsThroughVars branch in guardOf is gone, rightly: every head is selected off vars now, and vars holds every bound variable.

The text-slot judge. RIGHT, forced. Its date-macro remedy named assignments: { v: '{TODAY() + 7}' }, a spelling this change refuses; it now names the envelope of the string form with the edge sentence, and 18.flow-text-slot-single-brace-refused.ts's replacement says the same.

@objectstack/lint. RIGHT, patch-level. flow-double-brace-interpolation in a value slot now names the envelope for the holes the author meant (DOUBLE_BRACE_HOLES → single-brace tokens → the judge at the position) where it named {record.title}, which the slot refuses; flow-bare-dollar-reference asks the judge at the position (valueSlotRefusalAt returns the label and the path), so a nested or legacy-shape string gets the spelling that evaluates there. No rule added, no severity moved; the double-brace finding's message text changes in value slots only. The two #1315 guards are re-titled with their assertions unchanged.

Descriptions, docs, README. RIGHT. The two .describe() strings and the regenerated builtin-node-config.mdx, flows.mdx (FROM → TO rows, the literal-data paragraph, the dialect table, the failure modes, the guard row), the published service-automation README and the three docblocks no longer say the macros are kept or that CEL has no string form.

Public surface. RIGHT — nothing new reaches an entry. The diff adds exactly two export lines, celDateMacro and CelDateMacro, both in flow-template-token.ts; automation/index.ts re-exports flow-value-slot-template and flow-text-slot-template and not the token module; spec's exports map has no wildcard subpath; no api-surface/** or export-origins/** file is among the 24, and api-surface/automation.json at the head still lists the same functions. No export is removed.

Census. Consistent. No examples/** file is in the diff, and at the head git grep finds no single-brace date macro string in examples/** or packages/**/src/** outside tests and docs except the comment at validate-field-consumers.ts:335, which the dev lists in the Acceptance notes.

② Semver level

  • @objectstack/spec major, @objectstack/service-automation major — RIGHT for the v18 line. An accept-set narrowing on a published authoring surface (FlowValueSlotSchema / AssignmentValueSchema and the catchall). .changeset/pre.json on main reads mode: pre, tag: next, so the release is 18.0.0-next.*; triage 6038868940 grades a breaking change landing after release(v18): enter Changesets pre mode on main (changeset pre enter next) with one major marker, so the first v18 prerelease is 18.0.0-next.0 — the opening ruled B on #22050 #22080 as major; pass 1 (6058684331) and pass 2 (6092730938) graded the same two packages major under at-tier PASS records. The fixed group in .changeset/config.json holds spec, lint and service-automation together, so the group takes the highest bump in any case. The BREAKING banner, the FROM → TO table and the ADR-0087 marker are present.
  • @objectstack/lint patch — RIGHT. Lint's own surface (rules, exports, severities) is unchanged; its diff is hint and message text, and the expression-invalid narrowing arrives through spec's judge. This is pass 2's level for lint (pass 1 graded lint major), so the PR body's "pass 1 and pass 2's levels" is pass 2's for this package — a wording slip, not a grading error.
  • Clause-②: no (narrowing) — RIGHT, and the right spelling. Confirmed as above: nothing new reaches a package entry, no snapshot moved, check:api-surface / check:export-origins run in Lint & Repo Gates, which is green on this head. The precedent 72af58c621 took yes (narrowing) because its new exports checkPageRequiresKind and COMPILED_PAGE_KINDS entered api-surface/ui.json; this diff enters nothing there, so the pair is no with its direction arm, the spelling pass 2 took for the same reason and the one the dispatch prescribes. The claim line reads no, the same value without the arm; the PR body and the changeset carry the arm.

③ Boundary flags

  • Surface crossings — accepted, each forced. crud-nodes.ts / logic-nodes.ts (docblocks that said the macros reach interpolate()); flow-text-slot-template.ts, its test and 18.flow-text-slot-single-brace-refused.ts (a remedy that named a refused spelling); builtin-node-config.test.ts, logic-nodes.test.ts, value-slot-template-grammar.test.ts, assignment-value-envelope.test.ts (pinned the kept spelling; the last two now host the engine pins the carries require). No governed path among the 24 files.
  • ADR-0087 — legal, and the entries cover the change. The ADR's closed vocabulary admits not-required (already-registered id, …) when every named id pre-dates the merge base; check-adr-0087-registration.mjs verifies resolve-at-HEAD and exist-at-base, and both ids do (pass 1 registered flow-value-slot-template-dialect-refused, [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 registered flow-text-slot-single-brace-refused); the Require an ADR-0087 disposition step is green on this head. The ADR says the gate never verifies COVERAGE, so read for it: the amended entry's surface, replacement, reason and acceptance criteria name the macros, their four forms, the three edges, the literal-data positions and the chain guard; D3's one-entry-per-retirement-family holds (no second entry for the family); the entries sit on the unreleased 18.0.0-next line, so no shipped chain step is rewritten. registry.ts carries the same text in the generated step and the hand-kept STEP18_RATIONALE fragment. Precedent: pass 2 amended the same entry with the same marker under an at-tier PASS. No D2 conversion — right, the fraction and the silent-zero cases are not lossless.
  • main moved — checked with the compare. At this read main is 1b99388505, one commit past the f368b7e980 the dev reported (fix(service-automation): the run row's highlight set carries the first filter's qualifier, unmeasured_count #22632, fix(service-automation): the run row's highlight set …, touching engine.ts, index.ts, sys-automation-run.object.ts and a test). compare 3c97f71067...main: 7 commits ahead, 75 paths, 0 of them among the PR's 24; registry.ts is not among them; the PR reads mergeable: true. The dev's "share no path" holds for all seven, so no third merge was owed for this review; CI runs on the merge ref.
  • Check-runs on the head: read 2026-10-10T07:57Z: 34 check-runs, 33 completed (31 success, 2 skipped, 0 failure), 1 still in progress (Test Core (4/6)). The in-progress ones are not judged green by this record; the seat reads them settled before enqueue. Their conclusions are the gate verdicts.
  • Out-of-scope (1): record with no record variable — escalated, needs its own card (does not block this PR). Read at the head: celScope returns { extra: { ...vars, vars, current_user }, record: vars }, so the formula engine's record binding IS the variables map whenever the flow binds no record variable, and record.assignee silently reads a variable named assignee instead of faulting. The dev measured it through evaluateValueEnvelope only, but the executors call that primitive with celScope, and registerFlow + execute are public doors — one call away, un-gated. It is the silent-tolerance class, with a named producer (celScope, record: vars), and the nuance the card must carry: record: vars is also how bare variable names resolve in flow CEL, so the fix is a scope decision (mount bare names through extra only; bind record only when a record variable exists), not a one-liner. Not caused by this PR; the judge's chain guard reads vars.record and does not trip it.
  • Out-of-scope (2)–(5) — carried right. 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 (open, pm:blocked) is the DELIBERATE CORRECTION card; the seat should append the three rows this PR supersedes (pass 1's and pass 2's 19939-* changesets, 22110-flow-text-slot-double-brace.md) to its list. 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 (open, skills lane) carries skills/objectstack-automation/SKILL.md. The two stale comments (validate-field-consumers.ts:335, flow-node-expression-paths.test.ts:130) stay Acceptance notes — no regeneration is owed. HotCRM's 15 date-macro sites go to seat post [PM seat] domain:spec — ⏳ vacant #6017's cross-repo ledger.
  • PR feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases #22609 (seat 3, open): adds 5 fixture lines to lint-flow-patterns.test.ts, measured disjoint from this PR's hunks (6094071127); the later lander merges main.
  • Dev deviations answered: a fifth ablation (A5, the guard) beyond the order's four — welcome; the Clause-② arm (claim no, PR no (narrowing)) — same value, above; the attribution trailer form — AGENTS.md's; the stray /abl-A2.pid the dev reports at its container's filesystem root is outside the repository and the diff — nothing in this record acts on it.
  • Six rider positions: untouched, as the claim forbids; the carry line on [v18] retire the {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 (6092730938) stands.

Implemented-by: claude/issue-19939-pass3-date-macros
Reviewed-by: session_01VZqqwTj2wsihZEbfT6yyYN

VERDICT: PASS

This was referenced Oct 10, 2026
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants