Repository navigation
The demo makes M3's reminder layer look broken: 54 of 120 contracts have no legal_owner, so four of six scheduled jobs notify nobody - #55
Merged
Conversation
…ing `in_review` onto types that refuse it The seeded corpus gave M3's reminder layer nobody to tell and nobody but the administrator to tell it to. Measured on `a7b7db5` with the README operator setup performed, one run of each scheduled job: 44 notification receipts, and all 5 that `legal_owner` addressed carried the dev admin — an account that holds no `clm_*` permission set, so `clm_legal.access` gates every 法务工作台 item away from it and 审查中 (`legal_owner == me`) is a screen it can never open. F3 selected 10 contracts in review over 30 days and notified 5. Two causes, one column: 1. `legal_owner` named `DEMO_USER`. It is `Field.user` and optional, so like `owner_id` (#26) it may name accounts that do not exist yet: an unresolvable name lands NULL and never refuses the row, and every dataset is an upsert, so the operator creates the accounts and re-runs `pnpm demo` to take delivery. It now names the `clm_legal_counsel ×2` of DESIGN.md §10, dealt by counterparty relationship the way `ownerOf` deals requesters — 34 / 34. 2. Six of the twelve `in_review` contracts sat on the two contract types whose `requiresLegalReview` is false. `contract_state_machine` refuses that edge twice over and F2 never assigns a legal owner to such a type, so those six were rows no surface in this app could have made AND rows F3 selects and can tell nobody about — the whole of its 5-of-10 reading. A third repair in `dealStatuses` swaps them onto legal-review types, in the same style as the two repairs already there, so §10's spread survives; `assertDealtStates` re-proves that invariant and the blocked-counterparty one after all three passes, because a swap moves two rows and counting statuses cannot notice. One contract is deliberately handed back to the queue with no legal owner, so F3's partitioned "nobody to tell" edge — which card 09 measured working — stays exercised. `handBackInReview` proves reachability rather than assuming it: the row must sit in F3's `(-60, -30]` day band, past the 30-day threshold so the over-30 stage selects it and inside 60 so the over-60 stage (which copies `clm_legal_head`, giving the notification a recipient) does not take it first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
…erences `clm_contract.legal_owner` names `Legal Counsel 1` / `Legal Counsel 2` instead of the dev admin, so the operator setup has to name them exactly the way it already names the three business requesters — otherwise the column resolves to nothing, silently, and the empty-screen problem has moved rather than been fixed. The two exact-name groups are now one table, and the paragraph that said `legal_owner` points at the dev admin no longer says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
zhuangjianguo
marked this pull request as ready for review
September 10, 2026 07:30
This was referenced Sep 10, 2026
zhuangjianguo
pushed a commit
that referenced
this pull request
Sep 10, 2026
PR #55 deals `clm_contract.legal_owner` to the two `clm_legal_counsel` account names, so it no longer resolves against the dev admin. Two places in `scripts/demo.mjs` still enumerated it as one of three references that do: the `DEMO_USER` doc block, and the failure detail printed when the priming boot's account is not named `Dev Admin`. The mechanism is unchanged and the check keeps its place — `clm_review.reviewer` is `required: true` with `storage: { notNull: true }`, so a name drift still costs all 60 review rows. Only the enumeration moves: two references, not three. The sentence about what a drift costs is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
This was referenced Sep 10, 2026
zhuangjianguo
pushed a commit
that referenced
this pull request
Sep 10, 2026
PR #55 deals clm_contract.legal_owner to the two clm_legal_counsel account names (contract.seed.ts -> legalOwnerOf() -> LEGAL_OWNERS), so it no longer resolves against the dev admin. Two passages in scripts/demo.mjs still listed it as one of three references that do: the DEMO_USER doc block and the failure detail printed when the priming boot's account is not named "Dev Admin". Both now name the two that really do - clm_review.reviewer and clm_obligation.owner. The mechanism is untouched: clm_review.reviewer is required with storage.notNull, so a name drift still costs all 60 review rows and the check still earns its place. The lines saying what the drift costs and the recovery command are byte-identical. Verified by running the failure path rather than by the gates, which cannot see this file (tsconfig includes only objectstack.config.ts and src/**, and tsc --listFiles names zero files under scripts/): the pre-fix file prints the stale list on a wrong account name, this one prints the corrected list on the same input, and on the correct name the check does not fire at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
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.
Fixes #47
Data only. ⛔ No file under
src/flows/, no hook, noDESIGN.md. Four files: three insrc/data/, plus the operator table inREADME.mdbecause the deal names two accounts an operator has to create.Both are close and both are off, and one of them changes what the fix has to be. Read from the driver's own SQLite file (
node:sqlite, read-only) onorigin/main@a7b7db5, clean database,pnpm demo, README operator setup performed, one run of each job.a7b7db5legal_ownerlegal_owner empty: 51sys_notification_receiptrows carry the dev adminThe premise holds — the demo's legal reminders were the administrator writing to themselves — but its arithmetic was measured before #26's
owner_iddeal was in the database. What is actually true after #26 is sharper and smaller:owner_idalready reaches three real requesters, and the only user references still parked on the dev admin areclm_contract.legal_owner(this card) andclm_obligation.owner(filed as #52).And 51 is not the number to fix. Drafts,
cancelledcontracts,submittedcontracts nobody has picked up (§05's 待受理 queue) and every contract on a type that skips legal review are ownerless because F2 never assigns one to them. Filling those would be inventing history. The rows that actually cost a notification are the twelve atin_review, because F3 is the only job whose recipients arelegal_owneralone.Two causes, and the second one is the whole of the 5-of-10
1.
legal_ownernamed the dev admin — 69 rows, one accountField.user, optional, so likeowner_id(#26) it may name accounts that do not exist yet. Re-measured here on 17.4.0, because the card asked (Zone 2, assumption 2) and because the fixture's own rule is that a(measured)annotation which is not one is worse than no comment: seeded on a clean database with the accounts absent, all 120 rows landlegal_owner = NULLand every row survives — 120 contracts / 60 reviews / 200 obligations / 300 instalments. An unresolvable name never refuses the row.Field.userandField.lookup('sys_user')resolve identically here.It now names the
clm_legal_counsel ×2of DESIGN.md §10, dealt by counterparty relationship — the identical ruleownerOfuses for requesters, and the onekeys.tsalready argues for at length (a lawyer owns a relationship, not a random slice of the book). 34 / 34.2. Six
in_reviewcontracts sat on types that refuse to be in reviewThis is the finding the card did not have, and it is the entire 5-of-10:
Six of the twelve
in_reviewcontracts were onSOW/ORD, the two types whoserequiresLegalReviewis false. F2 never assigns a legal owner to such a type — so those six were rows no surface in this app could have made, and they were exactly the six F3 selects and can tell nobody about.plan.ts's own stated property 1 — no row is in a state the write layer would have refused — had a third edge nobody had covered.⛔ Giving those six an owner would not have fixed them; it would have swapped one impossible claim for another (a legal owner on a type F2 skips) and let the fixture keep lying while the number looked better. So
dealStatusesgains a third repair that swaps them onto legal-review types, in the same style as the two repairs already there, and §10's spread survives untouched.assertDealtStatesthen re-proves both per-row invariants — blocked counterparty, andin_reviewtype — after all three passes, because a swap moves two rows and counting statuses cannot notice a repair undoing an earlier one.The deliberate ownerless row — this is test coverage, and it is signposted as such
One contract in review is handed back to the queue with no legal owner, and the site says why in about forty lines: card 09 measured the partitioned "nobody to tell" edge working,
loop-node.tsiterates with a bareawaitso that edge is all that stands between one unassigned row and a sweep reportingacted: 0for every row after it, and a reader who "tidies this up" is deleting the only place this corpus can exercise it.Two things make the row provable rather than hopeful, and
handBackInReviewthrows on either:(-60, -30]day band. Past 30 so the over-30 stage selects it — and inside 60, because the over-60 stage copiesclm_legal_head, which would give the notification a recipient and take the notify edge instead. Only the over-30 stage haslegal_owneras its sole recipient.Selected vs notified, before and after — read from the database
Identical protocol both sides: clean
.objectstack/data,pnpm demoon port 3147, the same README operator setup (3 requesters + all 7 positions + dev admin intoclm_admin), re-seed so the upsert hands the rows over, then one trigger of each job throughPOST /api/v1/automation/{name}/trigger; counts read from each run's own node summary.legal_review_sla(F3) over 30dlegal_review_sla(F3) over 60dturn_stalled(F4)obligation_due(F10)payment_overdue(F11)renewal_notice(F12)expiration_sweep(F13)Every run green,
failed=0on both sides. F12's zero is unchanged and untouched — the card ruled it correct and out of scope, and the cause (the once-per-contract key excluding rows the seed already marksis_expiring) is not this column.selectedhonestly: it fell from 10 to 6 and that is not an improvement or a regression, it is noise.timelineFordraws eachin_reviewcontract's review age from-8 … -58days, so the share past 30 days is a draw; the status swap changes which contracts arein_reviewand shifts the shared PRNG stream, so the draw is a different one. 10-of-12 was a lucky sample, 6-of-12 is near the mean. The numbers that are not noise:in_reviewrows carrying a legal ownerlegal_owner-addressed receipts on the dev adminin_reviewrows in a state the app refusesWhere the notifications actually land
The quiet edge, at row level
Selected, ownerless, run green, nothing sent — the edge is still exercised, on a row that is now legal (a lawyer handing a file back clears the column through the ordinary edit form; nothing holds it set after the transition, and
legal_owneris in thepartiesgroup so_grants.tsdoes not lock it).And out of the box, with no operator accounts
owner_idhas behaved this way since #26, out of the boxGET /api/v1/meta/app/clmserves the only existing accountnavigation: []so nobody can open the inbox to see them anyway, and the dev admin could not act on them if they did. The state a demo is actually evaluated in is the one after the README setup, and that is where the table above is measured.Browser
Chromium 1194 at
/opt/pw-browsers/chromium-1194/chrome-linux/chrome;playwright installwas not run. Signed in as each lawyer in turn at/_console/, both landing on/_console/apps/clm/clm_contract/view/my_contractswith the full five-section navigation.Legal Counsel 1 — bell badge
2, panel reads "Inbox · 2 total · 2 notifications · 0 pending approvals":Legal Counsel 2 — bell badge
3, panel reads "Inbox · 3 total · 3 notifications · 0 pending approvals": LSE-2026-0004, ICA-2026-0004, NDA-2026-0016.Two people, two different inboxes, five notices between them —
200 GET /api/v1/data/sys_notification_receipt?top=200&filter=[["and",["user_id","=","…"],["channel","=","inbox"]]]with a differentuser_idin each, which is what makes them genuinely two recipients rather than one list rendered twice. Before this PR all five of those rows carried the dev admin's id.Console: one
404for a static asset (present on the baseline, see PR #42) and403 PERMISSION_DENIEDonGET /api/v1/data/sys_activity— the platform Console's own activity feed, which noclm_*set grants; it appears because this is the first browser pass driven as a non-admin rather than as the dev admin. Neither is application output and neither is caused by this diff. No application errors.Gates
Exit codes captured before any pipe (
cmd > file 2>&1; EXIT=$?), ona3b7af1with a clean working tree:All 21 warnings are
field-no-consumersand all 5 suggestionsapproval-approvers-may-resolve-empty, the same families and the same counts as the baseline: this PR adds none. Boot: the two known pre-existing platform ERROR lines (objectstack#17175_objectstack_sequences,#17176sys_oauth_resource) and the pre-existing[Seeder] Inline seed exceeded 8000ms budgetnotice. Nothing else. Both re-seeds completed with zeroFailed to writelines.Acceptance notes
scripts/demo.mjsnow carries two stale sentences and this PR deliberately does not fix them. ItsDEMO_USERdoc block and its priming-failure message both still listclm_contract.legal_owneramong the references that resolve against the dev admin. The check itself is unaffected and still earns its place (clm_review.reviewerisrequired, so a name drift still costs all 60 review rows) — only the enumeration is now two items, not three. That file is held by PRpnpm demoprints its "now create the three requesters" instruction ~290 lines and 120 ERROR lines before the ready banner, so nobody reads it #51 (pnpm demoprints its "now create the three requesters" instruction ~290 lines and 120 ERROR lines before the ready banner, so nobody reads it #49) this round, so editing it here would collide. Filed asscripts/demo.mjsstill tells the operator thatclm_contract.legal_ownerresolves against the dev admin — PR for #47 made that false #54, to land after both.clm_obligation.owneris the dev admin on all 200 rows. After this PR they are the only receipts left on that account (2 of 45). Same shape, different column, and it needs a fixture-design answer of its own about how much of F10's own quiet edge to preserve. Filed as The obligation reminders are still the administrator talking to themselves:clm_obligation.owneris the dev admin on all 200 seeded rows #52.legal_ownerwhileis_backfilledsays on the same row that no review happened.legal_owner's own description is "The lawyer who accepted the review". This PR deals the column but deliberately does not change which rows carry it — and clearing them would cost F12/F13 a recipient, since §10 draws the expiring corpus from exactly those rows. Filed as 34 backfilled contracts carry alegal_owner, andis_backfilledsays on the same row that no review ever happened #53 with both readings and their costs.POST /api/v1/auth/admin/create-userland withmust_change_password = 1, and every API call then answers403 PASSWORD_EXPIREDuntil the password is rotated.sys_user.must_change_passwordis not writable through the data API (ADR-0092), so the rotation has to go throughPOST /api/v1/auth/change-password. Noted, not filed: an operator following the README uses Setup → Users, not this endpoint, and whether that path sets the same flag was not measured here.selectedcount is a sample from the review-age distribution, not a design parameter. ⛔ It was deliberately not tuned. MovingtimelineFor'sin_reviewwindow to make more rows fall past 30 days would inflate a reminder count against a number DESIGN.md does not pin — the exact "seeded number that looks computed" shape AGENTS.md forbids.🤖 Generated with Claude Code
https://claude.ai/code/session_01R3n3GGzobdegM4HUzah1iR
Generated by Claude Code