Skip to content

clm_contract.department is a fixed option list — make it a lookup to the platform's sys_business_unit, as #20 ruled for our_entity - #128

Merged
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-109-department-business-unit
Oct 25, 2026
Merged

zhuangjianguo merged 2 commits into
mainfrom
claude/issue-109-department-business-unit

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #109

clm_contract.department was an eight-option Field.select (sales · procurement · legal · finance · operations · people · it · other). That is organisation structure written as schema, the shape #20's ruling C (comment 6396457129, step 5) took away from our_entity. This PR makes it a lookup to the platform's sys_business_unit, filtered to active department-kind units. The field name stays department, because business_unit is a platform reserved word. The departments are seeded as data, the same way #118 seeded the signing entities.

Premises re-checked first (card's three)

  1. 18.1.0 can express a department distinct from a company: yes. The installed @objectstack/platform-objects 18.1.0 sys_business_unit declares kind as a required select with the options company · division · department · office · cost_center, default department. So no app-side object is needed, and none was added.
  2. Every consumer was enumerated (git grep department over the tree) and re-pointed. See the table below. The §13 Q2 note is kept on the field: department-level sharing is a customer overlay. src/sharing/** has no reference to department.
  3. DESIGN.md is not touched. The §03 wording the seat's governed PR should carry is under Acceptance notes.

What changed

The field (src/objects/contract.object.ts). department: Field.lookup('sys_business_unit', …) with lookupFilters kind eq department and active eq true. It uses the eq spelling that our_entity uses; lookupFilters[].operator is an enum in which equals is refused. The description now points to Setup → Business Units.

Optional, as it was. No required and no storage.notNull, for three reasons:

  • the old select was optional;
  • a fresh install has no department until an admin adds one, and a contract should not wait for the org chart to be written;
  • backfilled (F16) and renewed (F12/F13) contracts may have none.

our_entity is required because a contract cannot be signed without a signing entity. The requesting department is reporting data.

The seed (src/data/business-unit.seed.ts, plan.ts, strings.ts, both locale bundles):

  • The upsert that writes the three companies (on code) also writes seven departments of kind: department. The companies come first, so a department's parent resolves.
  • The seven are ARC-SALES · ARC-PROC · ARC-LEGAL · ARC-FIN · ARC-OPS · ARC-PEOPLE · ARC-IT. Names in en: Sales · Procurement · Legal · Finance · Operations · People & HR · IT. Names in zh: 销售部 · 采购部 · 法务部 · 财务部 · 运营部 · 人力资源部 · 信息技术部.
  • All seven sit under the US parent ARC-US. A requesting department is a group function, and it is a different axis from the entity that signs. Entities follow governing law; departments follow contract type. If Operations sat under the EU subsidiary, a New York lease would be requested by a European department.
  • The old other option has no row. "Other" is not a unit of the organisation, and a contract without a department leaves the field empty.
  • ContractTypePlan.department now holds a department code, and each contract names its department by code. The type-to-department mapping did not change, and neither did any count.

Every consumer:

Consumer Change
contract-intake.flow.ts (F1) A flat screen has no record picker, so the department left screen_core. It is collected in the launch dialog with the other three pickers. The department variable stays an input. The header now names four pickers.
contract.actions.ts (launch_contract) department changed from a hidden headless text param to a field-backed picker param, required: false, with helpText.
contract-backfill.actions.ts (executed_upload) It was already field-backed ({ field: 'department' }), so it inherits the lookup and the filter. Only the comment changed.
executed-upload.flow.ts, renewal-notice.flow.ts (F12, and the RENEWAL_DRAFT_FIELDS that F13 shares), expiration-sweep.flow.ts No change. They copy the value, which is now an id. The F12 copy was measured below.
contract.view.ts, contract_detail.page.ts No change. The lookup renders the unit's name (measured).
contract.dataset.ts include gains department. The dimension is now department_name over department.name. The rename is explained in Acceptance notes.
legal.dashboard.ts The department global filter is now type: 'lookup' with optionsFrom sys_business_unit (id / name, kind = department). The eight static options are gone. Retired departments stay in its list, because their contracts are still on the board.
clm.app.ts Comment only. Both halves of 签约主体与部门 are business-unit rows now. Whether 管理 gets a sidebar row is left as a §05 question.
translations en / zh-CN The options blocks of fields.department are removed. The screen_core.fields.department entries are removed. The launch_contract param gets helpText. The dataset dimension key is renamed to department_name. The help text names Setup → Business Units.
src/sharing/**, src/mappings/**, src/skills/**, other datasets and dashboards Nothing names department.
README Nothing names the option list. See Acceptance notes for its row count.

Evidence: real boots on 18.1.0, clean .objectstack/data each

Seed counts (pnpm demo):

  • before, ae13f57: {"inserted":823,"updated":0,"skipped":0,"errored":404,"referencesDropped":0}
  • after, this branch: {"inserted":830,"updated":0,"skipped":0,"errored":404,"referencesDropped":0}

The +7 rows are the seven departments. The 404 errors are the same unresolved persona names on both sides. The de-duplicated WARN/ERROR/⚠/✗ signatures (timestamps, ids and counters stripped) are identical: 247 lines on each side, diff exit 0. degraded, no such table, failed to load and Failed to expand each match 0 lines in both logs.

pnpm demo:zh on a clean database: inserted 830, errored 404. It writes the seven 部 names.

Readback (admin, GET /api/v1/data/sys_business_unit and clm_contract?top=500&populate=department):

  • Units: 3 companies plus 7 departments, every department with parent = ARC-US, active: true and organization_id stamped.
  • 120 contracts: ARC-SALES 56 · ARC-LEGAL 34 · ARC-PROC 18 · ARC-OPS 6 · ARC-PEOPLE 6. Non-department 0 · unresolved 0 · null 0.

Datasets render names, not ids. POST /api/v1/analytics/dataset/query on contract_metrics, grouped by department:

  • Before (dimension department): Legal 34 · Operations 6 · People / HR 6 · Procurement 18 · Sales 56.
  • After (dimension department_name): Legal 34 · Operations 6 · People & HR 6 · Procurement 18 · Sales 56. The SQL is SELECT "department"."name" … LEFT JOIN "sys_business_unit" "department" ON "clm_contract"."department" = "department"."id".
  • In zh: 法务部 34 · 运营部 6 · 人力资源部 6 · 采购部 18 · 销售部 56.

A real requester persona, created the README way.

  • As the dev admin: POST /api/v1/auth/admin/create-user Business Requester 1 → 200 (mustChangePassword: true). Then, signed in as the persona, POST /api/v1/auth/change-password → 200.
  • Every reading in the next four blocks (launch dialog, launch, record page, contract form) was taken signed in as Business Requester 1, never as the dev admin (Playwright, Chromium /opt/pw-browsers/chromium). The blocks after them say admin where they were admin.

Launch dialog (sidebar Launch a Contract):

  • The labels read Contract type*, Counterparty, Our signing entity*, Requesting department. The help text reads "The department launching this contract."
  • The department picker requested GET /api/v1/data/sys_business_unit?populate=parent_business_unit_id,manager_user_id&top=50&filter=["and",["kind","=","department"],["active","=",true]] → 200.
  • It offered exactly Procurement · Sales · Finance · Legal · Operations · IT · People & HR, each "Kind: Department · Parent Business Unit: Arcadia Holdings Inc.".
  • The three companies were not offered. Two probe units created through the API (an inactive department and an active office) were not offered either.
  • zh-CN UI: the labels read 合同类型* · 相对方 · 我方签约主体* · 发起部门, with the same wire and the same 7 options. Names are data, so they appear as seeded.

Launch with a department picked:

  1. Type NDA, counterparty Aurora Systems, entity Arcadia Europe GmbH, department Legal, then Confirm.
  2. The Contract details screen no longer has a department field: 0 matches for "Requesting department".
  3. Title, governing law and confidentiality, then Submit. Then First version, then Submit. The upload screen showed the new contract.

Readback as the requester: NDA-2026-0021 draft, owner Business Requester 1, department → {code: ARC-LEGAL, name: Legal, kind: department}, entity ARC-EU.

Record page as the requester:

  • The new contract reads REQUESTING DEPARTMENT Legal.
  • A seeded contract handed to the requester (SUP-2026-0012) reads REQUESTING DEPARTMENT Procurement in en and 发起部门 Procurement in zh-CN.

Contract form, Edit, as the requester:

  • The department picker sent the same filtered request and offered the same 7, with Legal marked "Selected".
  • Picking Sales and pressing Update gave POST /api/v1/batch → 200 and the toast "Contract updated successfully".

Browser console on every page: only the pre-login get-session 401 and one resource 404, plus the three pre-existing record.status predicate warnings on the record header. No new error.

Contract Register (admin): the "Requesting Department" column reads e.g. AMD-2026-0001 | Legal.

Legal workbench filter (admin, /dashboard/legal_workbench):

  • The filter bar reads Requesting Department: All.
  • Its options are read by POST /api/v1/analytics/dataset/query with the inline dataset {object: "sys_business_unit", dimensions: id, name} and the runtime filter {"kind":"department"}. They are All · Probe Retired Department · Procurement · Finance · Sales · Legal · Operations · IT · People & HR. No office and no company.
  • Picking Legal sends runtimeFilter {"$and":[{"status":"submitted"},{"department":"…ARC-LEGAL id…"}]}. The board then reads Awaiting Intake 1 · In Review 3 · Approved This Month 3, which matches the Legal status counts (Submitted 1 · In Review 3 · Approved 3).

F12 renewal. As admin, POST /api/v1/actions/clm_contract/start_renewal on LSE-2026-0002 (department ARC-OPS) → 200. The new draft "Renewal — Lease Agreement — Corvus Security Services" carries department ARC-OPS Operations.

Write side, no rule (measured, admin):

  • PATCH clm_contract/ID {department: ARC-EU company id} → 200, and the company is stored (restored afterwards).
  • PATCH {department: "sales"} → 400 VALIDATION_FAILED, fields[0] = {field: "department", code: "reference_not_found"}, "no sys_business_unit record has id "sales"".

So a dangling id is already refused by the engine, and a unit of the wrong kind is not. See Acceptance notes.

Existing databases (select string → lookup id)

These were measured on the database the ae13f57 boot left behind, which holds "department":"sales" and similar strings. The new code was then run against it.

  • os migrate plan reports no change on clm_contract. With the new metadata it gives 0 change(s): 0 safe, 0 needs-confirm, 0 destructive. The control, the same command with src at ae13f57 on the same database, gives the identical plan. Both list the same 7 platform tables to create (sys_automation_run …), which are unrelated to this change. The column is text on both sides, so schema drift sees nothing.
  • Boot (pnpm dev, no seed) emits nothing about it. Old rows read back raw: GET …?populate=department → "department":"sales". The dataset groups all 120 under null. An edit that leaves department alone still saves (PATCH {summary} → 200).
  • The demo re-seed remaps its own rows. pnpm demo on that database gave {"inserted":7,"updated":120,"skipped":703,…}. The upsert re-asserts every contract's department by code, and the dataset reads Legal 34 · Operations 6 · People & HR 6 · Procurement 18 · Sales 56 again.
  • Rows the demo did not write keep their string. No data migration ships, and no data was hand-edited. A deployment with its own contracts would need a one-off remap from option value to unit id. That remap is listed under Acceptance notes for the seat.

NOT MEASURED

  • F13 auto-renew draft and the sweeps. They are cron-only. The F13 draft uses the same RENEWAL_DRAFT_FIELDS copy as the F12 path measured above.
  • Backfill (F16) end to end. It needs the execute_contract capability (records persona), and I did not attempt it. Its department param was already field-backed and is unchanged.
  • The headless launch_contract door with a department. I did not run it. The param now goes through the field-backed declaration.
  • Other personas (legal, finance, records, executive). The dispatch asked for a requester. All of them hold clm_requester, which grants read on sys_business_unit (A requester cannot launch a contract — clm_requester has no read on sys_business_unit, and our_entity is required #126).

Gates (head f65f9be, each exit captured to a file before any pipe)

pnpm install                                       exit 0
pnpm validate                                      exit 0   ✓ Validation passed · Data: 11 Objects 167 Fields · UI: 1 Apps 7 Views 1 Pages 3 Dashboards 10 Actions
pnpm lint                                          exit 0   6 suggestion(s) (the six contract_approval approval-approvers-may-resolve-empty notes, unchanged)
pnpm typecheck                                     exit 0   tsc --noEmit
pnpm lint:i18n-gate                                exit 0   COVERAGE : 0 missing keys across 2 locale(s)
CLM_DEMO_SEED=1 pnpm validate                      exit 0   ✓ Validation passed
OS_SEED_LOCALE=zh-CN CLM_DEMO_SEED=1 pnpm validate exit 0   ✓ Validation passed

Acceptance notes

  • The §03 line, for the seat's governed design PR (not edited here). In the clm_contract row, replace department select(种子) with department lookup sys_business_unit(发起部门,kind = department,可选,种子). The §04 sentence that calls department "合同上的冗余标量" stays true, and so does §13 Q2.
  • Why the dataset dimension is renamed department_name (measured on 18.1.0). A widget's runtimeFilter key is resolved through a dimension of the same name first.
    • With a dimension named department over department.name, the workbench filter's id compiled to WHERE "department"."name" = $1, and every widget read 0.
    • After the rename, the key reaches the column: WHERE department = $1.
    • GlobalFilterSchema.field documents the key as resolving "against the object behind each bound widget's dataset (dataset.object), not against that dataset's declared dimensions". So this is reported to the seat as a possible spec/runtime seam.
    • The name follows the contract_type_name precedent. No widget selected the old department dimension.
  • A write-side kind rule (the A write can make a department the signing entity — our_entity's company-only filter shapes the picker, not the write #125 pattern) was not added. It is warranted in principle, because a REST, import or flow write can name a company or an office as the department (200 above). But mirroring A write can make a department the signing entity — our_entity's company-only filter shapes the picker, not the write #125 exactly would also judge active, and that would refuse an F12/F13 renewal of a contract whose department has been retired since. For an optional reporting field that is a product decision. The report on clm_contract.department is a fixed option list — make it a lookup to the platform's sys_business_unit, as #20 ruled for our_entity #109 gives the options.
  • README row count. README says pnpm demo loads 820 rows. It has been 823 since our_entity is a lookup to the platform's sys_business_unit (kind company), seeded as data #118 and is 830 now. The README is outside this card's claimed surface unless it names the option list, so it is left for the seat.
  • Nav row. 管理's 签约主体与部门 row is still absent. Both halves now live in Setup → Business Units, and whether the app adds a row is a §05 decision.
  • Probe units created during measurement (PROBE-RETIRED, PROBE-OFFICE) and the contracts launched and renewed were written to throwaway local databases only.

🤖 Generated with Claude Code

https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn

…it (kind department)

The requesting department was an eight-option select: organisation structure
written as schema, the shape #20's ruling C took away from our_entity. It is
now a lookup to the platform's sys_business_unit filtered to active
department-kind units, with the field name kept (business_unit is a platform
reserved word) and still optional.

The demo seeds seven departments under the US parent in the same upsert as the
three signing entities, and every contract names its department by code. The
intake asks for the department in the launch dialog with the other pickers,
the dataset groups by the unit's name, and the legal workbench's filter reads
its options from the units.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn
…orkbench filter reaches the column

Measured on 18.1.0: a widget's runtime filter key is resolved through a
dimension of the same name first. With a dimension called `department` over
`department.name`, the legal workbench's department filter, which applies the
business unit's id, compiled to `department.name = <id>` and every widget read
zero. Named like `contract_type_name`, the dimension still groups by the
department's name, and the filter reaches the `department` column.

Also states on the field that the picker is the only check on a unit's kind.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · head f65f9be · 2026-10-25T15:23Z

check reading
shape draft → main, Fixes #109; assignee zhuangjianguo; mergeable clean against main ae13f57
CI on the head both Validate check-runs success on f65f9be = PR head.sha
scope 22 files (+225/−122), all in the claim's surface: department becomes Field.lookup('sys_business_unit') with kind = department / active lookupFilters, optional; seven departments seeded under ARC-US by code; the picker moves from the core screen to the launch dialog; contract_metrics groups by department_name over department.name; the legal workbench filter reads its options from sys_business_unit; option entries leave both bundles. Nothing governed
premises ① 18.1.0's sys_business_unit.kind has department — holds; ② consumers enumerated (seat grep on the head: page, view, dashboard, dataset, flow, actions, seed — all re-pointed); ③ §03 wording goes to the seat's governed PR #120 (pushed as 952a079)
measured seed 823 → 830 (+7 departments), boot signatures identical; dataset counts and names unchanged in en and zh-CN; as a requester persona the picker offers exactly the 7 active departments and the record page names the choice; F12 renewal carries the department
seat calls on the open questions write-side kind check → B, as its own card (refuse a non-department kind, do not judge active, so a retired department never blocks a renewal); a 管理 nav row → no, Setup → Business Units is the home; non-demo old rows → no remap, as #118 did
not in this PR a dashboard global filter on a key that is also a dimension name is lowered to that dimension (department.name = <id>), against the spec's documented resolution against the dataset object — the seat files it upstream; README's demo row count (now 830) → the #95 remainder

Landing: under the maintainer's merge authorization (project thread: 「你可以自己审核的就自己合并」).


Generated by Claude Code

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.

clm_contract.department is a fixed option list — make it a lookup to the platform's sys_business_unit, as #20 ruled for our_entity

2 participants