Skip to content

The intake wizard's "Submit now" fails for every requester — FLOW_FAILED, the edit-window RLS policy refuses the post-submit row, while the header Submit button on the same draft succeeds - #91

Draft
objectstack-fleet[bot] wants to merge 1 commit into
mainfrom
claude/issue-90-wizard-submit-now

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #90

What was wrong (measured on 17.7.0, origin/main @ 46e65f0)

  • The intake wizard's "Submit now" fails for every requester. As Business Requester 1, POST /api/v1/actions/clm_contract/launch_contract with submit_now: true answered 400 FLOW_FAILED "Node 'submit_contract' failed: update_record(clm_contract) failed: You are not allowed to save this record with the values you entered.". The server logged [Security] RLS check FAILED on update 'clm_contract' — write denied (fail-closed). The contract stayed draft. The browser wizard shows the same message as a toast.
  • It is the row-level CHECK after the hooks. The stack trace runs through byIdImageCheck (objectql index.mjs:17358), which is the post-hook image check. On 17.5+, check is judged on the row as its beforeUpdate hooks leave it (RowLevelSecurityPolicySchema.check). contract_requester_edit_window declares no check, so its using (status in draft, submitted) stands in. contract_state_machine rewrote status from submitted to in_review (or in_approval) inside the submitter's own write, so the judged row was outside the window.
  • Every caller-scoped door failed, not only the wizard. A requester's PATCH /api/v1/data/clm_contract/ID {"status":"submitted"} → 403 with the same message. submit_now on an Order Form (no legal review, hop to in_approval) → 400 too, and the contract stays draft.
  • Why the header Submit button worked. A script action's body is TRUSTED. The platform gives it ctx.api = { ...caller, isSystem: true } (runtime buildActionExecutionContext), and the security middleware returns early for an isSystem write (if (opCtx.context?.isSystem) return next()). So the header button never met the window at all. The server prints this on every press: [action-audit] REST action 'clm_contract/submit_contract' — body executes TRUSTED (system-elevated context, RLS/FLS-bypassing). The lifecycle actions' docblock claimed these buttons write "AS THE CALLER", which is false.
  • The auto-hop itself is intended. DESIGN.md §06 F2 says "…分配 legal_owner 并进 in_review,否则直进 in_approval". The hop is not the defect. The defect is that the hop is written as part of the requester's write.

What changed

  • src/objects/contract.hook.ts
    • contract_state_machine still checks F2's onward hop on draft → submitted. It runs the hop through the same table and the same guard block a hand-made transition meets, so a hop that would be refused (for example, an open deviation on a type that goes straight to approval) still refuses the whole submission. It no longer rewrites status, so the submitter's write lands as submitted, inside the requester's window, and that window judges it. The hop check stamps nothing (checkingHop).
    • New hook contract_route_onward (afterUpdate, runAs: 'system', priority 150). After a contract enters submitted from draft, it re-reads the committed row and takes the same decision: in_approval when the type needs no legal review, in_review when a legal owner was assigned, otherwise none. It writes that hop as a system write. That write meets the state machine again and stamps review_started_at. userId is carried, so F5's run and the audit stamps name the submitter.
  • src/flows/contract-intake.flow.ts: comment only, on the submit_contract node. The node is unchanged and still writes as the launcher.
  • src/actions/contract-lifecycle.actions.ts: docblock only. It now states the measured truth: the header actions write system-elevated, and what still binds the caller is the caller-scoped read of the subject record plus requiredPermissions.

Nothing a requester may write directly widens. No check was added to the window, and the requester profile is untouched. A requester still cannot write in_review / in_approval (measured below).

Why not the suggested route ("make the wizard take the header's path"). The header's path is a system-elevated write. A flow node cannot run elevated on its own; it would need a runAs: 'system' child flow. Any authenticated user can trigger that flow by name (POST /api/v1/automation/NAME/trigger has no per-flow authorization), so it would need a hand-written ownership guard to avoid becoming a "submit any draft in the org" door. It would also leave the PATCH door broken. Splitting the hop off keeps every write as the launcher (the F1 header's own rule) and adds no new door.

