Repository navigation
feat(formula): isoDate(t) / isoDatetime(t), the string form of a CEL timestamp - #22347
Conversation
…timestamp
Two CEL stdlib functions render a timestamp as ISO text on the UTC
calendar: isoDate(t) is YYYY-MM-DD and isoDatetime(t) is
YYYY-MM-DDTHH:mm:ss.sssZ. These are the bytes the flow template dialect
writes for {TODAY()} and {NOW()}, and isoDate(daysFromNow(n)) /
isoDate(daysAgo(n)) are its {TODAY() +/- n}, measured over month, year,
leap-day and DST-transition instants under six host zones.
Named functions rather than a string(timestamp) overload: one overload
answers one shape per type, so it cannot spell the date shape, and CEL
defines string(timestamp) as RFC 3339 that drops a zero fraction, which a
byte match with the template would contradict. string(timestamp) stays
refused and is pinned.
The parameter is a timestamp: text, a number or null is refused (at build
when typed, at run otherwise), never coerced. Both names join
CEL_STDLIB_FUNCTIONS, the formula skill's stdlib table and the formulas
docs page. No write path changes: a timestamp envelope still evaluates to
a Date.
Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 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 1dd8c8d54231406cdd2d36b92cb5c2206e336281 && git checkout 1dd8c8d54231406cdd2d36b92cb5c2206e336281
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ff7cbe364f939a55633a04891650163c9e8884e eebccf401b5529d3f5700ac355f414be8a574d00 && git checkout -B drift-repro 5ff7cbe364f939a55633a04891650163c9e8884e && git merge --no-ff eebccf401b5529d3f5700ac355f414be8a574d00
node scripts/docs-audit/affected-docs.mjs --json 5ff7cbe364f939a55633a04891650163c9e8884e
|
Contract reviewServed-tier: Reviewed as an isolated at-tier reviewer, read-only, from the card (#22277, every comment), #11182's ruling D ( ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
维护者速读
|
…ince ADR-0096 D5 (objectstack-ai#22382) Fixes objectstack-ai#22372 Clause-②: no The published `objectstack-query` skill taught `{ flowRunId }` "for provenance alone" as a valid execution context. Since ADR-0096 D5 strict mode (PR objectstack-ai#22297) the security plugin refuses a non-system context that carries no principal with `403 PERMISSION_DENIED`, so an AI author following that line wrote a context the engine refuses. This PR rewrites that one paragraph in place so it names the two shapes the engine admits and the refusal otherwise, and records the `skills/**` enumeration the card asked for. ## The paragraph Before (`skills/objectstack-query/SKILL.md:65-69` on `main` at `16096e8d7`): ``` Pass any SUBSET of the execution envelope (identity, tenant, transaction): `{ isSystem: true }` for a system read, `{ flowRunId }` for provenance alone. On the READ methods it may sit in the query bag (above) OR in the trailing options argument, `engine.find(obj, query, { context })`; the trailing one wins when both are given. Writes take only the trailing argument. ``` After (`:65-70`): ``` Pass any SUBSET of the execution envelope (identity, tenant, transaction): `{ isSystem: true }` for a system read, otherwise the caller's own context (user, position or permission set), or `403 PERMISSION_DENIED` (ADR-0096 D5). On the READ methods it may sit in the query bag (above) OR in the trailing options argument, `engine.find(obj, query, { context })`; the trailing one wins when both are given. Writes take only the trailing argument. ``` Lines 68-70 are the original 67-69 re-wrapped, text unchanged. ## The contract the sentence follows — `main` at `16096e8d7`, verbatim at each site - `packages/plugins/plugin-security/src/security-plugin.ts:383-387`, `isPrincipalLessContext`: `positions.length === 0 && explicitPermissionSets.length === 0 && !context?.userId`. That is the "(user, position or permission set)" gloss. - `:398-404`, `principalLessDenial`, the refusal text: "was called with a context that carries no principal (no user, no position, no permission set) and is not a system context. Pass the caller's execution context, or, for platform plumbing whose own door already authorized the caller, the explicit system opt-in (isSystem: true)." — `PermissionDeniedError`, `403 PERMISSION_DENIED`. - `:349-367`, the predicate's docblock: "A non-system context of this class is REFUSED, at every layer that used to hand it through: the engine middleware throws principalLessDenial before it resolves anything, the object-admission probes (`canReadObject` / `canWriteObject` / `canExport`) answer `false`, and its row scope is the deny sentinel (`getReadFilter`)." and "The two ways to reach the engine are explicit, never a missing field: carry the caller's principal, or … the explicit system opt-in (`isSystem: true`)." - `docs/adr/0096-execution-surface-identity-admission.md`, the D5 note dated 2026-10-08: "An engine context that carries no principal (no user, no position, no permission set) and is not a system context is refused with `PermissionDeniedError` (`403 PERMISSION_DENIED`) wherever the security plugin used to hand it through". **Wording and PR objectstack-ai#22327.** PR objectstack-ai#22327 (card objectstack-ai#22302) is still an open draft as of this PR (read via REST: `state: open`, `draft: true`; it edits `packages/spec/src/contracts/security-service.ts`, `packages/spec/src/kernel/execution-context.zod.ts`, `packages/spec/src/data/data-engine.zod.ts`, `content/docs/kernel/contracts/data-engine.mdx` and `content/docs/permissions/access-recipes.mdx`). So the sentence here follows the D5 contract as it stands on `main` — the plugin's refusal text and predicate above — not that PR's draft text; the two agree on substance (principal or `isSystem: true`, else `403 PERMISSION_DENIED`, ADR-0096 D5). On `main` the old sentence still stands at `execution-context.zod.ts:335-336` and `:501`, `data-engine.zod.ts:67-70` and `data-engine.mdx:127-128`; those are objectstack-ai#22327's files and are not touched here. ## Enumeration pin — `git grep -n -i` over `skills/**` on `main` at `16096e8d7` **Class 1, a `{ flowRunId }`-only context.** `flowRunId`: 1 hit, `objectstack-query/SKILL.md:66`, fixed here. `provenance`: 1 hit, the same line. `runId` / `run id`: 2 hits, the same line plus `objectstack-automation/references/state-machines-and-approvals.md:208`, a `:runId` URL path parameter, not a context. **Class 2, a principal-less context that is admitted, keeps its scope or falls open.** Zero hits for each of: `no principal`, `without a principal`, `principal-less`, `principalless`, `anonymous context`, `context: {}` (fixed-string, and the regex `context:\s*\{\s*\}`), `empty context`, `fall open`, `falls open`, `fall-open`, `fail open`, `fail-open`, `hand(ed|s)? (it )?through`, `keeps its scope`, `skips? (the )?(permission |security )?checks`, `no identity`, `without identity`, `resolves no identity`, `without (a )?context`, `contextless`, `no context`, `RLS-on`, `sees-nothing`, `SYSTEM_CTX`, `passes only`. Non-zero query words, each hit read and dispositioned: - `unauthenticated` (4): `objectstack-api/SKILL.md:162` — `authRequired: false` opens an anonymous HTTP entry point (ADR-0121 D6 pairing); that is the door's authentication, not an engine context. The public-form endpoints at `:87-88` run under a synthetic `{ permissions: ['guest_portal'], anonymous: true }` context, which carries a named permission set and so is not principal-less under the predicate. `objectstack-data/references/data-hooks.md:361`, `:602`, `:629` — `ctx.user` is `undefined` for system / unauthenticated writes: the ctx shape, no admission claim. - `no user` (2): `objectstack-automation/SKILL.md:192-194` — a `'user'` hook whose trigger resolved no user has its `ctx.api` refused (`HOOK_UNSCOPED_DATA_ACCESS`, 403) "rather than run unscoped": fail-closed, consistent with D5. `data-hooks.md:930` — "system operations carry no user", a system context. - `context-less` (1): `objectstack-ui/rules/actions.md:127` — `ctx.user` is `undefined` for a context-less / self-invoked call (the `ScopedRepo.execute()` path, `packages/runtime/src/sandbox/body-runner.ts:1188-1198` says that path carries no caller identity). It describes `ctx.user`; it does not say the engine admits such a context. Left alone. - `carries no` (5), `sees nothing` (1), `anonymous` (11), `bypass` (7), `elevat` (12), `runAs` (20), `system context` (3), `resolve[sd] no` (3), `no resolvable` (1), `unscoped` (4): every hit is either an explicit elevation the engine admits (`isSystem`, `runAs: 'system'`: `objectstack-automation/SKILL.md:183`, `references/examples-flows.md:23`, `:88` teach `runAs: 'system'` for a run with no trigger user, which is the D5-correct prescription) or a different subject (anonymous records, sharing `bypass`, public forms, a search axis, a time dimension). Control: `isSystem` hits 3 files (`data-hooks.md`, `objectstack-query/SKILL.md`, `objectstack-query/evals/filters-pagination-search.json`), matching the seat's reading. No hit lands in `skills/objectstack-formula/SKILL.md` (PR objectstack-ai#22347) or `skills/objectstack-ui/references/react-blocks.md` (PR objectstack-ai#22322); neither file is touched. ## Evals `skills/objectstack-query/evals/filters-pagination-search.json`: the 2 `isSystem` hits are both in case `id: 5`, whose `expected_output` prescribes `context: { isSystem: true }` and whose `must_contain` is `["context", "isSystem: true", "limit: 1"]`. `flowRunId` / `provenance` / `envelope`: 0 hits across `evals/`. The old line is not asserted; the eval is unchanged and stays true. ## Budget — net-line budget 0, cap +1: spent +1 | reading | before (`16096e8d7`) | after (`f4e5170b`) | |---|---|---| | `skills/objectstack-query/SKILL.md`, lines | 400 | 401 (+1) | | whole package, all `skills/*/SKILL.md`, lines | 4409 | 4410 (+1) | | `SKILL.md` tokens, `ceil(bytes/4)` (ceiling 5552) | 4109 | 4128 (headroom 1424) | Why not 0: the replaced clause (`{ flowRunId }` for provenance alone) was 37 characters; the replacement that states the admitted shape, the refusal code and the ADR is 112. A 0-net fit required deleting content — the envelope's "(identity, tenant, transaction)" or the principal gloss — and re-wrap is not a currency, so the one line the cap allows was spent instead. `scripts/pm/check-skill-line-ratchet.mjs` does not cover the published root (its header says so); the token ratchet is the binding one and it is green with headroom. ## Changeset `skills/**` is in no released package's `files[]`: no `package.json` under `packages/` names a `skills` path and none carries an entry that escapes its own directory (both measured over every `packages/**/package.json`); the catalog reaches customers through `npx skills add objectstack-ai/objectstack/skills` from this repository, which `packages/create-objectstack` invokes at scaffold time rather than bundling (`src/created-summary.ts:31`). Positive control: the same old sentence in `packages/spec/src/kernel/execution-context.zod.ts:501` IS inside spec's `files[]` (`src/**/*.zod.ts`), which is why objectstack-ai#22327 carries a changeset and this PR does not. No `.changeset/*.md`; `skip-changeset` is the repo's skip form. ## Gates — merge base `16096e8d7`, final commit `f4e5170b` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 24 families from the worktree change set (1 path); each ran with its exit code captured before any pipe; `--ran` reconciliation: "24 derived, 24 run, 0 NOT-MEASURED, 0 UNRUN", with every line carrying its exit code ("a DERIVED zero — all 24 recorded an exit code and none of them is 3"). All 24 exit 0: - `node scripts/check-skills-token-ratchet.mjs` (+ `--self-test`): "skills/objectstack-query/SKILL.md is 4128 tokens (ceiling 5552; headroom 1424)". - `pnpm --filter @objectstack/lint run check:doc-formula-expressions`, after building its prerequisite under the verify lock (`pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint`, `VERDICT command-exit 0`, held 105s, waited 0s). - `pnpm --filter @objectstack/spec run check:skill-docs` (reads frontmatter only; unchanged), `pnpm check:doc-authoring`, `pnpm check:skill-identifier-liveness`, `pnpm check:skill-frame-sync`, `pnpm check:skill-compatibility`, `pnpm check:corpus-claim-drift`, `pnpm check:cross-package-test-inputs`, `pnpm check:agent-test-spelling`, `pnpm check:role-word`, `pnpm check:gitlink-declared`, `pnpm check:driver-memory-census`, `pnpm check:refd-timer-probe`, `pnpm check:watch-hint-literal`, `pnpm check:pm-governed-merges`, `pnpm check:nul-bytes`, `node scripts/check-ci-filter-parity.mjs`, `node scripts/check-closing-keyword-parity.mjs` (+ `--self-test`), `node scripts/check-comment-mask-corpus.mjs`, `node scripts/check-doc-route-spelling.mjs --advisory` (+ `--self-test`). Beyond the derivation: `pnpm check:pm-skill-ratchet` exit 0 (the published root is outside its map, as its header states); `pnpm --filter @objectstack/spec run check:skill-refs` exit 0 ("9 generated files in sync", nothing to regenerate); a control-byte scan over the edited file finds none. The 52 artifact-roster families, the 11 declared wide-population families and the type-check lanes the derivation lists outside the derived total are CI's runs on this PR; `pnpm lint` (repo-level eslint) was not run locally — the diff is one Markdown file. ## Acceptance notes - Governed surface, Tier H (`skills/**`): this PR stays draft; landing waits for an authorized approval, and the dispatch names the contract-review tier as mandatory on this path. - Commit identity: this cloud container cannot mint the fleet identity (`OS_FLEET_APP_ID` / `OS_FLEET_PRIVATE_KEY` are unset and the relay hands out no token for `git`), so the one commit carries the worktree's harness identity; every later commit on this branch keeps that same identity. - Observed, not filed (code comments are objectstack-ai#22345's lane, PR objectstack-ai#22357): `packages/runtime/src/sandbox/body-runner.ts:979-984` still says "A caller that has no context to give gets the same identity-less behavior as before", a pre-D5 reading in a code comment. - Historical `CHANGELOG.md` entries ("A run with no principal now passes provenance alone.") are release-owned records of what shipped and are not edited. ## 维护者速读(草稿) - **改了什么:** 已发布的 `objectstack-query` 技能里,"Execution Context" 一节的一段话。原来教"只传 `{ flowRunId }` 做溯源"也是合法的执行上下文;现在改为:系统读传 `{ isSystem: true }`,否则传调用者自己的上下文(带用户、岗位或权限集),两者都没有则引擎拒绝(`403 PERMISSION_DENIED`,ADR-0096 D5)。只改这一段,净增 1 行(预算 0、上限 +1)。 - **为什么改:** ADR-0096 D5 严格模式(PR objectstack-ai#22297)落地后,`plugin-security` 在全部站点拒收"无 principal 且非 system"的上下文。按旧句写出来的上下文会被引擎直接拒绝,而这份技能是通过 `npx skills add` 装进客户项目的,AI 作者先读到它、再撞上 403。同时按卡片要求对全部 `skills/**` 做了枚举:只有这一行教旧读法,其余命中都是 `isSystem`/`runAs:'system'` 这类显式提权或别的主题;评测文件没有断言旧句。 - **风险与代价(含回滚):** 纯文本改动,不碰代码、不发包、无 changeset(`skills/**` 不在任何已发布包的 `files[]` 内)。措辞按 `main` 上的 D5 契约原文写;PR objectstack-ai#22327 仍是 draft,它落地后两边说法一致。回滚即还原这一个文件的一次提交。 - **席位意见:** - **你要做的:** 看一眼第 65-70 行这一段表述是否认可,认可就给一个批准。 --- _Generated by [Claude Code](https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22277
Clause-②: yes (widening: CEL gains a string form for a Timestamp; no envelope that returns a Timestamp changes what it writes)
What this adds
Two CEL stdlib functions in
@objectstack/formularender a timestamp as ISO text on the UTC calendar:isoDate(t)YYYY-MM-DD{TODAY()}asisoDate(today()),{TODAY() + n}asisoDate(daysFromNow(n)),{TODAY() - n}asisoDate(daysAgo(n))isoDatetime(t)YYYY-MM-DDTHH:mm:ss.sssZ{NOW()}asisoDatetime(now()),{NOW() + n}asisoDatetime(addDays(now(), n))Both join
CEL_STDLIB_FUNCTIONS, sointrospectScopeadvertises them and the build check accepts what the run evaluates. They also join the formula skill's stdlib table and the formulas docs page, which the drift pins hold equal to the catalog. This is the string form #11182's ruling D names as the remedy for the date macros in flow value slots. #19939's pass 3 (refusing{NOW()}/{TODAY() ± N}with this as the remedy) is not in this PR and remains open.Why two named functions, not a
string(timestamp)overloadThe ruling asks for one spelling: an overload or a named function. This PR uses named functions, one name per shape.
string()overload cannot spell the date shape. One overload answers one shape for one type.string(today())could only be date-time text (…T00:00:00.000Z), never theYYYY-MM-DDthat{TODAY()}wrote.string()a dialect. CEL definesstring(timestamp)as RFC 3339 text that drops a zero fraction (2026-10-08T00:00:00Z). The template always writes.000Z. An overload that matched the template would answerstring()differently from CEL. An AI author who knows CEL would not see that difference.registerFunction('string(google.protobuf.Timestamp): string', …). A second registration of the same signature throwsoverlaps with existing overloadwhen the environment is built. So a cel-js upgrade that ships its ownstring(timestamp)would break every environment build. Named functions avoid that.date(s)/datetime(s), and they match the{{ x | date:iso }}/{{ x | datetime:iso }}formatters, which produce the same bytes.datetimestays one lower-case word, as in the field type anddatetime(s). A camel-casedisoDateTime(…)gets the existing did-you-mean, which suggestsisoDatetime(pinned).string(today())stays refused, and a test pins it. A cel-js upgrade that starts accepting it turns that test red, and a person decides.Only a timestamp is accepted. The parameter is
google.protobuf.Timestamp, neverdyn. Text, a number ornullis refused at build when the argument's type is known (isoDate('2026-10-08')) and at run otherwise (isoDate(record.d)wheredholds text). It is never coerced. Coercing through the stdlib'stoDatewould parse non-ISO text in the host's local zone and could render a different day. The repair for ISO text isisoDate(date(s)). An invalid timestamp, or one outside cel-js's owntimestamp()range (0001-01-01 to 9999-12-31), is refused at run, because outside that rangetoISOString()changes shape (+010000-…).Premises, measured before any behaviour change (at
28bff18d0c)P1 holds. Measured through the built
distwithnowpinned at2026-10-08T17:55:06.123Z:now(),today(),daysFromNow(3),daysAgo(1),addDays(today(), 3)andaddDays(now(), 3)each return aDate.string(today()),string(now())andstring(daysFromNow(3))faultfound no matching overload for 'string(google.protobuf.Timestamp)'at run.validateExpressionrefuses theminvalid-celin thevalue,predicateandformularoles. This matches #19939 pass 1's probes D6 and X13.P2 holds, byte for byte.
interpolateString(the shipped source, run through tsx) was measured with a fixed global clock at 9 instants under 6 host process zones (UTC,America/New_York,Europe/Berlin,Asia/Kolkata,Pacific/Auckland,Pacific/Honolulu). Each cell was compared with the new spelling over the Timestamp the flow value-slot CEL scope produces. That scope isAutomationEngine.celScope, which passes nonowand notimezone, so it runs on the wall clock and the UTC calendar. Result: 378 cells, 0 differences. No template cell depended on the host zone. Its output is a string in every cell.{TODAY()}{NOW()}{TODAY() + 3}{TODAY() - 1}{NOW() + 1}2026-10-08T17:55:06.123Z2026-10-082026-10-08T17:55:06.123Z2026-10-112026-10-072026-10-09T17:55:06.123Z2026-10-08T00:00:00.000Z(UTC midnight,.000)2026-10-082026-10-08T00:00:00.000Z2026-10-112026-10-072026-10-09T00:00:00.000Z2026-01-31T23:59:59.999Z(month end)2026-01-312026-01-31T23:59:59.999Z2026-02-032026-01-302026-02-01T23:59:59.999Z2026-02-28T12:00:00.000Z(Feb to Mar)2026-02-282026-02-28T12:00:00.000Z2026-03-032026-02-272026-03-01T12:00:00.000Z2028-02-28T12:00:00.000Z(leap)2028-02-282028-02-28T12:00:00.000Z2028-03-022028-02-272028-02-29T12:00:00.000Z2026-12-31T23:30:00.000Z(year end)2026-12-312026-12-31T23:30:00.000Z2027-01-032026-12-302027-01-01T23:30:00.000Z2026-03-08T07:30:00.000Z(US spring-forward)2026-03-082026-03-08T07:30:00.000Z2026-03-112026-03-072026-03-09T07:30:00.000Z2026-03-29T00:30:00.000Z(EU spring-forward)2026-03-292026-03-29T00:30:00.000Z2026-04-012026-03-282026-03-30T00:30:00.000Z2026-11-01T23:30:00.000Z(US fall-back)2026-11-012026-11-01T23:30:00.000Z2026-11-042026-10-312026-11-02T23:30:00.000ZtoISOString()). The host zone has no effect..000.Z, never an offset.± N: whole UTC days throughsetUTCDate, so a month, year or DST boundary moves the date and never the clock time. A variable offset ({TODAY() + n}) works the same way. A non-numeric offset becomes 0.due {TODAY()} at {NOW()}interpolates the same bytes into a string.today()follows the evaluation's reference timezone (ADR-0053 D1). The flow value-slot scope sets none, so it is the UTC day, the same day{TODAY()}writes. The P2 control measured the other case. At2026-10-08T23:30Zwithctx.timezone = Pacific/Auckland,isoDate(today())is2026-10-09(the reference day), whileisoDate(now())and{TODAY()}are2026-10-08. The renderer reads the UTC calendar because that is the only reading that keepstoday()'s UTC-midnight representation on its own day in zones west of UTC. A pin holds that.P3 holds. See point 3 above: the overload was possible, and the named functions were chosen on points 1, 2 and 4.
P4: the build-side readers.
validateExpression/celEngine.compiletype-check through the sameregisterStdLibthe run evaluates, so registering the names is what makes the build accept them. At BASE the build refusedisoDate(today())ascel-unknown-functionand the run refused it too. Now both accept it, andinferExpressionTypeanswerstext. The readers that had to learn the names, each held by an existing pin:CEL_STDLIB_FUNCTIONS(cel-stdlib-drift.test.tsB: every bare-callableregisterStdLibadds must be advertised);skills/objectstack-formula/SKILL.md(skill-catalog-sync.test.ts; measured red until the row landed);cel-engine.test.ts's runtime probe map (every advertised name needs a bare-call probe);The did-you-mean (
nearestCallable) readsCEL_STDLIB_FUNCTIONS, so it suggests the new names with no further change.firstUnknownFunctionCalland@objectstack/lint's visibility gate read the environment and needed no change. Thevalidate.tsdecomposition comment is re-measured: 75 registered names = 41 bare plus 34 receiver-only (cel-js's 33 plus ourcan), 29 bare names added byregisterStdLib, 37 advertised, a gap of 38. At BASE it said 72 / 33 / 27 / 35 / 37, already stale bycan.No write path changes (measured through the data engine)
This uses a real
AutomationEngineandcreate_recordover a realObjectQLengine with a recording driver, clock fixed at2026-01-31T23:59:59.999Z, run on BASE and on this head:fields.*valuetoday()Date2026-01-31T00:00:00.000ZDate2026-01-31T00:00:00.000Znow()Date2026-01-31T23:59:59.999ZDate2026-01-31T23:59:59.999ZdaysFromNow(3),addDays(today(), 3)Date2026-02-03T00:00:00.000ZDate2026-02-03T00:00:00.000Z{TODAY()}"2026-01-31""2026-01-31"{NOW()}"2026-01-31T23:59:59.999Z""2026-01-31T23:59:59.999Z"{TODAY() + 3}/{TODAY() - 1}"2026-02-03"/"2026-01-30""2026-02-03"/"2026-01-30"isoDate(today())cel-unknown-function)"2026-01-31"isoDatetime(now())"2026-01-31T23:59:59.999Z"isoDate(daysFromNow(3))/isoDate(daysAgo(1))"2026-02-03"/"2026-01-30"Every row that existed at BASE is byte-identical on this head (diffed). The new spellings write the same strings the template macros write, into the same columns. The probe ran in the scratchpad and is not committed, because
service-automationis not this card's to touch.Pins (
packages/formula/src/stdlib-timestamp-text.test.ts)isoDate(today()),isoDatetime(now()),isoDate(daysFromNow(3)),isoDate(addDays(today(), 3)),isoDate(daysAgo(1))andisoDatetime(addDays(now(), 1))equal the template's measured bytes. This runs with the host process inUTC,Pacific/AucklandandAmerica/New_York.isoDate(today())underPacific/Aucklandand underAmerica/New_Yorkis the reference day, andisoDate(now())is the UTC day.text.isoDte(…)andisoDateTime(…)are refusedcel-unknown-functionwithparams.suggestionisoDateandisoDatetime.string(today())andstring(now())stay refused (invalid-cel, namingstring(google.protobuf.Timestamp)).nullare refused at build (invalid-cel, naming the overload). Text, a double andnullarriving through a binding are refused at run (runtime). An invalid timestamp and both range edges are refused at run with the function's own message.isoDate(date(record.d))is the repair, and it works.today(),now(),daysFromNow(3)andaddDays(today(), 3)still evaluate to aDateat the same instant.The live cross-dialect parity pin (
interpolateStringagainst the envelope in one test) belongs inservice-automation, which this card does not touch. #19939's pass 3 is the natural carrier.crud-fields-value-envelope.test.tsalready pins each refused spelling beside "the CEL spelling that writes the same value". Here the template's bytes are recorded as measured literals.Reverse verification
The implementation was committed first (
eebccf401b). Then bothregisterFunctioncalls were deleted fromstdlib.tsthroughscripts/ablation-replace.mjs(anchor 1 to 0 hits; blobecf451d4074eto0349b05f6061), and the four files that read the registration were run. Predicted direction: red. Observed: 9 failed, 92 passed (101).date()repair and the function's own range message disappear;cel-stdlib-driftA (advertised but not registered); andcel-engine.test.ts's runtime probe.string(timestamp)refusal, the literal-type build refusals (nothing accepts those either way) and the no-write-path pin.The restore is proven, not assumed: the blob after restore equals the HEAD blob
ecf451d4074e, andgit diff HEADandgit status --porcelainare both empty. The tests importsrcby relative path, so nodistis in the resolution path and no rebuild leg applies.Tests and gates (at
eebccf401b)pnpm --filter @objectstack/formula build: exit 0.distwas rebuilt from this head before any gate that reads it.pnpm --filter @objectstack/formula test, the full task: 44 files, 1268 tests passed.pnpm --filter @objectstack/formula typecheck: exit 0.tsc --listFiles -p tsconfig.test.jsoncompiles 44 of 44 test files, and the new one has 0 errors. The 7 errors in that program are the ledgered debt in 3 other files, whichcheck:test-typecheckholds.@objectstack/lintvalidate-visibility-predicates.test.ts, which readsCEL_STDLIB_FUNCTIONSfrom the built package: 185 passed.dispatch-gates --commandsre-derived at this head (97), 99 commands, each exit code captured before any pipe.--ranreconciliation: 97 derived, 96 run, 1 NOT MEASURED, 0 unrun. The 2 claim-time families outside this derivation (check:dispatcher-error-vocabulary,check:swallow-census-controls) also ran and exited 0.check:skill-examplesfirst exited 3 (prerequisite:@objectstack/client-reactwas not built). After that closure was built it exited 0: 262 examples type-check.check:dual-build-cjs-loads. Reason: exit 3, because 37 workspace packages have nodisthere, and a whole-workspace build is CI's to run. Declared narrowing, which measures that gate's property on the one package this diff touches:requireof formula's published entry (./dist/index.js) loads, and it evaluatesisoDate(today()) + " " + isoDatetime(now())to2026-10-08 2026-10-08T17:55:06.123Z.check:skills-token-ratchet:skills/objectstack-formula/SKILL.md is 5403 tokens (ceiling 6002; headroom 599).28bff18d0c.File surface
The landing is the claim's:
packages/formula/src/stdlib.tsandvalidate.ts, their tests, the stdlib catalogs and the formulas docs page.cel-engine.ts,types.tsandindex.tsdid not need to change, because the overload route was not taken and no type or export was added. One file the claim did not list by name isskills/objectstack-formula/SKILL.md. It is the stdlib catalogskill-catalog-sync.test.tspins, and that pin measured red until the row landed. It is a governed path (skills/**, Tier H), so the landing tier is set by that file.The skills surface (two readings)
skills/objectstack-formula/SKILL.mdgains one table row (skill-catalog-sync.test.tsrequires every advertised name to be documented there):skills/objectstack-formula/SKILL.mdlinesceil(bytes / 4), the ratchet's unit)skills/*/SKILL.md, linesskills/**is a governed surface (Tier H), so this PR lands only on the maintainer's approval.维护者速读(草稿)
改了什么:公式引擎(CEL)新增两个函数
isoDate(t)/isoDatetime(t),把时间戳写成文本:2026-10-08与2026-10-08T17:55:06.123Z。目录、技能表、文档各加一行。为什么改:流程值槽里的
{TODAY()}/{NOW()}模板写的是这两种文本,而 CEL 只能产出时间戳对象(落库是Date)。#11182 裁决 D 要求先有"字符串形式",#19939 才能拒收这些模板写法并给出等价写法。实测 378 个组合逐字节一致。风险与代价(含回滚):纯新增,已有表达式与写入路径不变(实测仍写
Date)。只收时间戳,传文本/数字/null 会响亮报错。回滚即删两处注册与目录行。因触及skills/**,本 PR 属 Tier H。席位意见:
你要做的:审批本 PR(Tier H 需维护者批准)。若更倾向于
string(timestamp)重载这一拼写,请在此指出。Acceptance notes
packages/formula/src/unknown-function.ts(lines 39 to 41) andpackages/lint/src/validate-visibility-predicates.test.ts(line 1457) state "advertises 35 / registers 72 / 37-name gap" in the present tense. They were already stale bycan(73 / 38) and now read 37 / 75 / 38. Comments only, no behaviour. Carrier: none named; whoever next touches either file.docs/qa/platform-checklist/areas/api-backend.jsonitemapi-backend.formula-stdlib-matrixtitles itself "all 27 registered functions".registerStdLibnow registers 29 bare-callable functions. The item recounts from source at its step 1 and is not a per-PR gate. Carrier: the nextchecklist-authorsweep or a run of that item.{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 pass 3, measured: a fractional negative offset differs.{TODAY() - 1.5}writes2026-10-06(setUTCDatetruncates the sum), whileisoDate(addDays(today(), -1.5))writes2026-10-07(addDaystruncates the offset).daysAgo(1.5)is refused at build (an int parameter). The template documents integer offsets only, so the remedy mapping holds for integer N. Also,{NOW() ± n}maps toisoDatetime(addDays(now(), ±n)), not todaysFromNow, which lands on midnight.Generated by Claude Code