Skip to content

A finance controller cannot launch a contract — clm_finance's field locks also apply to the draft they create - #166

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-127-finance-launch-fls-when
Oct 27, 2026
Merged

zhuangjianguo merged 1 commit into
mainfrom
claude/issue-127-finance-launch-fls-when

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #127

Ruling A (comment 6406413107): the platform's field permission row condition shipped in @objectstack 18.2.0 (objectstack-ai/objectstack#25866, commit c8cb510, key when), and main runs on 18.2.0 since #155. This PR adds that condition to the two locks that also landed on a launcher's own draft. DESIGN.md §04 and §13 Q1 are unchanged.

What changed

Set Entries Before After
clm_finance legal fields + risk_level, liability_cap, status, termination_reason (10 entries, readable: true, editable: false) every row when: status in ["approved", "signing", "active", "expired", "terminated"] (POST_APPROVAL_STATUSES)
clm_records editableOnly(Contract, ['status', 'archive_no']) (every other contract field locked) every row when: status in ["signing", "active", "expired", "terminated"] (EXECUTION_STATUSES)
  • src/profiles/_grants.ts: new helper onRowsWhere(security, condition). It adds when to each entry and refuses an unreadable entry, as the spec refuses readable: false beside a when.
  • src/profiles/finance.profile.ts, src/profiles/records.profile.ts: the two locks above, with docblocks that say why.
  • Not changed: finance's stamp lock (CONTRACT_STAMPED_FIELDS) stays unconditional, and so does every other set.

How it combines with clm_requester (_grants.ts open). Under the spec, a conditioned entry votes on rows that match and is absent on every other row. The most-permissive merge and the rule that only declaring sets vote are unchanged. So on a draft, the finance or records lock has no vote, and clm_requester decides alone, as it does for every launcher. The requester's own locks still hold there: stamps, risk_level, liability_cap, termination_reason (measured below). Finance or records can only update a row outside their own edit window through the requester's window: draft/submitted, own_and_reports. So the lift reaches exactly the rows the holder edits as a requester. Rows they reach through a sharing rule before their window opens stay refused by RLS (in_approval for finance, approved for records, both measured). No other set carries a when, so legal, admin, the executive and GM rungs, and plain requesters get the same field answer on every row as before.

Evidence: pnpm demo:zh on 18.2.0, throwaway DB, port 3142

Both runs used a fresh .objectstack: before = origin/main 21f2319, after = feec816. Personas were set up the README way: POST /api/v1/auth/admin/create-user, then change-password (mustChangePassword), then a sys_user_position row by name, then set-user-manager for Business Requester 1, Finance Controller 1, Records Manager 1 and Legal Counsel 1 (manager Executive 1), then every contract sharing rule re-evaluated. Browser: Playwright on /opt/pw-browsers/chromium, locale zh-CN, signed in through the Console form as each persona. API readings were signed in as the persona. Every PATCH wrote the record's current value back, so a 200 changes no data and a 403 is FLS or RLS.

Launch through 发起合同: 双向保密协议 · 极光系统 · 阿卡迪亚欧洲有限公司. The wizard filled title, 摘要, 适用法律 德国法 and 保密期限 24, uploaded a first version, and ticked 立即提交 or left it unticked.

Persona before 21f2319 after feec816
Finance Controller 1 POST …/contract_intake/runs/…/resume → 400 FLOW_FAILED "Node 'create_contract' failed: … Field write denied: not permitted to edit [confidentiality_term_months, governing_law, status, summary]"; toast 您没有权限保存这条记录。 toast 合同已发起。: submitted run lands in_review (F2 onward hop), unsubmitted run stays draft; governing_law=德国法, confidentiality_term_months=24, summary set
Records Manager 1 same 400, "not permitted to edit [amount, auto_renew, confidentiality_term_months, contract_type, currency_code, department, end_date, governing_law, …]" 合同已发起。: in_review / draft, same values
Business Requester 1 (control) 合同已发起。, in_review / draft same

