Skip to content

Commit 383c353

Browse files
docs(skills): objectstack-automation carries the flow-authoring judgment of cloud's private corpus — option values, a named hour's zone, who an approval cascade runs as (#31155, port 2/3) (#31393)
Part of #31155 — card 1 of 4 of the objectstack-ai/cloud#3659 ruling-A split; this is the second per-domain port (`objectstack-automation`), after #31223 (`objectstack-data`). The `objectstack-ui` port follows as its own PR on the same card, so the card stays open. Clause-②: no Metadata-protocol: none **Rework round 1** (contract review 6718050728, FAIL on 2ab1ee0): `origin/main` 77a675d merged by merge commit b73f140 (no rebase, no force-push); `main` c0068ed (#31322) had added 24 bytes to `references/state-machines-and-approvals.md` under the first pin, so CI measured the merge result at 5844 against a 5838 row. The row is re-pinned AT the merged measurement after a same-file pay-down (the three legacy "(the old …)" glosses), its comment and the tables below carry `main`'s true base 22908; SKILL.md item 4 now states the two legs' different consequences; the two pre-existing one-word errors the reviewer listed in that file are corrected in the same pass. ## 维护者速读 - 改了什么:把云端私有搭建语料里「自动化」这一块、schema 表达不了的三条判断搬进公开的 `objectstack-automation` skill:① 流程条件和写入里的下拉值一律用选项的 `value`,不用用户嘴里说的标签(写在触发条件里:流程能过校验但永远不触发;写在 `fields` / `filter` 里:运行时以 `invalid_option` 拒绝);② 用户说「早上 9 点」就写 `'0 9 * * *'` 加 `timezone: 'Asia/Shanghai'`,不要自己换算成 UTC 裸写(裸写按 UTC 跑、也没记录你指的是哪个时区);③ 对审批结果做出反应的流程以「做出决定的那个人」的身份运行,保持 `runAs: 'user'`;只有 SLA 自动裁决和死亡运行释放这两种机器转换没有用户,要对它们也起反应的流程才声明 `runAs: 'system'`。外加 3 条 eval 用例。顺手把该 skill 一个参考文件里残留的 3 处中文注释改成英文(对外 skill 全英文),并改正该文件里两处原有小错(审批状态镜像值漏了 `returned`;一处示例条件缺 `record.` 前缀)。净增 71 行。返工第 1 轮:合并了 `main`(#31322 给同一文件加了 24 字节),体积上限按合并后实测重新钉住。 - 为什么改:执行 cloud#3659 裁决 A(一个知识源:本地开发与云端 AI 用同一套公开 skills)。第 ③ 条云端原文写的是「审批服务以系统身份回写,所以下游流程必须声明 system」,对照 `main` 上的审批服务是错的(回写带着决定人的身份),本 PR 按运行时的真实行为搬入,并在卡上记了更正。 - 风险与回滚:只改文档与 eval 夹具,无运行时改动;三个文件的体积上限按实测钉住(依据 cloud#3659 A 与「先削重复,余额按实测提高」,同文件先删了重复)。回滚 = revert 本 PR。 - 先看:`skills/objectstack-automation/references/state-machines-and-approvals.md`(Approval Best Practices 第 4 条——身份判断,对照 `packages/plugins/plugin-approvals/src/approval-service.ts` 的 `mirrorStatusField`)、`skills/objectstack-automation/references/examples-flows.md`(示例里新增的 `timezone`)、`skills/objectstack-automation/SKILL.md`(Valid-but-silently-wrong 第 4 条)。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs`,预期全绿且 objectstack-automation 三行 headroom 0;`python3 -c "import json;json.load(open('skills/objectstack-automation/evals/flows-triggers-approvals.json'))"` 不报错。 - 需要你做的:Tier H 受管面(`skills/**`),需要你的 APPROVED review 后由席位落地;本 PR 保持 draft。 ## What changes Four files under `skills/objectstack-automation/`, one changeset, three token-ratchet rows. - `SKILL.md` § Valid-but-silently-wrong — item 4: a `select` is compared and written by its option `value`, never the label the request speaks, in the start `condition` AND every `fields` / `filter` entry. The two legs fail differently and the item says so: a label in the `condition` parses, matches no row, and the flow stages clean and never fires; a label in `fields` / `filter` fails the run at the write as `invalid_option` (`packages/objectql/src/validation/record-validator.ts`). - `references/examples-flows.md` — the schedule example writes `schedule: { type: 'cron', expression: '0 9 * * *', timezone: 'Asia/Shanghai' }` with the rule beside it: a bare expression runs in UTC; a wall-clock hour the request names is written as said with its IANA `timezone`, never converted to UTC by hand (nothing then records which zone was meant). The time-relative cadence comment shows the same shape. Same-file pay-down: the `requires: ['automation', 'triggers']` / always-on `job` sentence, restated from SKILL.md § Prerequisite, is a pointer. - `references/state-machines-and-approvals.md` § Approval Best Practices — item 4: a flow reacting to the mirrored `approvalStatusField` runs as the person who acted (submit carries the submitter; approve / reject / recall / send-back the decider) and stays `runAs: 'user'`; only the SLA `escalation` auto-decision and the dead-run release carry no user, so a flow that must react to those too declares `runAs: 'system'`. Same-file pay-downs: the discount-approval example's four-line trigger comment (a restatement of SKILL.md § Trigger Configuration) is one pointer line; the three Chinese glosses (lines 145, 293, 366 on `main`) are gone; the three legacy "(the old `entryCriteria` / `back_to_previous` / `reject_process`)" glosses in § Branching, side-effects & rejection — approval-process keys with no author since ADR-0019 — are gone. Two one-word corrections of pre-existing text in the same file (the reviewer's optional items): the `approvalStatusField` row lists `returned` among the mirrored statuses (send-back writes it, approval-service.ts send-back path), and the dynamic-approval example's start condition reads `record.status == 'submitted'` like the rest of the file. - `evals/flows-triggers-approvals.json` — cases 17 (option value, never label; CEL envelopes, no single-brace token), 18 (a named hour with its zone, never a hand-converted UTC cron), 19 (an approval-outcome cascade stays `runAs: 'user'`). - `.changeset/31155-port-authoring-rules-automation.md` — `@objectstack/skills` patch, `Clause-②: no`, `Metadata-protocol: none`. - `scripts/check-skills-token-ratchet.mjs` — three rows re-pinned AT the measurement (table below). **Language scope (the approver's review on #31223, verbatim 「skills 对外发布的版本应该全部用英文」; the account owner's 「同意」):** every non-eval `skills/**` file this PR touches holds 0 CJK code points at the head (Python scan over the CJK, kana, fullwidth and CJK-punctuation blocks); eval prompt strings keep their Chinese under exemption (a); `objectstack-i18n` is untouched (exemption (b)). The touched reference file was one of the six pre-existing CJK carriers the delta reviewer listed on #31223; it is English now. The other five (`objectstack-formula/SKILL.md`, `objectstack-ui/rules/navigation.md`, `objectstack-ui/rules/record-page.md`, `objectstack-upgrade/SKILL.md`, `objectui/guides/i18n.md`) are not this PR's. ### Line readings (`skills/**`, vs `origin/main` 77a675d, merged in) | reading | before | after | |:--|:--|:--| | `SKILL.md` | 445 | 452 | | `references/examples-flows.md` | 134 | 136 | | `references/state-machines-and-approvals.md` | 399 | 404 | | `evals/flows-triggers-approvals.json` | 298 | 355 | | every `skills/*/SKILL.md` summed | 4517 | 4524 | | whole shipped `skills/` tree | 20060 | 20131 | Net **+71** lines against the dispatch's +250 budget (evals included). ### Token-ratchet rows (priced bytes; a SKILL.md's stamp line excluded) | row | `main` 77a675d | head | ceil/4 | ceiling (was → now) | same-file pay-down shown | |:--|:--|:--|:--|:--|:--| | `SKILL.md` | 25654 | 26108 (+454) | 6527 | 6426 → 6527 (+101) | none to cut; the row comment says so | | `references/examples-flows.md` | 5685 | 5976 (+291) | 1494 | 1436 → 1494 (+58) | the `requires` restatement | | `references/state-machines-and-approvals.md` | 22908 (after #31322's +24) | 23299 (+391) | 5825 | 5730 → 5825 (+95) | the trigger comment; three Chinese glosses; three legacy "(the old …)" glosses | All three pinned AT the measurement, headroom 0 (the reference file's row is pinned 19 below the first round's 5838 and 19 below the merge-result reading CI rejected, 5844). Authority: the ruling of record that orders this text into the published skills, objectstack-ai/cloud#3659 letter A (ruling comment 6682024726, 2026-11-06: "How to write valid, working metadata lives in the public skills … The private corpus's rules of that kind move there"), via the standing 2026-10-27 ruling, verbatim and untranslated: 「A:先削重复,余额按实测提高」 (ruling record 6513306068). ## No-loss mapping — the automation rows (v1 ledger 6711215678; corrections 6711710289, 6715112987; this PR's note on the card) Classes as in the ledger: CLOUD (orchestration / hosted-only, stays), SPEC (restates `packages/spec`), E/R (parses clean, dead at runtime — the spec lane, #31224), STATED (the published skill already says it), PORT (judgment, ported here). | private rule id | clause | class | target → this PR | regression kept | |:--|:--|:--|:--|:--| | 14 | trigger on the START node; one `triggerType` string; `record-after-write` for create-or-update; `previous` null on the create leg; no array of trigger types; bare CEL condition; `fields` / `filter` keys; no `recordId`; CEL value envelopes, no `{…}` tokens; `isoDate` / `current_user` envelopes | SPEC + STATED | `automation/flow.zod.ts` (top-level `triggerType` / `trigger` refused with the start-node prescription; node configs `strictObject`), `automation/flow-template-token.ts` (single-brace tokens refused at save); SKILL.md § Trigger Types / § Trigger Configuration / § Flow Variables / § Filter operands / § Valid-but-silently-wrong 1; objectstack-formula § 5 (`previous` is `null` on insert) | cloud `flow-value-envelope.test.ts` (9 cases) pins the envelope teaching — the spec refusal and SKILL.md § Valid-but-silently-wrong 1 are its equivalent; eval 17 now pins the envelope shape too (`dialect: 'cel'`, no single-brace token) | | 14 | the user speaks the label; the stored value is the option `value` — map it in the condition AND every `fields` entry | **PORT** | SKILL.md § Valid-but-silently-wrong item 4 | eval 17 (`== 'lost'` must_contain; `== '已流失'`, `status: '已流失'` must_not_contain) | | 14 | edges connect everything (an orphan action node never runs) | E/R | #31224 C | — | | 14b | `timeRelative` on the start node; `offsetDays` vs `withinDays`; `filter` INSIDE the descriptor; never a date-equality condition | SPEC + STATED | `automation/time-relative-trigger.zod.ts`; `references/examples-flows.md` § Time-relative triggers | cloud `flow-value-envelope.test.ts` "rule 14b writes a time-relative flow value the same way" — same equivalent as 14 | | 14b | daily cadence only; a sibling `filter` beside `timeRelative`; `runAs: 'user'` on a swept flow | E/R / already enforced | #31224 B (cadence, sibling `filter`); `runAs` is lint `flow-runas-unscoped` (already enforced, 6711340388); the `runAs: 'system'` sweep is STATED in `examples-flows.md` | — | | 14b | write the user's wall-clock hour with its `timezone`, never a silent UTC conversion | **PORT** | `references/examples-flows.md` — the schedule example and the time-relative cadence comment. Verified on `main`: `system/job.zod.ts` `CronScheduleSchema.timezone` (described, IANA-checked); `trigger-schedule/src/schedule-trigger.ts` `normalizeSchedule` carries `timezone` onto the job (both the schedule and the time-relative trigger call it); `service-job/src/cron-job-adapter.ts` hands it to the cron library | eval 18 (`timezone: 'Asia/Shanghai'`, `0 9 * * *` must_contain; `0 1 * * *`, `UTC+8` must_not_contain) | | 14b | clock-arming honesty (`CLOCK_TRIGGER_*` warnings), the tier's record-driven way out | CLOUD | hosted / restriction profile | stays in cloud | | 14c | approval is a NODE: `approvers: [{ type, value }]`, approver types, `behavior`; out-edges labelled `approve` / `reject`; `manager` resolves only when the directory is filled | SPEC + STATED | `automation/approval.zod.ts`; `references/state-machines-and-approvals.md` (approver table, `behavior` row, labelled edges in the example) | — | | 14c | "the approval service writes the outcome back with a SYSTEM write, which carries no user: a record-change flow reacting to it must declare `runAs: 'system'`" | **PORT, corrected** — the cloud sentence conflicts with the runtime on `main`: `packages/plugins/plugin-approvals/src/approval-service.ts` `mirrorStatusField` is elevated but attributed (submit passes `submitter_id`; approve / reject / recall / send-back / revision-limit auto-reject pass `actingUserId(context)`); only the SLA auto-decision and the dead-run release pass `null`. Lint `flow-runas-unscoped` leaves `record_change` undecidable by design ("caught at run time") | `references/state-machines-and-approvals.md` § Approval Best Practices item 4, stating what the runtime does | eval 19 (`runAs: 'user'`, the cascade's trigger shape must_contain; an approval node or a `sys_approval_request` read must_not_contain) | | flow-node-types | node `type` is the engine's vocabulary; a tool-named node = `NO_EXECUTOR` | E/R + STATED | SKILL.md § Flow Node Types + § Valid-but-silently-wrong 3; the `test_flow` / `toggle_flow` half is CLOUD. The E/R half (`os validate` / `os build` refusing a node `type` outside the composed registry) is in the ledger's row but NOT among #31224's four candidates — named on the card for the spec lane | cloud `authoring-rules.test.ts` corpus integrity — stays in cloud | | flow-node-tier, 14b-tier, 13-tier | the tier's vocabulary | CLOUD | restriction profile (#31156 / cloud#3713) | stay in cloud | | process-approval-flow (solution_design) | proactively draft the approval flow after the blueprint; its shape pointers are 14 / 14c / 17 | CLOUD | orchestration | stays in cloud | Coverage decision the v1 ledger left to this PR: cloud `flow-value-envelope.test.ts` stays in cloud pointing at the moved text; eval 17 is the automation-side equivalent of its "every envelope the skill teaches passes clean" case (an envelope per field, no single-brace token), beside the spec refusal at save. **Conflicts with spec on `main`:** none in the three ported clauses. One conflict with the runtime, resolved by porting the true statement (14c above); the cloud corpus's own sentence is for cloud to correct (card 4 of the split rewires that corpus). ## Tests Docs and eval fixtures only, no runtime change ⇒ no ablation owed. The eval file parses (`json.load`), ids 1–19 contiguous; a per-case cross-check over cases 17–19 finds no `must_not_contain` string inside its own `expected_output`. Prerequisite build for the skill gates under the shared verify lock: `pnpm --filter '@objectstack/lint...' build` — `os-verify-lock: VERDICT command-exit 0 · held the lock 71s`. ## Acceptance notes - **Cloud corpus text that is false against this repo's runtime (carrier: cloud, card 4 cloud#3714 or a cloud card):** rule 14c's last sentence (quoted in the table). The hosted agent today over-elevates every approval cascade; the published skill now says what the service does. - **Enforce-or-remove rows not in #31224 (for the seat, not filed here):** (i) `flow-node-types`' E/R half — a node `type` outside the composed registry parses (open string by contract, ADR-0018) and fails every run `NO_EXECUTOR`; the runtime audits it at boot (`sealNodeTypeVocabulary`), `os validate` does not; (ii) the mechanical half of rule 14's value-vs-label — a literal compared to a `select` field in a start `condition`, or written to one in `fields` / `filter`, that is none of the field's option `value`s; cloud's graph-lint has `flow_condition_uses_label` / `flow_field_value_uses_label`, this repo's lint has no equivalent. The judgment half (the request speaks the label) is the prose ported here. - **Pre-existing CJK on `main` in five other shipped non-i18n files** (listed above): not this PR's; a separate card if the English-only rule is to cover the catalog. - Eval `must_contain` tokens `== 'lost'` and `approval_status == 'approved'` are prompt-specific values, as `applicant_email` / `signed_copy` / `hotcrm.custom` are in cases 6, 8 and 13; the root catalog has no taught-token oracle (that gate scans `ui/skills` only). ## Verification Head cited: `874185a4` (the rework commit on top of merge commit b73f140 of `origin/main` 77a675d; the union below ran on it). Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` — 64 families; every exit code captured to a file before any pipe (`COMMAND > LOG 2>&1; status=$?`); reconciled with `--ran`. - `✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 63 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. - 63 families exit 0, among them: `node scripts/check-skills-token-ratchet.mjs :: exit 0` — `skills/objectstack-automation/SKILL.md is 6527 tokens (ceiling 6527; headroom 0)`, `references/examples-flows.md is 1494 tokens (ceiling 1494; headroom 0)`, `references/state-machines-and-approvals.md is 5825 tokens (ceiling 5825; headroom 0)`, `evals/flows-triggers-approvals.json is 5153 tokens — eval fixture, measured but not ratcheted`; `--self-test :: exit 0` (`97 cases pass`); `pnpm check:role-word :: exit 0`; `pnpm --filter @objectstack/spec run check:skill-docs :: exit 0`; `check:skill-refs :: exit 0`; `pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0`; `pnpm check:lean-entry-closure :: exit 0`; `pnpm check:dts-closure :: exit 0`; `pnpm check:pm-dispatch-gates :: exit 0` (detached, `tail --pid`); `check:nul-bytes`, `check:doc-authoring`, `check:closing-keyword-parity`, `check:skill-frame-sync`, `check:skill-identifier-liveness`, `check:skill-compatibility`, `check:pm-skill-id-lint` and the rest exit 0. - NOT MEASURED locally: `pnpm check:dual-build-cjs-loads :: exit 3` (PREREQUISITE NOT MET — needs every package's dist, a full `pnpm build`; CI's `Lint & Repo Gates` reads it). - Beyond the derived list, because the diff edits two `os:check` blocks (the schedule example; the dynamic-approval flow's condition): `pnpm --filter @objectstack/spec run check:skill-examples :: exit 0` — `285 prose examples type-check across 3 surface(s)`. - Prerequisite builds under the shared verify lock, after the merge (AGENTS.md §9: `packages/spec`, `lint` and `objectql` sources moved on `main`): `pnpm --filter '@objectstack/lint...' --filter '@objectstack/objectql...' --filter '@objectstack/client-react...' build` — `VERDICT command-exit 0 · held the lock 232s` (round 0: 71s + 242s). - CJK scan (Python, the blocks named above) over every non-eval file in the three-dot diff — `SKILL.md`, `references/examples-flows.md`, `references/state-machines-and-approvals.md`, the changeset, and the lines this PR adds to the ratchet script: 0 lines. Eval fixture: parses, ids 1–19 contiguous. - Round-0 CI red (`Type Check · source gates`, job 128644219285: `state-machines-and-approvals.md is 5844 tokens; the ratchet ceiling is 5838`) is the must-fix above; the row now reads 5825 on the merged tree. - `test:repo` not run locally (no test file in the diff; CI `Test Core`). Governed `skills/**` ⇒ Tier H: draft kept; landing waits for an APPROVED review from `GOVERNED_APPROVERS`; no seat arms, queues or readies it. CI check-runs: read by the seat at hand-over, not awaited by the dev. --- _Generated by [Claude Code](https://claude.ai/code/session_012dn2AdARJtc4RgABPH7PHk)_ <!-- signed: repo:cloud#1 · os-bob · session_012dn2AdARJtc4RgABPH7PHk --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent db50231 commit 383c353

6 files changed

Lines changed: 154 additions & 23 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): objectstack-automation carries the flow-authoring judgment the hosted build agent learned — option values in conditions and writes, a named hour with its time zone, who an approval-outcome cascade runs as
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Card 1 of the cloud#3659 ruling A split, the `objectstack-automation` port — the judgment the schema cannot express and the published skill did not yet state. `SKILL.md` § Valid-but-silently-wrong: a `select` is compared and written by its option `value`, never the label the request speaks, in the start `condition` and every `fields` / `filter` entry. `references/examples-flows.md`: a wall-clock hour the request names is written as said with its IANA `timezone` on the start node's `config.schedule`, never converted to UTC by hand (a bare expression runs in UTC and records no zone); the schedule example carries one. `references/state-machines-and-approvals.md`: a flow reacting to the mirrored `approvalStatusField` runs as the person who acted and stays `runAs: 'user'`; only the SLA auto-decision and the dead-run release carry no user, so a flow that must react to those declares `runAs: 'system'` — the hosted corpus's blanket "declare system" is corrected against the approval service on `main`. The same file's three Chinese glosses are gone: the shipped skill text is English-only. Three eval cases pin the teachings (flows-triggers-approvals 17–19).

‎scripts/check-skills-token-ratchet.mjs‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,22 @@ export const CEILINGS = new Map([
501501
// 6426 after the click-through default's three tokens landed on main and
502502
// the "A Flow is a directed graph of nodes" opener was cut (the node tables
503503
// and the `nodes` + `edges` FlowSchema line already say it).
504-
['skills/objectstack-automation/SKILL.md', 6426],
504+
// 6426 -> 6527 (card #31155, the objectstack-automation port of cloud's private
505+
// authoring rules): § Valid-but-silently-wrong gains item 4 — a `select` is
506+
// compared and written by its option `value`, never the label the request
507+
// speaks, in the start `condition` and every `fields` / `filter` entry (the
508+
// condition leg otherwise stages clean and never fires; the `fields` /
509+
// `filter` leg fails the run as `invalid_option`). No same-file restatement
510+
// was left to pay it; the comment says so honestly. Arithmetic in priced
511+
// bytes (stamp line excluded), against `main`'s 25654: 25654 -> 26108 (+454)
512+
// -> ceil(26108 / 4) = 6527; headroom was 12 tokens, so the ceiling moves +101,
513+
// pinned AT the measurement. Authorised by the ruling of record that orders
514+
// this text into the published skills: objectstack-ai/cloud#3659, letter A,
515+
// ruling comment 6682024726 (2026-11-06): "How to write valid, working
516+
// metadata lives in the public skills … The private corpus's rules of that
517+
// kind move there". The raise path is the standing 2026-10-27 ruling (ruling
518+
// record 6513306068), quoted verbatim and untranslated in the PR body.
519+
['skills/objectstack-automation/SKILL.md', 6527],
505520
// 13783 -> 13892 (2026-08-31 app-repo-principles raise, see the block above).
506521
// The invariant-vs-transition-gate choice, placed in the field-conditional-rule
507522
// bullet list where the tool is actually picked. Both wrong picks are silent:
@@ -934,7 +949,19 @@ export const CEILINGS = new Map([
934949
// (whose row is RE-LOCKED above), pinned AT its landed count — no headroom,
935950
// because a split that arrives with budget is a raise wearing a new path.
936951
// The package deltas are the pointer lines and the four new file titles only.
937-
['skills/objectstack-automation/references/examples-flows.md', 1436],
952+
// 1436 -> 1494 (card #31155, the objectstack-automation port of cloud's private
953+
// authoring rules): a wall-clock hour the request names is written as said
954+
// with its IANA `timezone` on the start node's `config.schedule`, never
955+
// converted to UTC by hand (a bare expression runs in UTC and records no
956+
// zone) — the schedule example and the time-relative cadence comment carry
957+
// one. Paid down first, one restatement cut: the `requires: ['automation',
958+
// 'triggers']` / always-on `job` sentence (SKILL.md § Prerequisite's table
959+
// says it), now a pointer. 5685 -> 5976 bytes (+291 net) -> ceil(5976 / 4)
960+
// = 1494; headroom was 14 tokens, so the ceiling moves +58, pinned AT the
961+
// measurement. Same authority as the SKILL.md row above: cloud#3659 letter A
962+
// (ruling comment 6682024726) orders the text in; the raise path is the
963+
// standing 2026-10-27 ruling (record 6513306068), quoted in the PR body.
964+
['skills/objectstack-automation/references/examples-flows.md', 1494],
938965
// NEW FILE (card #27223): one recipe per integration archetype — OpenAPI with
939966
// a static key, remote MCP with a static header, plain REST, the vendor
940967
// callback, a hand-written document for a spec-less vendor — plus the two
@@ -1026,7 +1053,30 @@ export const CEILINGS = new Map([
10261053
// bytes, 0 tokens, so the ceiling moves +143, pinned AT the measurement.
10271054
// Same maintainer ruling as the SKILL.md row above (2026-10-27, verbatim and
10281055
// untranslated): 「A:先削重复,余额按实测提高」.
1029-
['skills/objectstack-automation/references/state-machines-and-approvals.md', 5730],
1056+
// 5730 -> 5838 (card #31155, the objectstack-automation port of cloud's private
1057+
// authoring rules): Approval Best Practices gains item 4 — a flow reacting
1058+
// to the mirrored `approvalStatusField` runs as the person who acted and
1059+
// stays `runAs: 'user'`; only the SLA auto-decision and the dead-run release
1060+
// carry no user, so a flow that must react to those declares
1061+
// `runAs: 'system'` (the approval service's attributed mirror write on
1062+
// `main`, which corrects the hosted corpus's blanket "declare system").
1063+
// Paid down first: the discount-approval example's four-line trigger comment
1064+
// (a restatement of SKILL.md § Trigger Configuration) is one pointer line;
1065+
// the three Chinese glosses left the file — the shipped skill text is
1066+
// English-only (the approver's review on #31223); and the three legacy
1067+
// "(the old …)" glosses in § Branching, side-effects & rejection, naming
1068+
// approval-process keys that have no author since ADR-0019, are gone. Two
1069+
// one-word corrections ride along: the mirrored statuses list `returned`
1070+
// (send-back writes it), and the dynamic-approval example's condition reads
1071+
// `record.status`. Base is `main`'s 22908 — #31322 had added 24 bytes to
1072+
// this file (`record.amount != null && `) under the first pin, which CI
1073+
// measured on the merge result as 5844 against a 5838 row. 22908 -> 23299
1074+
// bytes (+391 net) -> ceil(23299 / 4) = 5825; headroom was 3 tokens, so the
1075+
// ceiling moves +95, pinned AT the measurement. Same authority as the
1076+
// SKILL.md row above: cloud#3659 letter A (ruling comment 6682024726) orders
1077+
// the text in; the raise path is the standing 2026-10-27 ruling (record
1078+
// 6513306068), quoted in the PR body.
1079+
['skills/objectstack-automation/references/state-machines-and-approvals.md', 5825],
10301080
// 1779 -> 1769 (#26297): RE-LOCK at the landed count after a net cut — two
10311081
// stale `{ type: 'lookup', reference: 'user' }` fields (an object that does
10321082
// not exist) became `type: 'user'`; -10 tokens, the row follows the

‎skills/objectstack-automation/SKILL.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,13 @@ write them right the first time:
288288
} });
289289
```
290290

291+
4. **A `select` is compared and written by its option `value`, never its label.**
292+
The request speaks the label ("Lost"); the row stores the `value` (`lost`).
293+
`condition: "record.status == 'Lost'"` parses, matches no row: the flow
294+
stages clean and never fires; `fields: { status: 'Lost' }` / a `filter`
295+
fails the run (`invalid_option`). Read the field's `options`; write the
296+
`value` in the `condition` AND every `fields` / `filter` entry.
297+
291298
---
292299

293300
→ State Machines & Approvals: [references/state-machines-and-approvals.md](./references/state-machines-and-approvals.md) — its state-introspection route is `GET /api/v1/meta/object/:name/state/:field?from=:state`.

‎skills/objectstack-automation/evals/flows-triggers-approvals.json‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,63 @@
293293
"type: 'record:alert'"
294294
]
295295
}
296+
},
297+
{
298+
"id": 17,
299+
"prompt": "`customer.status` 的选项是 `{ value: 'active', label: '活跃' }` 和 `{ value: 'lost', label: '已流失' }`。当客户状态变成「已流失」时,自动创建一条 `followup_task`:标题是「流失回访: 」加客户名,`customer` 指回这个客户,负责人是该客户的负责人。",
300+
"expected_output": "A `record_change` flow bound on the `start` node `config` (`objectName: 'customer'`, `triggerType: 'record-after-update'`) whose bare-CEL condition compares the option VALUE the field stores — `previous.status != 'lost' && record.status == 'lost'` — never the label the request speaks; one `create_record` on `followup_task` whose `fields` are CEL value envelopes: `subject: { dialect: 'cel', source: \"'流失回访: ' + record.name\" }`, `customer: { dialect: 'cel', source: 'record.id' }`, `owner_id: { dialect: 'cel', source: 'record.owner_id' }` — never a single-brace token over the record, which is refused at save; `edges` start → create → end.",
301+
"files": [],
302+
"assertions": {
303+
"must_contain": [
304+
"record-after-update",
305+
"previous.",
306+
"== 'lost'",
307+
"dialect: 'cel'",
308+
"create_record"
309+
],
310+
"must_not_contain": [
311+
"== '已流失'",
312+
"status: '已流失'",
313+
"{record."
314+
]
315+
}
316+
},
317+
{
318+
"id": 18,
319+
"prompt": "Every morning at 9, Shanghai time, send the `crm_sales` queue's members an inbox digest of `opportunity` records whose `close_date` falls this week.",
320+
"expected_output": "A `type: 'schedule'` flow, `runAs: 'system'` (a schedule run has no trigger user), the cadence on the START node's `config.schedule` written as the request said it, with its IANA zone: `{ type: 'cron', expression: '0 9 * * *', timezone: 'Asia/Shanghai' }` — never the hour converted to UTC by hand: a bare expression runs in UTC and records no zone, so the next reader takes the converted hour as the intended one; a `get_record` over `opportunity` with a `close_date` window and a `notify` to the queue's members.",
321+
"files": [],
322+
"assertions": {
323+
"must_contain": [
324+
"type: 'schedule'",
325+
"timezone: 'Asia/Shanghai'",
326+
"0 9 * * *",
327+
"runAs: 'system'",
328+
"notify"
329+
],
330+
"must_not_contain": [
331+
"0 1 * * *",
332+
"UTC+8"
333+
]
334+
}
335+
},
336+
{
337+
"id": 19,
338+
"prompt": "Our `invoice` approval flow mirrors its outcome onto `invoice.approval_status`. When that field becomes `approved`, create a `payment_task` for the finance queue. Should this reacting flow run as system?",
339+
"expected_output": "No. The mirror write is elevated but attributed to the person who decided, so a `record_change` flow on `invoice` (`triggerType: 'record-after-update'`, condition `previous.approval_status != 'approved' && record.approval_status == 'approved'`) stays `runAs: 'user'` and runs with the approver's own grants; its `create_record` on `payment_task` is judged as them. Only an SLA `escalation` auto-decision or the release of a record whose run died carries no user — a flow that must react to those too declares `runAs: 'system'`.",
340+
"files": [],
341+
"assertions": {
342+
"must_contain": [
343+
"runAs: 'user'",
344+
"record-after-update",
345+
"approval_status == 'approved'",
346+
"create_record"
347+
],
348+
"must_not_contain": [
349+
"type: 'approval'",
350+
"sys_approval_request"
351+
]
352+
}
296353
}
297354
]
298355
}

‎skills/objectstack-automation/references/examples-flows.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@ export const EscalateOverdueCasesFlow = defineFlow({
2525
{
2626
id: 'start',
2727
type: 'start',
28-
label: 'Daily at 09:00',
28+
label: 'Daily at 09:00 Shanghai time',
2929
// The cadence lives HERE — FlowSchema has NO top-level `schedule` key (a
3030
// named parse error). A bare cron string also works; a cron`…` tag does not.
31-
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
31+
// A bare expression runs in UTC. A wall-clock hour the request names is
32+
// written AS SAID with its IANA `timezone` — never converted to UTC by hand:
33+
// the arithmetic may be right, but nothing then records which zone was meant.
34+
config: { schedule: { type: 'cron', expression: '0 9 * * *', timezone: 'Asia/Shanghai' } },
3235
},
3336
{
3437
id: 'escalate_overdue',
@@ -98,8 +101,8 @@ day, a threshold is never missed.
98101
filter: { status: 'active' }, // optional, ANDed with the date window
99102
// maxRecords: 1000 // optional per-sweep cap (default 1000)
100103
},
101-
// Optional sweep cadence; omit for daily 08:00 UTC. Plain shape only:
102-
// schedule: { type: 'cron', expression: '0 8 * * *' }
104+
// Optional sweep cadence; omit for daily 08:00 UTC. Plain shape only, a
105+
// named hour with its zone: schedule: { type: 'cron', expression: '0 8 * * *', timezone: 'Asia/Shanghai' }
103106
},
104107
},
105108
// …downstream nodes (notify, update_record, …)
@@ -128,7 +131,6 @@ fires once, idempotent, no guard field. Each bound is a CEL value envelope
128131
query placeholder such as `{today}` is refused there (`expression-invalid`).
129132
For "days remaining" in a message, use `daysBetween(today(), record.end_date)`.
130133

131-
`timeRelative` ships in `@objectstack/trigger-schedule` — needs
132-
`requires: ['automation', 'triggers']` (the sweep cadence
133-
runs on the always-on `job` service). Full descriptor schema:
134+
`timeRelative` ships in `@objectstack/trigger-schedule` (its `requires` tokens:
135+
SKILL.md § Prerequisite). Full descriptor schema:
134136
`node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts`.

0 commit comments

Comments
 (0)