Skip to content

feat(formula): isoDate(t) / isoDatetime(t), the string form of a CEL timestamp - #22347

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-22277-cel-timestamp-string
Oct 9, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-22277-cel-timestamp-string

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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/formula render a timestamp as ISO text on the UTC calendar:

Spelling Returns Writes what the flow template wrote for
isoDate(t) YYYY-MM-DD {TODAY()} as isoDate(today()), {TODAY() + n} as isoDate(daysFromNow(n)), {TODAY() - n} as isoDate(daysAgo(n))
isoDatetime(t) YYYY-MM-DDTHH:mm:ss.sssZ {NOW()} as isoDatetime(now()), {NOW() + n} as isoDatetime(addDays(now(), n))

Both join CEL_STDLIB_FUNCTIONS, so introspectScope advertises 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) overload

The ruling asks for one spelling: an overload or a named function. This PR uses named functions, one name per shape.

  1. A 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 the YYYY-MM-DD that {TODAY()} wrote.
  2. A byte match would make string() a dialect. CEL defines string(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 answer string() differently from CEL. An AI author who knows CEL would not see that difference.
  3. It is safe in cel-js, but it can collide later (measured, P3). cel-js 8.0.0 accepts registerFunction('string(google.protobuf.Timestamp): string', …). A second registration of the same signature throws overlaps with existing overload when the environment is built. So a cel-js upgrade that ships its own string(timestamp) would break every environment build. Named functions avoid that.
  4. The names say the shape. They reverse date(s) / datetime(s), and they match the {{ x | date:iso }} / {{ x | datetime:iso }} formatters, which produce the same bytes. datetime stays one lower-case word, as in the field type and datetime(s). A camel-cased isoDateTime(…) gets the existing did-you-mean, which suggests isoDatetime (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, never dyn. Text, a number or null is refused at build when the argument's type is known (isoDate('2026-10-08')) and at run otherwise (isoDate(record.d) where d holds text). It is never coerced. Coercing through the stdlib's toDate would parse non-ISO text in the host's local zone and could render a different day. The repair for ISO text is isoDate(date(s)). An invalid timestamp, or one outside cel-js's own timestamp() range (0001-01-01 to 9999-12-31), is refused at run, because outside that range toISOString() changes shape (+010000-…).

Premises, measured before any behaviour change (at 28bff18d0c)

P1 holds. Measured through the built dist with now pinned at 2026-10-08T17:55:06.123Z: now(), today(), daysFromNow(3), daysAgo(1), addDays(today(), 3) and addDays(now(), 3) each return a Date. string(today()), string(now()) and string(daysFromNow(3)) fault found no matching overload for 'string(google.protobuf.Timestamp)' at run. validateExpression refuses them invalid-cel in the value, predicate and formula roles. 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 is AutomationEngine.celScope, which passes no now and no timezone, 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.

Instant {TODAY()} {NOW()} {TODAY() + 3} {TODAY() - 1} {NOW() + 1}
2026-10-08T17:55:06.123Z 2026-10-08 2026-10-08T17:55:06.123Z 2026-10-11 2026-10-07 2026-10-09T17:55:06.123Z
2026-10-08T00:00:00.000Z (UTC midnight, .000) 2026-10-08 2026-10-08T00:00:00.000Z 2026-10-11 2026-10-07 2026-10-09T00:00:00.000Z
2026-01-31T23:59:59.999Z (month end) 2026-01-31 2026-01-31T23:59:59.999Z 2026-02-03 2026-01-30 2026-02-01T23:59:59.999Z
2026-02-28T12:00:00.000Z (Feb to Mar) 2026-02-28 2026-02-28T12:00:00.000Z 2026-03-03 2026-02-27 2026-03-01T12:00:00.000Z
2028-02-28T12:00:00.000Z (leap) 2028-02-28 2028-02-28T12:00:00.000Z 2028-03-02 2028-02-27 2028-02-29T12:00:00.000Z
2026-12-31T23:30:00.000Z (year end) 2026-12-31 2026-12-31T23:30:00.000Z 2027-01-03 2026-12-30 2027-01-01T23:30:00.000Z
2026-03-08T07:30:00.000Z (US spring-forward) 2026-03-08 2026-03-08T07:30:00.000Z 2026-03-11 2026-03-07 2026-03-09T07:30:00.000Z
2026-03-29T00:30:00.000Z (EU spring-forward) 2026-03-29 2026-03-29T00:30:00.000Z 2026-04-01 2026-03-28 2026-03-30T00:30:00.000Z
2026-11-01T23:30:00.000Z (US fall-back) 2026-11-01 2026-11-01T23:30:00.000Z 2026-11-04 2026-10-31 2026-11-02T23:30:00.000Z
  • Timezone: UTC only (toISOString()). The host zone has no effect.
  • Precision: always three-digit milliseconds, including .000.
  • Suffix: always Z, never an offset.
  • ± N: whole UTC days through setUTCDate, 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.
  • Embedded use: 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. At 2026-10-08T23:30Z with ctx.timezone = Pacific/Auckland, isoDate(today()) is 2026-10-09 (the reference day), while isoDate(now()) and {TODAY()} are 2026-10-08. The renderer reads the UTC calendar because that is the only reading that keeps today()'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.compile type-check through the same registerStdLib the run evaluates, so registering the names is what makes the build accept them. At BASE the build refused isoDate(today()) as cel-unknown-function and the run refused it too. Now both accept it, and inferExpressionType answers text. The readers that had to learn the names, each held by an existing pin:

  • CEL_STDLIB_FUNCTIONS (cel-stdlib-drift.test.ts B: every bare-callable registerStdLib adds 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 formulas docs page's stdlib table.

The did-you-mean (nearestCallable) reads CEL_STDLIB_FUNCTIONS, so it suggests the new names with no further change. firstUnknownFunctionCall and @objectstack/lint's visibility gate read the environment and needed no change. The validate.ts decomposition comment is re-measured: 75 registered names = 41 bare plus 34 receiver-only (cel-js's 33 plus our can), 29 bare names added by registerStdLib, 37 advertised, a gap of 38. At BASE it said 72 / 33 / 27 / 35 / 37, already stale by can.

No write path changes (measured through the data engine)

This uses a real AutomationEngine and create_record over a real ObjectQL engine with a recording driver, clock fixed at 2026-01-31T23:59:59.999Z, run on BASE and on this head:

fields.* value Column type BASE writes This head writes
CEL today() text, date Date 2026-01-31T00:00:00.000Z Date 2026-01-31T00:00:00.000Z
CEL now() text, datetime Date 2026-01-31T23:59:59.999Z Date 2026-01-31T23:59:59.999Z
CEL daysFromNow(3), addDays(today(), 3) text Date 2026-02-03T00:00:00.000Z Date 2026-02-03T00:00:00.000Z
template {TODAY()} text, date "2026-01-31" "2026-01-31"
template {NOW()} text, datetime "2026-01-31T23:59:59.999Z" "2026-01-31T23:59:59.999Z"
template {TODAY() + 3} / {TODAY() - 1} text "2026-02-03" / "2026-01-30" "2026-02-03" / "2026-01-30"
CEL isoDate(today()) text, date refused at BASE (cel-unknown-function) "2026-01-31"
CEL isoDatetime(now()) text, datetime refused at BASE "2026-01-31T23:59:59.999Z"
CEL isoDate(daysFromNow(3)) / isoDate(daysAgo(1)) text refused at BASE "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-automation is not this card's to touch.

Pins (packages/formula/src/stdlib-timestamp-text.test.ts)

  • The two shapes: for every instant in the P2 table, isoDate(today()), isoDatetime(now()), isoDate(daysFromNow(3)), isoDate(addDays(today(), 3)), isoDate(daysAgo(1)) and isoDatetime(addDays(now(), 1)) equal the template's measured bytes. This runs with the host process in UTC, Pacific/Auckland and America/New_York.
  • The reference day: isoDate(today()) under Pacific/Auckland and under America/New_York is the reference day, and isoDate(now()) is the UTC day.
  • The build agrees with the run: both spellings validate clean in a value slot and infer text. isoDte(…) and isoDateTime(…) are refused cel-unknown-function with params.suggestion isoDate and isoDatetime. string(today()) and string(now()) stay refused (invalid-cel, naming string(google.protobuf.Timestamp)).
  • Non-timestamp arguments: literal text, an int and null are refused at build (invalid-cel, naming the overload). Text, a double and null arriving 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.
  • No write path changes: today(), now(), daysFromNow(3) and addDays(today(), 3) still evaluate to a Date at the same instant.

The live cross-dialect parity pin (interpolateString against the envelope in one test) belongs in service-automation, which this card does not touch. #19939's pass 3 is the natural carrier. crud-fields-value-envelope.test.ts already 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 both registerFunction calls were deleted from stdlib.ts through scripts/ablation-replace.mjs (anchor 1 to 0 hits; blob ecf451d4074e to 0349b05f6061), and the four files that read the registration were run. Predicted direction: red. Observed: 9 failed, 92 passed (101).

  • Red: the three host-zone shape pins; the reference-day pin; the build-accept pin; both run-refusal rows, because the date() repair and the function's own range message disappear; cel-stdlib-drift A (advertised but not registered); and cel-engine.test.ts's runtime probe.
  • Still green, as they should be: the misspelling did-you-mean (it reads the catalog), the 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, and git diff HEAD and git status --porcelain are both empty. The tests import src by relative path, so no dist is in the resolution path and no rebuild leg applies.

Tests and gates (at eebccf401b)

  • pnpm --filter @objectstack/formula build: exit 0. dist was 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.json compiles 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, which check:test-typecheck holds.
  • Import side: @objectstack/lint validate-visibility-predicates.test.ts, which reads CEL_STDLIB_FUNCTIONS from the built package: 185 passed.
  • Gates: the claim-time list (57) united with dispatch-gates --commands re-derived at this head (97), 99 commands, each exit code captured before any pipe. --ran reconciliation: 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-examples first exited 3 (prerequisite: @objectstack/client-react was not built). After that closure was built it exited 0: 262 examples type-check.
    • NOT MEASURED: check:dual-build-cjs-loads. Reason: exit 3, because 37 workspace packages have no dist here, 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: require of formula's published entry (./dist/index.js) loads, and it evaluates isoDate(today()) + " " + isoDatetime(now()) to 2026-10-08 2026-10-08T17:55:06.123Z.
    • check:skills-token-ratchet: skills/objectstack-formula/SKILL.md is 5403 tokens (ceiling 6002; headroom 599).
  • Size: 7 files, +267 / -9 against the merge base 28bff18d0c.

File surface

The landing is the claim's: packages/formula/src/stdlib.ts and validate.ts, their tests, the stdlib catalogs and the formulas docs page. cel-engine.ts, types.ts and index.ts did 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 is skills/objectstack-formula/SKILL.md. It is the stdlib catalog skill-catalog-sync.test.ts pins, 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.md gains one table row (skill-catalog-sync.test.ts requires every advertised name to be documented there):

Reading Before After
skills/objectstack-formula/SKILL.md lines 461 462
same file, tokens (ceil(bytes / 4), the ratchet's unit) 5367 5403 (ceiling 6002)
whole pack, all 10 skills/*/SKILL.md, lines 4409 4410
whole pack, tokens (sum of per-file counts) 52556 52592

skills/** 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

  • Out-of-surface comment counts, not edited: packages/formula/src/unknown-function.ts (lines 39 to 41) and packages/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 by can (73 / 38) and now read 37 / 75 / 38. Comments only, no behaviour. Carrier: none named; whoever next touches either file.
  • QA checklist count: docs/qa/platform-checklist/areas/api-backend.json item api-backend.formula-stdlib-matrix titles itself "all 27 registered functions". registerStdLib now registers 29 bare-callable functions. The item recounts from source at its step 1 and is not a per-PR gate. Carrier: the next checklist-author sweep or a run of that item.
  • For [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 pass 3, measured: a fractional negative offset differs. {TODAY() - 1.5} writes 2026-10-06 (setUTCDate truncates the sum), while isoDate(addDays(today(), -1.5)) writes 2026-10-07 (addDays truncates 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 to isoDatetime(addDays(now(), ±n)), not to daysFromNow, which lands on midnight.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/formula, touching 7 documentable anchor(s).

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

  • content/docs/data-modeling/formulas.mdx (via isoDate (literal, a string literal in CEL_STDLIB_FUNCTIONS; a string literal in isoTimestampText; a string literal in registerStdLib), isoDatetime (literal, a string literal in CEL_STDLIB_FUNCTIONS; a string literal in registerStdLib))
What this run could not see
  • 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 — 9 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 5ff7cbe364f939a55633a04891650163c9e8884e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1dd8c8d54231406cdd2d36b92cb5c2206e336281 — the merge of head eebccf401b5529d3f5700ac355f414be8a574d00 into base 5ff7cbe364f939a55633a04891650163c9e8884e, 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 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

⚠️ 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 5ff7cbe364f939a55633a04891650163c9e8884e → 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: eebccf401b5529d3f5700ac355f414be8a574d00
Local-runs: none

Reviewed as an isolated at-tier reviewer, read-only, from the card (#22277, every comment), #11182's ruling D (5805777944), PR #22347's body, file list and net diff against the merge base 28bff18d0c, the head's check-runs (one read), template.ts at the merge base, and stdlib.ts, validate.ts, cel-stdlib-drift.test.ts, the formula skill and skill-catalog-sync.test.ts at the head. Nothing was built, run or re-run. The dispatching seat's ACCEPT was read as an input to answer, not as a finding.

① Derived judgments

  1. The bytes, read from both sides. In template.ts (merge base, resolveToken) {NOW()} is new Date().toISOString(), {TODAY()} is that text cut to ten characters, and {TODAY() ± N} / {NOW() ± N} first shift the instant with setUTCDate(getUTCDate() + sign * offset). Only UTC accessors are used, so the host zone cannot enter: three-digit milliseconds always, a Z always, never an offset. At the head isoDatetime(t) is t.toISOString(), isoDate(t) is the same cut to ten characters, and addDaysUtc is setUTCDate(getUTCDate() + n), the same arithmetic, so month, year, leap-day and DST boundaries move the date identically. In the flow value-slot scope (celScope passes no timezone, buildEnv defaults to UTC, registerStdLib defaults to UTC, calendarDayUtc falls to startOfDayUtc) today() is the UTC day of the instant. So isoDate(today()) equals {TODAY()}, isoDate(daysFromNow(n)) / isoDate(daysAgo(n)) equal {TODAY() ± n} for integer n, and isoDatetime(addDays(now(), n)) equals {NOW() ± n} with the time of day preserved as the template preserves it. I re-derived every literal in the pin file's TEMPLATE_BYTES table (nine instants, six tokens) from the template's arithmetic by hand: all agree, including Feb 28 plus 3 in a leap year landing on Mar 2 and Dec 31 plus 3 landing on Jan 3 of the next year. The reference-timezone case is correctly kept apart: under a non-UTC ctx.timezone, isoDate(today()) is the reference day (ADR-0053 D1 makes today() that day at UTC midnight) while isoDate(now()) is the UTC day; that scope is not the flow scope, and the pin says so.

  2. Two named functions, not a string(Timestamp) overload. Three reasons are stated and each holds on reading. (a) One overload yields one shape for one type, and the card requires two shapes, so at least one new advertised name was unavoidable on either route. (b) CEL's own string(timestamp) is RFC 3339 with the fraction trimmed (cel-go formats with RFC3339Nano, which drops a zero fraction and trailing zeros alike, so an instant ending .120Z would read .12Z), while the template always writes three digits; a byte-matching string() would be a dialect of CEL, not CEL. (c) Registering a signature cel-js already has throws "overlaps" at environment build, a dev measurement I did not re-run; its consequence, that a later cel-js shipping its own string(timestamp) would break every environment build, is a sound risk argument whichever way the measurement went. string(today()) and string(now()) stay refused and are pinned, so each shape has exactly one spelling. P1 agrees with [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 pass 1's probes D6 / X13, an earlier and independent measurement the card body cites.

  3. Refusals are loud and nothing is coerced. The parameter is google.protobuf.Timestamp, never dyn, so cel-js refuses text, int, double and null at build when the type is known and at run otherwise, naming the overload; nothing reaches toDate. isoTimestampText refuses an invalid Date (NaN fails the range test) and any instant outside 0001-01-01 to 9999-12-31 with a message that carries the repair; the two millisecond constants are the correct bounds for those instants, and inside them toISOString() has one fixed shape. date('not a date') reaches the function as an Invalid Date because toDate does not throw, so the pinned refusal path is the path that runs.

  4. The build knows what the run knows. Both names are in CEL_STDLIB_FUNCTIONS. cel-stdlib-drift.test.ts A and B make a registration without a catalog entry and a catalog entry without a registration red, and C keeps the withheld set exact. nearestCallable reads the catalog, so the did-you-mean follows with no further change. cel-engine.test.ts's runtime probe map gained both names. skill-catalog-sync.test.ts requires the literal `isoDate( and `isoDatetime( in the skill, and the one new row carries both. The other readers of the catalog at the head (packages/lint's visibility gate and its test, packages/lint/scripts/check-doc-formula-expressions.mjs, packages/spec/src/automation/flow-node-expression-paths.ts) import it or mention it in prose; none keeps a second list. lint-flow-patterns.ts's TIME_FNS lists timestamp producers for the date-equality lint; the new names produce strings and are correctly absent from it. The re-measured validate.ts decomposition (75 equals 41 bare plus 34 receiver; 41 equals 29 plus 8 plus 4; 37 advertised; a gap of 38) is internally consistent.

  5. No envelope that returns a Timestamp changes what it writes. The net diff touches no existing registration, not coerce, not cel-engine.ts, nothing in service-automation or objectql; the two registrations are appended after date / datetime. The pin file holds today(), now(), daysFromNow(3) and addDays(today(), 3) to the same Date at the same instant.

  6. The skill row and the docs page. One row each, in the Dates block after date(s) / datetime(s), return type string, with both shapes and the isoDate(date(s)) repair for text. The skill row is accurate within the ratchet (hosted by Type Check · source gates, which completed success). The docs row also says the bytes are the flow template's, which ①.1 confirms.

② Semver level

.changeset/22277-cel-timestamp-string.md declares "@objectstack/formula": minor and carries Clause-②: yes (widening). @objectstack/formula publishes (17.7.0, not private, one exports entry) and the repo is in pre mode (tag next). AGENTS.md: a yes takes at least minor. The PR body's Clause-②: yes (widening: CEL gains a string form …) reads as the arm widening under clause2-line.mjs: yes is the first token after the colon, the parenthetical opens with widening, and the colon that follows is outside the word class. Nothing is breaking: no export, type or signature changed, no existing registration moved, a bare identifier isoDate in an authored expression is a variable reference and unaffected, and only a call isoDate(…), refused at the base, now resolves. Check Changeset, which hosts check-changeset-no-major and check-adr-0087-registration, completed success on the head. Level: minor, a widening, declared correctly.

③ Boundary flags

  1. Governed skills/** surface, Tier H. The claim's clause "the catalogs that mirror the stdlib" covers skills/objectstack-formula/SKILL.md by construction (skill-catalog-sync.test.ts is red without the row), and the card's two-shape requirement made a new advertised name unavoidable under either spelling, so the overload route would not have avoided it. Governed Surface Queue Guard completed success and the PR carries the 维护者速读 draft. This record is the at-tier contract review the claim owed; it is not the landing authorization. The PR lands on the maintainer's approval, and the draft's seat-opinion paragraph is still blank for the seat to fill.

  2. PR assignee unset. No assignee at this read. The dev's label-write --assign was refused by its environment and correctly not re-routed. A form item for the seat, not a contract item; no check-run on the head depends on it.

  3. Live cross-dialect parity pin deferred. The card asks to pin that the new spelling is the same text interpolateString produces. The head pins the template's bytes as measured literals, and I re-derived every literal from the template's code (①.1), so the equivalence holds on this head by construction. A live pin cannot live in packages/formula: it would import service-automation, which depends on formula, and service-automation is off this claim's surface. Accepted as deferred. Escalated: [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 pass 3, serial behind this card, must carry the live pin beside crud-fields-value-envelope.test.ts's existing "the CEL spelling that writes the same value" rows, and the seat's round report should name that as one of pass 3's acceptance criteria.

  4. check:dual-build-cjs-loads NOT MEASURED locally. ci.yml's Build Core hosts it as an unconditional step ("Every published require entry point actually loads"), and Build Core completed success on this head. Measured by CI; the dev's narrowing was honest and is superseded.

  5. The fractional offset and the {NOW() ± n} mapping, for pass 3. Both derivable from the two codes: the template adds a possibly fractional offset to the day of month and lets setUTCDate truncate the sum, while addDays truncates the offset first, so a negative fractional offset lands one day apart (a positive one agrees), and daysFromNow(int) refuses a fraction at build. The template's docblock promises integer days only, so the remedy mapping holds on the documented domain. {NOW() ± n} maps to isoDatetime(addDays(now(), ±n)), never to daysFromNow, which lands on UTC midnight. One edge the dev did not name: an offset the template silently treated as zero (an unresolvable or non-numeric x in {TODAY() + x}) becomes an Invalid Date through addDays and is refused loudly by isoDate. That is the right direction, and pass 3's remedy text must say it.

  6. Stale counts. packages/formula/src/unknown-function.ts lines 39 to 41 and packages/lint/src/validate-visibility-predicates.test.ts line 1457 still read 35 / 72 / 37, and docs/qa/platform-checklist/areas/api-backend.json's formula-stdlib-matrix still says 27 registrations. Comments and a checklist title, none load-bearing: cel-stdlib-drift.test.ts re-measures and the checklist item recounts from source at its step 1. Already stale at the base by can; this PR widens the staleness by two. Not a blocker. Carrier: whoever next touches either file, and the next checklist-author sweep.

  7. Prose this head falsifies, not named in the dev report. content/docs/automation/flows.mdx line 267 ("there is no string form for one") is false at this head. The same contingency, phrased "yet" or "until CEL can", lives in packages/spec/src/automation/flow-value-slot-template.ts lines 37 to 60, skills/objectstack-automation/SKILL.md line 235, packages/lint/src/validate-expressions.fields-value-slot.test.ts line 158 and template.ts's own docblock. All are off this card's surface, and all are exactly the sites [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 pass 3 rewrites when it refuses the macros with this spelling as the remedy; the governing statement in each, that the macros keep their meaning for now, stays true until pass 3 lands. Not a blocker. Escalated: the seat's round report hands this list to pass 3, and pass 3 is not accepted while any of them still says CEL cannot write the macros.

  8. Check-runs on the head, read once. Completed success: Auto Label, Build Docs, Build Core, Type Check · source gates, Type Check · debt ledger, Check Changeset, Check PR Size, Governed Surface Queue Guard, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, filter, and the three card-and-branch claim checks; the Vercel status is success. Skipped: Console Pin Gate, Packed-tarball smoke. Still in progress at the read: Test Core 1 to 6 of 6, Dogfood Regression Gate 1 to 3 of 3, Temporal Conformance, Dogfood Verify CLI, Lint and Repo Gates, Type Check · workspace, Type Check · consumer gates. The new pin file, the drift test, the catalog-sync test and the lint tests run in Test Core; check:pm-governed-merges runs in Lint and Repo Gates. Their conclusions are the gate verdicts, and this record does not pre-empt them.

  9. The lock queue-timeouts in the dev report are process notes with no bearing on the diff.

Implemented-by: claude/issue-22277-cel-timestamp-string
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

domain:spec seat 1 (#6017) · os-litant · session session_01LAi5BVvQNiYzepSAcsoFLK · 2026-10-08T20:13Z。待批的是 head eebccf401b。席位复核记录是 #22277 的 6067638822;at-tier 合约复审在本 head 上 PASS(6067834614);CI 全绿(33 项通过,2 项按预期跳过)。

@os-zhuang
os-zhuang marked this pull request as ready for review October 8, 2026 23:58
@os-zhuang
os-zhuang enabled auto-merge October 8, 2026 23:59
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 345d3f3 Oct 9, 2026
41 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-22277-cel-timestamp-string branch October 9, 2026 00:35
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
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/m tests tooling

Projects

None yet

2 participants