Skip to content

Commit 00f72f9

Browse files
objectstack-fleet[bot]os-support-aiclaude
authored
docs(skills): teach the null-guard gate on a record-triggered flow's conditions in the formula skill and the flows page (#29864)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBUyArWn6eHL33NNgvdpkwiW)_ Fixes #29482 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:formula 技能与 flows 文档页补上「空值守卫门禁也判记录触发流的条件」的教学:判哪些槽位(start `config.condition`、每条边 `condition` 含嵌套区域、每个 `decision` 分支 `expression`)、`count`/`sum` 汇总字段恒有值而 `min`/`max`/`avg` 仍可空、裸字段名即 `record.FIELD`、start 条件里的守卫在下游每条边与分支都算数且要拼成 `record.FIELD != null` 两道门才都收、未守卫时「写入落地、流运行失败」。技能 evals 新增一条被拒的流边。 - 为什么改:PR #29474 已让 `os validate` / `os build` 与运行时发布门拒收这类流条件,技能与文档仍只说 validation rule 与 hook,作者会在无教学的情况下撞上拒收。 - 风险与回滚:仅教学文本与一条 eval,不动任何代码或 schema;回滚即 revert 本 PR。`skills/**` 为 Tier H,等维护者批准后由席位落地。 - 先看:`skills/objectstack-formula/SKILL.md` 第 115–125 行(发布时拒收段落)与第 399–407 行(流条件例外的 callout);`content/docs/automation/flows.mdx` 的 Expressions in flows 警告框第 5 条。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs`,预期 `skills/objectstack-formula/SKILL.md is 5956 tokens (ceiling 5997; headroom 41)`。 - 需要你做的:批准(Tier H:`skills/**`);之后席位落地。 ## What changes - **`skills/objectstack-formula/SKILL.md`** (491 → 487 lines against `main` after PR #29439, net −4; 5948 → 5956 tokens of 5997, headroom 41). Two paragraphs teach what `packages/lint/src/validate-null-guards.ts` (the ledger's flow row) and `validate-expressions.ts` (`checkFlowNullGuards`) do as merged in PR #29474: - the publish-time rejection paragraph (lines 115–125) names the flow slots the gate judges on a record-triggered flow (start `triggerType: 'record-*'`): start `config.condition`, edge `condition` (nested regions too), `decision` branch `expression` — beside the validation-rule and hook predicates it already named — and adds the roll-up nuance to the nullable definition: a `count` / `sum` summary is always valued (empty reads `0`), `min` / `max` / `avg` stay nullable; - the "Flow conditions are the exception to row 1" callout (lines 399–407) carries the bare-name rule (the run spreads the trigger record's fields, nulls included, to top level wherever no flow variable or node id holds the name, so a bare `budget` IS `record.budget`; `os validate` judges either spelling, bare names only while the flow's bound names are provable; a guard on one guards both), the start-condition presumption (what the start condition proves non-null holds at every edge and branch), the two-door spelling (`record.budget != null`: the runtime publish gate judges the written flow alone and resolves no bare name) and the outcome (the **run** fails at that condition; the write that fired it still lands). - Paid by deleting restatement in the same file, never by re-wrapping: the `OLD.x` / `ISCHANGED(x)` callout (6 → 3 lines) restated §5's `previous` table and its ABORTS paragraph; the "Verify your work" opener dropped its pre-ADR-0032 history clause; and after PR #29439 (#29094) landed on `main` and the merged file read 6086 tokens (over by 89), three more: the "Null + string throws" section (it restated pattern 1, *always coalesce nullable operands* — "CEL throws on null + string"), the ABORTS paragraph's compile sentence (pattern 3 states it) and its insert-leg remedy sentence (the `previous` table's insert row and the `OLD.x` callout state it), and the flow callout's own "prefer `record.x`" sentence (row 1 of the table above it says the same). - PR #29439's sparse-action paragraph and its edit to the total-record paragraph are untouched; the merge of `origin/main` @ `4b71a82b` auto-merged this file with no conflict. - **`skills/objectstack-formula/evals/cel-predicates-formulas.json`** (85 → 95 lines; eval fixture, measured but not ratcheted — 1504 → 1769 tokens): eval 9 asks for the edge condition of a record-change flow on a nullable `budget`; expected `record.budget != null && record.budget > 200000`, must not contain the `has()`-only trap. - **`content/docs/automation/flows.mdx`** (3061 → 3073 lines, net +12): item 5 of the "failure modes to memorize" callout in *Expressions in flows* — the slots, the nullable definition with the roll-up nuance, the `!= null` / `== null` / `!isBlank()` guard, why (the run binds every declared field, `null` when empty; CEL has no `>` for null; the run fails there while the write lands), the bare-name rule and the start-guard spelling; the Edges table's `condition` row points at it (same row, no new line). - **`.changeset/29482-formula-skill-flow-null-guards.md`**: `'@objectstack/skills': patch`, both declaration lines. ## Why PR #29474 (#29026) widened the null-guard gate to record-triggered flow conditions and left `Skills-docs: follow-up owed`. Until this lands, an author who reads the skill or the flows page writes `budget > 200000` on an edge and meets the refusal untaught; the skill said the gate covers "any validation-rule or hook predicate" and nothing about flows, and the flows page had no nullable-field rule at all. ## Tests Every sentence was read off the merged code, then measured against it (readings repeated on the merged head `d97d2e33`, unchanged; `554bea27` adds one wording edit to the callout, same line and token count): - `packages/lint` null-guard suites under `scripts/pm/os-verify-lock.sh`: `src/validate-expressions.flow-null-guards.test.ts` + `src/validate-null-guards.test.ts` — Test Files 2 passed (2), Tests 46 passed (46); lock verdict command-exit 0. - A probe through `tsx` against `packages/lint/src/validate-expressions.ts` (`validateStackExpressions` = the `os validate` door; `runStackExpressionPasses(…, { runtimeWriteType: 'flow' })` = the runtime publish gate) on a `record-after-update` flow over `project` (`budget` currency, nullable; `line_count` a `count` summary; `largest` a `max` summary): | condition | `os validate` | runtime publish gate | |:--|:--|:--| | edge `budget > 200000` (bare) | refused at edge `e2` | not judged (bare name unresolvable there) | | edge `record.budget > 200000` | refused | refused | | edge `has(record.budget) && record.budget > 200000` | refused | refused | | edge `record.budget != null && record.budget > 200000` (eval 9's answer) | accepted | accepted | | start `budget != null`, edge `record.budget > 200000` | accepted | refused | | start `record.budget != null`, same edge | accepted | accepted | | edge `line_count > 1` (`count` roll-up) | accepted | accepted | | edge `largest > 1` (`max` roll-up) | refused | not judged (bare) | The second commit (`487ad8da`) is what that table changed: the three places that said "the gate judges a bare name" / "refused the same way" now say `os validate`, since the runtime gate judges no bare name. - No ablation: no tested behaviour changed (docs, a skill and an eval fixture). ## Acceptance notes - Premise measured on the branch base (`origin/main` @ `c76c0e04`): `skills/objectstack-formula/SKILL.md` line 113 opened "**This is a publish-time rejection, not advice.**", the file had no `record_change` / `record-triggered` mention (0 hits; "record-change flow trigger" appears only in §5's bulk-write table, which teaches `previous`, not the gate), and `content/docs/automation/flows.mdx` had no `nullable` (0 hits). The order's premise holds. - The order's line budgets hold: SKILL.md net +6 against the base (473 → 479) and net −4 against `main` after the token payback (491 → 487); flows.mdx net +12 (3061 → 3073). The evals file got the one eval the card asks for (+10 lines); the order set no numeric budget for it and the token ratchet measures but does not ratchet eval fixtures. - Token reading for the Tier H review: 5948 (`main`) → 5956 (ceiling 5997, headroom 41). Every token paid is a deletion in the same file, listed above; no ceiling row moves. - The Basic Structure example (`order_amount > 10000`) is left as it is: that flow has no record trigger and `order_amount` is a declared variable, so the gate does not judge it — the rule is taught where edge and decision conditions are taught (the *Expressions in flows* callout and the Edges table). - The skill's "bare names only while the flow's bound names are provable" is `flowCelRootScope`'s own criterion (the bound set is unprovable with a `script`, `subflow`, `map`, `wait`, `connector_action` or plugin node, an entrance that hands a record not in hand, or a contract-less screen); the full list stays in the code. - The previous-operand clause (`previous != null && previous.x != null` on a flow's create leg) is not restated: §5 and the `OLD.x` callout already teach it for every surface that binds `previous`. - Out of scope, noted, not filed: none. ## Verification Head `554bea27`: the two commits on `c76c0e04`, a merge of `origin/main` @ `4b71a82b`, one payback commit, and one wording commit from the at-tier review (record 6599648862). Readings taken on `d97d2e33` unless marked; the wording commit changed one blockquote line and no reading. <details><summary>Package readings</summary> - `pnpm --filter '@objectstack/client-react...' --filter '@objectstack/lint...' build --concurrency=2` under the lock after the merge: VERDICT command-exit 0, held 484s. - Lint null-guard suites (above): 2 files, 46/46, lock verdict 0, on the merged head. - `pnpm check:pm-skill-ratchet` :: exit 0 (the `.claude/**` line ratchet; this diff touches none of its files — run because the order asks). `pnpm check:pm-skill-id-lint` :: exit 0. </details> <details><summary>Derived gates — `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 68 commands, run serially, `CMD :: exit N` recorded, reconciled with `--ran`</summary> - On head `487ad8da` (before the merge): `--ran` reconciliation `68 derived famil(ies) accounted for — 66 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`; change set 4 paths, 70 changed lines (+54 / −16), under the 3000 human-merge threshold. 66 run, every one `:: exit 0`, among them `check-changeset-no-major --base origin/main`, `check-empty-changeset`, `check-adr-0087-registration`, `check-skills-token-ratchet` (+ self-test), `check-doc-route-spelling`, `check-docs-section-name`, `check-section-landing-index`, `@objectstack/lint check:doc-formula-expressions`, `check:doc-security-posture`, `@objectstack/spec check:docs`, `check:empty-state`, `check:liveness`, `check:skill-docs`, `check:skill-examples`, `check:strictness-ledger`, `check:variant-docs`, `check:yaml-examples`, `check:doc-anchors`, `check:doc-authoring`, `check:doc-frontmatter`, `check:docs-single-h1`, `check:docs-spec-enumerations`, `check:docs-transcript-drift`, `check:docs-redirects`, `check:nul-bytes`, `check:pm-governed-merges`, `check:pm-skill-id-lint`, `check:published-files`, `check:published-readme-links`, `check:role-word`, `check:skill-compatibility`, `check:skill-frame-sync`, `check:skill-identifier-liveness`, `check:closed-ceremony-text`, `check:corpus-claim-drift`, `check:cross-package-test-inputs`, `check:merge-driver`, `check:gitlink-declared`, `check:vendor-version-stamps`, `check:watch-hint-literal`, and the `--self-test` rows of the ci / release scripts. - On the merged head `d97d2e33` (and again on `554bea27` for `check-skills-token-ratchet` + self-test, `check:pm-skill-id-lint`, `@objectstack/spec check:skill-docs`, `check:skill-frame-sync`, `check:skill-identifier-liveness`, all `:: exit 0`), the families the skill and docs edits read, rerun: `check-skills-token-ratchet` (+ self-test) 5956 / 5997, `check:pm-skill-id-lint`, `check:pm-skill-ratchet`, `check:skill-compatibility`, `check:skill-frame-sync`, `check:skill-identifier-liveness`, `check:doc-authoring`, `check:nul-bytes`, `check:role-word`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `@objectstack/spec check:skill-docs`, `@objectstack/spec check:skill-examples` (283 prose examples type-check across 3 surfaces, after the post-merge closure build), `@objectstack/lint check:doc-formula-expressions` — all `:: exit 0`. - NOT MEASURED (2): `pnpm check:dual-build-cjs-loads` and `pnpm check:lean-entry-closure` — both exit 3, PREREQUISITE NOT MET: they load every package's built `dist/`, which needs a full repo build this docs-and-skill diff does not warrant on the shared box (no package `src/` moves); CI's `Lint & Repo Gates` run is the reading for those two. </details> ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (no schema touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run (repo-wide scan, not run locally). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer digest above is Chinese by AGENTS.md's communication rule). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfSZ55f7xwAfpfkPgY)_ <!-- signed: domain:skills#3 · os-support-ai · session_01WGufzhd2Ad9ANGTyAMW7pH --> --------- Co-authored-by: os-support-ai <support@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent b4a9d04 commit 00f72f9

