diff --git a/src/profiles/_grants.ts b/src/profiles/_grants.ts index add44ee..352e956 100644 --- a/src/profiles/_grants.ts +++ b/src/profiles/_grants.ts @@ -78,6 +78,36 @@ export function open(object: ObjectLike, fields: readonly string[]): FieldSecuri return Object.fromEntries(fields.map((field) => [`${object.name}.${field}`, { readable: true, editable: true }])); } +/** + * The same entries, voting only on the rows `condition` matches — the field + * permission's row condition (`when`, `@objectstack/spec` 18.2.0, + * objectstack-ai/objectstack#25866). On every other row the entry is ABSENT, + * not open: the most-permissive merge and the "only declaring sets vote" rule + * ({@link open}) are unchanged, so a co-held set that locks the same field — + * `clm_requester`'s stamps, `risk_level`, `liability_cap` — still locks it + * there, and a set that says nothing still lifts nothing. + * + * The write gate judges the condition on the STORED row before an update (a + * lock on `status in [...]` holds on an approved contract even when the write + * moves `status`) and on the incoming row of an insert. The condition reaches + * the EDIT vote only, so a {@link hidden} entry is refused here, as the spec + * refuses `readable: false` beside a `when`. Readers that hold no row — the + * record form's field projection, `/auth/me/permissions` — report a + * conditioned entry as its lock on every row. + * + * Use it for "this set locks these fields from a stage onward": the lock then + * stays off the draft a holder of the set launches as a requester. + */ +export function onRowsWhere(security: FieldSecurity, condition: string): FieldSecurity { + const unreadable = Object.entries(security).filter(([, perm]) => perm.readable === false).map(([key]) => key); + if (unreadable.length > 0) { + throw new Error( + `onRowsWhere: ${unreadable.join(', ')} ${unreadable.length === 1 ? 'is' : 'are'} not readable — a row condition reaches the edit vote only, so a read lock cannot carry one.`, + ); + } + return Object.fromEntries(Object.entries(security).map(([key, perm]) => [key, { ...perm, when: condition }])); +} + /** Every authored field of the object except `except` is {@link open}. */ export function openAllExcept(object: ObjectLike, except: readonly string[]): FieldSecurity { assertFields(object, except, 'openAllExcept'); diff --git a/src/profiles/finance.profile.ts b/src/profiles/finance.profile.ts index 1c09e5c..32809a8 100644 --- a/src/profiles/finance.profile.ts +++ b/src/profiles/finance.profile.ts @@ -3,7 +3,7 @@ import { Contract } from '../objects/contract.object.js'; import { Party } from '../objects/party.object.js'; import { Review } from '../objects/review.object.js'; import { POST_APPROVAL_STATUSES } from '../sharing/_lifecycle.js'; -import { CONTRACT_LEGAL_FIELDS, CONTRACT_STAMPED_FIELDS, CONTRACT_TERMINATION_REASON, editableOnly, hidden, inList, open, readOnly } from './_grants.js'; +import { CONTRACT_LEGAL_FIELDS, CONTRACT_STAMPED_FIELDS, CONTRACT_TERMINATION_REASON, editableOnly, hidden, inList, onRowsWhere, open, readOnly } from './_grants.js'; /** * `clm_finance` — the finance controller (DESIGN.md §04). @@ -13,9 +13,11 @@ import { CONTRACT_LEGAL_FIELDS, CONTRACT_STAMPED_FIELDS, CONTRACT_TERMINATION_RE * arrive through `contract_finance_post_approval` (`edit`, approved onward); * the row policy below repeats that window so it survives the OR-merge with * `clm_requester` (see `legal.profile.ts`); and the FLS entries are the lock: - * a finance user's write that names `status` — or any legal field — is refused - * with 403 (`Field write denied`). Card 04 measured exactly that on a running - * app; the PR carries the transcript. + * a finance user's write that names `status` — or any legal field — on an + * approved-or-later contract is refused with 403 (`Field write denied`). Card + * 04 measured exactly that on a running app; the PR carries the transcript. + * The lock carries the same window as a row condition (`onRowsWhere`), so it + * does not land on the draft a finance controller launches (#127). * * On `clm_review`, "RCU(stage=finance)": finance reads every review of a * contract it can see (legal's `internal_note` withheld) and creates or @@ -45,7 +47,17 @@ export const FinanceSet = definePermissionSet({ // §04 "FLS 锁法律字段" + §13 Q1 "与 status". The termination reason travels // with `status` on the same write, so locking one without the other would // leave finance able to author half of a transition it may not make. - ...readOnly(Contract, [...CONTRACT_LEGAL_FIELDS, 'status', CONTRACT_TERMINATION_REASON]), + // + // From `approved` onward only — the rows finance edits as finance (#127, + // ruling A). Before that the entries are absent, so on the draft a finance + // controller launches as a requester, `clm_requester` alone decides: the + // legal fields and `status` are open there as they are to every launcher, + // and `risk_level`, `liability_cap` and the termination reason stay locked + // by the requester's own entries. The stamps above stay unconditional. + ...onRowsWhere( + readOnly(Contract, [...CONTRACT_LEGAL_FIELDS, 'status', CONTRACT_TERMINATION_REASON]), + inList('status', POST_APPROVAL_STATUSES), + ), ...hidden(Review, ['internal_note']), ...editableOnly(Party, ['bank_name', 'bank_account']), // editableOnly declares only the fields it locks, so the banking pair has diff --git a/src/profiles/records.profile.ts b/src/profiles/records.profile.ts index b3eb738..d3af9e7 100644 --- a/src/profiles/records.profile.ts +++ b/src/profiles/records.profile.ts @@ -3,7 +3,7 @@ import { Contract } from '../objects/contract.object.js'; import { Party } from '../objects/party.object.js'; import { Signature } from '../objects/signature.object.js'; import { EXECUTION_STATUSES } from '../sharing/_lifecycle.js'; -import { editableOnly, hidden, inList } from './_grants.js'; +import { editableOnly, hidden, inList, onRowsWhere } from './_grants.js'; /** * `clm_records` — the records manager (DESIGN.md §04, global-first revision: @@ -21,7 +21,9 @@ import { editableOnly, hidden, inList } from './_grants.js'; * - `archive_no` — the archive reference F14 stamps `archived_at` from. * * Everything else on the contract, including the stamps §04 locks for every - * position, is readable and locked (`editableOnly`). On `clm_signature`, + * position, is readable and locked (`editableOnly`) on the rows of that edit + * window — a row condition (`onRowsWhere`), so the lock does not land on the + * draft a records manager launches (#127). On `clm_signature`, * "RU(执行形式、执行副本)": the formalities, the executed copy, the completion * date a wet-ink round records, and the round's status so the round can be * completed at all (F7's wet-ink path names this position). On `clm_party` @@ -46,7 +48,11 @@ export const RecordsSet = definePermissionSet({ clm_approval_rule: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, }, fields: { - ...editableOnly(Contract, ['status', 'archive_no']), + // From `signing` onward only — the rows records edits as records (#127, + // ruling A). Before that the entries are absent, so on the draft a records + // manager launches as a requester, `clm_requester` alone decides, stamps + // and assessed fields included. + ...onRowsWhere(editableOnly(Contract, ['status', 'archive_no']), inList('status', EXECUTION_STATUSES)), ...editableOnly(Signature, ['status', 'formalities_done', 'executed_file', 'completed_at']), ...hidden(Party, ['bank_account', 'contact_phone']), },