diff --git a/README.md b/README.md index 715d3ef..99c13e6 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ console does not list them; this table is where they are named. | Account | Sign in as | Position | What it holds in the demo | |---|---|---|---| -| `Business Requester 1` · `2` · `3` | `requester1@example.com` · `requester2@example.com` · `requester3@example.com` | none — every member holds `clm_requester` | the 120 contracts' `owner_id`, dealt by counterparty — 43 / 43 / 34 · and the 143 delivery and reporting obligations under them — 50 / 53 / 40 · the 2 business reviews — 0 / 1 / 1 | +| `Business Requester 1` · `2` · `3` | `requester1@example.com` · `requester2@example.com` · `requester3@example.com` | none — every member holds `clm_requester` | the 120 contracts' `owner_id`, dealt by counterparty — 43 / 43 / 34 · and the 144 delivery and reporting obligations under them — 50 / 54 / 40 · the 2 business reviews — 0 / 1 / 1 | | `Legal Counsel 1` · `2` | `counsel1@example.com` · `counsel2@example.com` | `clm_legal_counsel` | receives contracts on submission (round-robin by open load) and owns review · the `legal_owner` of every contract legal has accepted — 34 / 34 · all 56 compliance obligations — 29 / 27 · the `decided_by` of all 17 decided deviations — 10 / 7 · the reviewer of their contracts' 34 legal reviews — 16 / 18, the 7 still pending among them — 2 / 5 | | `Head of Legal` | `legal.head@example.com` | `clm_legal_head` | rung 2 of the approval ladder whenever `route_legal_head` is stamped · the reviewer of the legal review of the contract handed back to the queue | | `Finance Controller` | `finance@example.com` | `clm_finance_controller` | rung 2/3, reads every contract with `route_finance`, and the only position that may edit bank details · the reviewer of all 23 finance reviews, the 7 on contracts still in review or in approval among them, recorded there through **Record Finance Review** (记录财务审查) | @@ -146,8 +146,9 @@ twice. The same goes for a contract someone has terminated or archived, or an instalment finance has marked paid. Measured after one run of every daily job plus those four desk actions: the re-run refused no row and dropped none. -Three limits. Any other column you edited, such as a summary or a reassigned -legal owner or a review's reviewer, goes back to the fixture's value. A +Three limits. Any other column you edited, such as a summary, a reassigned +legal owner or obligation owner, or a review's reviewer, goes back to the +fixture's value. A deviation, signature round or review you decided is not covered yet: the re-run puts its fixture status or decision back, and for a deviation or a signature round the state machine may refuse that. The lifecycle columns also keep the dates they @@ -155,11 +156,10 @@ were stamped with, while the fixture's own dates (start, end, due and planned dates) move forward on each re-run. To get back to the corpus exactly as shipped, start from a clean database: `rm -rf .objectstack/data && pnpm demo`. -**One obligation is deliberately left with no owner** and is not a name you have -missed. F10 (`obligation_due`) reminds an obligation's owner and takes a "nobody -to tell" edge when there is none — the demo keeps exactly one unowned row, due -seven days after boot, so that edge stays exercised. `src/data/plan-children.ts` -proves it reachable and says why one is both the minimum and the maximum. +**Every obligation has an owner**, its platform owner (`owner_id`), and that is +the person accountable for it: the one who finds it under **我负责的履约**, gets +F10's (`obligation_due`) reminders, and may update its progress. Legal and the +CLM administrator can hand an obligation to someone else; the owner cannot. The dev admin is deliberately none of them. Like every member it holds `clm_requester`, so it sees 我的合同 and 分析 (`GET /api/v1/meta/app/clm` serves diff --git a/src/data/keys.ts b/src/data/keys.ts index adced66..62a361d 100644 --- a/src/data/keys.ts +++ b/src/data/keys.ts @@ -31,7 +31,7 @@ import { assertUniqueKeys } from './_shared.js'; * The account the demo is driven as — the platform's dev admin. * * No REFERENCE names it any more. `clm_contract.legal_owner` left for - * {@link LEGAL_OWNERS}, `clm_obligation.owner` for {@link obligationOwnerOf}, + * {@link LEGAL_OWNERS}, `clm_obligation.owner_id` for {@link obligationOwnerOf}, * `clm_deviation.decided_by` for {@link deviationDeciderOf}, and the last one, * the `required` `clm_review.reviewer`, for {@link reviewerOf} (#186). * Enumerated rather than sampled: `grep -rn DEMO_USER src` over this tree @@ -140,7 +140,7 @@ export const LEGAL_OWNERS: readonly string[] = [ * 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` + * the compliance share of `clm_obligation.owner_id` * ({@link obligationOwnerOf}) and `clm_deviation.decided_by` * ({@link deviationDeciderOf}). * @@ -169,8 +169,8 @@ export const legalOwnerOf = (contract: ContractPlan): string | null => contract.hasLegalOwner ? counselFor(contract) : null; /** - * Who is accountable for PERFORMING an obligation — or `null` for the one row - * this fixture deliberately leaves unassigned. + * Who is accountable for PERFORMING an obligation — the row's platform owner + * `owner_id` (issue #64, ruled A: `clm_obligation.owner` is retired). * * ## Why not {@link DEMO_USER}, which is what this column held before * @@ -186,14 +186,14 @@ export const legalOwnerOf = (contract: ContractPlan): string | null => * hides 我负责的履约 from it, and F10's reminder is the one kind of notice whose * whole point is that the person receiving it goes and does the thing. * - * `owner` is optional (DESIGN.md §03, and the field's own description: "Empty - * means unassigned, not the contract owner"), so like the other two optional - * references a name no account carries lands NULL, silently, and never - * refuses the row. The five accounts it names are declared demo staff - * (`demoStaff`, PR #171), created by the platform on the demo boot before the - * seed runs, so the obligations reach them on the first `pnpm demo`. The deal - * names NO new account: it spends only the five the fixture already deals - * contracts to. + * Every row names someone: `owner_id` is never empty. Like the contract's + * `owner_id`, a name no account carries would resolve to nothing and the + * seed-settle claim would hand the row to the platform admin (see + * `contract.seed.ts`), silently. The five accounts it names are declared demo + * staff (`demoStaff`, PR #171), created by the platform on the demo boot + * before the seed runs, so the obligations reach them on the first + * `pnpm demo`. The deal names NO new account: it spends only the five the + * fixture already deals contracts to. * * ## The rule: the desk that performs it, and the counterparty decides which * @@ -215,22 +215,22 @@ export const legalOwnerOf = (contract: ContractPlan): string | null => * contract's `owner_id`. DESIGN.md §03 declines to default this column to the * contract owner; a fixture that put all 200 on the launching requester would * assert by construction the very thing §03 refuses to assert by default, and - * a reader could no longer tell from the data that the two columns are - * independent. Dealing the compliance third away from the business owner is - * what makes that independence visible — and it is why `obligation_metrics` - * (§09's third dataset, whose first dimension after status and kind is - * `owner`) reads as five bars rather than one. - * - * ⚠️ Nothing here claims to reproduce a flow. No flow assigns this column: - * F9 creates renewal obligations from the type's defaults and leaves the owner - * to a person, and S5's `extract_obligations` proposes a 负责人 that a person - * confirms (§07). The deal states a rule a legal desk would recognise, in the + * a reader could no longer tell from the data that the two are independent. + * Dealing the compliance third away from the business owner is what makes + * that independence visible — and it is why `obligation_metrics` (§09's third + * dataset, whose first dimension after status and kind is `owner_id`) reads as + * five bars rather than one. + * + * ⚠️ Nothing here claims to reproduce a flow. The one flow that assigns this + * column is F9, and only for the renewal obligation it opens on activation, + * which goes to the contract's business owner (the renewal decision is + * theirs); S5's `extract_obligations` proposes a 负责人 that a person confirms + * (§07). The deal states a rule a legal desk would recognise, in the * same counterparty-relationship style {@link ownerOf} and {@link legalOwnerOf} * already use — so `demo-en` and `demo-zh` deal identically, no locale bundle * being consulted for any of it. */ -export const obligationOwnerOf = (obligation: ObligationPlan): string | null => { - if (!obligation.hasOwner) return null; +export const obligationOwnerOf = (obligation: ObligationPlan): string => { const contract = CONTRACT_PLAN[obligation.contractIndex]!; return obligation.kind === 'compliance' ? counselFor(contract) : ownerOf(contract); }; @@ -288,10 +288,11 @@ export const obligationOwnerOf = (obligation: ObligationPlan): string | null => * * ## 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 + * {@link legalOwnerOf} keeps one deliberately empty because a FLOW takes a + * "nobody to tell" edge over that column — F3 over `legal_owner` — and an empty + * column is what exercises it. (F10 used to do the same over the obligation's + * `owner`; its accountable person is now `owner_id`, which is never empty, so + * that edge left with the column, issue #64.) 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 diff --git a/src/data/plan-children.ts b/src/data/plan-children.ts index 417be96..e665717 100644 --- a/src/data/plan-children.ts +++ b/src/data/plan-children.ts @@ -190,12 +190,6 @@ export interface ObligationPlan { readonly dueDate: number; readonly status: 'pending' | 'in_progress' | 'done' | 'overdue' | 'waived'; readonly completedAt: number | null; - /** - * Whether `owner` names an account at all. False on exactly - * {@link UNASSIGNED_OBLIGATIONS} rows — see {@link leaveUnassigned}; the - * name itself is `keys.ts`'s {@link ../keys.ts obligationOwnerOf}. - */ - readonly hasOwner: boolean; } type ObligationRole = 'overdue' | 'due_soon_pending' | 'due_soon_progress' | 'done' | 'future' | 'waived'; @@ -236,25 +230,22 @@ const planObligations = (): ObligationPlan[] => { const contract = parents[slot % parents.length]!; const round = Math.floor(slot / parents.length); const kind = KINDS[round % KINDS.length]!; - // `hasOwner` is true for every row here; `leaveUnassigned` takes it away - // from exactly one afterwards, so the deal below reads as the RULE and the - // exception is stated once, where its reachability is proved. switch (role) { case 'overdue': - return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: -(2 + Math.floor(random() * 38)), status: 'overdue', completedAt: null, hasOwner: true }; + return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: -(2 + Math.floor(random() * 38)), status: 'overdue', completedAt: null }; case 'due_soon_pending': - return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: 1 + Math.floor(random() * 29), status: 'pending', completedAt: null, hasOwner: true }; + return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: 1 + Math.floor(random() * 29), status: 'pending', completedAt: null }; case 'due_soon_progress': - return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: 1 + Math.floor(random() * 29), status: 'in_progress', completedAt: null, hasOwner: true }; + return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: 1 + Math.floor(random() * 29), status: 'in_progress', completedAt: null }; case 'done': { const dueDate = -(20 + Math.floor(random() * 280)); - return { contractIndex: contract.index, kind, titleIndex: slot, dueDate, status: 'done', completedAt: dueDate - 1 - Math.floor(random() * 4), hasOwner: true }; + return { contractIndex: contract.index, kind, titleIndex: slot, dueDate, status: 'done', completedAt: dueDate - 1 - Math.floor(random() * 4) }; } case 'waived': - return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: -(10 + Math.floor(random() * 190)), status: 'waived', completedAt: null, hasOwner: true }; + return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: -(10 + Math.floor(random() * 190)), status: 'waived', completedAt: null }; case 'future': default: - return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: 35 + Math.floor(random() * 460), status: 'pending', completedAt: null, hasOwner: true }; + return { contractIndex: contract.index, kind, titleIndex: slot, dueDate: 35 + Math.floor(random() * 460), status: 'pending', completedAt: null }; } }); assertCount('clm_obligation', plans.length, 200); @@ -262,121 +253,24 @@ const planObligations = (): ObligationPlan[] => { }; /** - * How many obligations are deliberately left with NO `owner`. - * - * ## ⛔ This is test coverage. Do not "tidy it up" by giving it an owner. - * - * The same edge `UNASSIGNED_IN_REVIEW` protects in `plan-contracts.ts`, on the - * other of the only two jobs that can reach it. `_daily-sweep.ts` partitions - * every notifying row: `clm_notify_recipients` reports `hasRecipient`, and the - * two out-edges (`HAS_RECIPIENT` / `NO_RECIPIENT`) are exact complements, so a - * row nobody can be told about takes the quiet edge instead of failing the - * step. That matters more than it sounds: `loop-node.ts` iterates with a bare - * `await` and no `try`/`catch` of its own, so one failing row would end the - * whole sweep and report `acted: 0` for every row after it. - * - * F10 is one of exactly two places this corpus can exercise that edge, and it - * is the only one on `clm_obligation`: - * - * - all three of F10's stages take `{obligation.owner}` as their SOLE - * recipient — no position is copied in, the way F3's over-60 stage copies - * `clm_legal_head` — so an unowned obligation has nobody to tell in any of - * them; - * - and `owner` is the one recipient column in the fixture the model itself - * licenses to be empty: DESIGN.md §03 declines to default it to the - * contract owner, and `_daily-sweep.ts` names that permission explicitly - * when it explains why `hasRecipient` exists at all. - * - * ⇒ Give all 200 an owner and F10's quiet edge is never taken again on this - * corpus; a regression in it would ship invisibly. - * - * ## Why ONE, and why one is also the MOST - * - * One row is what coverage costs. Every row beyond it is a reminder the demo - * does not send — the exact defect this deal exists to fix — so the set is the - * minimum that keeps the edge live, not a sprinkle. Raising this constant is a - * one-line change and {@link leaveUnassigned} re-proves reachability for - * whatever number it is given. - */ -export const UNASSIGNED_OBLIGATIONS = 1; - -/** - * The day offset the unassigned row is due on: F10's `week` stage selects - * `due_date` in the half-open `[T+7, T+8)` window. - */ -const F10_WEEK_DAY = 7; - -/** - * Leave {@link UNASSIGNED_OBLIGATIONS} obligations unowned — and prove F10 - * will actually select them, which is the whole difference between coverage - * and a hole with a comment over it. - * - * ## Why the row's due date is CONSTRUCTED rather than drawn - * - * Measured on `main` @ `2d63324`, one `obligation_due` run on a - * freshly seeded database: `week_select selected=2`, `today_select selected=0`, - * `arrears_select selected=0`. The two zeroes are structural, not a bad day — - * every stage filters `status IN (pending, in_progress)`, and this plan draws - * every such row's due date from `1 + rng*29` or `35 + rng*460`, so no open - * obligation is ever due today (the minimum is +1) or already past due (the - * seeded arrears are born `overdue`, which no stage selects). The `week` stage - * is therefore the ONLY one that can carry this edge on this corpus, and how - * many rows land in its one-day window is a draw. - * - * So the row is placed at exactly {@link F10_WEEK_DAY} instead of being hoped - * into the window. It is drawn from the `due_soon_pending` band — `pending`, - * due in `[1, 29]` — so moving it inside that band leaves DESIGN.md §10's "40 - * due in the next 30 days" exactly where it was; taking a `future` row (+35 and - * out) would have quietly made it 41. + * Every obligation has an owner, and that is new (issue #64, ruled A). * - * `deliverable` is preferred for the story rather than the mechanics: an - * extracted commitment nobody has been made accountable for yet is precisely - * the state §07's S5 leaves behind between "AI proposes a 负责人" and "a person - * confirms". Any kind would exercise the edge; this one also explains itself - * on screen. + * This fixture used to leave exactly one obligation with no `owner`, due seven + * days after boot, so that F10 (`obligation_due`) would take its "nobody to + * tell" edge on this corpus. That edge existed because `clm_obligation.owner` + * was optional. The column is retired: the accountable person is now the + * row's platform owner `owner_id`, and `owner_id` is never empty (the platform + * stamps the acting user on a person's insert, the seed and F9 name one + * explicitly, and nobody without the transfer grant can change it). So there + * is no unowned obligation for F10 to be quiet about, and keeping one in the + * fixture would assert a state the product can no longer reach. * - * ⚠️ The proof is good on the BOOT DAY, which is what `dayOffset` means: every - * date in this fixture is boot-relative, so a database seeded today and swept - * tomorrow has the row at +6 and out of the window. Re-running `pnpm demo` - * slides it back to +7, which is the same property the whole demo has. + * The edge itself belongs to the shared sweep (`_daily-sweep.ts`) and stays + * exercised by F3, whose `legal_owner` may still be empty + * (`UNASSIGNED_IN_REVIEW` in `plan-contracts.ts`). The row that carried it + * here is now dealt like every other one, on its drawn due date. */ -const leaveUnassigned = (plans: readonly ObligationPlan[]): ObligationPlan[] => { - const dueSoonPending = plans - .map((plan, slot) => ({ plan, slot })) - .filter(({ plan }) => plan.status === 'pending' && plan.dueDate >= 1 && plan.dueDate <= 29); - const eligible = [ - ...dueSoonPending.filter(({ plan }) => plan.kind === 'deliverable'), - ...dueSoonPending.filter(({ plan }) => plan.kind !== 'deliverable'), - ]; - if (eligible.length < UNASSIGNED_OBLIGATIONS) { - throw new Error( - `demo fixture: only ${eligible.length} obligations are pending and due inside the next 30 days, ` + - `so ${UNASSIGNED_OBLIGATIONS} cannot be left unassigned inside that band — moving one in from ` + - 'anywhere else would change DESIGN.md §10\'s "40 due in the next 30 days", and leaving none ' + - 'unassigned would stop F10\'s "nobody to tell" edge being exercised anywhere in this corpus.', - ); - } - const slots = new Set(eligible.slice(0, UNASSIGNED_OBLIGATIONS).map(({ slot }) => slot)); - const dealt = plans.map((plan, slot) => - slots.has(slot) ? { ...plan, hasOwner: false, dueDate: F10_WEEK_DAY } : plan); - - // Re-proved AFTER the rewrite rather than assumed from it: the row has to be - // selectable by F10's `week` stage, which reads BOTH columns. - const quiet = dealt.filter((plan) => !plan.hasOwner); - assertCount('clm_obligation (unassigned)', quiet.length, UNASSIGNED_OBLIGATIONS); - for (const plan of quiet) { - if (plan.dueDate !== F10_WEEK_DAY || (plan.status !== 'pending' && plan.status !== 'in_progress')) { - throw new Error( - `demo fixture: the unassigned obligation is ${plan.status} and due at day ${plan.dueDate}, so F10's ` + - `week stage (status IN (pending, in_progress) AND due_date IN [T+${F10_WEEK_DAY}, T+${F10_WEEK_DAY + 1})) ` + - 'would not select it and its "nobody to tell" edge would stop being exercised.', - ); - } - } - return dealt; -}; - -export const OBLIGATION_PLAN: readonly ObligationPlan[] = leaveUnassigned(planObligations()); +export const OBLIGATION_PLAN: readonly ObligationPlan[] = planObligations(); // ─────────────────────────────────────────────────── clm_payment_plan (300) ── diff --git a/src/data/post-signature.seed.ts b/src/data/post-signature.seed.ts index 341ffcb..5b6c8d6 100644 --- a/src/data/post-signature.seed.ts +++ b/src/data/post-signature.seed.ts @@ -33,10 +33,11 @@ const obligationRecords = OBLIGATION_PLAN.map((obligation) => ({ kind: obligation.kind, due_date: dayOffset(obligation.dueDate), // The desk that performs it — a requester for deliverables and reports, the - // counterparty's lawyer for compliance filings, and `null` on the one row - // `leaveUnassigned` keeps F10's "nobody to tell" edge alive with. See - // `keys.ts` for why it is not the dev admin any more. - owner: obligationOwnerOf(obligation), + // counterparty's lawyer for compliance filings — as the row's platform + // owner, which is the accountable person (issue #64). The seed is a system + // write, so the platform stamps no acting user and the name is given here. + // See `keys.ts` for why it is not the dev admin. + owner_id: obligationOwnerOf(obligation), status: obligation.status, completed_at: obligation.completedAt === null ? null : dayOffset(obligation.completedAt), // Proof of performance on the 90 obligations that are done, and on no diff --git a/src/datasets/obligation.dataset.ts b/src/datasets/obligation.dataset.ts index 36786f7..a28885a 100644 --- a/src/datasets/obligation.dataset.ts +++ b/src/datasets/obligation.dataset.ts @@ -31,7 +31,8 @@ export const ObligationDataset = defineDataset({ dimensions: [ { name: 'status', label: 'Status', field: 'status', type: 'string' }, { name: 'kind', label: 'Kind', field: 'kind', type: 'string' }, - { name: 'owner', label: 'Owner', field: 'owner', type: 'lookup' }, + // The accountable person is the platform owner (issue #64). + { name: 'owner_id', label: 'Owner', field: 'owner_id', type: 'lookup' }, { name: 'contract_title', label: 'Contract', field: 'contract.title', type: 'string' }, { name: 'contract_status', label: 'Contract Status', field: 'contract.status', type: 'string' }, { name: 'counterparty', label: 'Counterparty', field: 'contract.party.name', type: 'string' }, diff --git a/src/flows/obligation-due.flow.ts b/src/flows/obligation-due.flow.ts index 7af571c..c2acdd7 100644 --- a/src/flows/obligation-due.flow.ts +++ b/src/flows/obligation-due.flow.ts @@ -39,6 +39,13 @@ import { must, ref } from './_values.js'; * date EQUALITY is the anti-pattern `os lint` calls * `flow-date-equality-filter` — a date value carries a time component, so * `due_date == {7_days_from_now}` silently matches nothing. + * + * ## The recipient is the obligation's platform owner + * + * Each stage tells `owner_id`, the person accountable for the obligation + * (issue #64, ruled A: one owner concept). It is never empty, so unlike F3's + * `legal_owner` this job has no row with nobody to tell; the shared + * `hasRecipient` edge stays, because it is the sweep's, not this flow's. */ /** The arrears write. One row, addressed by scalar id — no `multi`, no predicate write. */ @@ -76,11 +83,11 @@ export const ObligationDueFlow: Flow = sweepFlow({ status: { $in: ['pending', 'in_progress'] }, due_date: { $gte: '{7_days_from_now}', $lt: '{8_days_from_now}' }, }, - fields: ['id', 'title', 'contract', 'owner', 'due_date', 'kind', 'status'], + fields: ['id', 'title', 'contract', 'owner_id', 'due_date', 'kind', 'status'], item: 'obligation', body: rowBody({ id: 'week', - recipients: { primary: ref('obligation.owner') }, + recipients: { primary: ref('obligation.owner_id') }, notice: { topic: 'clm_obligation_due_soon', severity: 'info', @@ -99,11 +106,11 @@ export const ObligationDueFlow: Flow = sweepFlow({ status: { $in: ['pending', 'in_progress'] }, due_date: { $gte: '{today}', $lt: '{tomorrow}' }, }, - fields: ['id', 'title', 'contract', 'owner', 'due_date', 'kind', 'status'], + fields: ['id', 'title', 'contract', 'owner_id', 'due_date', 'kind', 'status'], item: 'obligation', body: rowBody({ id: 'today', - recipients: { primary: ref('obligation.owner') }, + recipients: { primary: ref('obligation.owner_id') }, notice: { topic: 'clm_obligation_due_today', severity: 'warning', @@ -124,12 +131,12 @@ export const ObligationDueFlow: Flow = sweepFlow({ status: { $in: ['pending', 'in_progress'] }, due_date: { $lt: '{today}' }, }, - fields: ['id', 'title', 'contract', 'owner', 'due_date', 'kind', 'status'], + fields: ['id', 'title', 'contract', 'owner_id', 'due_date', 'kind', 'status'], item: 'obligation', body: rowBody({ id: 'arrears', lead: [flagOverdue], - recipients: { primary: ref('obligation.owner') }, + recipients: { primary: ref('obligation.owner_id') }, notice: { topic: 'clm_obligation_overdue', severity: 'critical', diff --git a/src/objects/contract.hook.ts b/src/objects/contract.hook.ts index be781d3..f45e8db 100644 --- a/src/objects/contract.hook.ts +++ b/src/objects/contract.hook.ts @@ -790,7 +790,13 @@ const contractActivate: Hook = { title: `Renewal decision: ${typeof title === 'string' && title ? title : 'contract'}`.slice(0, 200), kind: 'renewal', due_date: dueDate, - owner: typeof owner === 'string' && owner ? owner : null, + // The contract's business owner is accountable for the renewal + // decision, so they are the obligation's platform owner (issue #64). + // This is a system write, which the platform does not stamp with an + // acting user, so the owner is named here. Every contract carries an + // `owner_id`; the key is left out rather than written empty if one + // ever does not. + ...(typeof owner === 'string' && owner ? { owner_id: owner } : {}), status: 'pending', notes: `Opened by F9 on activation. Decide renewal or notice by ${dueDate} — ${noticeDays} days before the contract ends.`, }); diff --git a/src/objects/obligation.object.ts b/src/objects/obligation.object.ts index e9d345c..8e96d5c 100644 --- a/src/objects/obligation.object.ts +++ b/src/objects/obligation.object.ts @@ -39,14 +39,15 @@ export const Obligation = ObjectSchema.create({ // on one. Only a by-id update is opened: creating, deleting, and moving an // obligation to another contract still need edit on the contract. // - // ⚠️ The platform's owner is the injected `owner_id`, NOT the `owner` field - // below that 我负责的履约, F10 and `obligation_requester_own` key on. All - // 200 demo obligations carry `owner_id` = the dev admin, and F9 inserts its - // renewal obligation as a system write, so on its own this admits no - // requester (measured on 18.2.0, issue #64). + // The accountable person IS that platform owner, `owner_id` (issue #64, + // ruled A): one owner concept, the one the platform's gate, the + // `obligation_requester_own` policy, 我负责的履约 and F10's reminder all + // read. Handing an obligation to someone else is a change of `owner_id`, + // which needs the transfer grant (`allowTransfer`), held by legal and the + // administrator only — the owner cannot give their own obligation away. sharing: { controlledByParent: { ownerMayEdit: true } }, nameField: 'display_name', - highlightFields: ['display_name', 'kind', 'due_date', 'owner', 'status'], + highlightFields: ['display_name', 'kind', 'due_date', 'owner_id', 'status'], fieldGroups: [ { key: 'obligation', label: 'Obligation', icon: 'list-checks' }, @@ -72,7 +73,7 @@ export const Obligation = ObjectSchema.create({ inlineTitle: 'Obligations', // The columns the contract page's list shows (ADR-0085: the record page // derives the list from this relationship since @objectstack 18.3). - relatedListColumns: ['display_name', 'title', 'kind', 'due_date', 'owner', 'status'], + relatedListColumns: ['display_name', 'title', 'kind', 'due_date', 'owner_id', 'status'], }), title: Field.text({ label: 'Title', @@ -103,10 +104,20 @@ export const Obligation = ObjectSchema.create({ storage: { notNull: true }, description: 'The date the daily check measures arrears against.', }), - owner: Field.user({ + // `owner_id` is the platform ownership anchor, and here it is also the + // person accountable for performing the obligation (see `sharing` above). + // Declared (rather than left to injection) so validate resolves it, the + // label says what it means on this object and it sits in the obligation + // group; `system: true` keeps the injected marker, the same declaration + // `clm_contract.owner_id` makes. No defaultValue: the security middleware + // stamps the acting user on an insert that leaves it empty, and a system + // write (the seed, F9) names the person explicitly. + owner_id: Field.lookup('sys_user', { label: 'Owner', group: 'obligation', - description: 'Who is accountable for performing it. Empty means unassigned, not the contract owner.', + system: true, + readonly: false, + description: 'Who is accountable for performing it. Legal or a CLM administrator can hand it to someone else.', }), status: Field.select({ diff --git a/src/profiles/admin.profile.ts b/src/profiles/admin.profile.ts index c2e75ee..a66cecf 100644 --- a/src/profiles/admin.profile.ts +++ b/src/profiles/admin.profile.ts @@ -91,7 +91,10 @@ export const AdminSet = definePermissionSet({ clm_review: full, clm_deviation: full, clm_signature: full, - clm_obligation: full, + // `allowTransfer` (implied by `modifyAllRecords`, authored so it is + // readable here): the administrator reassigns an obligation's accountable + // person, its platform owner `owner_id` (issue #64). + clm_obligation: { ...full, allowTransfer: true }, clm_payment_plan: full, clm_party: config, clm_contract_type: config, diff --git a/src/profiles/legal.profile.ts b/src/profiles/legal.profile.ts index 69ce9dc..f4f8e8b 100644 --- a/src/profiles/legal.profile.ts +++ b/src/profiles/legal.profile.ts @@ -1,5 +1,6 @@ import { definePermissionSet } from '@objectstack/spec/security'; import { Contract } from '../objects/contract.object.js'; +import { Obligation } from '../objects/obligation.object.js'; import { Party } from '../objects/party.object.js'; import { Review } from '../objects/review.object.js'; import { Signature } from '../objects/signature.object.js'; @@ -58,7 +59,9 @@ export const LegalSet = definePermissionSet({ clm_review: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, clm_deviation: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, clm_signature: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, - clm_obligation: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + // `allowTransfer`: legal hands an obligation to the person accountable + // for it, which is a change of its platform owner `owner_id` (issue #64). + clm_obligation: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, allowTransfer: true }, clm_payment_plan: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, clm_party: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, clm_contract_type: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, @@ -73,6 +76,9 @@ export const LegalSet = definePermissionSet({ ...open(Party, ['bank_account', 'contact_phone']), // Read-only for clm_requester, which every member holds (#133). ...open(Party, PARTY_ASSESSED_FIELDS), + // Read-only for clm_requester too (an owner updates progress, not the + // contract or the due date, #64); legal re-dates and re-files obligations. + ...open(Obligation, ['contract', 'due_date']), }, rowLevelSecurity: [ { diff --git a/src/profiles/requester.profile.ts b/src/profiles/requester.profile.ts index cd183fd..201fdcf 100644 --- a/src/profiles/requester.profile.ts +++ b/src/profiles/requester.profile.ts @@ -1,5 +1,6 @@ import { definePermissionSet } from '@objectstack/spec/security'; import { Contract } from '../objects/contract.object.js'; +import { Obligation } from '../objects/obligation.object.js'; import { Party } from '../objects/party.object.js'; import { Review } from '../objects/review.object.js'; import { REQUESTER_EDITABLE_STATUSES } from '../sharing/_lifecycle.js'; @@ -154,6 +155,13 @@ export const RequesterSet = definePermissionSet({ ...readOnly(Contract, [CONTRACT_TERMINATION_REASON]), // Legal's working note; the conclusion for the requester goes in `comments`. ...hidden(Review, ['internal_note']), + // The owner of an obligation updates its progress (`status`, `notes`, + // `evidence`, `completed_at`), not what it is a commitment to or when it + // falls due: re-dating it would move F10's reminders and the arrears + // sweep, and re-pointing it would move it under another contract. Legal + // opens both again in its own set; handing it to someone else is the + // transfer grant this set does not hold. + ...readOnly(Obligation, ['contract', 'due_date']), // Payment and contact details are the easiest to leak (§04 FLS table). ...hidden(Party, ['bank_account', 'contact_phone']), // Assessed by legal, or stamped by the screening connector (§08), never @@ -174,11 +182,15 @@ export const RequesterSet = definePermissionSet({ { name: 'obligation_requester_own', label: 'Requesters update the obligations they own', - // DESIGN.md §04: RU(本人负责). + // DESIGN.md §04: RU(本人负责). Keyed on `owner_id`, the platform owner, + // because that is the column the controlled_by_parent opt-in on + // `clm_obligation` (`ownerMayEdit`) admits without edit on the contract + // (issue #64): keyed on anything else, this policy only narrows contract + // editors and admits no requester at all. description: 'A requester sees the obligations of the contracts they can see, and updates only the ones assigned to them.', object: 'clm_obligation', operation: 'update', - using: 'owner == current_user.id', + using: 'owner_id == current_user.id', }, ], }); diff --git a/src/translations/en/app.ts b/src/translations/en/app.ts index 9bf1515..7496413 100644 --- a/src/translations/en/app.ts +++ b/src/translations/en/app.ts @@ -369,7 +369,7 @@ export const appSurface: Pick = { label: 'Due Date', help: 'The date the daily check measures arrears against.', }, - owner: { + owner_id: { label: 'Owner', - help: 'Who is accountable for performing it. Empty means unassigned, not the contract owner.', + help: 'Who is accountable for performing it. Legal or a CLM administrator can hand it to someone else.', }, status: { label: 'Status', diff --git a/src/translations/zh-CN/app.ts b/src/translations/zh-CN/app.ts index d70b452..2a736b9 100644 --- a/src/translations/zh-CN/app.ts +++ b/src/translations/zh-CN/app.ts @@ -369,7 +369,7 @@ export const appSurface: Pick = { label: '到期日', help: '每日检查以这个日期判断是否逾期。', }, - owner: { + owner_id: { label: '负责人', - help: '由谁负责完成。留空表示尚未指派,不代表由合同负责人承担。', + help: '由谁负责完成。法务或 CLM 管理员可以改派给其他人。', }, status: { label: '状态', diff --git a/src/views/obligation.view.ts b/src/views/obligation.view.ts index d850827..6a00ab1 100644 --- a/src/views/obligation.view.ts +++ b/src/views/obligation.view.ts @@ -42,7 +42,7 @@ export const ObligationViews = defineView({ { field: 'title', width: 260 }, { field: 'kind', width: 130 }, { field: 'due_date', width: 120, sortable: true }, - { field: 'owner', width: 140 }, + { field: 'owner_id', width: 140 }, { field: 'status', width: 120, sortable: true }, ], sort: [{ field: 'due_date', order: 'asc' }], @@ -55,9 +55,10 @@ export const ObligationViews = defineView({ * 我负责的履约 — mine, soonest first (§05: 履约 grid(due_date 升序)). * * Unfinished only. An obligation the person already closed is history, and - * `clm_requester` holds edit on exactly the rows it owns — so this list is - * both what they must do and what they may act on. The full book, closed - * rows included, is the `all_obligations` tab beside it. + * `clm_requester` holds edit on exactly the rows it owns (`owner_id`, the + * column the platform admits an owner's update on, issue #64) — so this + * list is both what they must do and what they may act on. The full book, + * closed rows included, is the `all_obligations` tab beside it. */ my_obligations: { name: 'my_obligations', @@ -66,7 +67,7 @@ export const ObligationViews = defineView({ description: { en: 'Obligations assigned to me that are not finished, soonest due first.', 'zh-CN': '指派给我且尚未完成的义务,快到期的排在前面。' }, data: { provider: 'object', object: 'clm_obligation' }, filter: [ - { field: 'owner', operator: 'equals', value: '{current_user_id}' }, + { field: 'owner_id', operator: 'equals', value: '{current_user_id}' }, { field: 'status', operator: 'in', value: ['pending', 'in_progress', 'overdue'] }, ], columns: [