Filling the refused fields afterwards (API, on the persona's own draft):

Check before after
fc1 PATCH own draft governing_law, summary, confidentiality_term_months, jurisdiction no draft (launch refused) 200
fc1 PATCH own draft title, amount, payment_terms — 200
fc1 PATCH own draft {status: 'draft'} — 200
fc1 PATCH own draft {status: 'approved'} — 422 state machine "cannot go from draft to approved" (FLS judged the stored draft row, the hook still guards)
rm1 PATCH own draft legal fields / title, amount, payment_terms no draft 200 / 200
fc1 / rm1 / br1 PATCH own draft risk_level · submitted_at — / — / 403 · 403 403 · 403 on all three (requester's locks still hold)
fc1 POST clm_contract {status:'approved', governing_law, …} — 403 "Field write denied … [governing_law, status]" (insert judged on the incoming row)
rm1 POST clm_contract {status:'signing', …} — 403 "Field write denied … [contract_type, governing_law, our_entity, party, title]"

§13 Q1 kept:

Check before after
fc1 PATCH approved contract, each of governing_law, summary, confidentiality_term_months, status, termination_reason, jurisdiction 403 FLS ×6 403 FLS ×6
fc1 PATCH approved payment_terms, amount (control) 200 200
fc1 PATCH signing governing_law, status / payment_terms 403 FLS / 200 403 FLS / 200
fc1 PATCH an in_approval contract it reads via contract_finance_routed 403 RLS 403 RLS
rm1 PATCH signing title · governing_law · amount · risk_level 403 FLS ×4 403 FLS ×4
rm1 PATCH signing status (same value) · expired archive_no 200 · 200 200 · 200
rm1 PATCH expired title · approved title 403 FLS · 403 RLS 403 FLS · 403 RLS
lc1 PATCH in_review governing_law, summary, risk_level, liability_cap · approved governing_law 200 · 200 200 · 200
br1 PATCH someone else's active contract 404 404

POST /api/v1/security/explain (update, after) names the conditioned entries. Finance: "10 field permission(s) carry a row condition … governing_law (locked when status in ["approved", "signing", "active", "expired", "terminated"], set "clm_finance") …". Records: 56 entries, when status in ["signing", …].

Record form (browser, after, 编辑 dialog):

Form Inputs disabled Inputs enabled
fc1 own draft 摘要, 适用法律, 保密期限, 管辖, 风险等级, 状态 标题, 付款条件, 档案号; saving 标题 → POST /api/v1/batch 200, toast 合同…已更新
rm1 own draft every probed field 档案号 only
br1 own draft (control) 风险等级 only the legal fields
fc1 approved the legal fields —
rm1 signing everything but 档案号 档案号

The disabled inputs on fc1's and rm1's own drafts are the platform's declared behaviour, not this change: "Readers that hold no row (/auth/me/permissions, the field projections, the write preview on an update) report a row-conditioned entry as its lock" (FieldPermissionSchema.when, 18.2.0). The launch wizard and API writes reach the write gate, which judges the row. The Console form does not. See Acceptance notes.

Boot logs: boot-phase WARN/⚠ lines are identical before and after once timestamps are stripped (diff empty). The only [Security] lines are the same three RLS refusals my own probes caused in both runs. No "row condition … applied as its lock" WARN appeared in the after run (0 lines), so the wizard's submit_contract update was judged on its row and not treated as unjudged. Seed summary: inserted 830, errored 404 in both runs (README's missing-owner lines).

Reverse check: changing the finance when to statuz in [...] turned pnpm validate red (exit 1): "field permission when field "statuz" is not a field on object "clm_contract". Did you mean "status"?". I restored it with git checkout HEAD --, and git diff HEAD was empty with the hash matching HEAD.

Gates: on feec816, each exit code captured before any pipe

pnpm install                    exit 0
pnpm validate                   exit 0   (8 ⚠, the same set main prints: hierarchy-security edition, 6 approval-approvers, obligation_requester_own)
pnpm lint                       exit 0   1 warning(s), 6 suggestion(s) — the one warning is #64's security-controlled-by-parent-owner-policy-without-opt-in
pnpm typecheck                  exit 0
pnpm lint:i18n-gate             exit 0   COVERAGE : 0 missing keys across 2 locale(s)
CLM_DEMO_SEED=1 pnpm validate   exit 0

Acceptance notes

  • Residual (platform, not filed here): in the Console record form, the field projection is row-blind. On their own draft, finance sees the legal fields and status disabled, and records sees every field but 档案号 disabled, although the write gate admits those writes (API PATCH 200, wizard create 201). After this PR a finance or records launcher completes the launch, but afterwards edits their draft's legal fields only through a non-form door. The 18.2.0 spec declares this deliberately ("never wider than the gate"), so it is a capability request (a form projection that judges the row it edits), not a defect. The seat decides whether to raise it upstream. carrier: none.
  • clm_requester's locks (stamps, risk_level, liability_cap, termination_reason) are what still hold on a finance or records launcher's draft. DESIGN.md §04 gives clm_requester update on its own obligations, and controlled_by_parent refuses it for all 200 — 我负责的履约 is read-only for the audience it is built for #64 edits requester.profile.ts this round. Its diff should leave those entries in place.
  • Records' lock starts at signing, as ruled. On approved (records' read-only window) its entries are absent, but RLS refuses the update (measured 403 above).
  • No ablation beyond the reverse check above: no test suite pins this behaviour, and the change is metadata.

