Skip to content

fix(formula)!: the formula gate refuses bare date subtraction, which reads null or {} at runtime - #29058

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-3vt4ig
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-3vt4ig

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

Requested by Leehom · project thread

Fixes #28944

Clause-②: yes (narrowing)
Metadata-protocol: narrowed
Skills-docs: follow-up #29061 — skills/objectstack-formula/SKILL.md (the "No date arithmetic" callout says end - start "does not fault — it yields a duration stored as {}"; after this change it is refused at build, and it read null at runtime), skills/objectstack-ui/evals/analytics-inline-vs-dataset.json id 6 (its prompt describes a stored end_date - start_date formula). skills/** is a Tier H surface outside this claim's file surface, so it is not edited here. content/docs/data-modeling/formulas.mdx is updated here.

维护者速读

  • 改了什么:公式里直接相减两个日期字段(record.end_date - record.start_date),或用当前时间减日期字段(today() - record.start_date),现在在 os validate / os build / os lint 和保存对象时都会报错(保存为 422 expression-invalid),错误信息说明运行时会读成 null 或 {},并给出 daysBetween(a, b) 的改法。datetime 字段同样处理。
  • 为什么改:运行时日期字段是文本(YYYY-MM-DD 或 ISO 时间),不是时间戳;两个文本相减会报错,字段每条记录都读成 null;时间戳减日期得到一个时长,字段读成 {}。以前的检查把日期字段当时间戳,所以放行了这两种写法,构建全绿、线上空值。
  • 风险与回滚:这是一次收窄(Metadata-protocol: narrowed),formula 与 spec 升 minor。被拒的公式本来每条记录都是 null 或 {},没有能用的写法会被拒;本仓库 packages、examples 和 hotcrm 里没有这种公式。(end - start) + 1 原有的报错一字不变。回滚即 revert 本 PR。
  • 先看:packages/formula/src/cel-engine.ts(firstDateTextVerdict 与 registerDateTextOverloads:日期字段按运行时文本声明的类型和它被允许的运算)、packages/formula/src/validate.ts(dateTextIssue:两种拒绝及其文案)、packages/formula/src/validate-date-text-arithmetic.test.ts(每一行同时问检查和运行时)。
  • 两分钟验证:pnpm --filter @objectstack/formula exec vitest run src/validate-date-text-arithmetic.test.ts,预期 27 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

  • @objectstack/formula
    • New firstDateTextVerdict(source, fieldCelTypes, scope) in cel-engine.ts (package-internal, not on the entry). It type-checks the expression with every date / datetime field declared as DateText, the text the runtime actually holds, instead of the Timestamp firstTypeMismatch declares. registerDateTextOverloads declares, for that check-only env, exactly the overloads the evaluator answers on that text, each measured through celEngine.evaluate: ordering and equality against another date field, a text literal or a Timestamp (bool), Timestamp - DateText and DateText - Timestamp (Duration, since the evaluator coerces the text against a Timestamp), and + with text (concatenation). Everything else on the text faults the check exactly where it faults the evaluator: DateText - DateText, DateText + int, DateText - Duration, Timestamp + DateText. Nothing is matched in the source text. The verdict falls out of the typing.
    • validateExpression asks firstTypeMismatch first, unchanged. Only when that passes does dateTextIssue ask firstDateTextVerdict. So every shape the gate already refused keeps its code, params and words. That includes the + 1 shape (date-arithmetic, google.protobuf.Duration + int).
    • Two new codes in the closed refusal set, both errors:
      • date-text-arithmetic (params: { operands, reference? }): arithmetic on a date field's text that the evaluator faults on, in any role. record.end_date - record.start_date reads null, and so does a predicate record.end_date - record.start_date > duration("72h").
      • date-duration-value (params: { reference? }): a formula (value role) whose computed type is google.protobuf.Duration, which a field reads as {}. today() - record.start_date, now() - record.start_at, and also today() - daysAgo(3): the verdict reads the computed type, not whether a date field is named. A predicate that compares a duration (today() - record.start_date > duration("72h")) evaluates and is untouched.
    • The pass runs when the hint carries fieldTypes (as before) and there is something to find: a date field to retype, or a value whose type is the question.
  • @objectstack/spec: ADR-0087 D3 entry formula-date-subtraction-refused (migrations/entries/semantic/18.formula-date-subtraction-refused.ts), with the FROM → TO and why there is no mechanical rewrite.
  • content/docs/data-modeling/formulas.mdx: a "Date subtraction" row in the type-soundness table.
  • No change in @objectstack/lint source. Its formula pass (validateExpression('value', …, { fieldTypes, scope: 'record' })) and every object-bound predicate site already hand the validator the field types. So os validate, os build, os lint and the runtime authoring gate behind the object save door (runRuntimeAuthoringRules) all answer from the one judge. A lint test pins the stack face.

