diff --git a/README.md b/README.md index d9767d7..35535b2 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,14 @@ references them by name: | Name it exactly | Give it | It receives | |---|---|---| | `Business Requester 1` · `Business Requester 2` · `Business Requester 3` | the default `clm_requester` set | the 120 contracts' `owner_id`, dealt by counterparty — 43 / 43 / 34 · and the 143 delivery and reporting obligations under them — 50 / 53 / 40 | -| `Legal Counsel 1` · `Legal Counsel 2` | the `clm_legal_counsel` position | the `legal_owner` of every contract legal has accepted — 34 / 34 · and all 56 compliance obligations, by the same counterparty relationship — 29 / 27 | +| `Legal Counsel 1` · `Legal Counsel 2` | the `clm_legal_counsel` position | the `legal_owner` of every contract legal has accepted — 34 / 34 · all 56 compliance obligations, by the same counterparty relationship — 29 / 27 · and the `decided_by` of all 17 decided deviations — 10 / 7 | Each requester's **我的合同 › Launched by Me** and **› My Obligations**, and each lawyer's **法务工作台 › 审查中** and their own **My Obligations**, fill the moment -the account exists. All three columns are optional references, so naming an -account that is not there yet costs nothing and no row: it simply stays empty. +the account exists; a decided deviation's **Decided By** shows its lawyer on the +contract page's Deviations grid. All four columns are optional references, so +naming an account that is not there yet costs nothing and no row: it simply +stays empty. Every dataset is an upsert, so **create the accounts and run `pnpm demo` again** and their contracts and obligations are handed over. Get a name wrong by one character and the column stays empty with no error — that is the one thing to diff --git a/scripts/demo.mjs b/scripts/demo.mjs index 89d5692..f58cd70 100644 --- a/scripts/demo.mjs +++ b/scripts/demo.mjs @@ -72,13 +72,13 @@ const SEED_LOCALE_ENV_VAR = 'OS_SEED_LOCALE'; * whole row, so it must name an account that exists while the seed runs, and * on a first boot the dev admin is the only account there is. * - * NOT `clm_contract.owner_id`, NOT `clm_contract.legal_owner` and NOT - * `clm_obligation.owner`. All three name accounts of DESIGN.md §10's persona - * mix — three business requesters and two legal counsel — which no seed may - * create and this script does not mint. They can afford to be missing: all - * three are optional, so a name with no account lands NULL and the row - * survives, and re-running `pnpm demo` once the operator has created them - * hands the rows over. See `src/data/keys.ts`. + * NOT `clm_contract.owner_id`, NOT `clm_contract.legal_owner`, NOT + * `clm_obligation.owner` and NOT `clm_deviation.decided_by`. All four name + * accounts of DESIGN.md §10's persona mix — three business requesters and two + * legal counsel — which no seed may create and this script does not mint. They + * can afford to be missing: all four are optional, so a name with no account + * lands NULL and the row survives, and re-running `pnpm demo` once the operator + * has created them hands the rows over. See `src/data/keys.ts`. * * ⚠️ This MIRRORS `DEMO_USER` in `src/data/keys.ts`. They have to agree, and * they cannot be one constant: this file is plain `.mjs` that runs before diff --git a/src/data/keys.ts b/src/data/keys.ts index c2ee28b..fc1add5 100644 --- a/src/data/keys.ts +++ b/src/data/keys.ts @@ -2,7 +2,7 @@ import { STRINGS } from './demo-locale.js'; import type { ObligationPlan } from './plan-children.js'; -import { CONTRACT_PLAN, type ContractPlan } from './plan-contracts.js'; +import { CONTRACT_PLAN, type ContractPlan, type DeviationPlan } from './plan-contracts.js'; import { assertUniqueKeys } from './_shared.js'; /** @@ -32,14 +32,16 @@ import { assertUniqueKeys } from './_shared.js'; * REQUIRED user reference has to name: `clm_review.reviewer` is * `required: true`, and a name resolving to nothing costs the whole row. * - * `clm_review.reviewer` is now the last REFERENCE that has to name it. - * `clm_contract.legal_owner` left for {@link LEGAL_OWNERS} and - * `clm_obligation.owner` for {@link obligationOwnerOf} — both optional, so - * both may name accounts that do not exist yet. What still spells this - * constant besides `reviewer` is `clm_deviation.decided_by` (the - * administrator did decide those, on this database) and the `our`-side entry - * of `clm_signature.signers`, which is a JSON string rather than a reference - * and resolves against nothing at all. + * `clm_review.reviewer` is now the last REFERENCE that has to name it, and + * the only one left that spells this constant at all. + * `clm_contract.legal_owner` left for {@link LEGAL_OWNERS}, + * `clm_obligation.owner` for {@link obligationOwnerOf} and + * `clm_deviation.decided_by` for {@link deviationDeciderOf} — all three + * optional, so all three may name accounts that do not exist yet. Enumerated + * rather than sampled: `grep -rn DEMO_USER src` over this tree returns the + * declaration, this file's four cross-references, `reviewer`, and one thing + * that is not a reference at all — the `our`-side entry of + * `clm_signature.signers`, a JSON string that resolves against nothing. */ export const DEMO_USER = 'Dev Admin'; @@ -139,14 +141,20 @@ export const LEGAL_OWNERS: readonly string[] = [ ]; /** - * The lawyer who knows this counterparty — the one rule both columns that - * name a lawyer are dealt by, extracted so there is one of it. + * The lawyer who knows this counterparty — the one rule every column that + * names a lawyer is dealt by, extracted so there is one of it. Enumerated, + * those columns are three: `clm_contract.legal_owner` ({@link legalOwnerOf}), + * the compliance share of `clm_obligation.owner` + * ({@link obligationOwnerOf}) and `clm_deviation.decided_by` + * ({@link deviationDeciderOf}). * * ⚠️ Being the counterparty's lawyer is NOT the same fact as owning that * contract's review: {@link legalOwnerOf} additionally requires that F2 would - * have assigned one at all, and {@link obligationOwnerOf} deliberately does - * not — a compliance filing under a signed contract needs a lawyer whether or - * not that particular contract ever went through legal review. + * have assigned one at all, and {@link obligationOwnerOf} and + * {@link deviationDeciderOf} deliberately do not — a compliance filing under a + * signed contract needs a lawyer, and an off-playbook clause needs a ruling + * from one, whether or not that particular contract ever went through legal + * review. */ const counselFor = (contract: ContractPlan): string => LEGAL_OWNERS[contract.partyIndex % LEGAL_OWNERS.length]!; @@ -231,6 +239,82 @@ export const obligationOwnerOf = (obligation: ObligationPlan): string | null => return obligation.kind === 'compliance' ? counselFor(contract) : ownerOf(contract); }; +/** + * Who decided this deviation — the lawyer who knows the counterparty — or + * `null` while it is still open. + * + * ## Why not {@link DEMO_USER}, which is what this column held before + * + * Measured on `main` @ `3d11db8`, clean database, one `pnpm demo`: **25 + * deviations, 17 of them decided, and every one of the 17 carried the dev + * admin** (`GET /api/v1/data/clm_deviation` → `{"total":25}`, `decided_by` + * resolving to `Dev Admin` ×17, the 8 open ones NULL). So the 决定人 column of + * every decided deviation said the administrator adjudicated it. + * + * That is a different cost from the one {@link CONTRACT_OWNERS}, + * {@link LEGAL_OWNERS} and {@link obligationOwnerOf} answer, and the + * difference is worth stating rather than copying their argument across. + * `decided_by` is an AUDIT STAMP, not a recipient: nothing is routed to it, no + * reminder lands on an account that cannot act on it, and no screen is keyed + * on it. What it does is claim who performed an act — and §04 gives the act to + * legal, not to the administrator: on `clm_deviation`, `clm_requester` holds + * `RC(本人合同)` and `clm_legal` is the only non-admin set with **U**, so + * moving a deviation out of `open` is a lawyer's write and nobody else's. + * A demo whose every deviation was decided by the dev admin tells an evaluator + * that adjudicating the playbook is an administrative act. + * + * ## {@link counselFor} and not {@link legalOwnerOf}, which is the real choice + * + * Both name a lawyer; they differ on the rows where F2 never assigned one. + * Enumerated on this fixture: the 17 decided deviations sit on 17 distinct + * contracts, and **3 of those 17 are of a type whose `requiresLegalReview` is + * false**, so `legalOwnerOf` returns `null` for them (contracts #17, #44, #48 + * — two `in_approval`, one `approved`). + * + * Dealing by {@link legalOwnerOf} would therefore leave 3 rows that are + * `accepted`/`rejected`/`withdrawn`, carry a `decided_at`, and name nobody. + * The write layer never produces that pair: `deviation_state_machine` + * (`contract.hook.ts`) stamps `decided_by` with the acting user on any + * decision that leaves it empty, so a decided deviation always has a decider. + * This fixture's standing rule is that no seeded row is in a state the write + * layer would have refused or would never have written — the same rule that + * keeps all 8 open deviations on `in_review` contracts, because F6 would not + * let an `in_approval` contract carry one. + * + * {@link counselFor} claims only what a lawyer's involvement needs: the + * counterparty's lawyer looked at the clause and ruled on it. It does NOT + * claim that contract went through a legal review, which is the extra fact + * {@link legalOwnerOf} carries and the distinction `counselFor`'s own note + * draws — the same reason {@link obligationOwnerOf} deals compliance work by + * `counselFor` and not by `legalOwnerOf`. A deviation exists precisely where + * somebody wanted off-playbook wording, and someone in legal answered, whether + * or not the contract's type routes through review at all. + * + * ## No deliberate unassigned set here, and that is not an oversight + * + * {@link legalOwnerOf} and {@link obligationOwnerOf} both keep one deliberately + * empty because a FLOW takes a "nobody to tell" edge over that column — F3 over + * `legal_owner`, F10 over `owner` — and an empty column is what exercises it. + * No flow reads `decided_by`: it is written once and displayed. An empty one + * would exercise nothing and would instead assert the one thing this column + * cannot mean, that a decided deviation was decided by nobody. So every decided + * row names a lawyer and every open row is `null` — which is not an unassigned + * set but the column's own semantics, and matches what the hook writes. + * + * ## Structure, and no new account + * + * The deal is {@link counselFor}, so it is the counterparty relationship the + * fixture already deals `owner_id`, `legal_owner` and the compliance + * obligations by: no locale bundle is consulted and `demo-en` and `demo-zh` + * deal identically. It spends NO new account — the same two `Legal Counsel` + * names `README.md` already asks the operator to create, 10 / 7 across the 17. + * Like the other optional references it may name an account that does not + * exist yet: measured on 17.4.0, an unresolvable name lands NULL and never + * refuses the row, and the next `pnpm demo` upsert hands the rows over. + */ +export const deviationDeciderOf = (deviation: DeviationPlan): string | null => + deviation.status === 'open' ? null : counselFor(CONTRACT_PLAN[deviation.contractIndex]!); + /** * ` — `, with a qualifier when a counterparty has more * than one contract of the same type. diff --git a/src/data/negotiation.seed.ts b/src/data/negotiation.seed.ts index 5ea7db2..cb6438d 100644 --- a/src/data/negotiation.seed.ts +++ b/src/data/negotiation.seed.ts @@ -8,7 +8,7 @@ import { Signature } from '../objects/signature.object.js'; import { dayOffset } from './_shared.js'; import { STRINGS } from './demo-locale.js'; -import { DEMO_USER, titleOfIndex } from './keys.js'; +import { DEMO_USER, deviationDeciderOf, titleOfIndex } from './keys.js'; import { CONTRACT_PLAN, DEVIATION_PLAN, typeOf } from './plan-contracts.js'; import { REVIEW_PLAN, SIGNATURE_PLAN } from './plan-children.js'; import { contactEmailFor } from './plan.js'; @@ -65,6 +65,12 @@ export const reviewSeed = defineSeed(Review, { * `in_approval` while any deviation is open. A seed is exempt from that gate; * the plan applies it anyway, so no seeded row is in a state the write layer * would have refused. + * + * The same rule decides `decided_by`: `deviation_state_machine` stamps the + * acting user on any decision that leaves the column empty, so a decided + * deviation always names a decider and an open one never does. WHICH lawyer it + * names is {@link deviationDeciderOf}, and it is a lawyer because §04 gives + * `clm_legal` the only non-admin `U` on this object. */ export const deviationSeed = defineSeed(Deviation, { externalId: ['contract', 'clause'], @@ -76,7 +82,7 @@ export const deviationSeed = defineSeed(Deviation, { requested_position: deviation.requestedPosition, justification: STRINGS.deviationJustifications[deviation.justificationIndex]!, status: deviation.status, - decided_by: deviation.status === 'open' ? null : DEMO_USER, + decided_by: deviationDeciderOf(deviation), decided_at: deviation.decidedAt === null ? null : dayOffset(deviation.decidedAt), })), });