4 files changed

Lines changed: 52 additions & 24 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the formula skill teaches the null-guard gate on a record-triggered flow's conditions
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The publish-time null-guard gate (`os validate` / `os build` / `os lint` and the runtime publish gate) has judged a record-triggered flow's start `config.condition`, every edge `condition` and every `decision` branch `expression` since the lint change that widened it; the formula skill now says so where it teaches the gate: which slots are judged, that a `count` / `sum` roll-up is always valued while `min` / `max` / `avg` stay nullable, that a bare field name in a flow condition is the field and is judged alike, that a guard in the start condition holds at every edge and branch and is spelled `record.<field> != null` so both doors accept it, and that an unguarded null fails the run while the write that fired it still lands. One eval exercises a refused flow edge. The flows docs page carries the same rule where edge and decision conditions are taught.

‎content/docs/automation/flows.mdx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1810,7 +1810,7 @@ Edges connect nodes and define the execution path:
18101810
| `source` | `string` | ✅ | Source node ID |
18111811
| `target` | `string` | ✅ | Target node ID |
18121812
| `type` | `enum` | optional | `'default'` (success), `'fault'` (error), `'conditional'` (expression-guarded), or `'back'` (declared back-edge, ADR-0044); defaults to `'default'` |
1813-
| `condition` | `string` | optional | Boolean CEL predicate for branching (a bare string is stored as `{ dialect: 'cel', source }`) |
1813+
| `condition` | `string` | optional | Boolean CEL predicate for branching (a bare string is stored as `{ dialect: 'cel', source }`); on a record-triggered flow a nullable field needs its `!= null` guard — see [Expressions in flows](#expressions-in-flows) |
18141814
| `label` | `string` | optional | Label displayed on the connector — cosmetic only. It does **not** select a path except on a branching node (`decision` / `approval`), which picks its out-edge by label. |
18151815
| `isDefault` | `boolean` | optional | BPMN default flow — the **"otherwise" branch**. Traversed only when no sibling `condition` on the same source node matched; never part of the unconditional parallel fan-out. Mutually exclusive with `condition`, at most one per node |
18161816

@@ -2443,6 +2443,18 @@ and is read as `vars["current_user"]` / `vars["vars"]`.
24432443
4. **A single-brace token in a text slot** — `title: 'Deal won: {record.name}'`
24442444
— is refused with its `{{ }}` spelling ([text slots](#text-slots-read-double-brace-holes)): it is no placeholder there,
24452445
and would go out as literal text.
2446+
5. **An ordering or arithmetic operator on a nullable field, unguarded** — on a
2447+
record-triggered flow (`triggerType: 'record-*'`), `os validate` and the
2448+
runtime publish gate refuse a start `condition`, an edge `condition` (nested
2449+
regions too) or a `decision` branch `expression` that applies `< <= > >=` or
2450+
`+ - * / %` to a field of the trigger object with no `required: true`,
2451+
`defaultValue` or default option (a `count` / `sum` roll-up reads `0` when
2452+
empty; `min` / `max` / `avg` stay nullable), unguarded by `!= null` /
2453+
`== null` / `!isBlank()` in the same branch: the run binds every declared
2454+
field, `null` when empty, CEL has no `>` for null, and the run fails there
2455+
while the write that fired it still lands. A bare `budget` is `record.budget`,
2456+
and `os validate` judges it too; a start guard holds at every edge and branch — spell
2457+
it `record.budget != null`, which both doors accept, and `has()` is no guard.
24462458
</Callout>
24472459

24482460
<Callout type="info">

‎skills/objectstack-formula/SKILL.md‎

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,12 @@ record.start_date != null && record.end_date != null && record.end_date < record
114114

115115
**This is a publish-time rejection, not advice.** `os build` /
116116
`os validate` / `os lint` and the runtime publish gate reject any validation-rule
117-
or hook predicate that applies an ordering (`< <= > >=`) or arithmetic
117+
or hook predicate — and a record-triggered flow's (`triggerType: 'record-*'`)
118+
start `config.condition`, edge `condition` (nested regions too) or `decision`
119+
branch `expression` — that applies an ordering (`< <= > >=`) or arithmetic
118120
(`+ - * / %`) operator to a **declared nullable** field — no `required: true`,
119-
no `defaultValue`, no default option — unless an explicit `!= null` / `== null` /
121+
no `defaultValue`, no default option, not a `count` / `sum` roll-up (empty reads
122+
`0`; `min` / `max` / `avg` stay nullable) — unless an explicit `!= null` / `== null` /
120123
`!isBlank()` test dominates it in the same boolean branch. `has()` deliberately
121124
does not satisfy that gate. `has()` over an **undeclared** key stays legal: that
122125
is its use there — telling "absent from this PATCH" apart from "explicitly null".
@@ -137,12 +140,6 @@ has(record.amount) && record.amount != null && record.amount > 1000
137140
has(record.viewer) && has(record.viewer.can_approve) && record.viewer.can_approve == true
138141
```
139142

140-
### Null + string throws
141-
142-
CEL has no implicit `null` coercion. `null + 'foo'` throws
143-
`no such overload: dyn<null> + string`. Wrap every nullable string operand
144-
in `coalesce(..., '')`.
145-
146143
---
147144

148145
## ObjectStack CEL standard library
@@ -339,9 +336,7 @@ a typo'd key (`record.stauts`), a retired field, or a comparison CEL has no
339336
overload for — does **not** degrade to "the hook did not fire": it **fails the
340337
write**, with an error naming the hook and the key — `before*` and `after*`
341338
alike, with no `onError` escape (`onError` governs a handler that throws, and
342-
the condition is evaluated before any handler runs). A condition that does not
343-
even **compile** aborts the same way. So on an insert event read `previous`
344-
only against `null`, and put a column read off it behind `previous != null &&`.
339+
the condition is evaluated before any handler runs).
345340

346341
**A transition condition needs no special handling for bulk writes.**
347342
Write it once, on a `before*` or an `after*` event, and it means the same thing
@@ -397,17 +392,19 @@ When migrating Salesforce-flavor metadata, apply these rules in order:
397392
| `ISCHANGED(x)` | `previous.x != record.x` |
398393
| `MONTH_DIFF`, `MID`, `LEFT`, `RIGHT`, `SUBSTITUTE` | _not in stdlib — propose addition_ |
399394

400-
> ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which has a value
401-
> only where `previous` is a **row** — see §5. On an insert event `previous` is
402-
> `null`, so the read does not quietly skip the hook, it **fails the write**
403-
> unless guarded by `previous != null &&`. On update and delete
404-
> events it IS bound, per matched row, in `before*` and `after*` alike, on bulk
405-
> and single-record writes.
395+
> ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which is `null` on an
396+
> insert event and **fails the write** there unless guarded by `previous != null
397+
> &&`; on update and delete events it is bound per matched row — see §5.
406398
407-
> ⚠️ **Flow conditions are the exception to row 1.** The automation engine
408-
> spreads the record's variables to top level, so a bare `status` resolves in a
409-
> flow start condition. `record.status` resolves there too and is the only
410-
> spelling valid on every other surface — prefer it.
399+
> ⚠️ **Flow conditions are the exception to row 1.** The engine spreads the
400+
> trigger record's fields, nulls included, to top level wherever no flow variable
401+
> or node id holds the name, so in a start, edge or `decision` condition a bare
402+
> `budget` IS `record.budget`: `os validate` judges either spelling (bare names
403+
> only while the flow's bound names are provable) and a guard on one guards both.
404+
> What the start condition proves non-null is presumed at every edge and branch;
405+
> spell that guard `record.budget != null` — the runtime publish gate judges the
406+
> written flow alone and resolves no bare name. An unguarded null fails the
407+
> **run** at that condition; the write that fired it still lands.
411408
412409
---
413410

@@ -471,8 +468,7 @@ data instead, `{{ amount | currency:@currency_code }}`.
471468

472469
## Verify your work
473470

474-
A malformed expression does not fail silently (ADR-0032). It used to evaluate to
475-
`null` / `false` — a flow "fired" and did nothing; now `os validate` and
471+
A malformed expression does not fail silently (ADR-0032): `os validate` and
476472
`os build` run the shared validator over every formula and predicate in the
477473
stack — CEL syntax **plus** `record.<field>` existence on the target object —
478474
and fail non-zero with a located, did-you-mean message, while at runtime the

‎skills/objectstack-formula/evals/cel-predicates-formulas.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@
8080
"must_contain": ["currency:@"],
8181
"must_not_contain": ["currency:currency_code", "currency:USD", "currency:CNY", "| currency }}"]
8282
}
83+
},
84+
{
85+
"id": 9,
86+
"prompt": "A record-change flow on `project` (start `triggerType: 'record-after-update'`, `objectName: 'project'`) escalates when the budget passes 200000. `budget` is a currency field with no `required` and no `defaultValue`. Write the edge condition from the decision node to the escalation branch.",
87+
"expected_output": "`record.budget != null && record.budget > 200000`. The run binds every declared field of the trigger record, `null` when empty, and CEL has no `>` overload for null, so `os validate` and the runtime publish gate refuse the unguarded edge; at runtime the run would fail at that edge while the write that fired it still lands. A bare `budget > 200000` is the same field, and `os validate` refuses it too; and `has(record.budget)` is not a guard: a declared field holding null is still present.",
88+
"files": [],
89+
"assertions": {
90+
"must_contain": ["record.budget != null", "record.budget > 200000"],
91+
"must_not_contain": ["has(record.budget) && record.budget >", "{record."]
92+
}
8393
}
8494
]
8595
}

0 commit comments

Comments
 (0)