diff --git a/README.md b/README.md index 77f1310..a1b4e4e 100644 --- a/README.md +++ b/README.md @@ -129,26 +129,29 @@ Three things about where these accounts exist: a position it is missing. No account is duplicated. Every dataset is an upsert, so `pnpm demo` also runs again on a database that -already holds the demo, sliding every boot-relative date forward. - -**Run it again only before the daily jobs have run on the database.** Only a clean -database is guaranteed to come back as the known corpus: the fixture re-asserts -every column it declares, the job-written ones included, so once rows have -moved on — the jobs' work above all — a re-run does one of two things to each. - -- **Where the state machine allows the way back, it walks the job's work back, - silently.** The 18 part-paid instalments `payment_overdue` (F11) moved to - `overdue` return to `partial` — a legal edge, so they count as ordinary - `updated` rows with no error — and a reset `is_expiring` makes F12 send a - renewal notice it has already sent. -- **Where it does not, the row is refused and dropped whole.** A contract that - has since expired or been terminated is not moved back to `active`: the row - shows as `errored` in the loader's `Seed loading completed` summary, and none - of its other columns are written either. - -The fix is the platform's — seed columns written on create only, upstream -`objectstack-ai/objectstack#25761`, still open — and until it lands, a fresh -database is the only way back to the corpus as shipped. +already holds the demo, after the daily jobs have run and after you have worked +in it. A re-run slides every boot-relative date forward, puts back any demo row +that has been deleted, and refreshes the rest of the fixture. + +**It does not turn the clock back.** The columns the app itself writes once a +row exists are declared `applyOn: 'create'` in the dataset (`src/data/`): the +seed writes them when it creates the row and leaves them alone after that. They +are `status` on contracts, obligations and payment instalments, the flag F12 +sets, the stage timestamps and routing flags, the termination reason, the +archive number, and the receipt recorded on an instalment. So what the daily +jobs did stays done. An instalment F11 moved to `overdue` stays overdue, a +contract F13 expired stays expired, and F12 does not send a renewal notice +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, goes back to the fixture's value. A deviation or signature round +you decided is not covered yet: the re-run puts its fixture status back, and the +state machine may refuse that. The lifecycle columns also keep the dates they +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 diff --git a/src/data/contract.seed.ts b/src/data/contract.seed.ts index e7623c1..b0a0fe6 100644 --- a/src/data/contract.seed.ts +++ b/src/data/contract.seed.ts @@ -46,6 +46,34 @@ import { SIGNING_ENTITY_PLAN } from './plan.js'; * for what card 10's dashboards will therefore show. */ +/** + * The columns the contract's lifecycle owns once the row exists — written when + * the seed CREATES a contract, and left alone when a re-run of `pnpm demo` + * matches one (`applyOn: 'create'`, issue #41). + * + * The set is the single-writer family `contract.object.ts` names (the stage + * timestamps and routing flags, `approval_status`, `is_expiring`, + * `archived_at`, `is_backfilled`), plus the three a transition writes beside + * them: `status` itself, `termination_reason` (written by Terminate in the same + * write as `terminated`) and `archive_no` (the records desk's number, which is + * what stamps `archived_at`). Each has a writer after creation that is not the + * fixture — F12 sets `is_expiring`, F13 moves `active` to `expired`, the state + * machine and F2/F5/F14 stamp the rest — and the state machine refuses most of + * the ways back, so a re-run that re-asserted them either walked a job's work + * back silently or had the row refused and dropped whole. + * + * They travel as ONE set because they are one fact: a create-only `status` + * beside an `always` `approved_at` would let a re-run blank the stamp of a + * contract somebody approved since, which is a contract the write layer could + * never have produced. The cost is stated in the README: these columns keep + * the dates they were stamped with, while the fixture's own dates + * (`start_date`, `end_date`, …) still slide forward on every re-run. + * + * `legal_owner` and `owner_id` are NOT here: they are how the book is dealt to + * the demo staff, and a re-run re-dealing them is the behaviour, not a defect. + */ +const CREATE_ONLY = { applyOn: 'create' } as const; + /** * Six of the fourteen contracts in a terminal state have been through the * records desk; the other eight are what §05's "待归档" list is for. @@ -58,6 +86,26 @@ const archived = new Set( export const contractSeed = defineSeed(Contract, { externalId: 'title', mode: 'upsert', + columns: { + status: CREATE_ONLY, + is_expiring: CREATE_ONLY, + is_backfilled: CREATE_ONLY, + route_legal_head: CREATE_ONLY, + route_finance: CREATE_ONLY, + route_executive: CREATE_ONLY, + route_gm: CREATE_ONLY, + approval_status: CREATE_ONLY, + submitted_at: CREATE_ONLY, + review_started_at: CREATE_ONLY, + approved_at: CREATE_ONLY, + signed_at: CREATE_ONLY, + executed_at: CREATE_ONLY, + activated_at: CREATE_ONLY, + closed_at: CREATE_ONLY, + termination_reason: CREATE_ONLY, + archive_no: CREATE_ONLY, + archived_at: CREATE_ONLY, + }, records: CONTRACT_PLAN.map((contract) => { const type = typeOf(contract); const law = lawOf(contract); @@ -78,8 +126,7 @@ export const contractSeed = defineSeed(Contract, { // `business-unit.seed.ts` upserts on `code`, as `our_entity` is. department: type.department, // `owner_id` names a user the way `legal_owner` on the next line does — - // a `lookup('sys_user')` resolved against `sys_user.name` by the two-boot - // handover `scripts/demo.mjs` sequences. It replaces two claims that + // a `lookup('sys_user')` resolved against `sys_user.name`. It replaces two claims that // stood here and were both false; each was re-measured on 17.3.0 before // this line was written, because a `(measured)` annotation that is not // one is worse than no comment at all. @@ -94,11 +141,12 @@ export const contractSeed = defineSeed(Contract, { // platform admin — under `pnpm demo` the PRIMING boot, with no // contract to claim — so the demo boot left all 120 NULL. From 17.5 // (17.7 widened it again) it also runs whenever a seed settles - // (`app:seeded`), against the existing platform admin. Measured on - // 17.7.0 (#82): `pnpm demo`'s second boot ends with all 120 owned by - // the dev admin, and once the operator has created the requesters - // the next `pnpm demo` hands them over (43 / 43 / 34). The name below - // is still what decides the owner; the claim only fills the gap. + // (`app:seeded`), against the existing platform admin. Since #28 + // (PR #171) the requesters are declared demo staff + // (`objectstack.config.ts` `demoStaff`) that the platform creates on + // the demo boot BEFORE the seed runs, so the name resolves on the + // first `pnpm demo` (43 / 43 / 34, README). The name below is what + // decides the owner; the claim only fills a name no account carries. // // Which requester owns which contract is {@link ownerOf} in `keys.ts`. owner_id: ownerOf(contract), diff --git a/src/data/keys.ts b/src/data/keys.ts index 6ebfe24..429ca4e 100644 --- a/src/data/keys.ts +++ b/src/data/keys.ts @@ -10,7 +10,9 @@ import { assertUniqueKeys } from './_shared.js'; * * Each dataset runs `mode: 'upsert'` so `pnpm demo` is re-runnable on a * database that already holds the demo — the second run slides every - * boot-relative date forward instead of writing a second copy of the book. An + * boot-relative date forward instead of writing a second copy of the book, and + * leaves the columns the lifecycle owns as the jobs and the desks left them + * (`applyOn: 'create'` in `contract.seed.ts` and `post-signature.seed.ts`). An * upsert is only as good as its key, so the keys are built HERE, once, and * both the parent row and every child that points at it call the same * function. A child that spelled its parent's title itself would resolve to @@ -28,9 +30,12 @@ import { assertUniqueKeys } from './_shared.js'; /** * The account the demo is driven as — the platform's dev admin. * - * 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_review.reviewer` is `required: true`, so a name resolving to nothing + * costs the whole row, and the dev admin is the account `scripts/demo.mjs` + * verifies exists before the demo boot seeds anything. Since #28 (PR #171) the + * demo staff exist at seed time too (`demoStaff`, created by the platform + * before the seed runs); whether `reviewer` should name a lawyer instead is + * open and is not decided here. * * `clm_review.reviewer` is now the last REFERENCE that has to name it, and * the only one left that spells this constant at all. @@ -66,21 +71,19 @@ export const DEMO_USER = 'Dev Admin'; * gates every 我的合同 item away from it: measured on this fixture, * `GET /api/v1/meta/app/clm` serves that account `navigation: []`. Contracts * parked on it would be owned by the one account that can never open the - * screen they are meant to fill. The accounts that CAN are the ones DESIGN.md - * §10 has the operator create in Setup, so those are the names the deal uses. + * screen they are meant to fill. The accounts that CAN are DESIGN.md §10's + * persona mix, which the stack declares as `demoStaff` and the platform + * creates on the demo boot, so those are the names the deal uses. * - * That is also why the OPTIONAL user references in this fixture name operator - * accounts while the REQUIRED one names {@link DEMO_USER}, which is not an - * inconsistency but the difference between the two kinds of reference: + * The two kinds of reference still differ in what a missing account costs. * `clm_review.reviewer` is `required: true`, so a name that resolves to - * nothing costs the whole row and it must be an account that exists AT SEED - * TIME — the dev admin, the only one there is. `owner_id` and - * `clm_contract.legal_owner` are optional, so they may name accounts that do - * not exist yet: measured on 17.3.0 for `owner_id` and re-measured on 17.4.0 - * for `legal_owner`, an unresolvable name lands NULL, silently, and never - * refuses the row. Every dataset is an upsert, so creating the accounts and - * running `pnpm demo` again hands their contracts over. The README says so - * where the operator reads it. + * nothing costs the whole row. `owner_id` and `clm_contract.legal_owner` are + * optional: measured on 17.3.0 for `owner_id` and re-measured on 17.4.0 for + * `legal_owner`, an unresolvable name lands NULL, silently, and never refuses + * the row. Since #28 (PR #171) the platform creates the demo staff on the + * demo boot BEFORE the seed runs, so on `pnpm demo` every one of these names + * resolves on the first run and no account is left for the operator to + * create. The README names the accounts where the evaluator reads it. * * ## Why the counterparty decides * @@ -119,11 +122,11 @@ export const ownerOf = (contract: ContractPlan): string => * makes for `owner_id`, and it applies here for the same reason. * * `legal_owner` is `Field.user` and optional, so — like `owner_id` and unlike - * the `required` `clm_review.reviewer` — it may name an account that does not - * exist yet: an unresolvable name lands NULL, silently, and never refuses the - * row. Every dataset is an upsert, so the operator creates these two accounts - * and runs `pnpm demo` again to hand the contracts over. The README names them - * where the operator reads it. + * the `required` `clm_review.reviewer` — a name no account carries lands NULL, + * silently, and never refuses the row. These two are declared demo staff + * (`demoStaff`, PR #171): the platform creates them on the demo boot before + * the seed runs, so the contracts reach them on the first `pnpm demo`. The + * README names them where the evaluator reads it. * * ## Why the counterparty decides, and what that does NOT claim * @@ -194,12 +197,12 @@ export const legalOwnerOf = (contract: ContractPlan): string | null => * * `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. + * 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. * * ## The rule: the desk that performs it, and the counterparty decides which * @@ -309,10 +312,9 @@ export const obligationOwnerOf = (obligation: ObligationPlan): string | null => * 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. + * demo staff the contracts are dealt to, 10 / 7 across the 17, created by the + * platform before the seed runs. Like the other optional references, a name + * no account carries lands NULL (measured on 17.4.0) and never refuses the row. */ export const deviationDeciderOf = (deviation: DeviationPlan): string | null => deviation.status === 'open' ? null : counselFor(CONTRACT_PLAN[deviation.contractIndex]!); diff --git a/src/data/negotiation.seed.ts b/src/data/negotiation.seed.ts index bf2b930..229262f 100644 --- a/src/data/negotiation.seed.ts +++ b/src/data/negotiation.seed.ts @@ -38,9 +38,13 @@ import { contactEmailFor } from './plan.js'; * second, with the review row landing and its `display_name` mirror reading * "Finance · Dev Admin". * - * Every seeded reviewer is therefore the demo account itself. That is honest — - * it is the only user in the database — and the README says which positions to - * create and reassign to before the demo means anything as a permissions story. + * Every seeded reviewer is therefore the demo account itself. That was the + * honest choice while it was the only user in the database. Since #28 + * (PR #171) the demo staff — Legal Counsel 1 and 2 among them — exist before + * the seed runs, so the constraint above no longer forces the dev admin; which + * account each review STAGE should name (legal, finance, compliance, + * business) is open and not decided here, and the README tells the evaluator + * to reassign a review to put it on a lawyer's desk. */ export const reviewSeed = defineSeed(Review, { diff --git a/src/data/plan-contracts.ts b/src/data/plan-contracts.ts index bf1965a..e2d4358 100644 --- a/src/data/plan-contracts.ts +++ b/src/data/plan-contracts.ts @@ -26,9 +26,11 @@ import { * `skipTriggers` means F5 never fires over a seeded row. Even if it did, it * could not complete: rung 1 is `type: 'manager'`, and at seed time there is * nobody for it to resolve — every contract is launched by one of the three - * business requesters DESIGN.md §10 leaves to the OPERATOR to create - * (`keys.ts`), so on a fresh database no owner exists to have a manager - * (measured on 17.4.0: 120 `owner_id` references unresolved after pass 2). + * business requesters (`keys.ts`), and although the platform creates them + * before the seed runs (`demoStaff`, PR #171), a `demoStaff` entry cannot + * carry a manager: `sys_user.manager_id` stays the operator's one manual step + * (README, "Rung 1 of the approval ladder is a manager"), so on a fresh + * database no owner has a manager for rung 1 to resolve. * WHICH boot mints the dev admin is a separate fact, measured in exactly one * place — `scripts/demo.mjs` — and this argument does not rest on it: #21's * two-boot handover primes that account BEFORE the seed runs. `pnpm validate` @@ -688,12 +690,10 @@ export const RENEWAL_DEADLINE_TODAY = 1; * a legal owner and an end date beyond 90 days, so neither §10's 30-day count * nor the 90-day tile moves; soonest-ending first. * - * ⚠️ The non-renewing row is one more contract in #41's set: once F13 has - * expired it, re-running `pnpm demo` re-asserts `active` on a terminal - * contract — measured on this fixture: `819 ok / 1 error`, that row refused - * by `contract_state_machine`, loudly. #41 is the open decision on exactly - * that and covers every contract the jobs move; this row makes it reachable - * on boot day instead of five days after it. + * Once F13 has expired the non-renewing row, a re-run of `pnpm demo` leaves + * it expired: `status` is `applyOn: 'create'` in `contract.seed.ts` (#41). + * Before that, the re-run re-asserted `active` on a terminal contract and + * `contract_state_machine` refused the row, which was then dropped whole. */ export const TERM_ENDED_YESTERDAY_PER_BRANCH = 1; diff --git a/src/data/post-signature.seed.ts b/src/data/post-signature.seed.ts index 9548345..56a5beb 100644 --- a/src/data/post-signature.seed.ts +++ b/src/data/post-signature.seed.ts @@ -51,15 +51,46 @@ assertUniqueKeys( OBLIGATION_PLAN.map((o) => `${o.contractIndex}::${obligationTitle(o.kind, o.titleIndex)}`), ); +/** + * What a re-run of `pnpm demo` leaves alone on a row it matches (issue #41): + * the status and what the transition into it records. Written when the seed + * creates the row, never again — `applyOn: 'create'`. + * + * `status` is the column the daily jobs own once the row exists: F10 moves an + * obligation into `overdue`, F11 an instalment into `due` and `overdue`, and + * nothing else may (both state machines refuse `overdue` from any non-system + * write). Re-asserted on a re-run, it either walked the job back along a legal + * edge — measured: the 18 part-paid instalments F11 had flagged went from + * `overdue` back to `partial`, silently — or was refused, and the refused row + * was dropped whole (a `paid` instalment cannot go back to `due`). + * + * The other columns move WITH the status: `completed_at` is stamped by + * `obligation_state_machine` on `done`, and `actual_date` by + * `payment_plan_state_machine` on `partial` / `paid`, in the same write as the + * amount and invoice the finance desk records. Left `always`, a re-run would + * blank the receipt of an instalment somebody has since paid. + */ +const CREATE_ONLY = { applyOn: 'create' } as const; + export const obligationSeed = defineSeed(Obligation, { externalId: ['contract', 'title'], mode: 'upsert', + columns: { + status: CREATE_ONLY, + completed_at: CREATE_ONLY, + }, records: obligationRecords, }); export const paymentPlanSeed = defineSeed(PaymentPlan, { externalId: ['contract', 'seq'], mode: 'upsert', + columns: { + status: CREATE_ONLY, + actual_date: CREATE_ONLY, + actual_amount: CREATE_ONLY, + invoice_no: CREATE_ONLY, + }, records: PAYMENT_PLAN.map((instalment) => ({ contract: titleOfIndex(instalment.contractIndex), seq: instalment.seq,