🤖 Generated with Claude Code

https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn

…edit windows

clm_finance locked the legal fields and status on every contract row, and
clm_records locked everything but status and archive_no, so the locks also
landed on the draft a finance controller or records manager launches as a
requester: the intake wizard's create_contract was refused with 403 "Field
write denied". Ruling A on #127: the platform's field permission row
condition (`when`, @objectstack 18.2.0, objectstack#25866) now scopes
finance's lock to approved onward and records' lock to signing onward.
Elsewhere the entries are absent, so clm_requester alone decides on the
launcher's own draft; §04 and §13 Q1 are unchanged.

A new helper, onRowsWhere, adds the condition to a set of entries and
refuses an unreadable one, as the spec does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · head feec816 · 2026-10-27T09:29Z

check reading
shape draft → main, Fixes #127; mergeable clean
CI on the head both Validate check-runs success on feec816 = PR head.sha
scope 3 files (+56/−8): _grants.ts (onRowsWhere helper), finance.profile.ts (the lock is conditioned on POST_APPROVAL_STATUSES), records.profile.ts (the lock is conditioned on EXECUTION_STATUSES). Within the claim's surface. Nothing governed; lint has only main's one warning
the ruling 6406413107 letter A — delivered on 18.2.0's field-permission when (objectstack#25866). Before: Finance Controller 1 and Records Manager 1 got 400 Field write denied at 发起合同. After: both launch, with the contract reaching draft or in_review. §04 and §13 Q1 are unchanged, as the ruling requires
no widening on approved and later rows, finance is still refused the legal fields, status and termination_reason (403 ×6). On signing and later rows, records is still limited to status and archive_no. On a launcher's own draft, clm_requester's locks still hold (stamps, risk_level, liability_cap). Finance and records on rows outside their window are refused by RLS. Requester and legal readings are unchanged. Reverse check: a misspelt when field fails pnpm validate
helper onRowsWhere refuses a hidden entry, as the spec refuses readable: false beside when
residual on their own draft, the console 编辑 dialog still disables the conditioned fields for finance and records, while the write is admitted (wizard and API: 200). That is the 18.2.0 form projection, which ignores the row by declared contract. The seat files it upstream; hotclm waits, with no app change

Landing: under the maintainer's merge authorization (project thread: 「你可以自己审核的就自己合并」).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A finance controller cannot launch a contract — clm_finance's field locks also apply to the draft they create

2 participants