Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,31 +89,39 @@ 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 |
| `Legal Counsel 1` · `Legal Counsel 2` | the `clm_legal_counsel` position | the `legal_owner` of every contract legal has accepted — 34 / 34 |

Each requester's **我的合同 › Launched by Me** and each lawyer's **法务工作台 ›
审查中** fills the moment the account exists. Both 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 are handed over. Get a name
wrong by one character and the column stays empty with no error — that is the
one thing to double-check.
| `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 |

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.
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
double-check.

**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.

The other five positions are named however you like; they are reached through
the position, not by name.

The dev admin is deliberately none of them: it holds no `clm_*` permission set,
so `clm_requester.access` hides every 我的合同 item from it,
`GET /api/v1/meta/app/clm` serves it `navigation: []`, and `clm_legal.access`
gates the legal workbench away too. A contract parked there — as a business
owner or as a legal owner — belongs to the one account that cannot open the
screen it is for, and its reminders go to somebody who cannot act on them.

`clm_review.reviewer` and `clm_obligation.owner` still point at the dev admin,
and `reviewer` has to: it is `required: true`, so a name that resolves to
nothing takes the row with it, and the dev admin is the only account that exists
while the seed runs. Reassign those two once the real accounts are there.
gates the legal workbench away too. A row parked there — a contract's business
owner or legal owner, an obligation's owner — belongs to the one account that
cannot open the screen it is for, and its reminders go to somebody who cannot
act on them.

`clm_review.reviewer` is the one reference that still **has to** name the dev
admin: it is `required: true`, so a name that resolves to nothing takes the row
with it, and the dev admin is the only account that exists while the seed runs.
Reassign it once the real accounts are there.

### Assigning a position from a script

Expand Down
38 changes: 22 additions & 16 deletions scripts/demo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,20 @@ const DEMO_SEED_ENV_VAR = 'CLM_DEMO_SEED';
const SEED_LOCALE_ENV_VAR = 'OS_SEED_LOCALE';

/**
* The account name the fixture's `clm_review.reviewer` and
* `clm_obligation.owner` references resolve against.
* The account name the fixture's `clm_review.reviewer` resolves against.
*
* NOT `clm_contract.owner_id`: that one names the three business-requester
* accounts of DESIGN.md §10, which no seed may create and this script does not
* mint. It can afford to — `owner_id` is optional, so a name with no account
* lands NULL and the row survives — where `reviewer` is `required: true` and
* must name an account that exists while the seed runs. See `src/data/keys.ts`.
* It is the ONLY reference this boot has to satisfy, and the reason is the
* `required: true` on that one column: a name resolving to nothing costs the
* 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`.
*
* ⚠️ 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
Expand Down Expand Up @@ -260,11 +266,10 @@ const primeAdminAccount = async () => {
fail(
`the account you log in as is named ${JSON.stringify(session.name)}, not ${JSON.stringify(DEMO_USER)}.`,
[
'The demo fixture resolves `clm_review.reviewer` and `clm_obligation.owner`',
`against a \`sys_user\` named ${JSON.stringify(DEMO_USER)}.`,
'Against any other name those references resolve to nothing, and every',
'review row is refused while the rest of the seed reports success — so',
'nothing was seeded and the database is exactly as it was.',
`The demo fixture resolves \`clm_review.reviewer\` against a \`sys_user\` named`,
`${JSON.stringify(DEMO_USER)}. Against any other name that reference resolves to`,
'nothing, and every review row is refused while the rest of the seed reports',
'success — so nothing was seeded and the database is exactly as it was.',
'',
'Start over on a clean database:',
'',
Expand Down Expand Up @@ -403,10 +408,11 @@ const OPERATOR_SETUP_NOTE = [
' Before you open the app — one setup step, and one thing about the log',
' ────────────────────────────────────────────────────────────────────────',
'',
' 1. The seeded contracts have no owner yet. Every contract is launched by',
' one of the three business requesters DESIGN.md §10 asks you to create,',
' and no seed may create a user (§10) — so until those accounts exist,',
' `owner_id` is NULL on every contract row and 我的合同 stays empty. Add',
' 1. The seeded rows have no people on them yet. Contracts are launched by',
' business requesters, reviewed by legal counsel and their obligations',
' performed by both — the persona mix DESIGN.md §10 asks you to create —',
' and no seed may create a user (§10). Until those accounts exist every',
' column naming one is NULL, so 我的合同 and 法务工作台 stay empty. Add',
' them in Setup → Users and run this again; the README names them and',
' says who gets what.',
'',
Expand Down
94 changes: 91 additions & 3 deletions src/data/keys.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { STRINGS } from './demo-locale.js';
import type { ObligationPlan } from './plan-children.js';
import { CONTRACT_PLAN, type ContractPlan } from './plan-contracts.js';
import { assertUniqueKeys } from './_shared.js';

Expand Down Expand Up @@ -30,8 +31,15 @@ import { assertUniqueKeys } from './_shared.js';
* The only account that exists while the seed runs, so it is what every
* REQUIRED user reference has to name: `clm_review.reviewer` is
* `required: true`, and a name resolving to nothing costs the whole row.
* `clm_contract.legal_owner` is no longer one of them — see
* {@link LEGAL_OWNERS} for what it names now and why.
*
* `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.
*/
export const DEMO_USER = 'Dev Admin';

Expand Down Expand Up @@ -130,6 +138,19 @@ export const LEGAL_OWNERS: readonly string[] = [
'Legal Counsel 2',
];

/**
* 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.
*
* ⚠️ 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.
*/
const counselFor = (contract: ContractPlan): string =>
LEGAL_OWNERS[contract.partyIndex % LEGAL_OWNERS.length]!;

/**
* The lawyer who accepted this contract into review, by counterparty
* relationship — or `null` where F2 would never have assigned one.
Expand All @@ -141,7 +162,74 @@ export const LEGAL_OWNERS: readonly string[] = [
* handed-back half that keeps F3's "nobody to tell" edge exercised.
*/
export const legalOwnerOf = (contract: ContractPlan): string | null =>
contract.hasLegalOwner ? LEGAL_OWNERS[contract.partyIndex % LEGAL_OWNERS.length]! : null;
contract.hasLegalOwner ? counselFor(contract) : null;

/**
* Who is accountable for PERFORMING an obligation — or `null` for the one row
* this fixture deliberately leaves unassigned.
*
* ## Why not {@link DEMO_USER}, which is what this column held before
*
* Measured on `main` @ `2d63324`, clean database, README operator setup
* performed, one run of each scheduled job: **all 200 obligations carried the
* dev admin, and F10's were the only notification receipts still addressed to
* it** — `clm_obligation_due_soon -> Dev Admin x2`, every other topic already
* reaching a real account after #26 and #47. The argument against leaving them
* there is the one {@link CONTRACT_OWNERS} makes for `owner_id` and
* {@link LEGAL_OWNERS} makes for `legal_owner`, and it is sharper here than in
* either: the dev admin holds no `clm_*` permission set, so `clm_requester`'s
* `RU(本人负责)` row of DESIGN.md §04 grants it nothing, `clm_requester.access`
* 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 it may name accounts that do not exist yet — an unresolvable name
* lands NULL, silently, and never refuses the row. Every dataset is an upsert,
* so the operator creates the accounts and runs `pnpm demo` again to hand the
* obligations over. The README names them where the operator reads it, and it
* names NO new account: this deal spends only the five the fixture already
* asks for.
*
* ## The rule: the desk that performs it, and the counterparty decides which
*
* A `compliance` obligation goes to the lawyer who knows that counterparty; a
* `deliverable` or a `report` goes to the requester who launched the contract.
* Both halves are grounded rather than picked:
*
* - §04's permission matrix gives `clm_legal` **RCU** on `clm_obligation` —
* the only non-admin set that may CREATE one — and the four compliance
* titles this fixture draws from are all legal-and-compliance work (an
* insurance certificate, a sanctions re-screen, a data-transfer safeguard
* confirmation, an anti-bribery statement). Nobody in the business does
* those.
* - `clm_requester` holds `RU(本人负责)` and §05 puts 我负责的履约 in the
* 我的合同 group every employee reaches, which is exactly what a delivery
* or a periodic report is: the launching desk's own work.
*
* ⚠️ And it deliberately does NOT make the obligation owner a copy of the
* 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
* 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;
const contract = CONTRACT_PLAN[obligation.contractIndex]!;
return obligation.kind === 'compliance' ? counselFor(contract) : ownerOf(contract);
};

/**
* `<type> — <counterparty>`, with a qualifier when a counterparty has more
Expand Down
Loading