Skip to content

A write can put a company or office in a contract's department — the department-kind filter shapes the picker, not the write - #130

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-129-department-kind-check
Oct 25, 2026
Merged

zhuangjianguo merged 1 commit into
mainfrom
claude/issue-129-department-kind-check

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #129

Clause-②: no
Metadata-protocol: none

clm_contract.department's lookupFilters (kind = department, active = true) only shaped the picker. A REST write, /api/v1/batch, an import or a headless launch_contract could store a company or an office as the requesting department. This PR adds the write-side rule, the #125 pattern with the card's one difference: it judges kind only, never active.

What changed

Metadata only, three files: one validation rule on clm_contract (src/objects/contract.object.ts) and its message in both bundles.

  • contract_department_set (conditional). when is record.department != null && record.department != "" && (previous == null || previous.department != record.department). It is true on an insert or an update that sets the department to a unit, or changes it to a different one.
  • contract_department_is_department (cross_field, fields: ['department']) is the then branch. Its condition is record.department.kind != "department". The error is attached to the department field.
  • objects.clm_contract._validations.contract_department_is_department.message is in en and zh-CN, next to A write can make a department the signing entity — our_entity's company-only filter shapes the picker, not the write #125's key.
  • The department field comment no longer says that no write-side rule exists. It names the rule, and says why active is not judged.

Why the empty guard sits in when (measured on 18.1.0). A hop through an empty reference has no row to read, and the engine refuses that write rather than passing it. null and "" both need the guard, because the engine stores a REST "" on this lookup as given. I first booted the rule with only the null half. PATCH {department: ""} then answered 400 rule_violation "Validation rule 'contract_department_is_department' could not be evaluated (cannot read 'kind' through department (object 'sys_business_unit'): the related record was not found)". With both halves it answers 200, the same as on main.

Why active is not judged. The card says so: the department is an optional reporting field, and F12 / F13 renewals insert a new contract that copies it. The renewal leg below shows that a retired department still renews.

Evidence: real boots on 18.1.0, pnpm demo --port 3129, clean .objectstack/data each

Seed counts. Both runs gave {"inserted":830,"updated":0,"skipped":0,"errored":404,"referencesDropped":0}: main abd5401, and this branch with the rule (head 32eef5b). The 404 errors are the same unresolved persona names on both sides.

Boot warnings. I de-duplicated the WARN/ERROR/⚠/✗ signatures, with timestamps, ids and timings stripped:

  • The before log has 426 signatures and the after log has 425. The one line only the before log has is the contract_number read-only drop that the import-update probe caused, not a boot line.
  • Requesting Department matches 0 lines of the after boot, so no seed row trips the rule.
  • degraded, no such table, failed to load and Failed to expand match 0 lines in both logs.

The persona, created the README way.

  1. As the dev admin, POST /api/v1/auth/admin/create-user Business Requester 1 → 200 (mustChangePassword: true).
  2. As the persona, POST /api/v1/auth/change-password → 200, then sign in → 200, role=user.

Every row marked requester below ran signed in as that account. Rows marked admin ran as the dev admin.

Probe units. Two units were created through the API as admin: PROBE-OFFICE (kind office, 201) and PROBE-RETIRE-DEPT (kind department, active, 201). Companies are the seeded ARC-EU, and departments are the seeded ARC-LEGAL / ARC-SALES / ARC-FIN.

