Repository navigation
Commit bfc1539
fix(plugin-approvals): the status mirror writes only a value the field declares (#24656)
Fixes #24577
Clause-②: no
## What changes
The approval status mirror (`ApprovalService.mirrorStatusField`,
`packages/plugins/plugin-approvals/src/approval-service.ts`) now writes
only a value the status field declares. The rule, in one sentence (the
changeset carries the same sentence):
> When `approvalStatusField` is an option field (a `select`) whose
options do not declare the status, a recall or the dead-run sweep writes
the value the field held when the flow started (the request's submit
snapshot), else the field's declared default, else empty, and every
other transition writes nothing and logs a `warn` naming the object,
field and value.
- A status the field declares is written as before. A field with no
declared value set (text, any type that is not an option type, an option
type authored without options) gets every status verbatim, as before.
- The declared options are read from `engine.getSchema(object)`, which
answers `registry.getObject(object)`: the same object the engine's
update path validates the write against. The option types are the spec's
`SINGLE_OPTION_TYPES` / `MULTI_OPTION_TYPES`; the option codes are read
the way the engine's record validator reads them.
- The write's context is unchanged (#24519's `attributedMirrorContext`,
or `SYSTEM_CTX` for the sweep). No `packages/spec` edit, no new stored
column, no new spec key.
## Measured on a real stack (A1, A2)
Real `ObjectKernel`, real ObjectQL engine and record validator, real
record-change trigger and automation engine, real approval node and
record-lock hook, driver-sql on better-sqlite3. Each door driven through
`ApprovalService`. Three status fields: a readonly select with hotcrm's
four options and default (`crm_opportunity.approval_status` in shape),
an editable select with the same options, and a text field.
| door | readonly select, `main` at 35450f0 | readonly select, this PR
| editable select, `main` | editable select, this PR | text field (both)
|
|---|---|---|---|---|---|
| submit | `pending` | `pending` | `pending` | `pending` | `pending` |
| approve | `approved` | `approved` | `approved` | `approved` |
`approved` |
| reject | `rejected` | `rejected` | `rejected` | `rejected` |
`rejected` |
| recall | `recalled` (undeclared, stored) | `not_required` (its value
at submit) | write refused by the validator: stays `pending`, warn |
empty (its value at submit) | `recalled` |
| send-back | `returned` (undeclared, stored) | `pending` (not written,
warn) | refused: stays `pending`, warn | `pending` (not written, warn) |
`returned` |
| resubmit | `pending` | `pending` | `pending` | `pending` | `pending` |
| dead-run sweep | `recalled` | `not_required` | refused: stays
`pending`, warn | empty | `recalled` |
Why `recalled` landed: the engine judges a `readonly` field's value for
shape only (record validator, update walk), and the spec advises a
readonly status field. On an editable select the engine refuses the
undeclared value, and the mirror's `catch` logged it at `warn` and left
the field reading `pending` with no request pending: the same defect
class, also closed here.
A2, the value the flow started from: `sys_approval_request.payload_json`
already holds it. It is the snapshot of the run's `$record`, which the
automation engine seeds once at run start (`seedRunVariables`), so a
second approval node's request carries the same trigger record. No new
column is needed.
The card's case on the stack, with hotcrm's submit gate bound
(`record-after-update`, admits a large deal at `not_required` or empty):
on `main`, the owner raises the amount, a request opens; the recall
leaves `recalled`; the owner's next save answers 200 and opens no
request. With this PR the recall restores `not_required` and the deal
re-enters approval.
## Deviations from the dispatch's suggested route
**1. Send-back keeps the field (no write, `warn`) instead of restoring
the submit value.** Measured with the suggested route: a one-off run
with send-back passing the snapshot (a mutation through
`scripts/ablation-replace.mjs`, restored to HEAD after, blob hash
checked), hotcrm's gate bound, a revise-capable flow:
- the send-back's own restore write re-admitted the gate, which opened a
second request on the same record at once (`returned`, `pending`);
- the record re-locked, so the submitter's revision was refused
(`RECORD_LOCKED`);
- the submitter's `resubmit` was refused `DUPLICATE_REQUEST`.
With send-back keeping `pending` (this PR, pinned): the revision lands,
no second request opens, and `resubmit` opens round 2.
Four axes:
- Real business need: measured above. hotcrm's flow has no `revise` edge
today, so hotcrm itself is not affected either way; any app with a
revise loop and a gate on the mirrored field is.
- Long-term soundness: ADR-0044 (accepted) keeps the run alive in the
revise window, and its Consequences tell authors to gate start
conditions on the mirrored approval-status field. A restore there
contradicts the ADR. `pending` is the true reading in a vocabulary
without `returned`: the approval has not concluded.
- Preventing AI mistakes: one rule. Only a transition that ends the
request restores, and no transition ever writes an undeclared value. The
`warn` names the remedy (declare the option).
- Startup scope: no new key, column or gate.
**2. The fallback after the snapshot is the field's declared default,
then empty** (the suggested route said empty). The suggested route's own
reason for empty is "what a never-submitted record carries". A field
with a declared default carries that default from insert (hotcrm's
`not_required`). It is a value the field declares, which triage allows.
Reached only when the snapshot lacks the field or holds an undeclared
value, such as a deal left at `recalled` by the old mirror.
## A consequence to know: a gate that admits the restored value
re-enters at the recall itself
The restore is itself an update of the record. A `record-after-update`
gate that admits the restored value without comparing `previous`
re-admits the record on that write. On the hotcrm-shaped fixture, the
recall opens a new request at once and the record re-locks, so the owner
cannot edit between the recall and the new request. This follows from
every value triage allows: any value the gate admits re-admits it at
once, and a value it does not admit never re-enters, which is the card's
defect. The app's own start condition decides, as triage intended. An
app that wants recall-then-edit can compare `previous` in its gate (for
example, admit only when `previous.approval_status` is also at the entry
value). This is for the hotcrm thread, not this repo. Measured on the
fixture, not on hotcrm itself.
## Tests (at `cb6360bf1`, the final head, after merging `origin/main`
`14ccb1da9`)
- New
`packages/plugins/plugin-approvals/src/status-mirror-declared-values.integration.test.ts`,
seven pins on the real stack:
- (a) recall on the hotcrm-shaped select: the field reads
`not_required`, the owner's edit lands, and a new submit opens a new
request;
- (a) with hotcrm's gate bound: a recalled deal re-enters approval, and
every value written is declared (`pending`, `not_required`, `pending`);
- (b) send-back: the field keeps `pending`, the `warn` names `crm_deal`
/ `approval_status` / `returned`, the revision lands with no parallel
request, and resubmit opens round 2;
- (d) the dead-run sweep restores `not_required`;
- a held value the select does not declare falls back to the declared
default, then to empty, on an editable select;
- a decision the select does not declare (`approved`) is not written,
the skip is logged, and no undeclared write is attempted;
- (c) a text field keeps `recalled` / `returned` verbatim.
- `pnpm --filter @objectstack/plugin-approvals test`: 84 files, 1139
tests passed. `pnpm --filter @objectstack/plugin-approvals typecheck`:
exit 0, test layer OK against `test-typecheck-debt.json`. Both ran on
`cb6360bf1`.
- Existing pins on a text status field (`status-mirror-cascade`,
`status-mirror-envelope`, `approval-revise` "status mirror follows the
rounds") pass unchanged. They are case (c).
**Ablation** (committed first, through `scripts/ablation-replace.mjs`).
`if (!declared || declared.includes(status)) return status;` was
replaced with `return status;`, which is the verbatim write. 6 of 7 new
pins went red:
- recall: `expected 'recalled' to be 'not_required'`;
- the gate pin timed out: the deal never re-entered, which is the card's
symptom;
- send-back: `expected 'returned' to be 'pending'`;
- the sweep: `recalled`;
- the fallback: `recalled`;
- the decision pin: `['pending','approved']` was attempted.
The text-field pin stayed green, as it should. Restore: blob
`b417ef74320e` equals HEAD's and `git diff HEAD` is empty. The subject
is imported from source (`./approval-service.js`), so no build sits
between the mutation and the run.
**Local gates** were derived by `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` at `cb6360bf1`: 69
commands. Each was run with its exit code captured before any pipe. 67
exited 0. 2 exited 3 (prerequisite not met), so they are NOT MEASURED:
- `pnpm check:dual-build-cjs-loads` needs every package's `dist/`;
- `pnpm check:i18n` needs the CLI's build closure.
This diff touches no object metadata, label or translation. Both are
declared to CI. `dispatch-gates --ran` reads: 69 derived, 67 run, 2
NOT-MEASURED, 0 UNRUN.
## Acceptance notes
- The spec's TSDoc on `ApprovalNodeConfigSchema.approvalStatusField`
names `pending`/`approved`/`rejected`/`recalled`. It omits `returned`
(written since ADR-0044) and does not say that a select receives only
its declared values.
`skills/objectstack-automation/references/state-machines-and-approvals.md`
carries the same four-value list, and
`content/docs/automation/approvals.mdx` does not state the
declared-values rule. Both are outside this card's surface (no
`packages/spec/**`). Noted, not filed: doc drift, no carrier.
- `test:repo` and dogfood were not run locally. Both are declared to CI.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 31e0735 commit bfc1539
3 files changed
Lines changed: 447 additions & 3 deletions
File tree
- .changeset
- packages/plugins/plugin-approvals/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
61 | | - | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
62 | 64 | | |
63 | 65 | | |
64 | 66 | | |
| |||
1150 | 1152 | | |
1151 | 1153 | | |
1152 | 1154 | | |
| 1155 | + | |
| 1156 | + | |
| 1157 | + | |
| 1158 | + | |
| 1159 | + | |
| 1160 | + | |
| 1161 | + | |
| 1162 | + | |
| 1163 | + | |
| 1164 | + | |
| 1165 | + | |
| 1166 | + | |
| 1167 | + | |
| 1168 | + | |
| 1169 | + | |
| 1170 | + | |
| 1171 | + | |
| 1172 | + | |
| 1173 | + | |
| 1174 | + | |
| 1175 | + | |
| 1176 | + | |
| 1177 | + | |
| 1178 | + | |
| 1179 | + | |
1153 | 1180 | | |
1154 | 1181 | | |
1155 | 1182 | | |
| |||
3227 | 3254 | | |
3228 | 3255 | | |
3229 | 3256 | | |
| 3257 | + | |
| 3258 | + | |
| 3259 | + | |
| 3260 | + | |
3230 | 3261 | | |
3231 | 3262 | | |
3232 | 3263 | | |
| |||
3235 | 3266 | | |
3236 | 3267 | | |
3237 | 3268 | | |
| 3269 | + | |
3238 | 3270 | | |
3239 | 3271 | | |
| 3272 | + | |
| 3273 | + | |
| 3274 | + | |
| 3275 | + | |
| 3276 | + | |
| 3277 | + | |
| 3278 | + | |
| 3279 | + | |
| 3280 | + | |
3240 | 3281 | | |
3241 | 3282 | | |
3242 | 3283 | | |
3243 | | - | |
| 3284 | + | |
3244 | 3285 | | |
3245 | 3286 | | |
3246 | 3287 | | |
3247 | 3288 | | |
3248 | 3289 | | |
| 3290 | + | |
| 3291 | + | |
| 3292 | + | |
| 3293 | + | |
| 3294 | + | |
| 3295 | + | |
| 3296 | + | |
| 3297 | + | |
| 3298 | + | |
| 3299 | + | |
| 3300 | + | |
| 3301 | + | |
| 3302 | + | |
| 3303 | + | |
| 3304 | + | |
| 3305 | + | |
| 3306 | + | |
| 3307 | + | |
| 3308 | + | |
| 3309 | + | |
| 3310 | + | |
| 3311 | + | |
| 3312 | + | |
| 3313 | + | |
| 3314 | + | |
| 3315 | + | |
| 3316 | + | |
| 3317 | + | |
| 3318 | + | |
| 3319 | + | |
| 3320 | + | |
| 3321 | + | |
| 3322 | + | |
| 3323 | + | |
| 3324 | + | |
| 3325 | + | |
| 3326 | + | |
| 3327 | + | |
| 3328 | + | |
| 3329 | + | |
| 3330 | + | |
| 3331 | + | |
| 3332 | + | |
| 3333 | + | |
| 3334 | + | |
| 3335 | + | |
| 3336 | + | |
| 3337 | + | |
| 3338 | + | |
| 3339 | + | |
| 3340 | + | |
3249 | 3341 | | |
3250 | 3342 | | |
3251 | 3343 | | |
| |||
4554 | 4646 | | |
4555 | 4647 | | |
4556 | 4648 | | |
| 4649 | + | |
| 4650 | + | |
4557 | 4651 | | |
4558 | 4652 | | |
4559 | | - | |
| 4653 | + | |
4560 | 4654 | | |
4561 | 4655 | | |
4562 | 4656 | | |
| |||
6609 | 6703 | | |
6610 | 6704 | | |
6611 | 6705 | | |
| 6706 | + | |
| 6707 | + | |
6612 | 6708 | | |
6613 | 6709 | | |
| 6710 | + | |
6614 | 6711 | | |
6615 | 6712 | | |
6616 | 6713 | | |
| |||
0 commit comments