Repository navigation
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
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #90
What was wrong (measured on 17.7.0,
origin/main@46e65f0)POST /api/v1/actions/clm_contract/launch_contractwithsubmit_now: trueanswered400 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 stayeddraft. The browser wizard shows the same message as a toast.byIdImageCheck(objectqlindex.mjs:17358), which is the post-hook image check. On 17.5+,checkis judged on the row as itsbeforeUpdatehooks leave it (RowLevelSecurityPolicySchema.check).contract_requester_edit_windowdeclares nocheck, so itsusing(status in draft, submitted) stands in.contract_state_machinerewrotestatusfromsubmittedtoin_review(orin_approval) inside the submitter's own write, so the judged row was outside the window.PATCH /api/v1/data/clm_contract/ID {"status":"submitted"}→403with the same message.submit_nowon an Order Form (no legal review, hop toin_approval) →400too, and the contract staysdraft.ctx.api={ ...caller, isSystem: true }(runtimebuildActionExecutionContext), and the security middleware returns early for anisSystemwrite (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.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.tscontract_state_machinestill checks F2's onward hop ondraft → 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 rewritesstatus, so the submitter's write lands assubmitted, inside the requester's window, and that window judges it. The hop check stamps nothing (checkingHop).contract_route_onward(afterUpdate,runAs: 'system', priority 150). After a contract enterssubmittedfromdraft, it re-reads the committed row and takes the same decision:in_approvalwhen the type needs no legal review,in_reviewwhen a legal owner was assigned, otherwise none. It writes that hop as a system write. That write meets the state machine again and stampsreview_started_at.userIdis carried, so F5's run and the audit stamps name the submitter.src/flows/contract-intake.flow.ts: comment only, on thesubmit_contractnode. 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 plusrequiredPermissions.Nothing a requester may write directly widens. No
checkwas added to the window, and the requester profile is untouched. A requester still cannot writein_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/triggerhas 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 thePATCHdoor 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)46e65f0)6629072)launch_contractMSA,submit_now: true400 FLOW_FAILED(RLS CHECK), staysdraft200,in_review,legal_owner= least-loaded counsel,review_started_atstampedlaunch_contractOrder Form,submit_now: true400, staysdraft200,in_approval, exactly onesys_approval_request(pending,submitter_id= the requester)PATCH status: submitted(MSA draft)403200, storedin_reviewsubmit_contract(MSA / Order Form)200→in_review/in_approval200→in_review/in_approval(unchanged)Negatives after the fix, as the same requester:
in_reviewcontract,PATCH title→403 "You do not have access to this record". So doPATCH status: draftandPATCH status: in_approval. The row is unchanged.PATCH status: in_reviewon a draft →403 "You are not allowed to save this record with the values you entered", and the contract staysdraft.opendeviation, header Submit →422 INVALID_STATE "1 deviation(s) are still open; decide each one before the contract enters approval.".PATCH status: submitted→ the same422. Both leave the contractdraftwithsubmitted_atnull.status,legal_owner,route_*,approval_status, andupdated_by= the requester. The only difference is thatreview_started_atnow trailssubmitted_atby 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 onclm_legal_counsel.46e65f0after the fix was committed, server rebuilt and checked to have nocontract_route_onwardindist/objectstack.json): Launch Contract → MSA + counterparty → Contract details → First version → Upload → Submission, "Submit now" ticked → Submit. The dialog and a toast showNode '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) staysdraft.git checkout HEAD -- src/objects/contract.hook.ts. Afterwardsgit diff HEADis empty and the blob isf650506= HEAD's. The rebuilt artifact carries the hook again.Contract launched., and all fourresumecalls →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.in_reviewrecord page shows no Submit or Edit button. The overflow menu holds only "Share".PATCHfrom that page →403. The same requester's draft page does show Submit and Edit.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.clm_contract_version.allowEditin the console's copy ofGET /api/v1/auth/me/permissionsonly, so the upload form renders editable. The server-side grants are untouched. The version create landed201as the requester under its realallowCreate, 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.404on load (present on every page), and403onsys_approval_requestfrom 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_requestanswer 403 #86). Nothing new.Gates (all on
6629072, the head of this PR)Costs, stated
draft → submittedby the requester, thensubmitted → in_review / in_approval). Both carry the requester'suserId.PATCH status: submittedresponse echoesstatus: submitted, the submitter's write, while the stored row is alreadyin_review. Measured: PATCH bodysubmitted, GET right afterin_review. The wizard and the header button do not display that response.afterUpdatehooks. If the second write ever threw, the error would reach the caller while the contract rests insubmitted, 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)
allowEdit.@objectstack/console17.7.0checkField(object, field, 'write')readsobjects[o].allowEdit, and the form plugin disables every field that fails it whenevermode !== 'view', create included. A requester holdsclm_contract_versionallowCreate: 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.0main: 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.clm_contract_type.template_filereturns the raw id string, while the admin's returns{ id, name, size, mimeType, url }. The requester has nosys_fileread, so hydration is skipped, the same root as Full browser test on 17.7.0main: 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'sfile: '{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.submit_contracthas norequiredPermissions, 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.submitted)". After this PR the routing stamp and the legal-owner assignment still run there, and the hop is taken bycontract_route_onward(afterUpdate). The behaviour column stays true. Updating the row is outside this PR's file surface.Generated by Claude Code