Door Persona Request main abd5401 this branch
launch_contract, headless requester department = company ARC-EU 200, row created with ARC-EU 400 FLOW_FAILED "… create_record(clm_contract) failed: Requesting Department must be a department …", 0 rows with that title
launch_contract, headless requester department = office 200, row created 400, 0 rows
launch_contract, headless requester ARC-LEGAL / omitted / null 200 / 200 / 200 200 / 200 / 200, row created each time
launch_contract, headless admin department = company 200, row created 400, 0 rows
REST PATCH (requester's own draft) requester company / office 200, stored 400 VALIDATION_FAILED, fields[0] = {field: "department", code: "rule_violation"}, department unchanged
REST PATCH, accept-language: zh-CN requester company 200 400, 发起部门必须是部门,即类型为部门的业务单元。……
REST PATCH requester ARC-SALES / null / "" / ARC-LEGAL / summary only 200 ×5 200 ×5
POST /api/v1/batch update (the form's write path) requester company (en) / office (zh-CN) 200 / 200, stored 400 / 400, the zh-CN message on the zh-CN call
POST /api/v1/batch update requester ARC-SALES 200 200
REST POST requester company / ARC-SALES 201 / 201 400, 0 rows / 201
REST POST admin company / company zh-CN / office 201 ×3 400 ×3, the zh-CN call returns the zh message, 0 rows each
REST POST admin ARC-FIN / null / "" 201 ×3 201 ×3
REST PATCH (seeded draft SOW-2026-0004) admin company / office 200 / 200 400 / 400
REST PATCH (seeded draft SOW-2026-0004) admin ARC-SALES / null 200 / 200 200 / 200
POST /api/v1/batch create admin company 200, row created 400, 0 rows
Import insert (format: json) admin company / office ok=1 created=1 each 200, row result ok:false, code: rule_violation, field: department, nothing created
Import insert, zh-CN admin company ok=1 created=1 row error 发起部门必须是部门……
Import insert admin ARC-FIN ok=1 created=1 ok=1 created=1
Import update (writeMode: update on contract_number) admin SOW-2026-0004 → company ok=1 updated=1, company stored ok:false rule_violation, department unchanged
Import update admin SOW-2026-0004 → ARC-FIN ok=1 updated=1 ok=1 updated=1

A department retired after it was set. This ran on the requester's own draft:

  1. PATCH {department: PROBE-RETIRE-DEPT} → 200 while the unit is still active.
  2. As admin, PATCH sys_business_unit {active:false} → 200.
  3. As the requester: PATCH {title} → 200; PATCH {department: SAME_RETIRED_ID, summary} → 200 (re-sending it is not a change); batch {summary} → 200. The department still reads PROBE-RETIRE-DEPT throughout. Main gives the same four answers.

F12 renewal carrying a retired department. As admin, PATCH sys_business_unit ARC-PEOPLE {active:false} → 200. That is the department of active contract ICA-2026-0002. Then POST /api/v1/actions/clm_contract/start_renewal {recordId} → 200, and the drafts carrying ARC-PEOPLE went from 0 to 1. The renewal draft inserts with the retired department, the same as on main.

A company stored before the rule is not re-judged. This ran on the after server through its watcher.

  1. I reverted the three files to abd5401 and confirmed that the compiled artifact held 0 references to the rule. As the requester, PATCH NDA-2026-0022 {department: ARC-EU} → 200, so the company is stored.

  2. I restored them with git checkout HEAD -- …. git diff HEAD was empty, each blob hash matched HEAD, and the artifact held the rule again.

  3. As the requester:

    • PATCH {title} → 200;
    • PATCH {department: ARC-EU (same), summary} → 200;
    • batch {summary} → 200;
    • PATCH {department: PROBE-OFFICE} → 400.

    The company stays stored until a write changes it.

An incidental reading from the first try of that revert: with the rule removed but its bundle keys left in place, the compile is refused by translation-target-unknown ("Translations are keyed to validation rule "contract_department_is_department", which object "clm_contract" does not declare"). So the bundle key is bound to the rule's name by a gate.

Browser (Chromium /opt/pw-browsers/chromium, signed in as Business Requester 1, en). This ran on NDA-2026-0022, whose department is the company ARC-EU stored before the rule:

  • The record page reads REQUESTING DEPARTMENT | Arcadia Europe GmbH.
  • Edit → change Title → Update. The wire shows POST /api/v1/batch {"operations":[{"object":"clm_contract","action":"update","id":…,"data":{"title":"P129 browser edit — department untouched"}}],"atomic":true} → 200. The toast reads "Contract updated successfully". The form sends only the changed field, so a legacy department does not block a form save.
  • Edit → Requesting Department picker. The wire shows GET /api/v1/data/sys_business_unit?…&filter=["and",["kind","=","department"],["active","=",true]] → 200. The picker offered exactly Finance · Procurement · IT · Operations · Legal · Sales · People & HR. It offered no company, not PROBE-OFFICE and not the retired PROBE-RETIRE-DEPT.
  • Choosing Legal → Update sent POST /api/v1/batch … {"department":"…"} → 200 with the toast "Contract updated successfully". The record then reads REQUESTING DEPARTMENT | Legal.
  • The browser console showed the pre-login get-session 401, one resource 404, and the three pre-existing record.status header-action predicate warnings, the same as 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 recorded. There was no new error.

NOT MEASURED

  • F13 auto-renew draft. It is cron-only. It inserts through the same RENEWAL_DRAFT_FIELDS copy as the F12 path measured above, and the rule does not judge active.
  • Renewal and import as the requester. Both ran as admin. The requester legs are launch, PATCH, POST, batch, the retired-department edits and the browser.
  • Backfill (F16, executed_upload). It needs the records persona's execute_contract capability, which was not set up. Its department param is field-backed, and its create goes through the engine like the other doors.

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

pnpm install                                       exit 0   Done in 729ms
pnpm validate                                      exit 0   ✓ Validation passed (897ms) · Data: 11 Objects 167 Fields · UI: 1 Apps 7 Views 1 Pages 3 Dashboards 10 Actions · Logic: 12 Flows
pnpm lint                                          exit 0   6 suggestion(s) — the six contract_approval approval-approvers-may-resolve-empty notes, unchanged from main
pnpm typecheck                                     exit 0   tsc --noEmit (no output)
pnpm lint:i18n-gate                                exit 0   ✓ i18n gate · COVERAGE : 0 missing keys across 2 locale(s)
CLM_DEMO_SEED=1 pnpm validate                      exit 0   ✓ Validation passed (913ms)
OS_SEED_LOCALE=zh-CN CLM_DEMO_SEED=1 pnpm validate exit 0   ✓ Validation passed (1078ms)

These are the same commands ci.yml runs. The i18n gate does not walk validation messages, so the zh-CN message was proven at runtime: REST PATCH and POST, batch and import each return the zh text to a zh-CN caller.

Acceptance notes

🤖 Generated with Claude Code

https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn

clm_contract.department's lookupFilters shape the picker only: a REST
PATCH or POST, /api/v1/batch, an import, or a headless launch_contract
stored a company or an office as the requesting department (#129).

A conditional rule, the #125 pattern: `when` admits only a write that
sets or changes department to a value (null and "" are skipped, since a
hop through an empty reference has no row and the engine refuses that
write as unevaluable), and the cross_field `then` refuses a unit whose
kind is not department. It does not judge `active`, so a renewal that
copies a department retired since still inserts. Message in en and
zh-CN under _validations.

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 32eef5b · 2026-10-25T15:56Z

check reading
shape draft → main, Fixes #129; assignee zhuangjianguo; mergeable clean against main abd5401
CI on the head both Validate check-runs success on 32eef5b = PR head.sha
scope 3 files (+47/−5), exactly the claim's surface: one conditional validation (contract_department_set → contract_department_is_department), its message in both bundles, the department comment. Nothing governed
the card kind judged, active not — as the card set it. Refused on every door measured (PATCH, POST, batch, import insert/update, headless launch_contract), as a requester persona and as the admin; department, null and "" accepted; a retired department neither blocks other edits nor an F12 renewal; a row holding a pre-rule company stays editable on other fields. The "" guard in when is measured, not guessed (without it a clear-the-field PATCH was refused)
not in this PR the flow door answers in English under zh-CN — the same platform gap #125 hit, already filed as objectstack-ai/objectstack#25809; a REST "" on a lookup is stored as "", not null (platform behaviour, observation only)

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.

A write can put a company or office in a contract's department — the department-kind filter shapes the picker, not the write

2 participants