Evidence: API (Business Requester 1, after the fix @ 6629072)

Door Before (46e65f0) After (6629072)
launch_contract MSA, submit_now: true 400 FLOW_FAILED (RLS CHECK), stays draft 200, in_review, legal_owner = least-loaded counsel, review_started_at stamped
launch_contract Order Form, submit_now: true 400, stays draft 200, in_approval, exactly one sys_approval_request (pending, submitter_id = the requester)
PATCH status: submitted (MSA draft) 403 200, stored in_review
header submit_contract (MSA / Order Form) 200 → in_review / in_approval 200 → in_review / in_approval (unchanged)

Negatives after the fix, as the same requester:

  • Edits after submission are refused. On an in_review contract, PATCH title → 403 "You do not have access to this record". So do PATCH status: draft and PATCH status: in_approval. The row is unchanged.
  • A requester cannot write a hop target directly. PATCH status: in_review on a draft → 403 "You are not allowed to save this record with the values you entered", and the contract stays draft.
  • A refused hop still refuses the submission whole. On an Order Form draft with one open deviation, header Submit → 422 INVALID_STATE "1 deviation(s) are still open; decide each one before the contract enters approval.". PATCH status: submitted → the same 422. Both leave the contract draft with submitted_at null.
  • The stored shape matches the header path's. Stamps compared with a pre-fix header submission: same status, legal_owner, route_*, approval_status, and updated_by = the requester. The only difference is that review_started_at now trails submitted_at by about 150 ms, because the hop is a second write.

Evidence: browser (Chromium 1194, 1440×900, console on port 3492)

