Skip to content

Post-signature domain: clm_obligation, clm_payment_plan, and the contract roll-ups (card 03, M1) - #9

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-3-post-signature-domain
Sep 7, 2026
Merged

hotlong merged 3 commits into
mainfrom
claude/issue-3-post-signature-domain

Conversation

@hotlong

@hotlong hotlong commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Card 03 of M1: the post-signature domain. Two master-detail children of clm_contract, the five
roll-ups that summarise them, and the two child state machines that keep overdue honest.

What changed

Three commits, in the order the card suggested — objects, then roll-ups, then machines.

e54b24e — clm_obligation and clm_payment_plan (src/objects/obligation.object.ts,
payment-plan.object.ts, index.ts). Field lists transcribed from DESIGN.md §03. Both
controlled_by_parent, both master-detail on clm_contract with deleteBehavior: 'cascade',
both nameField: 'display_name'. Icons list-checks and banknote; amounts scale: 2, min: 0.

Two decisions inside those files worth naming:

  • The payment mirror is ASCII — #SEQ then a middot then the planned date — not a localized
    instalment 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-CN bundle concern (card 11).
  • (contract, seq) is a unique index, not a validation, matching the shape
    clm_contract_version already uses for (contract, version_no). The sequence number is what the
    mirror, 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: 2 on the same contract returns 409 UNIQUE_VIOLATION.

38712e3 — the five roll-ups (src/objects/contract.object.ts). Field.summary with
summaryOperations, in a new collapsed Roll-ups field group.

field child function filter
version_count clm_contract_version count —
open_deviation_count clm_deviation count { status: 'open' }
overdue_obligation_count clm_obligation count { status: 'overdue' }
planned_amount clm_payment_plan sum of planned_amount —
actual_amount clm_payment_plan sum of actual_amount —

relationshipField: 'contract' is declared on all five although the engine infers it — inference
takes 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 authored
number 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 kept
reporting 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.ts is now
537 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 overdue reservation is the point of both machines, and it is why these two — unlike their
siblings — also run on beforeInsert. Guarding only the update path would leave a row free to be
created straight into arrears: the same lie through a different door.

Zone 2 — the PM's mechanical assumptions

All four held. Measurements, not opinions:

  1. The five roll-ups are expressible as Field.summary. Yes, filters included.
    summaryOperations.filter is a FilterConditionSchema (the same DSL as a query where), and
    aggregateSummaryValue composes it as { $and: [ fkMatch, filter ] } before handing it to
    engine.aggregate. The plain object form { status: 'open' } is what the schema documents and
    what the engine reads. No refusal to report.
  2. They clear the lint suggestion. They do. Baseline on this branch's origin/main base was
    1 suggestion(s) — rollup/missing-summary on clm_contract_version.version_no. Now
    ✓ All checks passed. The rule fires once per master-detail child that has a numeric field and
    no summary rolling it up, so covering four children silences it for all of them.
  3. pnpm validate reports 11 objects. It does: Data: 11 Objects 169 Fields.
  4. Both machines belong in contract.hook.ts. Kept there. See the note on file size above.

Gates

$ pnpm validate
  ✓ Validation passed (169ms)
  Data: 11 Objects  169 Fields
  UI: 0 Apps   Logic: 0 Flows   Security: 0 Positions  0 Permissions

$ pnpm lint
  ✓ All checks passed (175ms)

$ pnpm typecheck
> tsc --noEmit
(no output, exit 0)

pnpm build was 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-admin on OS_PORT=3103, detached; Playwright 1.63 against the pre-installed
Chromium 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 from
inside that authenticated page.

The boot, read rather than assumed

✓ Server is ready, 30 plugins loaded, no System started with degraded capabilities, no
no such table, no failed plugin. The Setup app lists HotCLM · app.objectstack.hotclm · 0.1.0 · Enabled, and /api/v1/meta/object returns all eleven clm_* objects.

The boot is not clean, though, and the rule in AGENTS.md says to say so: it ends with
⚠ Boot diagnostics — 1 warning logged during startup — an ADR-0087 forward conversion of 40
required: true sites. 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 the
runtime names objects[0].fields.name (card 01's clm_contract_type.name) as the first converted
site. 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
200 from GET /api/v1/data/clm_contract/ID:

after version_count open_deviation_count overdue_obligation_count planned_amount actual_amount
contract created, no children 0 0 0 0 0
two payment-plan rows (50000 + 70000) 0 0 0 120000 0
one open deviation added 0 1 0 120000 0
that deviation accepted 0 0 0 120000 0
instalment 1 paid, actual_amount: 50000 0 0 0 120000 50000
attempted hand-write {version_count: 99, planned_amount: 999999} → 200 0 0 0 120000 50000

The deviation row is the load-bearing one. A filtered count that reads 0 proves nothing — it
looks 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 → 0 is the proof the filter form actually
evaluates.

overdue_obligation_count stays 0 throughout, and that is the correct reading, not a gap: no
user session can put a child into overdue at all (see below). It will first move under the
daily job of card 09.

The last row is the readonly check: the PATCH returns 200 and 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.file needs a committed
sys_file id, so two were created first):

after version_count
contract created 0
one version 1
two versions 2
one version deleted 1

Creating two versions on a contract reads back version_count == 2 through REST — met, and the
delete leg shows the count comes back down, not just up. That run logged no HTTP status >= 400
at all.

The state machines, through the API a user actually reaches

Allowed (200): obligation pending → in_progress → done; pending → waived; instalment
planned → due, due → paid.

Refused — every one a 422 carrying code: INVALID_STATE, the ADR-0112 envelope:

attempt response
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'} same message — the insert path is guarded too
PATCH clm_obligation done → pending A done obligation is closed; its status cannot change to pending. Record a new obligation instead.
PATCH clm_payment_plan planned → paid Payment 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'} same message on the insert path
POST clm_payment_plan with a duplicate seq 409 UNIQUE_VIOLATION — Duplicate record refused … No record was written.

Stamps, read back from the records afterwards: obligation status: done carries
completed_at: 2026-09-07T13:11:49.750Z; instalment status: paid carries
actual_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's
v1 · Draft / v2 · Clean still stamping beside them. Contract numbers stamped by card 02's hook
throughout (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/view returns
0 views and HotCLM contributes no app — src/views/ and src/apps/ arrive with card 05. The
only apps the Console can open are the platform's own (Setup, Marketplace). So "the surface a human
touches" for clm_obligation and clm_payment_plan today is the authenticated data API, and
that 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 organizations from an aborted
GET /api/v1/auth/organization/list, plus one 404 for a static asset. The Console renders and
functions 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: true stops implying a NOT
NULL 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 lists pending → … / overdue but gives in_progress no route to
    overdue. So an obligation someone has started can never be marked in arrears. Card 09's daily
    job will be refused on exactly those rows — the ones most likely to be late.
  • clm_payment_plan: §03 lists due → partial but gives partial no outgoing edge at all. A
    partially paid instalment can therefore never be completed to paid. Terminal states are marked
    explicitly elsewhere in §03 (open deviations, the contract's expired/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-decision card
rather 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-decision route.

Noted, not filed — no storage HTTP endpoint is mounted: /api/v1/storage and
/api/v1/storage/files both return 404 ENDPOINT_NOT_FOUND, so a file can only be attached by
inserting a sys_file row by hand (which is how the version fixtures above were made). Card 05's
version-upload UI will meet this. Adding a capability token is a needs_decision per AGENTS.md,
not a rider.

Fixes #3


Generated by Claude Code

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Post-signature domain: clm_obligation, clm_payment_plan, and the contract roll-ups (card 03, M1)

2 participants