Repository navigation
Post-signature domain: clm_obligation, clm_payment_plan, and the contract roll-ups (card 03, M1) - #9
Merged
Conversation
The post-signature domain of DESIGN.md §03: what a signed contract still owes (obligations) and what it is still owed (the payment schedule). Both are master-detail children of clm_contract with cascade delete and `controlled_by_parent` sharing, so access derives from the contract rather than being restated. Two details are deliberate: - `clm_payment_plan.display_name` mirrors "#<seq> · <planned_date>" in ASCII. A stored mirror is written once, in the source language, and cannot be re-rendered per reader; the localized instalment label is a zh-CN bundle concern (card 11), not a stored value. - `(contract, seq)` is a unique index, not a validation — the same shape clm_contract_version uses for `(contract, version_no)`. The sequence number is what the mirror, the reminder job and the finance hand-off address an instalment by, so a duplicate is a defect, not a preference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
`Field.summary` with `summaryOperations`, recomputed by the engine on every child insert/update/delete rather than derived per read: the two filtered counts exist to be listed, filtered and sorted on, which a formula cannot be. - version_count count of clm_contract_version - open_deviation_count count of clm_deviation where status == 'open' - overdue_obligation_count count of clm_obligation where status == 'overdue' - planned_amount sum of clm_payment_plan.planned_amount - actual_amount sum of clm_payment_plan.actual_amount `relationshipField` is named on all five although the engine infers it: inference takes the first lookup/master_detail field pointing back here, which is order-dependent on a key nothing else pins. None declares `max`. On an authored number a bound is a guardrail; on a derived one it is a trap — bounds are checked on the written value, so a count outgrowing its ceiling would have the engine's own recompute write refused and the roll-up would sit silently stale, the failure logged rather than raised, while every child write kept reporting success. This also silences the one `rollup/missing-summary` suggestion lint emitted on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
The obligation and payment-instalment machines of DESIGN.md §03, beside the deviation and signature machines card 02 put in the same file, and the two display_name mirrors beside their four siblings. `overdue` is the point of both. It is a MEASUREMENT the daily job of card 09 takes from due_date / planned_date, not an opinion anybody types — so unlike the other child machines these two run on beforeInsert as well: guarding only the update path would leave a row free to be created straight into arrears, which is the same lie by a different door. A non-system write to `overdue` is refused with the ledger's INVALID_STATE / 422 envelope on both paths. The transition tables are transcribed edge for edge from §03, including the two edges §03 does not draw: `in_progress` has no route to `overdue`, and `partial` has no route to `paid`. Both are §03 questions, and §03 is a governed surface this card does not get to widen — they are raised in the PR body instead of being quietly invented here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Card 03 of M1: the post-signature domain. Two master-detail children of
clm_contract, the fiveroll-ups that summarise them, and the two child state machines that keep
overduehonest.What changed
Three commits, in the order the card suggested — objects, then roll-ups, then machines.
e54b24e—clm_obligationandclm_payment_plan(src/objects/obligation.object.ts,payment-plan.object.ts,index.ts). Field lists transcribed from DESIGN.md §03. Bothcontrolled_by_parent, both master-detail onclm_contractwithdeleteBehavior: 'cascade',both
nameField: 'display_name'. Iconslist-checksandbanknote; amountsscale: 2, min: 0.Two decisions inside those files worth naming:
#SEQthen a middot then the planned date — not a localizedinstalment word. A stored mirror is written once, in the source language, and cannot be
re-rendered for the next reader; the localized form is a
zh-CNbundle concern (card 11).(contract, seq)is a unique index, not a validation, matching the shapeclm_contract_versionalready uses for(contract, version_no). The sequence number is what themirror, the reminder job and the finance hand-off all address an instalment by. This is the one
thing in the diff the card did not literally ask for; it is called out here so it can be refused
cheaply. Verified live: a second
seq: 2on the same contract returns409 UNIQUE_VIOLATION.38712e3— the five roll-ups (src/objects/contract.object.ts).Field.summarywithsummaryOperations, in a new collapsedRoll-upsfield group.version_countclm_contract_versionopen_deviation_countclm_deviation{ status: 'open' }overdue_obligation_countclm_obligation{ status: 'overdue' }planned_amountclm_payment_planplanned_amountactual_amountclm_payment_planactual_amountrelationshipField: 'contract'is declared on all five although the engine infers it — inferencetakes the first lookup/master_detail field on the child pointing back here, an order dependence
nothing else about the child pins.
None of the five declares
max, and that is deliberate rather than an omission. On an authorednumber a bound is a guardrail; on a derived one it is a trap. Bounds are checked on the written
value, and the writer here is the engine's own recompute — so a count that outgrew its ceiling
would have that write refused, and the roll-up would then sit silently stale: the recompute
failure is logged by
ObjectQL.recomputeSummaries, not raised, while every child write keptreporting success.
fc4a4ef— the two state machines and the two mirrors (contract.hook.ts,mirror.hook.ts),beside the deviation and signature machines card 02 put in the same file.
contract.hook.tsis now537 lines; still one coherent subject (the write-layer truth for one aggregate), so it was not split
— worth revisiting when the F-flows start adding to it.
The
overduereservation is the point of both machines, and it is why these two — unlike theirsiblings — also run on
beforeInsert. Guarding only the update path would leave a row free to becreated straight into arrears: the same lie through a different door.
Zone 2 — the PM's mechanical assumptions
All four held. Measurements, not opinions:
Field.summary. Yes, filters included.summaryOperations.filteris aFilterConditionSchema(the same DSL as a querywhere), andaggregateSummaryValuecomposes it as{ $and: [ fkMatch, filter ] }before handing it toengine.aggregate. The plain object form{ status: 'open' }is what the schema documents andwhat the engine reads. No refusal to report.
origin/mainbase was1 suggestion(s)—rollup/missing-summaryonclm_contract_version.version_no. Now✓ All checks passed. The rule fires once per master-detail child that has a numeric field andno summary rolling it up, so covering four children silences it for all of them.
pnpm validatereports 11 objects. It does:Data: 11 Objects 169 Fields.contract.hook.ts. Kept there. See the note on file size above.Gates
pnpm buildwas also run once, to confirm the four new handlers lower rather than bundle:Skipping legacy runtime bundle (all 12 callables are body-only).Browser evidence
pnpm dev --seed-adminonOS_PORT=3103, detached; Playwright 1.63 against the pre-installedChromium at
/opt/pw-browsers/chromium-1194/chrome-linux/chrome. Signed in through the real form(
admin@objectos.ai/admin123), landed on/_console/home, and drove everything below frominside that authenticated page.
The boot, read rather than assumed
✓ Server is ready, 30 plugins loaded, noSystem started with degraded capabilities, nono such table, no failed plugin. The Setup app listsHotCLM · app.objectstack.hotclm · 0.1.0 · Enabled, and/api/v1/meta/objectreturns all elevenclm_*objects.The boot is not clean, though, and the rule in
AGENTS.mdsays to say so: it ends with⚠ Boot diagnostics — 1 warning logged during startup— an ADR-0087 forward conversion of 40required: truesites. That warning is pre-existing and repo-wide, not introduced here:git grep -c "required: true"counts 31 sites on the base commit and 40 with this card, and theruntime names
objects[0].fields.name(card 01'sclm_contract_type.name) as the first convertedsite. It is filed as #8 rather than fixed here.
The roll-ups, step by step
One contract, children added one group at a time, the parent re-read after each. Every row is a
200fromGET /api/v1/data/clm_contract/ID:actual_amount: 50000{version_count: 99, planned_amount: 999999}→200The deviation row is the load-bearing one. A filtered count that reads
0proves nothing — itlooks identical whether the filter works or the roll-up is dead — so the deviation was moved
into and back out of the predicate.
0 → 1 → 0is the proof thefilterform actuallyevaluates.
overdue_obligation_countstays0throughout, and that is the correct reading, not a gap: nouser session can put a child into
overdueat all (see below). It will first move under thedaily job of card 09.
The last row is the readonly check: the
PATCHreturns200and the stored values do not move.The card's acceptance criterion, on its own contract
Second contract, versions added one at a time (
clm_contract_version.fileneeds a committedsys_fileid, so two were created first):Creating two versions on a contract reads back version_count == 2 through REST— met, and thedelete leg shows the count comes back down, not just up. That run logged no HTTP status
>= 400at all.
The state machines, through the API a user actually reaches
Allowed (
200): obligationpending → in_progress → done;pending → waived; instalmentplanned → due,due → paid.Refused — every one a
422carryingcode: INVALID_STATE, the ADR-0112 envelope:PATCH clm_obligation {status: 'overdue'}Only the daily obligation job marks an obligation overdue; it is measured from due_date, not typed in. …POST clm_obligation {status: 'overdue'}PATCH clm_obligation done → pendingA done obligation is closed; its status cannot change to pending. Record a new obligation instead.PATCH clm_payment_plan planned → paidPayment instalment status cannot go from planned to paid. Allowed from planned: due.PATCH clm_payment_plan {status: 'overdue'}Only the daily payment job marks an instalment overdue; …POST clm_payment_plan {status: 'overdue'}POST clm_payment_planwith a duplicateseq409 UNIQUE_VIOLATION—Duplicate record refused … No record was written.Stamps, read back from the records afterwards: obligation
status: donecarriescompleted_at: 2026-09-07T13:11:49.750Z; instalmentstatus: paidcarriesactual_date: 2026-09-07. Mirrors as stored:Deliver implementation plan,File quarterly compliance report,#1 · 2026-10-01,#2 · 2027-01-01, and card 02'sv1 · Draft/v2 · Cleanstill stamping beside them. Contract numbers stamped by card 02's hookthroughout (
MSA07194-2026-0001).What the browser could NOT reach, and why
There is no rendered list or record surface for these objects yet.
/api/v1/meta/viewreturns0views and HotCLM contributes no app —src/views/andsrc/apps/arrive with card 05. Theonly apps the Console can open are the platform's own (Setup, Marketplace). So "the surface a human
touches" for
clm_obligationandclm_payment_plantoday is the authenticated data API, andthat is what was driven. Reporting this rather than dressing the Setup dashboard up as a list view.
Console warnings seen during sign-in, unrelated to this card and present before it:
[AuthProvider] Failed to load organizationsfrom an abortedGET /api/v1/auth/organization/list, plus one404for a static asset. The Console renders andfunctions regardless.
验收备注
Out-of-scope findings. One filed, the rest noted for triage — none of them ridden into this PR.
Filed as #8 — the ADR-0087 boot warning described above.
required: truestops implying a NOTNULL column when the conversion retires in protocol 18; 40 sites repo-wide, 31 of them predating
this card.
Noted, needs the maintainer — two edges DESIGN.md §03 does not draw. Both machines were
transcribed literally, which is what Zone 1 asked for, and both literal readings have a consequence
worth a decision rather than an invention here:
clm_obligation: §03 listspending → … / overduebut givesin_progressno route tooverdue. So an obligation someone has started can never be marked in arrears. Card 09's dailyjob will be refused on exactly those rows — the ones most likely to be late.
clm_payment_plan: §03 listsdue → partialbut givespartialno outgoing edge at all. Apartially paid instalment can therefore never be completed to
paid. Terminal states are markedexplicitly elsewhere in §03 (
opendeviations, the contract'sexpired/terminated/cancelled); here they are terminal only by omission, which reads more like a gap than a ruling.Both live in DESIGN.md §01–§04, a governed surface, so they belong in a
needs-user-decisioncardrather than in this diff.
Noted, not filed — DESIGN.md §03's field table still spells the payment mirror as the localized
form, while this card's Zone 1 settled on ASCII with the reasoning quoted above. The code follows
the card. The table is governed text and wants the same
needs-user-decisionroute.Noted, not filed — no storage HTTP endpoint is mounted:
/api/v1/storageand/api/v1/storage/filesboth return404 ENDPOINT_NOT_FOUND, so a file can only be attached byinserting a
sys_filerow by hand (which is how the version fixtures above were made). Card 05'sversion-upload UI will meet this. Adding a capability token is a
needs_decisionper AGENTS.md,not a rider.
Fixes #3
Generated by Claude Code