From 029c9ef9a677b7e30100fbe61b79208a9b180d97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:51:21 +0000 Subject: [PATCH 1/3] fix(data): give F12 and F13 a row inside their windows on boot day On a fresh demo `renewal_notice` acted on 0 of 25 and `expiration_sweep` selected nothing: every active contract inside its notice window was seeded already flagged `is_expiring` (F12's once-per-contract key), and no active contract's term had ended. The jobs were correct and the renewal and expiry reminders M3 asks to see in the inbox were never sent. Read the fixture as the book at the start of boot day: a notice deadline on an earlier day was flagged by that day's run, one of today is not yet. Move one backfilled contract to a deadline of today (F12) and two to a term that ended yesterday, one per F13 branch, and re-prove the selections and that section 10's dated counts did not move. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HihZ11bQSqjCgjzHbpv4M1 --- src/data/contract.seed.ts | 17 ++--- src/data/plan-contracts.ts | 149 ++++++++++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/data/contract.seed.ts b/src/data/contract.seed.ts index a4d39f9..592a808 100644 --- a/src/data/contract.seed.ts +++ b/src/data/contract.seed.ts @@ -7,7 +7,7 @@ import { Contract } from '../objects/contract.object.js'; import { dayOffset } from './_shared.js'; import { STRINGS } from './demo-locale.js'; import { contractTitle, legalOwnerOf, ownerOf, titleOfIndex } from './keys.js'; -import { CONTRACT_PLAN, lawOf, typeOf, type ContractPlan } from './plan-contracts.js'; +import { CONTRACT_PLAN, lawOf, renewalFlagged, typeOf, type ContractPlan } from './plan-contracts.js'; /** * The 120 contracts of DESIGN.md §10, in the status spread its table pins. @@ -34,8 +34,10 @@ import { CONTRACT_PLAN, lawOf, typeOf, type ContractPlan } from './plan-contract * * - `route_*` comes from {@link routeFlagsFor}, which re-implements F2's * matching rules against the same six rules this demo installs; - * - `is_expiring` is F12's own predicate — `active`, and `end_date` inside - * the renewal notice window — evaluated against the seeded dates; + * - `is_expiring` is the flag F12 itself left on its last run — `active`, + * and a notice deadline on a day BEFORE boot day — evaluated against the + * seeded dates by {@link renewalFlagged}, which says why a deadline of + * today is still unflagged; * - `approval_status` mirrors the contract's own status, so every surface * inside the app agrees. The surface that does not is the platform's: * NO `sys_approval_request` exists behind any of these rows, because F5 @@ -43,13 +45,6 @@ import { CONTRACT_PLAN, lawOf, typeOf, type ContractPlan } from './plan-contract * for what card 10's dashboards will therefore show. */ -/** F12's predicate, evaluated against the seeded dates rather than guessed. */ -const isExpiring = (contract: ContractPlan): boolean => - contract.status === 'active' && - contract.timeline.endDate !== null && - contract.renewalNoticeDays !== null && - contract.timeline.endDate - contract.renewalNoticeDays <= 0; - /** * 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. @@ -121,7 +116,7 @@ export const contractSeed = defineSeed(Contract, { auto_renew: contract.autoRenew, renewal_notice_days: contract.renewalNoticeDays, parent_contract: contract.parentIndex === null ? null : titleOfIndex(contract.parentIndex), - is_expiring: isExpiring(contract), + is_expiring: renewalFlagged(contract), // Governing law is on the intake form of six of the nine types, and the // submission guard refuses a contract whose type asks for an intake diff --git a/src/data/plan-contracts.ts b/src/data/plan-contracts.ts index 388ac1d..c99c9fe 100644 --- a/src/data/plan-contracts.ts +++ b/src/data/plan-contracts.ts @@ -616,7 +616,154 @@ const handBackInReview = (contracts: readonly ContractPlan[]): ContractPlan[] => indexes.has(contract.index) ? { ...contract, hasLegalOwner: false } : contract); }; -export const CONTRACT_PLAN: readonly ContractPlan[] = handBackInReview(buildContracts()); +// ─────────────────────────────────────── the renewal and expiry windows ── + +/** + * Has F12 (`renewal_notice`) already flagged this contract `is_expiring`? + * + * The fixture is the book as the daily jobs leave it at the START of boot day: + * every earlier day's run has happened, today's has not. F12 flags a contract + * on the first run on or after its notice deadline (`end_date - + * renewal_notice_days`), so a deadline on an EARLIER day was flagged by that + * day's run, and a deadline of today is still waiting for today's. `< 0`, not + * `<= 0`, is that distinction — and it is what the seeded `is_expiring` says, + * so the column holds the value F12 itself left there (`_shared.ts`, rule 2). + * + * The flag is also F12's once-per-contract key (`is_expiring: { $ne: true }` + * in its filter): a flagged row is one whose owner the job has already told. + */ +export const renewalFlagged = (contract: ContractPlan): boolean => + contract.status === 'active' && + contract.timeline.endDate !== null && + contract.renewalNoticeDays !== null && + contract.timeline.endDate - contract.renewalNoticeDays < 0; + +/** + * How many active contracts reach their renewal-notice deadline ON boot day — + * the rows F12 finds unflagged inside their window on its first run. + * + * ## Why the corpus needs them — measured, #97 + * + * `main` @ `46e65f0`, 17.7.0, fresh `pnpm demo`, one manual trigger of each + * job: `renewal_notice` selected 25 and acted on 0; `expiration_sweep` + * selected 0 and 0. Both zeroes are structural, not a bad day. Every active + * contract inside its window (11) is seeded already flagged, which is the key + * that takes it out of F12's query; the 25 the query does read are all outside + * their windows, the nearest by six days. And no active contract's term has + * ended (the nearest ends in four days), so F13 has nothing to read at all. + * The jobs work — PR #42 measured both write paths on probe rows — and a + * fresh demo still never sends a renewal or an expiry reminder, which is half + * of what M3's acceptance asks to see in the inbox (DESIGN.md §11: + * 「到期、逾期提醒在收件箱可见」). + * + * ## Where the row goes, and why that is honest + * + * Under {@link renewalFlagged}'s reading, the ONE unflagged place inside a + * window is a deadline of today. So the row is moved there: its `end_date` + * becomes exactly `renewal_notice_days` from boot, and its seeded + * `is_expiring` is the `false` F12 computed yesterday. + * + * It is drawn from the backfilled corpus, as §10's ten expiring contracts are, + * and it must carry a legal owner, because F12 tells the business owner AND + * legal. The band keeps every dated count where it was: a notice period over + * 30 days keeps it out of §10's "10 within 30 days", and an end date already + * inside 90 keeps the 管理层 board's 90-day expiry tile (`expiring_90_days`) + * unchanged. Nearest to its window first — the smallest move. + * + * One row is what coverage costs, as for {@link UNASSIGNED_IN_REVIEW}. + */ +export const RENEWAL_DEADLINE_TODAY = 1; + +/** + * How many active contracts per branch of F13 (`expiration_sweep`) ended their + * term YESTERDAY — one that does not auto-renew (F13 expires it) and one that + * does (F13 drafts its renewal). Both branches notify, and neither is reachable + * on the corpus without this (measured above). + * + * Yesterday for the same reason as {@link RENEWAL_DEADLINE_TODAY}'s today: F13 + * selects `end_date < today`, so yesterday's run did not select a term that + * ended yesterday, and today's run will. Drawn from the backfilled corpus with + * 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. #41 is the open decision on exactly that, and it already covers + * every contract the jobs move; this row only makes it reachable on boot day. + */ +export const TERM_ENDED_YESTERDAY_PER_BRANCH = 1; + +/** §10's "10 contracts ending within 30 days", as a date band. */ +const EXPIRING_BAND_DAYS = 30; +/** The 管理层 board's `expiring_90_days` tile, as a date band. */ +const EXPIRY_TILE_DAYS = 90; + +/** Move a contract's whole timeline so its term ends on `endDate`, every stage keeping its distance. */ +const endingOn = (contract: ContractPlan, endDate: number): ContractPlan => { + const delta = endDate - (contract.timeline.endDate as number); + const shift = (day: number | null) => (day === null ? null : day + delta); + const t = contract.timeline; + return { + ...contract, + timeline: { + submittedAt: shift(t.submittedAt), reviewStartedAt: shift(t.reviewStartedAt), + approvedAt: shift(t.approvedAt), signedAt: shift(t.signedAt), executedAt: shift(t.executedAt), + activatedAt: shift(t.activatedAt), closedAt: shift(t.closedAt), startDate: shift(t.startDate), + endDate: shift(t.endDate), + }, + }; +}; + +const activeEndingIn = (contracts: readonly ContractPlan[], low: number, high: number) => + contracts.filter((c) => c.status === 'active' && c.timeline.endDate !== null && + c.timeline.endDate >= low && c.timeline.endDate <= high).length; + +/** + * Place {@link RENEWAL_DEADLINE_TODAY} and {@link TERM_ENDED_YESTERDAY_PER_BRANCH} + * — and prove afterwards that F12 and F13 will select exactly those rows and + * that no dated count of §10 or the boards moved. + */ +const placeInSweepWindows = (contracts: readonly ContractPlan[]): ContractPlan[] => { + const eligible = (c: ContractPlan): boolean => + c.status === 'active' && c.backfilled && !c.expiringSoon && c.hasLegalOwner && c.timeline.endDate !== null; + const end = (c: ContractPlan) => c.timeline.endDate as number; + + const renewal = contracts + .filter((c) => eligible(c) && c.renewalNoticeDays !== null && c.renewalNoticeDays > EXPIRING_BAND_DAYS && + end(c) > c.renewalNoticeDays && end(c) <= EXPIRY_TILE_DAYS) + .sort((a, b) => (end(a) - (a.renewalNoticeDays as number)) - (end(b) - (b.renewalNoticeDays as number))) + .slice(0, RENEWAL_DEADLINE_TODAY); + const ended = [false, true].flatMap((autoRenew) => contracts + .filter((c) => eligible(c) && c.autoRenew === autoRenew && end(c) > EXPIRY_TILE_DAYS) + .sort((a, b) => end(a) - end(b)) + .slice(0, TERM_ENDED_YESTERDAY_PER_BRANCH)); + if (renewal.length < RENEWAL_DEADLINE_TODAY || ended.length < 2 * TERM_ENDED_YESTERDAY_PER_BRANCH) { + throw new Error(`demo fixture: found ${renewal.length} contract(s) to bring to a renewal deadline of today and ${ended.length} to end yesterday — F12 and F13 would have nothing to act on (#97).`); + } + + const moveTo = new Map([ + ...renewal.map((c) => [c.index, c.renewalNoticeDays as number] as const), + ...ended.map((c) => [c.index, -1] as const), + ]); + const placed = contracts.map((c) => (moveTo.has(c.index) ? endingOn(c, moveTo.get(c.index)!) : c)); + + // Re-proved on the result, not assumed from the construction. + const active = placed.filter((c) => c.status === 'active'); + const deadlineToday = active.filter((c) => + c.renewalNoticeDays !== null && c.timeline.endDate !== null && c.timeline.endDate - c.renewalNoticeDays === 0); + assertCount('clm_contract (renewal deadline today, unflagged)', deadlineToday.filter((c) => !renewalFlagged(c)).length, RENEWAL_DEADLINE_TODAY); + const termEnded = active.filter((c) => c.timeline.endDate !== null && c.timeline.endDate < 0); + assertCount('clm_contract (active, term ended, not auto-renewing)', termEnded.filter((c) => !c.autoRenew).length, TERM_ENDED_YESTERDAY_PER_BRANCH); + assertCount('clm_contract (active, term ended, auto-renewing)', termEnded.filter((c) => c.autoRenew).length, TERM_ENDED_YESTERDAY_PER_BRANCH); + assertCount('clm_contract (active, ending within 30 days)', activeEndingIn(placed, 0, EXPIRING_BAND_DAYS), 10); + assertCount('clm_contract (active, ending within 90 days)', activeEndingIn(placed, 0, EXPIRY_TILE_DAYS), activeEndingIn(contracts, 0, EXPIRY_TILE_DAYS)); + if (placed.some((c) => moveTo.has(c.index) && (c.timeline.activatedAt ?? 0) > 0)) { + throw new Error('demo fixture: a contract moved into a sweep window would start in the future while seeded active.'); + } + return placed; +}; + +export const CONTRACT_PLAN: readonly ContractPlan[] = placeInSweepWindows(handBackInReview(buildContracts())); /** The counterparty a contract is with. */ export const partyOf = (contract: ContractPlan) => PARTY_PLAN[contract.partyIndex]!; From aaad12bbc673d224eb5009341e3512a4a703b827 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:22:05 +0000 Subject: [PATCH 2/3] docs(data): record the measured #41 interaction and scope the boot-day reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-renewing F13 row, once expired, is refused on re-seed (819 ok / 1 error) — state the measurement where the row is placed. The "start of boot day" reading is claimed for F12 and F13 only; F11's seeded partial instalments past their planned date are not read that way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HihZ11bQSqjCgjzHbpv4M1 --- src/data/plan-contracts.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/data/plan-contracts.ts b/src/data/plan-contracts.ts index c99c9fe..c98e5b3 100644 --- a/src/data/plan-contracts.ts +++ b/src/data/plan-contracts.ts @@ -621,8 +621,10 @@ const handBackInReview = (contracts: readonly ContractPlan[]): ContractPlan[] => /** * Has F12 (`renewal_notice`) already flagged this contract `is_expiring`? * - * The fixture is the book as the daily jobs leave it at the START of boot day: - * every earlier day's run has happened, today's has not. F12 flags a contract + * For F12 and F13 this fixture is the book as a daily run leaves it at the + * START of boot day: every earlier day's run has happened, today's has not + * (claimed for these two jobs only — F11's seeded `partial` instalments past + * their planned date are not read this way). F12 flags a contract * on the first run on or after its notice deadline (`end_date - * renewal_notice_days`), so a deadline on an EARLIER day was flagged by that * day's run, and a deadline of today is still waiting for today's. `< 0`, not @@ -688,8 +690,10 @@ export const RENEWAL_DEADLINE_TODAY = 1; * * ⚠️ 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. #41 is the open decision on exactly that, and it already covers - * every contract the jobs move; this row only makes it reachable on boot day. + * 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. */ export const TERM_ENDED_YESTERDAY_PER_BRANCH = 1; From 7d8a0590bd238645c0495e91fddc5d0ad69f488e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:23:21 +0000 Subject: [PATCH 3/3] fix(data): refuse a misplaced sweep-window row with what it costs The placement counts are the fixture's own, not section 10's, so they are refused with a message naming the job that would go quiet instead of the "DESIGN.md section 10 pins" wording assertCount uses for the table's counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HihZ11bQSqjCgjzHbpv4M1 --- src/data/plan-contracts.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/data/plan-contracts.ts b/src/data/plan-contracts.ts index c98e5b3..bf1965a 100644 --- a/src/data/plan-contracts.ts +++ b/src/data/plan-contracts.ts @@ -718,6 +718,13 @@ const endingOn = (contract: ContractPlan, endDate: number): ContractPlan => { }; }; +/** A count this placement owns (not one §10 pins), refused with what it would cost. */ +const assertPlaced = (rows: string, actual: number, expected: number, cost: string): void => { + if (actual !== expected) { + throw new Error(`demo fixture: ${actual} ${rows} after placing the sweep-window rows, expected ${expected} — ${cost} (#97).`); + } +}; + const activeEndingIn = (contracts: readonly ContractPlan[], low: number, high: number) => contracts.filter((c) => c.status === 'active' && c.timeline.endDate !== null && c.timeline.endDate >= low && c.timeline.endDate <= high).length; @@ -755,12 +762,16 @@ const placeInSweepWindows = (contracts: readonly ContractPlan[]): ContractPlan[] const active = placed.filter((c) => c.status === 'active'); const deadlineToday = active.filter((c) => c.renewalNoticeDays !== null && c.timeline.endDate !== null && c.timeline.endDate - c.renewalNoticeDays === 0); - assertCount('clm_contract (renewal deadline today, unflagged)', deadlineToday.filter((c) => !renewalFlagged(c)).length, RENEWAL_DEADLINE_TODAY); + assertPlaced('unflagged active contracts with a renewal deadline of today', deadlineToday.filter((c) => !renewalFlagged(c)).length, RENEWAL_DEADLINE_TODAY, + "F12 would select no row inside its window and the demo would send no renewal notice"); const termEnded = active.filter((c) => c.timeline.endDate !== null && c.timeline.endDate < 0); - assertCount('clm_contract (active, term ended, not auto-renewing)', termEnded.filter((c) => !c.autoRenew).length, TERM_ENDED_YESTERDAY_PER_BRANCH); - assertCount('clm_contract (active, term ended, auto-renewing)', termEnded.filter((c) => c.autoRenew).length, TERM_ENDED_YESTERDAY_PER_BRANCH); + assertPlaced('active, non-renewing contracts past their end date', termEnded.filter((c) => !c.autoRenew).length, TERM_ENDED_YESTERDAY_PER_BRANCH, + "F13's expiry branch would have nothing to expire, or more than the fixture means to hand it"); + assertPlaced('active, auto-renewing contracts past their end date', termEnded.filter((c) => c.autoRenew).length, TERM_ENDED_YESTERDAY_PER_BRANCH, + "F13's auto-renew branch would have nothing to draft, or more than the fixture means to hand it"); assertCount('clm_contract (active, ending within 30 days)', activeEndingIn(placed, 0, EXPIRING_BAND_DAYS), 10); - assertCount('clm_contract (active, ending within 90 days)', activeEndingIn(placed, 0, EXPIRY_TILE_DAYS), activeEndingIn(contracts, 0, EXPIRY_TILE_DAYS)); + assertPlaced('active contracts ending within 90 days', activeEndingIn(placed, 0, EXPIRY_TILE_DAYS), activeEndingIn(contracts, 0, EXPIRY_TILE_DAYS), + "the 管理层 board's 90-day expiry tile would move with a fixture change it has nothing to do with"); if (placed.some((c) => moveTo.has(c.index) && (c.timeline.activatedAt ?? 0) > 0)) { throw new Error('demo fixture: a contract moved into a sweep window would start in the future while seeded active.'); }