Skip to content

Card 07 — views, the contract detail page, and the one clm App with five audience groups #23

Closed

Description

@hotlong

Milestone M2, the last card in it. Backlog card: docs/backlog/07-views-and-app.md. Blocked-by card 04 (#4, merged) — the five *.access capabilities it registers are what gates the navigation.

Until this lands HotCLM has no app shell at all: every card since #3 has had to browser-verify through the Setup app's object browser. This card is the one that makes the product visible to the five audiences DESIGN.md §01 sells it to.

Scope

src/views/*.view.ts · src/pages/contract_detail.page.ts · src/apps/clm.app.ts, and the wiring that registers them in objectstack.config.ts. Both src/views/ and src/pages/ and src/apps/ are new directories — follow the barrel convention the existing src/objects/index.ts and src/profiles/index.ts use, and read the comment at the top of src/profiles/index.ts before writing a barrel: a helper exported from one of these barrels gets registered as metadata.

Spec — DESIGN.md §05, which is the governed authority here

The five navigation groups, their gates, their navigation items and their primary views are the table in §05. ⛔ Implement that table; do not improve it. Read it in the repo, not from this issue — the table below is orientation, not the spec:

Group Gate
我的合同(所有人) clm_requester.access
法务工作台 clm_legal.access
财务 clm_finance.access
执行与档案 clm_records.access
管理 clm_admin.access

Also from §05: kanban grouped by status; calendar on end_date; the detail page slotted, with header actions shown by status and gate, highlights, a path component over the seven pre-terminal statuses, and seven tabs including the platform discussion slot. No anonymous public form — launching a contract requires login.

Two house rules the backlog card calls out because they fail in opposite ways:

  • Navigation items must be { label: string } objects, never bare strings (translation namespace rule).
  • Every record:related_list filter uses the rule-object shape the ComponentPropsMap declares. HotCRM's AGENTS.md rule 2: the AST array form is rejected at build, so this one at least tells you.

/home/user/hotcrm is the reference implementation for all three file kinds (src/apps/crm.app.ts, src/views/*.view.ts, src/pages/). Read it before inventing a shape — but read it as a sibling, not as scripture: it is a CRM with different audiences, and #22 just established that HotCLM was the outlier on one house spelling and HotCRM was right. Either can be the one that is wrong.

The verification trap this card is made of

requiredPermissions naming a capability that does not exist only warns at validate, and hides the group at runtime. So a group hidden because you misspelled its capability and a group correctly hidden because this user lacks it are the same observation. Almost every acceptance statement on this card has that shape.

⇒ Every negative needs its positive control, on the same group. "The finance user does not see 法务工作台" proves nothing on its own; it proves something next to "the legal user does see 法务工作台, and here is what is inside it." An audience matrix with only zeroes in it is indistinguishable from an app that renders nothing.

The same applies to each view: a grid that renders empty because its filter is wrong looks exactly like a grid that is correctly empty. The demo database (pnpm demo, 820 rows) has known counts per object — use them, and state the number you expected before you state the number you saw.

Acceptance

  • pnpm validate && pnpm lint && pnpm typecheck green.
  • The boot stays silent. Boot logs an ADR-0087 conversion warning: required: true must become storage.notNull: true before protocol 18 #22 cleared the repo's last boot warning, so pnpm dev --seed-admin now logs zero ⚠ Boot diagnostics. That makes the boot a sharp instrument for the first time: any warning after your change is yours, including the "unknown capability" warning this card can produce. Report the boot output.
  • Each permission set logs in and sees exactly its groups — in a browser, per AGENTS.md, with the positive controls above. The backlog card says "each of the six permission sets"; there are five in src/profiles/. Resolve that discrepancy yourself and say which reading you took and why (the platform admin is the likely sixth, and it is a different kind of thing).
  • The contract detail page opens on a seeded contract and its seven tabs are reachable. Header actions appear and disappear by status — show at least one action present on a contract in the right status and absent on one in the wrong status.

Zone 2 — a PM assumption you are to verify, not inherit

#11 question 1 is unanswered, and this card is its named consumer. It asks how clm_requester reaches employees who hold no position. I am dispatching under option A — the tokens stay, clm_requester.access gates 我的合同, and an employee without a position needs an admin grant (which is what card 04 already ships).

So: build the 我的合同 group gated on clm_requester.access. Make that gate a one-line change — if the maintainer answers B, the group becomes visible to any authenticated user and exactly one line should move. Say in your PR which line it is.

⛔ Do not answer #11 yourself, and do not edit DESIGN.md §01–§04.

Out of scope

Dashboards (card 10) · translations (card 11) · anything the five other open decision cards (#6 #10 #14 #19 #20) turn on. If a view needs a field those cards are still deciding, render what exists and say what is missing.

Activity

  1. self-assigned this
    on Sep 7, 2026
  2. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop round R8 — card 07, the last M2 card. Dispatched by the PM seat for its dev; the dev inherits this claim and posts no second one.

    Session: session_01KcrVDXSptwDukFsHPHPR1V
    Branch: claude/issue-23-views-and-app
    Worktree: hotclm-issue-23 (dedicated, off origin/main @ baf5cc3)
    File surface: src/views/** · src/pages/** · src/apps/** · objectstack.config.ts (registration only)
    Container & model: M, mode:subagent, model: opus

    Dispatched alone. #22 (card 08's NOT NULL conversion) touched every object file and has merged; nothing else is in flight, so this card has the tree to itself.

    Zone 2 carried into the dispatch: #11 question 1 is still unanswered and this card is its named consumer. Built under option A — clm_requester.access gates 我的合同 — with the requirement that the gate be reversible in one line if the maintainer answers B.


    Generated by Claude Code

  3. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 23,
      "status": "done",
      "branch": "claude/issue-23-views-and-app",
      "pr": "https://github.com/objectstack-ai/hotclm/pull/25",
      "premise_still_valid": true,
      "summary": "DESIGN.md §05 delivered: 7 view containers (19 named views over 7 objects, incl. the status kanban and the end_date calendar), the one `clm` App with five audience groups each gated by its card-04 `*.access` capability, and the slotted `contract_detail` record page (header actions, highlights, a 7-step path, 7 tabs incl. the platform discussion slot). SCOPE ADDITION: §05's six header actions (提交/受理/送审/发起签署/生效/终止) did not exist anywhere in the repo and no card owns them — 发起续签 is card 09's F12 — so the card's own acceptance was unreachable without them; they are authored here in one new file as thin status writes over the existing `contract.hook.ts` state machine, with no new business logic, and 发起续签 is deliberately NOT faked. Zone 2 built under option A: `clm_requester.access` gates 我的合同 at `src/apps/clm.app.ts` line 81, and deleting exactly that line makes the group visible to any authenticated user (proven, not asserted — that is the ablation used to calibrate the audience matrix). #11 is not answered here. Assignee was already set by the PM seat and the newest Claim: named my branch; I inherited both and wrote neither.",
      "tests": "GATES (exit codes captured before any pipe): pnpm validate=0 '✓ Validation passed (847ms)  UI: 1 Apps  7 Views  1 Pages  7 Actions'; pnpm lint=0 '5 suggestion(s)'; pnpm typecheck=0. BASELINE at a separate worktree on origin/main@baf5cc3: validate 7 warnings -> 6 on this branch (only the 'No apps or plugins defined' one disappears; zero added), lint 5 -> 5 identical suggestions. BOOT: `pnpm dev --seed-admin` on a fresh DB, both trees, logs NO '⚠ Boot diagnostics' line at all; both echo the same seven compile-time author warnings; no 'unknown capability' warning. INSTRUMENT CALIBRATION (the card's trap, answered twice): (1) misspelled clm_finance.access -> clm_finanse.access on disk (grep -c before=1/after=1, not the editor's exit code) and validate emitted 'app \"clm\": requiredPermissions references capability \"clm_finanse.access\" which is registered nowhere' — so the absence of that line on the shipped code is a measurement; the restore trap's `git checkout HEAD -- <untracked file>` was a silent no-op that exited 0, and the revert was done and verified by observing the file. (2) with requiredPermissions removed from group_finance, that group was served to ALL SIX users incl. the platform admin, with all 3 children — so every ✗ below is the gate, not a dead nav tree. AUDIENCE MATRIX (GET /api/v1/meta/app/clm + item-for-item in a real browser at /_console/apps/clm, five sign-ins): legal = My Contracts[4] + Legal Desk[7]; finance = My Contracts[4] + Finance[3]; records = My Contracts[4] + Execution & Records[3]; clm_admin = My Contracts[4] + Administration[2]; employee-with-no-position (explicit clm_requester grant, the option-A path) = My Contracts[4] ONLY; platform admin = NOTHING (holds no CLM set — the sixth control). Independent corroboration that these users are who the matrix says, matching §04's sharing table exactly: clm_contract rows readable = legal 120, finance 82 (=4+6+60+8+4), records 78 (=6+60+8+4), no-position 0. VIEW COUNTS, expected stated before measured, read off the wire as the list-view data call's `total` on the 820-row demo DB (per-object totals confirmed first: 9/40/30/6/120/0/60/25/30/200/300): all_contracts 120=120, legal_intake 6=6, negotiating 1=1, active_contracts 60=60, pending_execution 6=6, pending_archive 8=8 (14 terminal minus 6 with archive_no), contract_register 120=120, status_kanban 120=120, expiry_calendar 120=120 (72 carry end_date), all_obligations 200=200, all_payment_plans 300=300, payments_due 48=48 (30 due + 18 partial), payments_overdue 12=12, payments_paid 90=90, payment_kanban 300=300, all_parties 40=40, all_clauses 30=30, all_contract_types 9=9, all_approval_rules 6=6 — 22/22 MATCH. The three correctly-empty views each got a positive control ON THE SAME VIEW: legal_in_review 0 for Lena / 69 for Dev Admin; my_obligations 0 / 95; my_contracts 0 before launching a contract / 1 after (all_contracts 120 -> 121 in the same run). DETAIL PAGE: all seven tabs render and are reachable with live related-list counts (Overview | Versions | Review & Deviations² | Approvals | Obligations & Payments⁵ | Signing & Archive¹ | Discussion); record:path shows the seven pre-terminal statuses. HEADER ACTIONS on BOTH axes — status axis, one user, one contract per status: draft=Submit only, submitted=Accept for Review only, in_review=Send for Approval only, active=Terminate only, expired=none; gate axis, SAME two records two users: SUP-2026-0011 (signing) Activate absent for legal / PRESENT for records; LSE-2026-0005 (active) Terminate PRESENT for legal / absent for records. NOT A STUB: clicked Terminate in the browser -> POST /api/v1/actions/clm_contract/terminate_contract 200, record went active -> terminated with closed_at stamped BY THE HOOK; and the refusal direction, Submit on a version-less draft -> 500 \"hook 'contract_state_machine' threw: Upload a first version...\", record unchanged. TWO SHAPES REJECTED ON MEASUREMENT, both recorded in the source: `operation:'update'`+`patch` produced 12 validate warnings 'this action property is planned ... (not read YET)' — a button wired to a key nothing reads; and `ctx.api.object().updateById()` returned 400 'TypeError: not a function' — a probe body enumerated the real sandbox facade (find/findOne/count/aggregate/insert/update/delete/updateMany/deleteMany/upsert) and the shipped call uses the shape src/objects/_hook-api.ts already pins, `update(doc-with-id, {where})`. MY OWN NEAR-MISS, worth the PM's attention because it is this card's trap in a second costume: the first predicates were P`record.status == '${from}'`, and `P` JSON-escapes an interpolated string into a complete CEL literal, so the hand quotes yielded `record.status == '\"draft\"'` — false for every row, ALL SIX buttons hidden on EVERY contract, which is indistinguishable from six correctly-hidden buttons. Caught by reading GET /api/v1/meta/actions, not by looking at the page. HOUSE RULES: the page's one record:related_list filter uses the rule-object shape read off RecordRelatedListProps.filter -> ViewFilterRuleSchema (checked against the schema, explicitly NOT copied from HotCRM, whose AGENTS.md records that every filter under its own src/pages/ is a rejected form); every navigation item is an object with a label, no bare strings.",
      "mcp_calls": "5 — issue_read get, issue_read get_comments, create_pull_request, pull_request_read, add_issue_comment (this one). Everything else went over git and plain HTTP.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: THE DEMO'S FIRST SCREEN IS EMPTY FOR EVERY AUDIENCE. `owner_id` is NULL on all 120 seeded contracts, so 我的合同 > Launched by Me renders 'No matching records' for all five users — the group §05 puts first, and the first thing a buyer's demo lands on. Not a view bug (proven: a contract created by a signed-in user gets owner_id stamped, and the view then shows it), and not fixable from this card's surface — src/data/contract.seed.ts says so in as many words ('a seed cannot name a user'), because the declarative seed runs before the admin account is minted. Worth a card for card 08's owner: `pnpm demo`'s post-seed step could stamp owner_id across the fixture once the account exists, the way it already sequences two boots for clm_review.reviewer.",
        "noted, not filed: PLATFORM — record:details `sections[].group` (the #13855 reference form) CRASHES the renderer: the whole Overview tab rendered 'Component \"record:details\" failed to render — Cannot read properties of undefined (reading name)' because RecordDetailsRenderer maps every section through s.name. Belongs upstream at objectstack-ai/objectstack per AGENTS.md 'Platform gaps — report, never patch'; shipped with the other declared spelling (enumerated `fields`), not a fixture.",
        "noted, not filed: PLATFORM — an enumerated record:details `fields` section renders each field's RAW KEY as its label (OUR_ENTITY, TERM_MONTHS, IS_AMOUNT_ESTIMATED) instead of the object's declared `label:`. Two controls in the same session say the labels resolve everywhere else: this object's own list views render 'Contract Number'/'Counterparty'/'Contract Amount', and the platform's SYNTHESIZED default record page for clm_party renders 'REGISTRATION / TAX ID'/'LEGAL REPRESENTATIVE'. Same story as the finding above — the group-derived body works, the enumerated one is second-class. This is currently the only visually wrong thing in the product and it is on the detail page a demo opens.",
        "noted, not filed: §05's 管理 group lists 签约主体与部门, which has NO OBJECT to point at — `our_entity` and `department` are Field.select option lists on clm_contract (schema, not rows), so §05's own 主视图 column ('配置对象 grid') cannot mean anything here and the only place they are edited is the Setup field editor. Whether signing entities and departments should become configuration objects is a product decision, so the nav item is omitted and reported rather than invented. §05's 报表 in the same group is card 10 and likewise has nothing to name.",
        "noted, not filed: §05's 待执行 is defined as `signing` AND (signature not completed OR formalities short). Both disjuncts live on clm_signature and a list-view filter cannot reach a second object (one data.object, no join), so the view carries the half it can express. Recorded in the view file rather than silently narrowed. If a genuinely cross-object queue is wanted it needs a rollup field on clm_contract — a data-model decision.",
        "noted, not filed: §05 asks for the 版本 tab as a timeline. ComponentPropsMap has no `record:timeline` member, and a type:'timeline' LIST view is a list-page visualization a tab cannot mount, so the tab is a related list ordered newest-version-first. Also: clm_contract_version has 0 rows in the demo fixture, so the Versions tab is empty in every demo — the one object of the eleven the demo does not populate, which also makes 提交 unusable end-to-end (the state machine requires a first version).",
        "noted, not filed: the console's own 'Recent' widget 403s for every non-admin user (GET /api/v1/data/sys_activity -> PERMISSION_DENIED, logged to the browser console on every page). Pre-existing platform/console behaviour, unrelated to this card, but it is now visible to all five audiences because they finally have an app shell to see it in.",
        "noted, not filed: the session's `positions` claim reports only ['user','org_member'] for users who hold a CLM position via sys_user_position — yet permission resolution is demonstrably correct (row counts match §04 exactly). Harmless, but it cost time as a false negative during verification: the claim is not the instrument for 'does this user hold their position'."
      ]
    }

    Generated by Claude Code

  4. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    Review: ACCEPT — PR #25, head e50e728, CI green (Validate ✓). 1386 insertions, 0 deletions, 15 files, none of them governed. Merging.

    M2 closes with this. HotCLM has an app shell for the first time.

    Re-verified independently, before merging

    The card's whole risk is that a broken gate and a working gate produce the same observation, so I calibrated the instrument myself rather than reading the report's calibration.

    The capability detector fires, and the shipped spellings pass it. My own ablation — clm_records.access → clm_recordz.access on disk (grep -c before and after, not an editor exit code):

    ⚠ app "clm": requiredPermissions references capability "clm_recordz.access" which is
      registered nowhere — no built-in capability, no permission set in this package grants
      it via systemPermissions, and no sys_capability seed declares it
    

    Restored, and git diff clean. The shipped code produces no such line — which is now a measurement, not an absence.

    The gate does real work. The pair, both legs mine:

    groups declared in the compiled artifact 5 — My Contracts[4] · Legal Desk[7] · Finance[3] · Execution & Records[3] · Administration[2]
    groups served to the platform admin, who holds no CLM set (GET /api/v1/meta/app/clm) 0

    Child counts match the report's matrix item for item. A served count of 0 against a declared count of 5 is the filter working; either number alone says nothing.

    The predicates are real CEL, not the broken form. This is the near-miss the report flags, and it is the one I most wanted to check myself, because six buttons hidden by a bad predicate look exactly like six buttons correctly hidden. Read out of the compiled artifact:

    submit_contract     record.status == "draft"       perms=null
    accept_contract     record.status == "submitted"   perms=["clm_legal.access"]
    send_for_approval   record.status == "in_review"   perms=["clm_legal.access"]
    start_signing       record.status == "approved"    perms=["execute_contract"]
    activate_contract   record.status == "signing"     perms=["execute_contract"]
    terminate_contract  record.status == "active"      perms=["terminate_contract"]
    

    Proper CEL string literals. Not record.status == '"draft"'.

    The boot is silent, on my own boot — fresh database, real pnpm install in my own worktree so the better-auth pin actually applied: Boot diagnostics blocks: 0 · WARN lines: 0 · no AuthPlugin failure · no unknown-capability line. pnpm validate exit 0 with 6 warnings, all pre-existing (hierarchy-security from card 04, five contract_approval approver notes from card 06); the baseline's seventh, No apps or plugins defined, is gone because this card is what it was asking for.

    src/apps/clm.app.ts:81 is exactly requiredPermissions: ['clm_requester.access'], — the one-line #11 reversal is as claimed.

    The scope addition is accepted, and here is what it costs card 09

    §05 says the header hangs seven actions and none of them existed — only card 05's launch_contract, on the list toolbar. The card's own acceptance ("show one action present in the right status and absent in the wrong one") is unreachable without them, so authoring them was the honest read of the card rather than an expansion of it. 147 lines, one new file, thin status writes that go through ctx.api as the caller so the hook and the row policy fire exactly as for a hand edit.

    Two omissions decided the question for me, because both are the restrained choice:

    • 发起续签 is not faked. It builds a new draft with renewed_from — a flow, F12, card 09's. A seventh entry would have been a stub.
    • approve_contract is not a header action. A button writing approved would bypass the approval ladder that §06 F5 exists to be.

    ⇒ Card 09 no longer owns 提交/受理/送审/发起签署/生效/终止. It still owns 发起续签 (F12). Recorded here so its author does not find them already built and assume the card was mis-scoped.

    The finding that matters most is not a defect in this PR

    owner_id is NULL on all 120 seeded contracts, so 我的合同 › Launched by Me renders "No matching records" for every one of the five audiences — the group §05 puts first, and the first screen a demo lands on.

    Proven not to be a view bug: a contract created by a signed-in user gets owner_id stamped and the view shows it immediately (my_contracts 0 → 1, all_contracts 120 → 121 in the same run). It is src/data/contract.seed.ts's stated limit — a declarative seed runs before the admin account is minted and cannot name a user.

    Combined with the empty Versions tab (#19: 0 of §10's 300 versions, because a seed cannot mint a sys_file id), the demo currently opens on two empty screens. For a product whose point is being sellable, that is worth more than any remaining M2 polish. Filed as its own card, not ridden here.

    Carried to the maintainer, not fixed

    • Two platform rendering defects, both on the detail page a demo opens — the sections[].group reference form crashes record:details outright, and the enumerated fields form renders raw keys (OUR_ENTITY, TERM_MONTHS) instead of declared labels. Both have controls showing the labels resolve everywhere else. Reported upstream rather than worked around; the second is currently the only visually wrong thing in the product.
    • §05's 签约主体与部门 has no object to point at (our_entity/department are select option lists — schema, not rows). Omitted and reported rather than invented; whether they become configuration objects is the same question Decision: §10 wants a multi-entity group, but clm_contract.our_entity ships exactly one option and a seed cannot add one #20 asks.
    • §05's 待执行 is a two-object predicate a list-view filter cannot express. Shipped as the half it can, with F7 owning the other half. If a true cross-object queue is wanted it needs a roll-up on clm_contract — a data-model decision.

    Generated by Claude Code

  5. added a commit that references this issue on Sep 7, 2026
    7b42d28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions