diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 3865027b4c7..0ead1e4605f 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -790,7 +790,18 @@ export const CEILINGS = new Map([ // ceiling moves +318, pinned AT the measurement. Same maintainer ruling as // the SKILL.md row above (2026-10-27, verbatim and untranslated): // 「A:先削重复,余额按实测提高」. - ['skills/objectstack-automation/evals/flows-triggers-approvals.json', 2141], + // 2141 -> 2427 (card #26362): one eval prompt for the record-anchored + // approval lock read — a record page that offers an edit to a member who is + // not on the approval, whose save the lock hook then refuses; the ideal + // answer reads the lock (`client.approvals.getRecordLock`) rather than a + // request row the non-participant is never served, written so it never + // spells either `must_not_contain` string. Paid down first: the file's nine + // fixtures share no sentence, nothing to cut (0 bytes). 8561 -> 9705 bytes + // (+1144) -> ceil(9705 / 4) = 2427; headroom was 3 bytes, 0 tokens, so the + // ceiling moves +286, pinned AT the measurement. Same maintainer ruling as + // the SKILL.md row above (2026-10-27, verbatim and untranslated): + // 「A:先削重复,余额按实测提高」. + ['skills/objectstack-automation/evals/flows-triggers-approvals.json', 2427], // 894 -> 1095 (#26179): one regression case for the field permission `when` // (the contract prompt: lock the legal terms for finance from approval on, // legal keeps the edit) — expected: `when` on the finance set's entries with @@ -871,7 +882,18 @@ export const CEILINGS = new Map([ // bullets and were deleted (~73 tokens). Landed 5555 -> 5587: +32 net, 14 // absorbed by headroom, ceiling +18. Same maintainer ruling as the SKILL.md // row above (2026-10-27, verbatim and untranslated): 「A:先削重复,余额按实测提高」. - ['skills/objectstack-automation/references/state-machines-and-approvals.md', 5587], + // 5587 -> 5730 (card #26362): one short section, "Reading the lock" — the + // record-anchored approval lock read every reader may ask (the route, the + // SDK call, `locked` alone with request content behind + // `approvalsVisibleToReaders`, an unreadable record answered as a missing + // one), +782 bytes. Paid down first, two genuine restatements of the + // Send-back section cut to pointers: the config table's `maxRevisions` row + // (-76 bytes) and the Branching section's send-back bullet (-134 bytes). + // 22346 -> 22918 bytes (+572 net) -> ceil(22918 / 4) = 5730; headroom was 2 + // bytes, 0 tokens, so the ceiling moves +143, pinned AT the measurement. + // Same maintainer ruling as the SKILL.md row above (2026-10-27, verbatim and + // untranslated): 「A:先削重复,余额按实测提高」. + ['skills/objectstack-automation/references/state-machines-and-approvals.md', 5730], // 1779 -> 1769 (#26297): RE-LOCK at the landed count after a net cut — two // stale `{ type: 'lookup', reference: 'user' }` fields (an object that does // not exist) became `type: 'user'`; -10 tokens, the row follows the diff --git a/skills/objectstack-automation/evals/flows-triggers-approvals.json b/skills/objectstack-automation/evals/flows-triggers-approvals.json index 8ad05dc2ef7..999b39b6cd8 100644 --- a/skills/objectstack-automation/evals/flows-triggers-approvals.json +++ b/skills/objectstack-automation/evals/flows-triggers-approvals.json @@ -90,6 +90,16 @@ "must_contain": ["record_link", "reference", "defaultValue", "outputVariable", "dialect: 'cel'"], "must_not_contain": ["type: 'record'", "]("] } + }, + { + "id": 10, + "prompt": "The `expense` record page shows an Edit button to every member who can read the record. While an expense is under approval, members who are not on the approval still get the button and their save is refused with `409 RECORD_LOCKED`. Make the page know the record is locked before it offers the edit.", + "expected_output": "Read the lock itself: `client.approvals.getRecordLock('expense', recordId)` (`GET /api/v1/approvals/records/expense/:recordId/lock`) answers `{ locked }` to anyone who can read the record, participant or not — the same judgement the lock hook applies to the write — so the page hides or disables Edit while `locked` is `true`. Not the request row: a non-participant is served none, and request content stays behind the object's `approvalsVisibleToReaders`, which is not switched on for this. A record the caller cannot read answers `404 RECORD_NOT_FOUND`, the same as a missing one.", + "files": [], + "assertions": { + "must_contain": ["getRecordLock", "locked"], + "must_not_contain": ["lock_record", "approvalsVisibleToReaders: true"] + } } ] } diff --git a/skills/objectstack-automation/references/state-machines-and-approvals.md b/skills/objectstack-automation/references/state-machines-and-approvals.md index d9e6b207818..20393706b09 100644 --- a/skills/objectstack-automation/references/state-machines-and-approvals.md +++ b/skills/objectstack-automation/references/state-machines-and-approvals.md @@ -219,6 +219,20 @@ reject) — enforced before any write, with no elevation bypass, so the run cannot resume past the node with the key a later `expression` approver reads still missing. +### Reading the lock + +A page over a record under approval must not offer an edit the server then +refuses with `409 RECORD_LOCKED`. The request row tells only a participant, +or any reader once the object declares `enable.approvalsVisibleToReaders`; +everyone else is served no row. Ask the lock itself — +`GET /api/v1/approvals/records/:object/:recordId/lock`, in the SDK +`client.approvals.getRecordLock(object, recordId)` — answered to anyone who +can read the record as `{ success: true, data: { locked } }`: `locked` and +nothing else (request, approvers and history stay behind that switch), the +same judgement the lock hook applies to an edit. The record is read as the +caller: one they cannot read answers `404 RECORD_NOT_FOUND`, the same body +an id that does not exist gets. + ### Approver Types | `type` | Resolves to | @@ -350,7 +364,7 @@ Object-hook `ctx` is a different vocabulary — see **objectstack-data** | `excludeSubmitter` | `true` drops the run's submitter (`submitter_id`) from the resolved slate, whatever approver type produced them — segregation of duties; a slate it empties takes the `onEmptyApprovers` route, never a silent pass. `onEmptyApprovers: 'auto_approve'` is **refused** beside it (it would approve exactly the request this exists to stop): use `admin_rescue`, `fail` or `fallback`. Default `false` | | `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `.` variables; undeclared keys reject the decision; `decision`/`requestId` reserved | | `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `timeoutHours` is **calendar (wall-clock) hours** — nights, weekends and holidays count; the platform ships no business-hours calendar. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) | -| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge | +| `maxRevisions` | Send-backs per run before auto-reject (ADR-0044) — see *Send-back for revision* above | ### Branching, side-effects & rejection @@ -362,9 +376,7 @@ These are wired on the **graph**, not in node config: `http`, a `notify` node, …) to the `approve` / `reject` out-edge. - **Roll back on reject** — route the `reject` edge as a **back-edge** to an earlier node so the submitter can revise (the old `back_to_previous`). -- **Send back for revision (ADR-0044)** — distinct from a plain reject: a - `revise` out-edge into an **`approval_revise`** window, closed by a - `type: 'back'` resubmit edge. See *Send-back for revision* above. +- **Send back for revision (ADR-0044)** — see *Send-back for revision* above. - **Hard reject** — route the `reject` edge to an `end` node (the old `reject_process`).