Measured before building

  • Shipped formulas (packages/**, examples/**, skills/**, content/** at 135054eb; hotcrm origin/main at d4ffe99): a grep for a date field, today() or now() on either side of a minus finds no formula that subtracts dates. The only hits are number subtraction (record.budget - …, record.fee - record.tax) and doc prose. hotcrm's 15 formula fields subtract no date (one uses daysBetween(record.stage_entry_date, today())), and its seeds' now() - duration('Nh') are seed values, not gate input. No shipped formula is refused.
  • Datetime (live, os dev --fresh on examples/app-todo with a probe object, main 135054eb, sqlite-wasm): record.end_at - record.start_at reads null with the same WARN, no such overload: dyn‹string› - dyn‹string› (the SQL driver serves a datetime as ISO text on every dialect). now() - record.start_at reads {}. So datetime is included, per the ruling.

Tests

  • packages/formula/src/validate-date-text-arithmetic.test.ts (new, 27). A table where each row is judged twice: by celEngine.evaluate over the row a read door serves (dates as text), and by validateExpression. It pins that the gate refuses exactly what reads null or {} and nothing the evaluator answers. Refused rows: the card's two shapes, both datetime shapes, record.start_date - today(), the subtraction inside a comparison, and record.start_date - duration("24h"). Controls: daysBetween (both forms), comparing a duration, (today() - record.start_date).getHours(), date ordering against a date, today(), now() and a literal, equality, the null guard, concatenation, addDays, and number subtraction. Further tests: the + 1 shape keeps date-arithmetic with google.protobuf.Duration + int; the flattened (flow) scope; date-duration-value is value-role only; no fieldTypes means no check.
  • packages/formula/src/expression-refusal-codes.test.ts: pins for both codes (code, params, full message), and two sweep sources, so both arms are provoked and their params are checked against the message. The existing date-arithmetic pins pass unchanged.
  • packages/formula/src/validate.test.ts: record.end_date - record.start_date leaves the "runtime-tolerated" list, where it was pinned as clean ("→ Duration, no further arith"). That pin is the behaviour this PR reverses.
  • packages/lint/src/validate-expressions.test.ts: validateStackExpressions refuses qa_span_bare and qa_since_today at their expression, as errors, and leaves qa_span_daysbetween clean.
Red first on base (the three formula source files restored to 135054e, tests kept)

git restore --source=135054eb on cel-engine.ts, validate.ts, expression-refusal.ts; formula reads src/.

  • validate-date-text-arithmetic.test.ts: 10 failed, 17 passed. The failures are the seven refused rows, the naming test, the flattened test and the role test. Every control row and the + 1 test pass on base.
  • Restored with git restore --source=HEAD --staged --worktree; git diff HEAD empty.

Live red, same base, CLI face: os validate, os lint and os build all exit 0 on the probe object, and REST answers qa_span_bare: null, qa_span_daysbetween: 4, qa_since_today: {}, qa_dt_span: null, qa_dt_since_now: {}.

Ablation: each half reds only its own cases

Both through node scripts/ablation-replace.mjs (anchor hit 1 → 0, blob restored == HEAD, git diff HEAD empty), formula read from src/, three suites (validate-date-text-arithmetic, expression-refusal-codes, validate).

  1. Drop the text typing (cel-engine.ts, temporalAsText && t === 'timestamp' ? DATE_TEXT_CEL_TYPE → String(temporalAsText) === 'ablated' ? DATE_TEXT_CEL_TYPE, blob d48b48384db5 → 0157c4c635ea): 8 failed, 180 passed. These are the four date-text-arithmetic rows, the naming and flattened tests, the date-text-arithmetic pin and the sweep. Every date-duration-value row stays green, because the Timestamp typing also computes Duration for those, and the controls and + 1 stay green.
  2. Drop the duration-value arm (validate.ts, if (role !== 'value' || verdict.type !== 'google.protobuf.Duration') return null; → if (String(role) !== 'ablated') return null;, blob 787a1532d20b → b6de2a9964e0): 7 failed, 181 passed. These are the three date-duration-value rows, the naming and role tests, the date-duration-value pin and the sweep. The controls stay green.

Acceptance notes

  • The + 1 case keeps its words by ordering, not by special-casing. The Timestamp typing answers first and is unchanged. Under the text typing alone, (end - start) + 1 would fault first at the inner subtraction.
  • date-duration-value is wider than the two named shapes. Any formula whose computed type is a Duration is refused, today() - daysAgo(3) included, because that is what the typing answers and every such value reads {}. No shipped formula has that shape either.
  • Masking stays conservative. The text-typed check reports only an arithmetic fault with DateText on a side. Any other first fault, such as isoDate(record.d) (which the evaluator refuses on text too), returns no verdict, so it can only miss a later fault in the same expression. It never invents one.
  • Message subject: when two date fields are named, reference is the first one the object declares that appears in the source, the same best-effort attribution firstTypeMismatch uses.
  • @objectstack/rest bundles a copy of @objectstack/formula into its dist (found while rebuilding for the live probe). The copy follows on its next build. This was noted, not changed, and is not filed.
  • Not merged with origin/main before opening: the 16 commits since 135054eb touch no file under packages/formula, packages/lint or packages/spec/src/migrations, and GitHub reports the merge. The merge queue tests the merged tree.

Verification

All readings at head e029ea77 unless noted (repo objectstack-ai/objectstack).

Package suites and typecheck
  • pnpm --filter @objectstack/formula test && … typecheck: Test Files 64 passed (64), Tests 1628 passed (1628), VERDICT command-exit 0.
  • pnpm --filter @objectstack/lint test && … typecheck: Test Files 189 passed (189), Tests 7830 passed (7830), VERDICT command-exit 0.
  • packages/spec vitest run src/migrations (after a spec build): Test Files 5 passed (5), Tests 194 passed (194).
Live green (os dev on examples/app-todo with the probe object, this branch)
  • os validate / os lint / os build: exit 1, four expression-invalid errors: qa_span_bare and qa_dt_span (date-text-arithmetic), qa_since_today and qa_dt_since_now (date-duration-value). qa_span_daysbetween is not refused.
  • Save door (PUT /api/v1/meta/object/NAME?package=app.probe28944 on a writable package): record.end_date - record.start_date gets 422 INVALID_METADATA, rule expression-invalid, at field 'span' expression. today() - record.start_date gets 422, same rule. daysBetween(record.start_date, record.end_date) gets 200.
  • The probe object and the app's dist/ were removed and rebuilt after the probe; the tree is clean.
Derived gate families

node scripts/pm/dispatch-gates.mjs --commands: 106 commands, all run, all exit 0. check-plugin-teardown-shape.mjs --self-test first answered exit 3 on the shallow clone, then exit 0 after git fetch --depth=1 origin 621a487607881c66b2899b7e3477115229a156b4. --ran reconciliation: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN. CI families: see this PR's check-runs.

Full dogfood (Clause-② narrowing arm)

pnpm --filter @objectstack/dogfood test under the verify lock at e029ea77: VERDICT command-exit 0, Test Files 383 passed and 3 skipped (386), Tests 3163 passed and 28 skipped (3191). No failure.

Overlap: the open PRs re-read before opening (titles, at the time of opening) touch neither packages/formula/src/ nor the lint formula gates.


Generated by Claude Code

… the text the runtime holds

A date or datetime field reaches the evaluator as its text, so subtracting two
of them faults (the formula reads null) and a timestamp minus one computes a
Duration, which a field serialises as {}. The gate declared the field a
Timestamp and passed both. What that typing passes is now re-checked with the
field declared as its text (DateText), refused as date-text-arithmetic or, for
a formula value, date-duration-value. The + 1 shape keeps its existing words.

Claude-Session: https://claude.ai/code/session_01FEkQ5nXr5BHHskLQ7qUH5x
Co-Authored-By: Claude <noreply@anthropic.com>
…try, changeset and the formulas page row

Claude-Session: https://claude.ai/code/session_01FEkQ5nXr5BHHskLQ7qUH5x
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/spec, touching 12 documentable anchor(s).

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

  • content/docs/automation/flows.mdx (via validateExpression (symbol, a top-level function))
  • content/docs/data-modeling/formulas.mdx (via validateExpression (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 169 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 0be247ce8b91ba3b9a3ce7235491b28a986b039a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 91d2d53f86c7b64a4f9aefd6183e1a5de4ea2970 — the merge of head e029ea772b1555ea33b2946e0978ff4175dbdd3e into base 0be247ce8b91ba3b9a3ce7235491b28a986b039a, 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 91d2d53f86c7b64a4f9aefd6183e1a5de4ea2970 && git checkout 91d2d53f86c7b64a4f9aefd6183e1a5de4ea2970
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0be247ce8b91ba3b9a3ce7235491b28a986b039a e029ea772b1555ea33b2946e0978ff4175dbdd3e && git checkout -B drift-repro 0be247ce8b91ba3b9a3ce7235491b28a986b039a && git merge --no-ff e029ea772b1555ea33b2946e0978ff4175dbdd3e

node scripts/docs-audit/affected-docs.mjs --json 0be247ce8b91ba3b9a3ce7235491b28a986b039a

⚠️ 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 0be247ce8b91ba3b9a3ce7235491b28a986b039a → 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

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e029ea772b1555ea33b2946e0978ff4175dbdd3e
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.formula-date-subtraction-refused.ts + packages/formula/src/cel-engine.ts, packages/formula/src/validate.ts, packages/formula/src/expression-refusal.ts, packages/formula/src/validate-date-text-arithmetic.test.ts, packages/formula/src/expression-refusal-codes.test.ts, packages/formula/src/validate.test.ts, packages/lint/src/validate-expressions.test.ts (the narrowing face's floor: every path of the diff outside the prose faces); read beside them, unchanged on this head: packages/formula/src/stdlib.ts, packages/formula/src/index.ts, packages/lint/src/validate-expressions.ts, packages/lint/src/authoring-rules.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/objectql/src/engine.ts (applyFormulaPlan, roundFormulaValue), packages/spec/src/migrations/types.ts, packages/spec/src/migrations/entries/README.md. Prose faces (.changeset/28944-formula-date-subtraction-refused.md, content/docs/data-modeling/formulas.mdx) are the lane seat's ACCEPT at TIER_DEFAULT; the changeset's declaration lines are judged under ② below.

Reviewed against merge-base 135054ebaa7fb4f1282714d6856a0a6a032a81fe (two commits on the head: 99ff16cdb, e029ea772). Inputs: card #28944 with triage 6569503923, the claim 6569744035 and the os-dev-report 6570740134; #3306, #28660; the precedent changesets and D3 entries of #28596 and #28688; PR #29058's body, file list and net diff; the head's check-runs. The dispatch order was not an input to the judgments below.

① Derived judgments

  1. What the accept set loses, named. At validateExpression (packages/formula/src/validate.ts, typeSoundnessIssue → new dateTextIssue), for a hint carrying fieldTypes, when firstTypeMismatch passes:
    • date-text-arithmetic (error, any role): an arithmetic operator (+ - * / %) on a date/datetime field's operand that the text typing cannot answer. Refused: DateText - DateText (date and datetime), DateText ± Duration, Timestamp + DateText / DateText + Timestamp, and the same inside a comparison (record.end_date - record.start_date > duration("72h")). Right: with the field typed as what rewriteFaultedOperands / coercionFor (cel-engine.ts) will see, the evaluator coerces a text operand only against a number or a temporal counterpart (a Date value, a today()/now()/daysAgo/daysFromNow call, a date()/datetime() call); a text counterpart (valueKind → 'string') and a duration() call (operandKind → 'unknown') get no coercion, so those pairs fault and the row reads null (applyFormulaPlan: r.ok ? … : null). The declared overload set in registerDateTextOverloads is exactly the complement: ordering and equality against another date field, text or a Timestamp (bool), Timestamp - DateText / DateText - Timestamp (Duration, the coerced case), + with text (concatenation), == null. Nothing is matched in the source text; the verdict is the checker's first fault, read through UNSOUND_OVERLOAD_RE and gated to an arithmetic operator with DateText on a side.
    • date-duration-value (error, value role only): a formula whose computed type is google.protobuf.Duration. Refused: today() - record.start_date, now() - record.start_at, record.start_date - today(), and any other Duration value (today() - daysAgo(3)). Right, and within the ruling's spirit: applyFormulaPlan writes r.value through roundFormulaValue, which passes a non-number through untouched, and no read door coerces a cel-js Duration object, so every Duration-valued formula serialises as {} (the dev's live REST reading qa_since_today: {} and qa_dt_since_now: {}, and the test's JSON.stringify(r.value) === '{}' pin, agree). A Duration is usable only when it is consumed inside the expression, and those shapes keep their value type and stay clean: (today() - record.start_date).getHours() (int) and a predicate comparing a duration (today() - record.start_date > duration("72h"), bool) are pinned clean. So the wider-than-two-shapes refusal does not over-reach: it refuses exactly the values that cannot reach a field, and nothing that can.
  2. Could the second pass refuse what the first accepts and the runtime answers? No. firstDateTextVerdict returns a verdict on two paths only: (a) an arithmetic fault with DateText on a side, and (b) a clean check whose type is a Duration (and only in value role). Every other first fault (a function overload on the text, in, size(), isoDate(record.d), an ordering against a number) returns null and is clean. Date comparisons (< against a date field, a literal, today() / now()), == / != and the null guard, concatenation, daysBetween(dyn, dyn), addDays(dyn, dyn) (stdlib declares them dyn), number subtraction and the flattened flow scope are all pinned clean, each row judged by celEngine.evaluate over a served row in the same test. The only cost is that a second typed parse now runs for every value-role expression and for every predicate on an object with a date field (dateTextIssue gates on that); masking stays conservative, as the PR says — a non-arithmetic first fault can hide a later date fault, never invent one.
  3. The + 1 shape keeps its words by ordering, not by special-casing. firstTypeMismatch still answers first and is unchanged (its docblock gained a pointer only); dateTextIssue runs only when it returns null. The test pins (record.end_date - record.start_date) + 1 → date-arithmetic with params google.protobuf.Duration + int, and the existing date-arithmetic pins in expression-refusal-codes.test.ts are untouched. Right.
  4. One judge for every face. os validate / os build / os lint → validateStackExpressions → validateExpression('value', …, { fieldTypes: fieldTypeIndex.get(objectName), scope: 'record', … }) for a formula field (packages/lint/src/validate-expressions.ts) and validateExpression('predicate', …, { fieldTypes, scope, … }) for every object-bound predicate. The object save door → runRuntimeAuthoringGate → runRuntimeAuthoringRules → AUTHORING_RULES entry validateStackExpressions (tier: 'gating', commands: ALL, input: 'parsed', packages/lint/src/authoring-rules.ts), the same function over the written object plus the live universe. No lint source changed; the lint test pins the stack face (two errors, daysBetween clean). The dev's live readings (CLI exit 1 with four expression-invalid errors; save door 422 INVALID_METADATA for both shapes, 200 for daysBetween) are consistent with this wiring. Right.
  5. Datetime included after measurement. The ruling made it conditional; the dev measured record.end_at - record.start_at → null (same dyn‹string› - dyn‹string› WARN) and now() - record.start_at → {} live, and the test table pins both rows against the evaluator. Right.
  6. Shipped formulas. Read, not run: a git grep of origin/main (packages/**, examples/**, non-test, non-prose) and of the hotcrm checkout for a today()/now()/*_date/*_at operand on the left of a minus against a record field or temporal call finds no formula; hotcrm's one date span is daysBetween(record.stage_entry_date, today()). The dev's "no shipped formula is refused" holds. Right.
  7. Public surface moved. On the @objectstack/formula entry (packages/formula/src/index.ts; exports is . only): EXPRESSION_REFUSAL_CODES (closed set) gains date-text-arithmetic and date-duration-value; the exported types ExpressionRefusalCode and ExpressionRefusalParams widen by those two members (additive). firstDateTextVerdict / DateTextVerdict are module exports of cel-engine.ts not re-exported from the entry, so they are not published. check:api-surface exit 0 (dev) and Lint & Repo Gates success on the head. The accept set of validateExpression narrows as in 1. Right — and this is why Clause-②: yes is the correct arm here where the two precedents (spec-only) carried no.
  8. ADR-0087 D3 entry packages/spec/src/migrations/entries/semantic/18.formula-date-subtraction-refused.ts: export const entry: SemanticMigration with id, surface, replacement, reason, acceptanceCriteria (the five required fields of SemanticMigration, types.ts); the file name 18.<id>.ts matches the id, which is what the generator concatenates into MIGRATIONS_BY_MAJOR[18].semantic (entries/README.md; registry.ts is generated and git-ignored, so no index edit is owed). The entry's shape matches the precedents 18.connector-instance-auth-inline-secret-refused and 18.connector-entry-undeclared-key-refused: a prose surface naming the doors, the replacement with the daysBetween forms, a reason that says why there is no mechanical rewrite, and acceptance criteria an upgrader can run. The reason also records that a stored formula of the refused shape held no usable value, so no stored data depends on it. Well-formed and registered. check-adr-0087-registration.mjs --base origin/main and --self-test exit 0 in the dev's gate record; Check Changeset and Lint · PM dispatch-gates are success on the head.
  9. No merge with origin/main, confirmed. origin/main is 16 commits past 135054eb; the set of files they touch (read with git diff --name-only) intersects the PR's file list nowhere, and the only packages/spec/src paths among them are automation/* and shared/*, outside migrations/. The 49 other open PRs (file lists read one by one) touch none of packages/formula/src/**, packages/lint/src/validate-expressions*, or the entry. No overlap.

② Semver level

  • .changeset/28944-formula-date-subtraction-refused.md: '@objectstack/formula': minor, '@objectstack/spec': minor; line-initial Clause-②: yes (narrowing) and Metadata-protocol: narrowed; exactly one marker <!-- adr-0087: registered formula-date-subtraction-refused -->; a **BREAKING** banner; a ## FROM → TO block with the one-line fix. The PR body carries the same two declaration lines. Consistent with AGENTS.md Post-Task Checklist 3: a value refused is narrowed; a kind takes at least minor on a package the diff moves (formula and spec both move; lint moves only a test and owes no changeset); (narrowing) is BREAKING and carries its migration and its ADR-0087 disposition. minor is what check-changeset-no-major.mjs --base origin/main admits (exit 0 in the gate record; Check Changeset success on the head).
  • Clause-②: yes is the right arm (① 7): the published formula entry's accept set and code set moved. The precedents' no (narrowing) was for spec-only diffs and is not the model here.

③ Boundary flags

  1. date-duration-value is wider than the two named shapes (dev flag, Acceptance notes). Answered in ① 1: every Duration-valued formula reads {} and nothing consumes a Duration from a field, so refusing the whole type is inside the order's "shapes that cannot produce a value"; the consuming shapes stay clean. Accepted.
  2. Skills-docs: follow-up owed (PR body header line, and out_of_scope_findings[0] class b in the report). The line is not a recognised declaration: nothing under scripts/pm/, the pm-dispatch skill, os-dev.md or AGENTS.md reads a Skills-docs: key, so it is harmless to the body guards and binds nothing. The recognised carriers are the PR's ## Acceptance notes and the report's out_of_scope_findings entry with class: + reach: + evidence + dedupe words (os-dev.md rule 3), which the report does carry; the PR body carries the finding as a header line instead of under ## Acceptance notes. The substance is right: skills/** is a Tier H surface (AGENTS.md PD feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14), outside this claim's file surface, and the callout at skills/objectstack-formula/SKILL.md lines 151–153 ("end - start does not fault — it yields a duration stored as {}") is now false twice over (it read null before this PR, and it is refused at build after it); skills/objectstack-ui/evals/analytics-inline-vs-dataset.json id 6 describes a stored end_date - start_date formula. What the seat does: file the class-b card in this repo with a Seam: line (the skill callout → packages/formula/src/validate.ts dateTextIssue / cel-engine.ts firstDateTextVerdict), then the ACCEPT lists the finding as filed #N and the carrier becomes that card (carrier: #N), replacing "follow-up owed"; the skill fix itself lands Tier H. No change to this PR is owed for it.
  3. @objectstack/rest bundles a copy of @objectstack/formula into dist (carrier: none · noted, not filed). A built artifact, not source; it follows on rest's next build. Not a card under rule 3's three classes. Accepted as noted.
  4. Evidence arithmetic. Red first on base: 10 red of 27 = the seven refused rows + the naming, flattened and role tests; the two + 1 rows and 13 controls green on base — consistent with the table. Ablation 1 (drop the text typing): 8 red = four date-text-arithmetic rows + naming + flattened + pin + sweep; ablation 2 (drop the duration-value arm): 7 red = three date-duration-value rows + naming + role + pin + sweep; each half reds only its own cases, so neither arm is carried by the other. 106/106 derived gates exit 0 (28944-gates-ran.txt); full dogfood 383 passed / 3 skipped (386), 3163 / 28 skipped (3191), VERDICT 0 at e029ea77. Consistent.
  5. CI on the head at review time. 67 check-runs: every completed run is success or skipped (Lint & Repo Gates, Governed Surface Queue Guard, Check Changeset ×2, Dogfood Regression Gate, Live E2E UI, Spec property liveness, PR-body guards, all Lint · *, Type Check · source gates / debt ledger, Build Docs, Build UI Deps, Check Documentation Links); still in_progress at the last read: Test Core 1, 2, 5, 6 of 8 (3, 4, 7, 8 completed green), Test UI 4–10, 12–14, 16 of 16, Type Check · workspace, Type Check UI. No red at any read. The landing check ② (all checks green, read off the head) is the seat's before enqueue; this record's verdict is ①②③ and does not wait on it.
  6. Author. PR user.login is objectstack-fleet[bot]; no author note owed.
  7. Residual, not a finding. offendingField attributes reference to the first declared date field the source names, so a Duration formula that merely names a date field elsewhere (record.start_date < today() ? duration("1h") : duration("2h")) is worded "from record.start_date"; the same best-effort attribution firstTypeMismatch has always used. A predicate whose whole value is a Duration (today() - record.d as a condition) stays clean by design (pinned); its non-boolean type is another check's question.

Implemented-by: claude/project-thread-3vt4ig
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-11-02T06:50Z · head e029ea77 · claimed by seat 2 on #28944 (comment 6569744035)

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
  2. Contract review at CONTRACT_REVIEW_TIER: PASS on this head (6570993197).
    • The yes arm is right, because the published refusal-code list widens by two codes.
    • The typing route is sound, and no valid runtime shape is refused.
    • Refusing every Duration-valued formula value is within the order: such a value is stored as {}.
    • One judge serves build, validate, lint and the save door.
  3. Shipped formulas: none subtract dates (the dev measured; the review re-read origin/main and hotcrm).
  4. Skills-docs: follow-up filed as skills: the formula skill and an analytics eval still teach date subtraction (end - start), which the formula gate now refuses (follow-up of #28944) #29061 (Tier H, blocked by this PR). The PR body's line names it; the seat edited the body, which keeps the head.
  5. Gates: CI on this head showed 45 success, 14 skipped, 0 red and 12 still running at the time of this ACCEPT. Auto-merge waits for them, and the merge queue re-runs CI against current main.
  6. Serial: none. No other open PR touches packages/formula/src/ or the lint expression gate.
  7. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 06:51
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 06:51
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 51058ef Nov 2, 2026
78 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-3vt4ig branch November 2, 2026 08:10
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Landing record · PR #29058

domain:server seat 2 · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T09:08Z · card #28944

Reading Probe Result
Merged PR #29058 state merged, merge commit 51058efc0d
On main git merge-base --is-ancestor 51058efc0d origin/main yes
Queue refs git ls-remote origin refs/heads/gh-readonly-queue/* matching 29058 0

Generated by Claude Code

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/l tests tooling

Projects

None yet

1 participant