Setup was the README operator setup: Business Requester 1–3 on clm_requester, and Legal Counsel 1–2 on clm_legal_counsel.

  • Before (hook temporarily put back to 46e65f0 after the fix was committed, server rebuilt and checked to have no contract_route_onward in dist/objectstack.json): Launch Contract → MSA + counterparty → Contract details → First version → Upload → Submission, "Submit now" ticked → Submit. The dialog and a toast show Node 'submit_contract' failed: update_record(clm_contract) failed: You are not allowed to save this record with the values you entered.. Network: resume -> 400 FLOW_FAILED. Server: [Security] RLS check FAILED on update 'clm_contract'. The contract (MSA-2026-0032) stays draft.
    • Restore: git checkout HEAD -- src/objects/contract.hook.ts. Afterwards git diff HEAD is empty and the blob is f650506 = HEAD's. The rebuilt artifact carries the hook again.
  • After (same clicks, restored build): toast Contract launched., and all four resume calls → 200. The record page of MSA-2026-0033 shows the stage bar at In Review, Legal Owner Legal Counsel 1, Versions 1. The same pass on an Order Form gives ORD-2026-0024 at In Approval.
  • Negative in the UI: the in_review record page shows no Submit or Edit button. The overflow menu holds only "Share". PATCH from that page → 403. The same requester's draft page does show Submit and Edit.
  • Header Submit re-check: on draft SUP-2026-0019, clicking Submit → POST /api/v1/actions/clm_contract/submit_contract -> 200 {"success":true}. After a reload the record shows In Review with Legal Owner Legal Counsel 2. No console errors.
  • Declared shim (client only): the wizard's step 4 cannot be completed by a requester on this console build, for a reason outside this card (see Acceptance notes, 1). The browser passes above flip clm_contract_version.allowEdit in the console's copy of GET /api/v1/auth/me/permissions only, so the upload form renders editable. The server-side grants are untouched. The version create landed 201 as the requester under its real allowCreate, and every write this card is about ran under the requester's real permissions. The record-page and header-Submit checks ran with no shim.
  • Keyboard: the narrow screen dialog does not scroll (objectui#12080), so the dialog's primary button was reached by keyboard focus plus Enter.
  • Console errors seen: one 404 on load (present on every page), and 403 on sys_approval_request from the Approvals tab (deliberate, A business requester and a legal counsel get "You don't have permission" on the contract's Discussion tab and a blank Approvals tab — sys_activity / sys_comment / sys_attachment / sys_approval_request answer 403 #86). Nothing new.

Gates (all on 6629072, the head of this PR)

$ pnpm validate
  ✓ Validation passed (1022ms)
EXIT=0   (7 pre-existing ⚠: hierarchy-security capability, contract_approval approver slates — not this diff)

$ pnpm lint
  6 suggestion(s) (1104ms)
EXIT=0   (0 errors, 0 warnings)

$ pnpm typecheck
> tsc --noEmit
EXIT=0

$ pnpm lint:i18n-gate
  COVERAGE : 0 missing keys across 2 locale(s)
EXIT=0

Costs, stated

  • Two writes per submission. There are two record-change events and two activity rows (draft → submitted by the requester, then submitted → in_review / in_approval). Both carry the requester's userId.
  • Stale write response. A PATCH status: submitted response echoes status: submitted, the submitter's write, while the stored row is already in_review. Measured: PATCH body submitted, GET right after in_review. The wizard and the header button do not display that response.
  • No transaction around the second write. A single update has no transaction around its afterUpdate hooks. If the second write ever threw, the error would reach the caller while the contract rests in submitted, which is a valid state that legal can accept by hand. The submitter's write already ran the same guards, so this needs a race.

Acceptance notes (out of scope, not fixed here)

  1. Platform (console): create-mode object form gates fields on allowEdit. @objectstack/console 17.7.0 checkField(object, field, 'write') reads objects[o].allowEdit, and the form plugin disables every field that fails it whenever mode !== 'view', create included. A requester holds clm_contract_version allowCreate: true, allowEdit: false (DESIGN.md §04 "RC"). So the wizard's "Upload the first version" form renders every field disabled ("You do not have edit access to this field."), and its POST omits them: POST /api/v1/data/clm_contract_version -> 400 VALIDATION_FAILED "Contract is required; Version No. is required; File is required". This blocks every requester at intake step 4 once permissions have loaded. Full browser test on 17.7.0 main: drive the whole contract lifecycle as every audience, in en and zh-CN, and report what a real user hits #87's pass got through, plausibly by rendering before they loaded. Reported in the dev report for the seat to file. No app-side change.
  2. Platform (file read shape) seen through the template branch. A requester's read of clm_contract_type.template_file returns the raw id string, while the admin's returns { id, name, size, mimeType, url }. The requester has no sys_file read, so hydration is skipped, the same root as Full browser test on 17.7.0 main: drive the whole contract lifecycle as every audience, in en and zh-CN, and report what a real user hits #87's P13. The flow's file: '{typeRecord.template_file.id}' is therefore empty for a requester, and "Draft from template" fails: resume -> 400 FLOW_FAILED "Node 'create_template_version' failed: create_record(clm_contract_version) failed: File is required". Reported in the dev report.
  3. The header actions are system-elevated. submit_contract has no requiredPermissions, so any caller who can READ a draft can submit it, even without write. No reader-but-not-writer of a draft was measured, so this is noted only, not filed.
  4. DESIGN.md §06 F2 type column. It reads "hook beforeUpdate(进入 submitted)". After this PR the routing stamp and the legal-owner assignment still run there, and the hop is taken by contract_route_onward (afterUpdate). The behaviour column stays true. Updating the row is outside this PR's file surface.

Generated by Claude Code

…r's submission lands

The requester's edit window (contract_requester_edit_window, using: status in
draft/submitted, no check) is judged on 17.5+ against the row as its
beforeUpdate hooks leave it. contract_state_machine rewrote status from
submitted to in_review / in_approval inside the submitter's own write, so every
caller-scoped submission by a requester was refused with the RLS CHECK: the
intake wizard's Submit now (issue #90), the same flow headless, and a PATCH of
status. The header Submit kept working only because a script action body runs
system-elevated and skips row-level security.

The state machine now CHECKS the onward hop on the submitter's write (so a hop
that would be refused still refuses the submission whole) and leaves status at
submitted; a new afterUpdate hook, contract_route_onward (runAs system), takes
the hop on the committed row. Nothing a requester may write directly widens.

Also corrects the lifecycle actions' docblock, which claimed the header
actions write as the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HihZ11bQSqjCgjzHbpv4M1
This was referenced Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants