Confirm and approve a duty before it dispatches - #114
Conversation
`duly_duty` gains `review_status` — `to_confirm → to_review → approved | returned` — and the dispatcher refuses anything that is not `approved`. A returned duty stops producing tasks the same night; the tasks it already produced stay where they are. The whole workflow rides the platform's own clickable status pipeline (`trackHistory` was already on) rather than declared actions, which render nowhere in the Console today (objectui#7234). Three enforcements, all platform surfaces, no authorization hook: - `review_status_transitions`, a `state_machine` rule, pins the legal steps and — through `initialStates` — stops a duty being born mid-pipeline. It adds one edge the card's table does not draw, `approved → returned`: measured, a terminal `approved` leaves a live duty with no correction path at all, for anyone. - `returned_needs_note` makes the reason mandatory, and it stays readable after the duty goes back up for review. - The two VERDICT options carry `visibleWhen: record.owner != current_user.id` — the one authoring surface whose predicate scope binds the caller, enforced by the rule validator on the write path, not just hidden in the picker. It names the record relationship rather than a position because `current_user.positions` is populated from rows a package may not declare, so a position predicate would fail closed in every deployment that has not done the manual binding step, `pnpm demo` included. Seed: 25 approved, 2 waiting on their owner, 2 waiting on a reviewer, 1 returned with a reason — two of them the demo account's own, so the pipeline is walkable on a record page without a second login. History is planned as `approved` throughout: those tasks were dispatched, which is exactly why returning a duty stops the next run rather than retracting the last six months. Views 「待我确认」 and 「待我审定」, both in the navigation, plus the zh-CN entries for every new label, option and view. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Measured in the running console: with `stageField` absent the record-detail stepper falls back to a heuristic that takes the first field named `status` / `stage` / `state` / `phase` — so `duly_duty`'s stepper was Active · Paused · Retired, which is not a progression. ADR-0085 calls that an unordered state set and says the stage heuristics should be suppressed for it. `review_status` is the linear one, so it is what the key names. The pipeline now renders To confirm · To review · Approved · Returned on the record page, with completed stages ticked. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
…-review-workflow # Conflicts: # src/apps/duly.app.ts # src/translations/zh-CN.ts
…#107) `review_status` is required, writable, and defaulted from `source` — the first field to be all three — so `test/import-samples.test.ts` demanded a column for it in `samples/duties.csv`. An imported catalog row lands `to_confirm` without one, which is exactly what the product wants an imported list to be, and the walkthrough already documents blank-cell-defers-to-default as the mechanism. The exclusion is narrow on purpose: only a RECORD-derived default (a CEL expression over other columns of the same row) drops out. A caller-derived default stays in the required set — `duly_duty.owner` defaults to `current_user`, so an omitted column silently gives every imported duty to whoever ran the import, which is the worse half of the failure this gate is for. Reverse-verified: deleting the `owner` column still fails the gate, naming `owner`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Reviewed — merging. Q1: A. Q2: D now, the deployment half is filed as #116.Gates, re-run by me on the head merged with current Two claims in my card did not survive measurement, and both corrections are the valuable part of this PR:
Q1 — keep Q2 — D, and the reasoning is exactly right. The platform's edit bit is object-level; opening it to let a member confirm a list also lets them rewrite their own cadence, and widening a manager's write scope makes approving indistinguishable from editing someone else's duty. Both overturn decisions written down with their reasons. This is p0 for a demo that runs as a platform admin, and it works there. The deployment question — whether approval is an ordinary field write at all — is a security-model decision for the maintainer, filed as #116 with the three routes and a recommendation to measure field-level write permission first. The owner gate is a good piece of work: Dispatch red-first (32 of 57 failures before any fixture moved) and the live dispatcher runs ( Generated by Claude Code |
Fixes #107
duly_dutygains areview_statuspipeline —to_confirm → to_review → approved | returned— and the dispatcher refuses to produce tasks for anything that is notapproved. A returned duty stops generating work the same night; the work it already generated stays exactly where it was.The premise this card was written on, re-measured
The card said an app-declared action renders nowhere in the Console (true — objectui#7234) but that a
trackHistory: trueselect "renders the platform's pipeline and it can be clicked". Half of that holds, and the half that does not is settled upstream rather than broken:@objectstack/console17.2.0stageFieldabsent the renderer takes the first field namedstatus/stage/state/phase, so this object's pipeline wasActive · Paused · Retireddiv role="listitem", no handler, notabindex; a full pointer sequence changes nothing. Same onduly_task.status, so it is the generic chrome, not this objectThe second one is a decision, not a defect: objectui#5768 asked for exactly this and was closed on 2026-08-24 by objectui#5917, "render
record:pathas a readout, not a fake segmented control". So nothing is filed about it here. It costs this card nothing either — the card's own plan is "pipeline + form", and the form is where advancing happens.The first one is fixed by naming the field, which is what [ADR-0085]
stageFieldis for.status(active/paused/retired) is an unordered state set — ADR-0085's own words — andreview_statusis the linear one, so the honest declaration puts the stepper where the progression actually is:Three enforcements, all platform surfaces, no authorization hook
1.
review_status_transitions— astate_machinevalidation rule. ADR-0020 retired the standaloneworkflowtype and made a record's legal transitions a validation rule with a flat{ from: [to] }table; the rule validator enforces it on the update path with the prior row in hand. Two details are load-bearing, both measured on@objectstack/objectql17.2.0:const allowed = transitions[from]; if (!Array.isArray(allowed)) return null— a missing key reads like "locked" and behaves like "wide open". Every state is written down.initialStatescloses the insert door, whichtransitionsdoes not cover at all: aselectaccepts any declared option on create, so without it a duty could be bornapprovedand dispatch immediately — the defect this card exists to close, arriving through the other door.2.
returned_needs_notemakes the reason mandatory (and the reason survives the trip back up — the owner is reading it while correcting the duty).3. Who may issue a verdict. The card asked me to measure whether the platform can restrict writing a value by position before writing any hook. It can, and the surface is per-option
visibleWhen— the one authoring predicate whose scope binds the caller, enforced server-side by the rule validator on every insert and update, not merely hidden in the picker.SelectOptionSchemasays so in as many words: "Client-side hiding is UX, not authorization … the server MUST also reject writes of its value."The predicate names the record relationship rather than a position, and that deserves its own line because the card asked for a position:
'duly_manager' in current_user.positionsevaluates and enforces correctly (measured both ways). Butcurrent_user.positionscomes fromsys_user_positionrows, which a package may not declare (src/security/positions.ts) — they are a manual rollout step. Until somebody does it, the list is empty for every caller,pnpm demoincluded, and a position predicate fails closed: nobody can approve anything. A gate that is inert-or-fatal depending on a manual step is not a gate.record.owner != current_user.idneeds nothing installed and says what the product means: a review you issue on your own list is not a review. Who else may write the record at all is already decided one layer up by the permission set's write scope — the platform's own axis for that question.Both refusals, through the REST boundary, signed in as the demo admin (ids elided):
The picker agrees with the server rather than being the only thing that knows: on the caller's own duty it offers
["To confirm","To review"]; on somebody else's it offers all four.Fail-open case, stated because it is real: a write with no acting user (the seed, an in-process job) cannot bind
current_user, so the predicate fails to evaluate and the platform admits the write with a warning. That is what lets the demo seed carryapprovedrows, and it means this predicate is a rule about people, not a containment boundary for server code. The log noise it produces (one WARN per seeded row) is filed upstream as objectstack-ai/objectstack#14416; the behaviour itself should stay.One edge the card's table does not draw
approved → returnedis admitted. Withapprovedterminal (approved: []) a live duty has no correction path at all — not for its owner, not for a manager, not for an administrator; the only remaining doors are a historical import and a re-seed. A cadence that turns out wrong, a duty approved on the wrong person, a catalog sync nobody re-read: each dispatches work every night with no way back into the pipeline that governs it.Returning is the auditable way out —
returned_needs_notemakes the reason mandatory, and the tasks already dispatched are untouched. Approval is still not self-issuable, so this is not a loophole around review. One line to revert (approved: ['returned']→approved: []) if the card meant the table literally.Dispatch
review_status = 'approved'joinsstatusandformin the sweep's query and inplanForDuty, because the planner has two callers: the nightly job passes through that query, a backfill and every planner test do not. The gate is=== 'approved', deliberately not "not returned": a row carrying no review state at all is not dispatched. Between "an obligation nobody approved goes out" and "an obligation stops until somebody looks at it", the second is the recoverable failure, and it shows up in the run'sskippedlist rather than in silence.Run against the live demo database, same duty, one field changed between the two runs:
Seed
25 approved, 2 waiting on their owner, 2 waiting on a reviewer, 1 returned with a reason. Two of the five are the demo account's own, so the pipeline is walkable on a record page without a second login.
History is planned as
approvedthroughout (demo-history.ts): those tasks were dispatched, which is precisely why returning a duty stops the next run rather than retracting the last six months. A duty sitting into_confirmwith tasks behind it is the import case this card is about — the work was always being done; the list is only now being formalised.Browser walk
pnpm demo, seeded demo organisation, signed in as the dev admin.to_confirm → to_review→ approvedreturned, reason on the record,→ to_reviewavailable to the ownerScreenshots live on the throwaway branch
claude/issue-107-screenshots; delete it with this PR.Landing second: what merging
maincostmainmoved twice while this was in flight (#110, #112). Merged, both conflicts resolved by keeping both sides —nav_peopleandnav_to_revieware two entries in the Team group, and both zh-CN labels stay.One of #110's gates then went red on this branch, and it is worth a paragraph because the fix is in their test:
test/import-samples.test.tsrequiressamples/duties.csvto carry every field that is required and writable, andreview_statusis now both — plus defaulted. An imported catalog row landsto_confirmwith no column at all, which is exactly what the product wants an imported list to be, anddocs/import/walkthrough.mdalready documents blank-cell-defers-to-default as the mechanism. So the gate now excludes a field the ROW supplies for itself.The exclusion is deliberately narrow: only a record-derived default (a CEL expression over other columns of the same row) drops out. A caller-derived default stays in the required set —
duly_duty.ownerdefaults tocurrent_user, so an omitted column would silently give every imported duty to whoever ran the import, which is the worse half of the failure that gate exists for. Reverse-verified by deleting theownercolumn from the sample: the gate still fails, namingowner.Two permission-set decisions this PR deliberately does NOT take
Both are real, both block the workflow for non-platform-admin callers in a deployment, and both contradict something recorded on purpose — so they are the maintainer's call, not a rider on this card:
allowEditonduly_duty.src/security/permission-sets.tssays why ("editing cadence after the fact is a correction, which belongs toduly_adminand toduly_catalog_sync"). An object-level edit bit is not per-field, so granting it to let members confirm their own list also lets them rewrite their own cadence.writeScopeonduly_dutyisown, with an explicit ⛔ against widening it, pinned bytest/security.test.ts— so a manager cannot write a report's duty, and cannot approve one. AGENTS.md's product invariant ("Managers do not enter status. Assigning is their only write.") is about task status, and this card introduces a manager write of a different kind; that tension is worth deciding rather than assuming.Neither is a blocker for the demo, which runs as a platform admin.
Gates
pnpm validate && pnpm typecheck && pnpm test && pnpm build— all four green onddcd075, run after the last commit (808 tests, 32 files;Validation passed).validateprints the one expected warning naming@objectstack/security-enterprise, which is this repo's documented steady state.New coverage:
test/duty-review.test.ts(23 assertions — defaults,initialStates, every legal and illegal transition, the note rule, and the owner gate driven with three different callers), plus the dispatch gate intest/dispatch.test.ts(red first: 32 tests failed the moment the planner started refusing unapproved duties) and the seed mix intest/seed.test.ts. No changeset — this repo has none.🤖 Generated with Claude Code
https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p