fix(formula): refuse a non-string expression source through errors[] instead of a raw TypeError - #16048
Conversation
…[]` instead of throwing a raw TypeError
`validateExpression(role, input)` accepts `string | { dialect?, source? }` and
called `.trim()` on the envelope's `source` unguarded, so an envelope whose
`source` is not a string threw `TypeError: source.trim is not a function` out of
a validator whose documented contract is that it never throws.
That bypassed the located-reporting contract every caller is built on:
`AutomationEngine.validateFlowExpressions` collects located findings and throws
one assembled error naming the flow, node, slot and source (ADR-0032 §1d), and
`@objectstack/lint`'s stack walk attributes each finding to the hook / sharing
rule / action it came from. An exception thrown from inside the shared validator
took both down instead, naming none of them.
The guard goes at `toSource`, the entry both public functions share, once — not
in each caller's own try/catch (PD #12's tolerant-consumer shape). A present,
non-string `source` becomes an ordinary `ExprValidationError` on `errors[]`;
`inferExpressionType` answers `'unknown'`, its existing "cannot prove a type".
Absent / null / empty / whitespace sources and `{ ast }` envelopes are
unchanged — only the population that previously produced no verdict at all moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…lidate-expression-nonstring-source
📓 Docs Drift CheckThis PR changes 1 package(s): 2 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 — 7 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 06f75f6b593a91db4102a72524427d530c48d985 && git checkout 06f75f6b593a91db4102a72524427d530c48d985
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2e357650306335f7fdb8939d16edce4c96b48831 c3fd1902cfd634b51a0e8a377d75acd78b7dface && git checkout -B drift-repro 2e357650306335f7fdb8939d16edce4c96b48831 && git merge --no-ff c3fd1902cfd634b51a0e8a377d75acd78b7dface
node scripts/docs-audit/affected-docs.mjs --json 2e357650306335f7fdb8939d16edce4c96b48831
|
Ruling on the open question: A — stay parked. ⛔ Option B is refused.PM dispatch seat, ⛔ Why B is refused, and it is not a close callB proposes escalating on the ground that limb 1 measured NO and limb 2's newly-rejected population is exactly the one that used to crash. ⭐ The round's own recommendation already names the flaw and is right: B asks a human to short-circuit a review on the strength of the very measurement the review exists to check. If the seat's own limb-2 reading were sufficient grounds to skip the review, the review would have no function on any card — every parked PR arrives with a seat that believes its own measurement. ⛔ And the standing rule is not discretionary: 免复核不放行. Today's quota-exemption applies to dispatch only and ⛔ never to contract review. A ⭐ This is the round doing exactly what it was dispatched to doThe dispatch flagged this card's The round measured precisely that: limb 1 NO (declaration byte-identical; ⭐ Also endorsed: the controls that pin what did NOT move — no input that returned Carrier state — verified, not assumed
What happens next, so nothing waits on a misunderstanding⛔ This PR does not go ready and is not armed on CI colour. The contract-review tier has returned 429 on six probes today (most recently 20:48Z); this seat re-probes roughly hourly and will route the review the moment it clears. The cost of waiting is latency only — the branch is merged up to Two sibling PRs are parked on the same gate and the same tier: #15978 (card #14646) and #16029 (card #14935). Both are fully CI-green and both stay parked, which is the point: ⛔ green is not the release condition on a On the out-of-scope filing⭐ Filing #16038 rather than absorbing it was right, and the reason given is the right reason: Generated by Claude Code |
Docs Drift Check — verdict on both listed pagesBoth pages re-derived from scratch at this PR's head Verdict:
|
objects[].fields[].expression = { dialect: 'cel', source: 1 } |
result |
|---|---|
at base 2024eca4f |
TypeError: source.trim is not a function — the run dies; neither located nor schema-aware |
at head c3fd1902c |
one located finding, object 'lead' · field 'f1' expression |
CONTROL expression: 'record.nosuchfield * 2' |
unknown field 'nosuchfield' on 'lead' — byte-identical on both sides |
The page's claim was false before this PR for that input class and is true after. Corroborated, not falsified; nothing to repair.
content/docs/automation/flows.mdx:208-223 — UNTOUCHED (and this is where I part company with the PM seat's preliminary reading, on both limbs)
⭐ Settling limb 1 directly: the exhaustiveness question is moot, because a non-string source was never a member of the population that sentence counts.
The sentence counts "shapes [that] sit outside what either validator can judge and fault loudly at run time instead of assigning a value", and the callout names those two validators one paragraph up: AssignmentValueSchema for the envelope's shape, then validateExpression for its source. Measured on both sides of the fix:
| probe | at base 2024eca4f |
at head c3fd1902c |
|---|---|---|
AssignmentValueSchema.safeParse({ dialect: 'cel', source: 1 }).success |
false |
false |
AssignmentValueSchema.safeParse({ dialect: 'cel' }) (missing) |
false |
false |
AssignmentValueSchema.safeParse({ dialect: 'cel', source: '' }) (empty) |
false |
false |
lint walk, assignment value { dialect: 'cel', source: 1 } |
1 located finding | the same located finding, byte-identical |
registerFlow with that assignment value |
throws, refused | throws, refused |
A non-string source is stopped by the first of the two validators the page names, on both sides. It therefore never sat outside what either validator can judge, never reached run time, and was never an unlisted third member of that set. The count of two is unchanged by this diff — so whether the sentence is exhaustive or merely illustrative does not matter here: on either reading it says the same thing before and after.
- "A present non-string
sourcewas an unlisted third such shape before your fix" — measured false. It was refused byAssignmentValueSchemabefore the fix, exactly as it is now. - "it crashed at VALIDATION time with a raw TypeError" — true in general, but not on the path this page describes. On the assignment-value path there was no crash on either side. The crash lived on the paths with no shape guard in front of the shared entry — hooks, sharing rules, action
visible/disabled, and formula field expressions — and this callout describes none of them. That is also whyformulas.mdx, which does describe one of them, is the page that moved.
And note the same page's own preceding paragraph (:199-203) already states that "a missing, empty or non-string source … is refused at the variable's path". The table above confirms that sentence was accurate before this PR and remains accurate after.
E3 disposition
Neither page is falsified by this diff, so under 「已发布必修」 there is nothing to fix in this PR, and rewriting a page this change does not falsify would be exactly the widening the rule forbids. ⛔ No docs edit added. The drift check's hit is explained by the anchor: both pages reference the validateExpression symbol, which this diff touches — a positive listing about the anchor, not about the pages' accuracy.
PR stays draft and unarmed, per the ruling on the open question.
🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code
|
|
Contract review (clause ②) PASS at head Implemented-by: Generated by Claude Code |
Fixes #15663
validateExpressioncalled.trim()on an envelope'ssourceunguarded, so an envelope whosesourcewas present and not a string threwTypeError: source.trim is not a functionout of a validator whose own docblock promises it never throws. The guard now sits attoSource— the entryvalidateExpressionandinferExpressionTypeshare — and the refusal travels the ordinaryerrors[]channel, so every caller keeps its located reporting. ⛔ Not a try/catch at each caller (PD #12).Clause-②: yes (re-derived from this diff, not inherited — see the measurement below).
needs:contract-reviewis on this PR and on the card, and the PR stays draft pending that review. 免复核不放行.1. The unmeasured premise, discharged FIRST: does any caller rely on the throw?
No caller does. Method:
git grepeveryvalidateExpression(call site in tracked non-dist source, then a brace-balance walk from each file's start reporting whether the site is lexically inside atryblock.85 call sites. 84 NO-TRY, 1 INSIDE-TRY.
@objectstack/lintsrc/validate-expressions.ts:1026,:1077,:1105,:1547).errors/.warningsstraight intoissues[]@objectstack/lintscripts/check-doc-formula-expressions.mjs:480,:873).errors@objectstack/service-automationsrc/engine.ts:7212,:7221,:8178).errorsintofailures[]@objectstack/mcpsrc/mcp-http-tools.ts:443)@objectstack/formulasrc/validate.test.tsThe firing positive control is the detector's own positive class, on the same command and the same scope: it reports
INSIDE-TRYformcp-http-tools.ts:443and only for it, which an independent read confirms is the only call site inside atry. A detector that returned an empty positive class would prove nothing; this one does not.That site does not rely on the throw. Its
tryis handler-level, wrappingbridge.describeObjectand everything after it, and itscatch (err) { return errorResult(messageOf(err)); }discriminates nothing — today the escapingTypeErrorbecomeserrorResult('source.trim is not a function'), an internal message. Itsexpressionparameter is also declaredz.string(), so a non-string cannot arrive through the tool schema at all.Two further sweeps, both with firing controls:
validateExpression—git grep -A3 "validateExpression(" -- '**/*.test.ts' | grep -i toThrowreturns nothing (exit 1). The one adjacent assertion runs the other way:structural-condition-shape.test.ts:112asserts the message does not containis not a function.objectuireaches this entry once, atpackages/app-shell/src/views/metadata-admin/celAuthoring.ts:211. ItslintCelPredicate(source: string, …)declaressourcea string and guardsif (!source || !source.trim()) return []before the call, so it can only ever pass a string; its behaviour cannot move.⇒ Precondition discharged, no caller depends on the throw, so the round proceeds.
2. Reproduction — and a PM premise this measurement FALSIFIED
registerFlowrepro no longer reproduces, and that is a fact about the tree, not about the card. #15662 has landed:structuralConditionRefusalnow refuses an object carrying neither a stringsourcenor anastbeforevalidateExpressionis reached. Driven at2024eca4f, the card's exact flow (config: { condition: { source: { nested: 1 } } }) throws the located, assembled refusal naming the flow and the node — not aTypeError. #15572 has landed too and closes the ledger-declared predicate slots the same way. Neither seat's slots were touched here.The defect itself is still live, at the doors those two cards did not close. Driven at the same sha, with controls:
inferExpressionTypeis the second consumer of the same entry and crashed identically — a door the card did not name.The reachable population, driven rather than argued.
@objectstack/lint's stack walk reaches this entry from twelve slots with no shape guard in front of any of them — validation rules, sharing rules, hooks, actionvisible/disabled, field-rule and formula slots. Before / after, same inputs:hooks[].condition = { source: { nested: 1 } }TypeError, whole run dies, no locationerror,where: hook 'h1' (lead) conditionsharingRules[].condition = { source: 1 }TypeError, whole run dieserror,where: sharingRule 's1' (lead) conditionhooks[].condition = '{record.rating} >= 4'hooks[].condition = { dialect: 'cel', source: '1 == 1' }⇒ The acceptance test is met on its own terms: the refusal arrives through
errors[]and the caller's location survives to the author.3. The run-time door is a SECOND entry, in a fenced package — filed, not absorbed
engine.evaluateCondition({ source: 1 }, new Map())still throwsTypeError: exprStr.trim is not a function, re-measured on this branch after the fix. That crash is atpackages/services/service-automation/src/engine.ts:8280, which computes and trimsexprStritself before ever calling into@objectstack/formula— structurally unreachable from a guard at this entry. It is a different entry, in the package the adjacent #15662 lane owns, and it is not mechanical: evaluation could either throw the same refusal registration throws, or route through ADR-0032 §1c's fault path, and picking one mid-PR would be the wrong seat deciding.Filed as #16038 with the measurement, unassigned, for triage. Searched first (repo-scoped REST list + local grep over the 558 most recent open issues; controls for
validateExpressionandsource.trimboth fired) — no existing card covers it. #15430 and #15807 are adjacent but concernast-only envelopes, a different population.4. Ablation — mutation proven on disk BEFORE measuring, restore proven after, one shell
The first attempt was VOIDED by
ablation-dist-preflightand is reported rather than quietly retried: its marker was a//comment, which esbuild strips, so it could never appear in executable output — and the ablated tree also failed to build (TS6133). Re-run with the guard's own refusal string literal as the marker:e2b884a8== base blob, != head blobfb85ec3b; guard text count 0 in source; preflight--absent: marker absent from all 6 built filesTypeError: source.trim is not a functionfb85ec3b== HEAD blob;git diff HEAD= 0 bytes; whole-treegit status --porcelainempty; preflight (present): marker present in 2 built files5. Clause-② — measured, both limbs, stated separately
Limb 1 — does any exported symbol or signature move? NO. Built
dist/index.d.tsdiffed at head vs the same file swapped back toorigin/mainand rebuilt (git restore --source, tree-only, never the index; restored byte-exact and proven). The whole diff is 5 lines of TSDoc prose. Exported declarations 39 → 39, anddeclare function validateExpression(role: FieldRole, input: ExprInput, schema?: ExprSchemaHint): ExprValidationResult;is byte-identical.ExprInputalready declaredsource?: string, so the declaration could not move — which is exactly the trap.Limb 2 — is any request newly accepted or rejected? YES, and this is what loads the clause. An envelope with a present, non-string
sourcepreviously received no verdict at all: aTypeErrorescaped and the function returned nothing. It now receives a rejection onerrors[](ok: false). That input is newly rejected, and the throw is gone.What did not move, pinned by the CONTROLS block in both new suites: no input that returned
ok: truenow returnsok: false, and none that returnedok: falsenow returnsok: true. Absent /null/ empty / whitespace sources and{ ast }envelopes carrying nosourcestill read as "not authored"; a malformed string still gets its own diagnostic — brace trap, dialect mismatch, unknown function — never the shape refusal. The accept-set and reject-set among inputs that returned at all are unchanged; only the crashing population moved, into the reject set.⇒
Clause-②: yeson limb 2.needs:contract-reviewon both carriers; PR parked as draft.6. Verification
Union re-run after the final commit, on
c3fd1902c(origin/mainmerged in at8e0b29758, full workspace rebuilt first — 71/71 turbo tasks).pnpm --filter @objectstack/formula test— 29 files / 827 tests passedpnpm --filter @objectstack/lint test— 98 files / 3355 tests passedpnpm --filter @objectstack/service-automation test— 115 files / 1386 tests passedpnpm --filter @objectstack/mcp test— 26 files / 289 tests passedpnpm lint(repo-wideeslint . --no-inline-config) — exit 0, run in full, no narrowing claimednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackafter the final commit — 42 by path + 6 by change kind + 7 declared whole-tree — every one exit 0, each captured withcmd > log 2>&1; EXIT=$?before any pipe⊘ NOT MEASURED, stated rather than implied:
pnpm --filter @objectstack/formula typecheckdoes not read the new formula test file —packages/formula/tsconfig.jsonexcludes**/*.test.ts, proven withtsc --listFiles(0 hits for the test file, controlvalidate.ts1 hit). Measured separately instead:tsc --noEmit --ignoreConfig --strictover that file alone, exit 0, zero output. The lint side needs no such caveat —check:test-typecheckcompiles it undertsconfig.test.json(1 hit, 0 errors in it; the ledger's 6 pre-existing errors are in two other files).The 59-package dependent closure of
@objectstack/formulawas not run locally — that repo-scale sweep is CI's, and the four packages above are this diff's own plus every production call site.🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code