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
43 changes: 23 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 55 additions & 7 deletions src/data/contract.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand All @@ -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.
Expand 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),
Expand Down
66 changes: 34 additions & 32 deletions src/data/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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]!);
Expand Down
10 changes: 7 additions & 3 deletions src/data/negotiation.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
18 changes: 9 additions & 9 deletions src/data/plan-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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;

Expand Down
31 changes: 31 additions & 0 deletions src/data/post-signature.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading