Skip to content

Dogfood pass on M3-complete main: drive the whole contract lifecycle in a browser and report what a real user hits #45

Closed

Description

@zhuangjianguo

Maintainer request, 2026-09-09: 「使用浏览器测试,并提出优化改进意见。」

⛔ Observation only. This card produces NO code and NO pull request. Its deliverable is a report: what the browser actually did, and where the product hurts. The PM turns that into cards. ⛔ Do not fix anything you find, however tempting or small — a fix inside an observation pass is an unreviewed change with no card behind it.

Why now

main @ a7b7db5 is the first tree where M1 · M2 · M3 are all complete. The reminder layer landed hours ago (src/flows/ went 3 → 11). Nothing has ever been driven end-to-end on a tree where the whole lifecycle exists — every prior browser pass verified one card's surface. This is also the pre-read for card 14, whose acceptance is 「陌生人 clone 一条命令跑起」.

Two passes, and the first one matters more than it looks

Pass 1 — 开箱, as a stranger. ⛔ Do NOT perform the README operator setup.

Clone-equivalent state, pnpm install, pnpm demo, open the browser, sign in as the seeded dev admin, and stop there. Report exactly what a first-time evaluator sees: which navigation groups appear, which are empty, what the first screen says, and what — if anything — tells them what to do next.

This is the measurement behind decision cards #28 and #11. Prior runs established that without the operator setup (three requester accounts, clm_requester grants, dev admin into clm_admin) the app serves navigation: [] and 我的合同 is blank. Re-measure it on this tree rather than assuming it — M3 added an inbox and eleven flows since, and the dev admin's position may now resolve differently.

Report it as a first-run experience, not as a defect list: what would a buyer conclude in the first sixty seconds?

Pass 2 — the full lifecycle, after the operator setup

Do the README setup, then drive a contract all the way through as the right audience at each step — intake → review/deviation → approval ladder → signature and execution formalities → activation → obligations and payments → renewal or expiry → archive. Use the five audience groups (requester, legal, finance, records, admin) rather than doing everything as admin: a screen that only works for an admin is not a working screen.

Then open the inbox and the three dashboards, and do one full pass in zh-CN.

Known suspects — verify, ⛔ do not assume

Each of these is on the record from earlier runs. Say for each whether it still reproduces on this tree:

  1. navigation: [] without the operator setup ([Decision] pnpm demo 开箱即用时没有任何账号能打开「我的合同」——要不要让脚本建账号,与 #11 第一问耦合 #28, [Decision] DESIGN.md §04 makes clm_requester every employee's default, but a set that grants clm_requester.access cannot bind to everyone #11) — the pass-1 measurement above.
  2. 21 fields declared with no consumer ([Decision] 17.4 的新规则指出 21 个字段声明了却没人读——展品该给它们消费者,还是删掉声明 #38) — 17.4's new field-no-consumers rule reports clm_party.address / bank_account / contact_phone, clm_signature.envelope_id / signers, clm_review.internal_note and more. Which of them show up as a visible hole in a screen a user actually opens? That is the reading [Decision] 17.4 的新规则指出 21 个字段声明了却没人读——展品该给它们消费者,还是删掉声明 #38's decision needs and does not have.
  3. The backfill action is invisible to legal ([Decision] §06 F16 说补录动作「档案岗**或**法务可用」,但实现只能给档案岗——权限是 AND,没有两边都持有的能力 #43) — records + admin only, because the permission gate is an AND. Confirm by signing in as legal.
  4. F3's notifications reach only half their targets — the card-09 run measured 10 contracts over 30 days and 5 notified, because only 5 carry a legal_owner; the rest took a "nobody to tell" edge. Is a silent no-recipient path acceptable to a user who expects to be nudged?
  5. pnpm demo stops being re-runnable once the jobs have run (pnpm demo stops being re-runnable once the daily jobs have run: the seed re-asserts statuses the state machines refuse to go back to #41) — reproduce it and describe what the operator sees.
  6. Chart category labels render English in a zh-CN console on same-object dimensions, and view descriptions are declared, walked, counted as covered, and not rendered — both routed upstream from the card-11 run. Confirm on 17.4.

What "优化改进意见" means here — the actual deliverable

⛔ Not a bug list. For each thing you report, say what a user was trying to do and what got in their way, then propose the smallest change that would fix it. Rank by how much it costs the product, not by how easy it is to fix.

Specifically wanted:

  • The first-sixty-seconds problem. If a buyer's first screen is empty or confusing, that outranks every other finding.
  • Dead ends — any screen where the obvious next action is missing, disabled, or refuses without saying why.
  • Places the product lies — a label, count, description or empty state that does not match what the data actually says. This repo has been bitten repeatedly by annotations that were true once; the same class exists in the UI.
  • Anything an evaluator would have to be told rather than being able to see.
  • Where a platform limitation is the cause, say so and name it — those go upstream, ⛔ not into this app.

Reporting

  • Screenshots for anything visual. Console and network readings for anything behavioural — GET … → 200 {"total":0} beats "the list looked empty".
  • ⛔ Do not report a "missing / broken / unreachable" finding from a post-navigation DOM read alone. Confirm with a screenshot or the server's own metadata response first — a React hydration artefact reads exactly like a P0 and is not one.
  • Two known-pre-existing platform ERROR lines appear on any boot after the first (objectstack#17175, objectstack#17176). Those are not yours. Anything else in the boot log is worth reporting.
  • Note the tree you measured (a7b7db5) and the @objectstack/* version (17.4.0) on every reading.

Environment notes

/home/user/hotclm exists but sits on a stale branch with no node_modules; the container has restarted twice this session, so assume nothing about local state. Chromium is pre-installed at /opt/pw-browsers/chromium-1194/chrome-linux/chrome — ⛔ never run playwright install. Work in your own worktree off origin/main @ a7b7db5, and pick a non-default port.

Activity

  1. self-assigned this
    on Sep 9, 2026
  2. zhuangjianguo commented on Sep 9, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Claim: PM loop round 3 (seat repo:hotclm). Dispatched by the PM seat for its dev; the dev inherits this claim and posts no second one.

    Session: session_01R3n3GGzobdegM4HUzah1iR
    Branch: none — ⛔ this card produces no commits. A worktree is needed only to boot the app; nothing is committed to it and no PR is opened.
    Worktree: hotclm-dogfood-45 (dedicated, off origin/main @ a7b7db5)
    Domain: repo:hotclm (single-lane repo — no domain:* axis)
    File surface: ⛔ none. Read-only observation. The only writes are to a local gitignored database and a scratch directory. Any urge to edit a source file is a finding to report, not a change to make.
    Container & model: L, mode:subagent, model: default judgement tier — the card is judgement-heavy (it asks what hurts a user, not whether a gate is green), so ⛔ not the floor tier.
    Clause-②: no — an observation pass writes nothing and changes no acceptance set or published surface.
    Thread-read: none (no comments on this card at claim time, 2026-09-09T20:4xZ)
    Serial constraints cleared: nothing in flight. Zero pm:dispatched cards besides this one, zero devs running. One open PR — #40 (DESIGN.md, awaiting the maintainer's merge) — which cannot conflict: this card touches no files at all. origin/main @ a7b7db5, read 2026-09-09T20:4xZ.

    Why this is dispatched rather than done at the seat: booting an objectstack app end-to-end (install → seed → two boots → a full-lifecycle browser drive across five audiences → a zh-CN pass) is long-running work, and this container has already been restarted twice mid-task today. A dev's worktree survives a restart; a seat's half-finished browser session does not. The judgement half — turning findings into ranked improvement proposals and cards — stays with the seat, per 「PM 永不写代码」 and because ⛔ deciding what the product should do is not a dev's call.

    ⚠️ Note for the reviewer of this card: there is no diff to review. Acceptance is judged on whether the readings are real (screenshots and server responses, ⛔ not post-navigation DOM reads) and whether each finding names what a user was trying to do.


    Generated by Claude Code

  3. claude commented on Sep 10, 2026

    @claude
    Contributor

    os-dev-report

    Dogfood pass on main @ a7b7db5, @objectstack/* 17.4.0, better-sqlite3, port 3987, Chromium 141 headless.
    No code, no commits, no PR. Detached worktree hotclm-dogfood-45, removed at the end.
    Every reading below is from a running app; screenshots and server responses are quoted inline.


    1. Pass 1 — the first sixty seconds, as a stranger

    pnpm install (9.8s, cache warm) · pnpm demo on an empty .objectstack/ · browser · sign in. Nothing else.

    What a buyer sees. The login page is branded ObjectOS, not HotCLM, with a friendly yellow box handing over admin@objectos.ai / admin123. Signing in lands on /_console/apps/clm?package=app.objectstack.hotclm — the HotCLM app, isDefault: true — and the app renders an entirely blank left sidebar and, in the content area, the platform's Studio metadata console: "Your control center for modeling data, designing experiences, and automating the platform", with Quick actions "New Object / New View / New Page / New Dashboard / New Flow / New Action".

    There is no mention of a contract anywhere on that screen. Nothing says what to do next.

    Authoritative reading, not a hydration artefact — the sidebar DOM node measures innerText.length = 0, and:

    GET /api/v1/meta/app?id=clm  →  200
    { "name":"clm", "label":"HotCLM", "isDefault":true,
      "navigation": [], "_diagnostics": { "valid": true } }
    

    The setup app in the same response carries a full navigation tree, so the platform serves navigation fine — it is HotCLM's five clm_*.access gates that empty it. The app declares 5 groups / 20 items; the dev admin holds none of the five capabilities, so 0 render.

    The data is all there. 120 contracts, 200 obligations, 300 payment instalments, 60 reviews, 30 signatures, 25 deviations, 40 counterparties, 30 clauses, 9 contract types, 6 approval rules. clm_contract_version 0 and sys_approval_request 0, exactly as the README documents.

    What a buyer would conclude. "This is a metadata modelling tool. I was told it was a contract system, and I cannot find a single contract." The product has 820 rows of a beautifully built demo and shows the evaluator none of it. Everything needed to fix this is already true — the app is correct, the data is correct, the gate is correct — but the one screen that decides the sale is the platform's developer console.

    Three more things a first-run evaluator meets:

    • pnpm demo prints its own instructions, then buries them. The script helpfully explains at log line ~11 that three business-requester accounts must be created. Then roughly 290 more lines scroll past — including 120 ERROR lines — before ✓ Server is ready. The one instruction the evaluator needs is far up the scrollback.
    • The success banner arrives before the errors. The boot prints ✓ Server is ready and ⚠ Boot diagnostics — 2 warnings, and then the backgrounded seeder emits 120 ERROR [SeedLoader] Deferred reference UNRESOLVED after pass 2 — clm_contract.owner_id stays NULL …. The seed is deliberately backgrounded ([Seeder] Inline seed exceeded 8000ms budget … continuing in background), so the "all good" banner is printed before the seed has finished.
    • The seeder's own summary contradicts the database. WARN [Seeder] Seed loading completed with 120 dropped record(s) and 120 error(s) with {"inserted":820,"errored":120,"referencesDropped":0}. Zero records were dropped (clm_contract total = 120) and 120 references were dropped (owner_id = null matches 120). Both counters are wrong in opposite directions. Platform-layer.
    • Studio claims "All healthy — No validation issues across your metadata" on the same boot that printed 26 author-time warnings (21 field-no-consumers, 5 approval-approvers-may-resolve-empty).
    • README.md still reads "Status: M0 — scaffold and configuration domain" on a tree where M1·M2·M3 are complete.

    Screenshots: 01-landing, 02-after-signin.


    2. Pass 2 — the lifecycle walk

    2.1 The operator setup, done as written

    Setup → Users → Create User, ten times; then permission sets and positions. Roughly 21 console operations before the first screen has anything on it. Two things stood out on the way:

    • The "User Created" dialog says "Copy the temporary password now — it is shown only once and never stored", shows only the Email field, no password, and the button reads "I have saved this password". Reproduced twice, with POST /api/v1/auth/admin/create-user {"generatePassword":false,…} → 200. There is nothing on screen to save. (08-after-create-user)
    • In Grant permission set, searching Requester returns four rows (Finance, Legal, Records, Requester) because the search matches descriptions — the operator has to read the API-name column.

    2.2 ⛔ The documented operator setup does not work: positions grant nothing

    The README's table tells the operator to assign seven positions. I assigned them, restarted the server, and measured:

    account granted via GET /api/v1/meta/app?id=clm
    Legal Counsel One position clm_legal_counsel navigation: []
    Records Manager position clm_records_manager navigation: []
    Finance Controller position clm_finance_controller navigation: []
    Dev Admin position clm_admin navigation: []
    Legal Counsel Two direct sys_user_permission_set → clm_legal navigation: 1 — Legal Desk

    Identical users, identical sys_user_position rows; the only difference is the direct set grant. The platform's own explain endpoint gives the verdict, as Legal Counsel One:

    POST /api/v1/security/explain {object:"clm_contract", operation:"read"}  →  200
      allowed: false
      principal.positions      = ["org_member","position_mtur8hz5jp7ptm3r","everyone"]   ← clm_legal_counsel IS present
      principal.permissionSets = ["member_default"]                                       ← clm_legal is NOT
      layer principal    : "...resolving to permission set(s) [member_default] (union-merged, most-permissive)"
      layer object_crud  : "denies — No resolved permission set grants read on 'clm_contract'."
    

    All 13 sys_position_permission_set rows exist and are correct (clm_legal_counsel → clm_legal among them; a duplicate insert is refused 409 UNIQUE_VIOLATION). They are simply not reflected in the principal.

    2.3 ⛔ The same root cause, second half: sharing never materialises

    Even with the permission sets granted directly — navigation then correct for all five audiences — row access stays empty:

    GET /api/v1/data/OBJECT?top=1&count=true
    audience     contract review deviation obligation payment_plan signature | clause type party rule
    ADMIN           123     60      25        200        300         30      |   30   9    40   6
    LEGAL             0      0       0          0          0          0      |   30   9    40   6
    FINANCE           0      0       0          0          0          0      |   30   9    40   6
    RECORDS           0      0       0          0          0          0      |   30   9    40   6
    EXEC              0      0       0          0          0          0      |   30   9    40   6
    REQUESTER1       46     24      13         64        105          9      |   30   9    40   6
    

    (123/46 include 3 orphan drafts my own failed intake runs left. Config objects are public_read, hence the last four columns.)

    GET /api/v1/data/sys_record_share?top=1&count=true → 200 {"total":0} — zero grants exist. I ran the remedy src/sharing/contract.sharing.ts itself documents:

    POST /api/v1/sharing/rules/RULE_ID/evaluate   (all six rules, all active, recipient_type "position")
      contract_legal_all_counsel      matchedRecords 123  expandedUsers 0  grantsCreated 0
      contract_legal_all_head         matchedRecords 123  expandedUsers 0  grantsCreated 0
      contract_finance_post_approval  matchedRecords  82  expandedUsers 0  grantsCreated 0
      contract_records_execution      matchedRecords  78  expandedUsers 0  grantsCreated 0
      contract_executive_routed       matchedRecords  23  expandedUsers 0  grantsCreated 0
      contract_gm_routed              matchedRecords   4  expandedUsers 0  grantsCreated 0
    

    The criteria match perfectly (123 records). The position recipient expands to zero users, although two accounts hold clm_legal_counsel and the explain endpoint lists that position on the principal.

    One root cause, two symptoms: sys_user_position holders are not reachable from a position, so (a) sys_position_permission_set bindings never resolve → navigation: [], and (b) sharing rules with a position recipient expand to 0 → zero rows. This is upstream — report to objectstack-ai/objectstack, do not patch this app. Untested hypothesis worth handing over: every catalog row (sys_position, sys_permission_set, sys_position_permission_set, sys_sharing_rule) carries organization_id: null while sys_user_position rows carry organization_id: org_mtur8hy89tlw2z8y.

    Boot 1 also logged [sharing-rule] a bulk write touched more rows than can be recomputed inline — every rule grant on this object was revoked and is being re-granted in the background for clm_contract. The re-grant never landed.

    2.4 The legal desk, as Legal Counsel One

    All seven Legal Desk screens load and are correctly labelled. All seven that read contracts are empty.

    • All Contracts shows "Nothing here yet — Create your first record to get started." while 123 contracts exist. This is an access-empty rendered as a data-empty, and it invites a lawyer to hand-create a contract. (33-legal-all-contracts)
    • Expiry Calendar renders a September 2026 month grid with every cell blank, while the README promises "ten contracts always expire within the next thirty days". (33-legal-expiry-calendar)
    • Clause Library (30) and Contract Types (9) work — they are public_read.
    • Every audience's first screen fires GET /api/v1/data/sys_activity → 403 PERMISSION_DENIED, logged as a console error and invisible in the UI.

    2.5 ⛔ Intake — three dead ends on the product's primary action

    (a) The "Contract type" picker does not render. The sidebar's Launch a Contract opens the launch_contract param dialog. Both Contract type * and Counterparty render as bare text inputs — {role:null, aria-autocomplete:null, readOnly:false}, 0 [role=option], 0 [role=listbox], and zero GET /api/v1/data/clm_contract_type on the wire. Typing Master and confirming produces:

    Node 'refuse_type' failed: script function 'clm_intake_refuse' (node 'refuse_type') failed: The chosen contract type does not exist or is inactive; pick an active type.

    The action declares { field: 'contract_type', required: true } and clm_contract.contract_type is type: 'lookup' with lookupFilters. The flow's own header comment records this as measured on 17.3.0: "the launch_contract ACTION's param dialog, whose field-backed lookup params render the real record pickers." That measurement is no longer true on 17.4.0. Pasting the real id WWS8I0YzPhm8EdS5 makes the flow proceed normally, so the value is the only thing missing. Positive control: the object-form screen later in the same flow does render a proper record picker for its Contract * lookup — so lookups are fine in object forms; it is the action-param dialog that degrades one to a text box. (17-contract-type-open, 19-confirm-attempt)

    (b) The flow dialog is taller than the viewport and cannot scroll. At 1500x950 the "Contract details" screen measures height 1162, top -106, bottom 1056, vh 950, with overflow-y: visible, scrollHeight == clientHeight, body { overflow-y: hidden } and no scrollable ancestor. The Submit button sits at y = 991 — 41px below the viewport. Playwright: "element is visible, enabled and stable … element is outside of the viewport", 32 retries. At 1500x1400 the same dialog submits fine. On a 1080p laptop the NDA intake cannot be completed. (23-dialog-overflow-950)

    (c) The file widget never uploads, so version 1 cannot be saved. Step 4 is an object form on clm_contract_version with a required File. Two independent paths:

    • setInputFiles → Save & Continue → POST /api/v1/data/clm_contract_version → 400, body sent {"file":{"name":"nda-v1.pdf","size":193,"mime_type":"application/pdf","url":"blob:http://localhost:3987/101ab3a5-…"}}, inline red error "File has an invalid file value: Invalid input: expected string, received object".
    • A real page filechooser event via "or click to browse" → the widget shows nda-v1.pdf (0.2 KB) and fires zero network requests. It makes an object URL and never uploads.

    The engine wants a sys_file id string; the console posts a browser blob object. No contract can be submitted through the UI, because the submission guard requires a first version. The README already notes a seeded draft cannot be submitted and directs you to drive a new contract instead — that path is blocked too. Side effect: the flow creates the clm_contract before the version, so each failed attempt leaves an orphan draft. (29-after-version-save, 30-filechooser-upload)

    2.6 The record page and the lifecycle buttons — the strongest part

    The contract record is genuinely good: a lifecycle stepper, seven tabs, grouped sections with "Show N empty fields" progressive disclosure, roll-ups, typed badges. (34-contract-record-admin)

    The transition buttons work and are status-gated correctly:

    REQUESTER on draft   →  ["Submit","Edit"]
    ADMIN     in_review  →  []                      (send_for_approval needs clm_legal.access)
    ADMIN     approved   →  ["Start Signing"]
    ADMIN     signing    →  ["Activate"]
    ADMIN     active     →  ["Terminate","Start Renewal"]
    

    Obligations & Payments shows 3 obligations with kind badges and an overdue date in red, plus a 5-row payment schedule with Paid/Planned, actual dates and invoice numbers. Signing & Archive shows the archive card with EXECUTED AT, SIGNED AT, EXECUTION FORMALITIES: Countersigned copy returned, BACKFILLED ✓. Honest and complete.

    Note the practical gap: in_review → in_approval is unreachable by anyone. It needs clm_legal.access, and the only holders are legal, who can see zero rows.

    2.7 ⛔ A correct business refusal is a 500, and is invisible

    A requester clicks Submit on their own draft:

    POST /api/v1/actions/clm_contract/submit_contract  →  500
    {"code":"INTERNAL_ERROR","httpStatus":500,
     "message":"hook 'contract_state_machine' threw: Error: Upload a first version, or choose a contract
                type that carries a template, before submitting."}
    

    and on a blocked counterparty, "…threw: Error: Counterparty Delta Ridge Holdings is blocked; a contract with a blocked party cannot be submitted."

    The messages are excellent. Two things are wrong: they are served as HTTP 500 INTERNAL_ERROR prefixed with hook '…' threw: Error:, and nothing is shown to the user — 0 toast elements, no inline error, the record still reads Draft. Confirmed by screenshot, not DOM alone. (38-submit-500, 39-submit-nonblocked)

    The same product does toast a flow refusal (2.5a). So one refusal path tells you and the other says nothing.

    2.8 Dashboards — the best thing in the repo

    All three load with real numbers and unusually honest descriptions: "Each bar is its own currency — no FX conversion exists in this app", "Not an average duration — see the PR", "a fixed threshold, not the per-type breach". Legal Workbench 6/12/10/1 and 本月已批准 2 (↓75%); Executive: value by currency, 12 expiring within 90 days, 19 high-risk, routes 11/41/23/4; Finance: 逾期金额 2,176,250, 12 overdue instalments, a top-10 unsettled table, planned-vs-settled by month.

    One defect, on the widget the legal team looks at first: "Pipeline by Stage" renders as a funnel that widens at Signing and Active and spills outside its card as a bowtie — because a contract book normally has more late-stage rows than early-stage ones, which is the one shape a funnel mark cannot draw. (40-dash-legal-workbench)

    2.9 Inbox and notifications

    The inbox works and reads "You're all caught up". It is empty because nothing has ever been delivered: sys_notification_receipt and sys_inbox_message were both 0 until I triggered the jobs by hand. Afterwards, all 12 receipts carry user_id = the dev admin. No other account has ever been notified.

    2.10 One full zh-CN pass

    The bundle is excellent — navigation, object labels, view tabs, dashboard titles and descriptions, KPI units, empty states, even 预览版 / 相对方 / 补录已签合同. Two gaps, both in §3 suspect 6 below. The locale switch is also well hidden: avatar → Language (a row that opens only on click, not hover) → 中文(中国). (47-zh-contract-list, 48-zh-dash-legal-workbench)


    3. The six known suspects

    1. navigation: [] without the operator setup (#28, #11) — STILL REPRODUCES, and it is worse than recorded. GET /api/v1/meta/app?id=clm → 200 {"navigation":[]} for the dev admin, exactly as the README documents by design. What is new: it also reproduces for every account set up the way the README instructs, because positions grant nothing (§2.2). The measurement #28/#11 need is that the fix is not only "give the dev admin a set" — it is that the documented remedy is currently inert.

    2. 21 fields declared with no consumer (#38) — REPRODUCES at build time; but essentially none is a visible hole. All 21 warnings still fire. In the browser, the console's auto-generated record detail page renders every one of them, grouped into sections, populated from real data: clm_party.legal_representative / address / contact_phone / screened_at render on the Counterparty page; bank_name + bank_account render once the collapsed Banking section is opened ("Rheinische Kreditbank", "DE89 3704 0044 1000 4384" — all 40 parties carry both); clm_clause.standard_text and fallback_text render in full; clm_contract_type.description renders; clm_contract_version.notes is an editable field on the intake object-form screen. Data behind them: review.comments 60, review.internal_note 35, signature.envelope_id 28, deviation.deviation_text 25, party.risk_note 3. The only pair that reads as a hole is clm_contract_type.template_file / template_placeholders, which render as — and have no writer either. So the rule is measuring declared consumers (view column, form section, page binding, flow, dataset) and is correct on its own terms; "no declared consumer" is not "the user cannot see it". (49-rec-clm_party, 50-party-banking-expanded, 51-type-sections-expanded)

    3. The backfill action is invisible to legal (#43) — STILL REPRODUCES. Contract list toolbar, measured per audience: LEGAL ["New","Import","Launch Contract"]; RECORDS and ADMIN additionally carry "Backfill Executed Contract"; REQUESTER same as legal. src/actions/contract-backfill.actions.ts already documents why (§06 F16 wants records OR legal, requiredPermissions is an AND, so it gates on execute_contract). Unchanged on 17.4.0.

    4. F3's notifications reach only half their targets — REPRODUCES exactly, and the rest of the reminder layer is worse. Node-level summaries from POST /api/v1/automation/FLOW/trigger:

    legal_review_sla  selected 10  over30_decide runs 10  over30_tell (notify) runs 5   acted 0  unmeasured 5
    renewal_notice    selected 21  notice_gate  runs 21  notice_flag (update_record) runs 0   skipped 21
    obligation_due    selected  2  acted 0  skipped 2
    turn_stalled      selected  1  acted 0  skipped 1
    payment_overdue   selected 36  acted 18  skipped 18      ← the only flow that acts
    expiration_sweep  selected  0  acted 0
    

    The 10-selected / 5-notified shape is exactly as recorded. Cause confirmed: 12 contracts in_review, 6 with a legal_owner, 6 without; 54 of 120 contracts carry no legal_owner at all. Answering the card's question — is a silent no-recipient path acceptable? — no, and the reason is sharper than "half": four of the six reminder flows select work and then notify nobody, with no error, no counter surfaced to a user, and no trace anywhere a person looks. A user who expects to be nudged is not nudged and is not told they will not be.

    5. pnpm demo stops being re-runnable once the jobs have run (#41) — DOES NOT REPRODUCE on this tree. Three re-runs on a live database. Boot 4 was run after payment_overdue had moved 18 instalments to overdue (count 12 → 30). Result: Seeds: app.objectstack.hotclm 820 rows, 0 ERROR lines, no seeder warnings — and afterwards payment_plan overdue was back to 12. The seed silently reverted the 18 instalments the job had moved, with no state-machine refusal. The expiration half was not exercised: expiration_sweep selected 0, so I cannot speak to active → expired. Two notes for #41's decision: its premise did not hold for the payment path here, and the behaviour I did see — a re-seed silently undoing a job's work — is its own problem, differently shaped from the one the card describes.

    6. Chart labels and view descriptions — BOTH REPRODUCE on 17.4.0.
    (a) Chart category labels render English in a zh-CN console, on same-object dimensions: 法务工作台 各阶段合同数 shows Active / Approved / Draft / In Approval / In Review / Signing / Submitted (clm_contract.status); 管理层看板 已签合同额(按方向) shows Purchase / Sales (clm_contract.direction); 生效合同额(按币种) shows EUR — Euro, GBP — Pound…, USD — US Do…. Not universal — 财务看板 renders 计划 / 采购 / 销售 and 已结清 / 计划 in Chinese. A second defect rides along: in zh-CN the funnel is ordered alphabetically by the English label, so a "pipeline by stage" chart is no longer in stage order.
    (b) View descriptions: they are rendered, but not localised. In the Chinese console the contract list shows "Contracts I launched, grouped by where each one has got to." surrounded by Chinese, while src/views/contract.view.ts:89 declares description: { en: '…', 'zh-CN': '我发起的合同,按各自进行到哪一步分组。' }. Ten views declare a bilingual description; the console serves the en branch.


    4. Ranked improvement proposals

    Ranked by cost to the product.

    P0-1 — The documented setup produces a dead app. A buyer follows the README exactly and still gets an empty screen for seven of ten accounts. The position → permission-set and position → sharing-recipient expansions both resolve to nothing. Smallest fix: file the upstream bug against objectstack-ai/objectstack with the security/explain output and the expandedUsers: 0 evaluate result; in this repo, change the README's setup table to grant the permission sets directly (Setup → Users → Permission Sets → Grant permission set), which is measurably the only path that works today, and keep the positions as a second step for sharing once upstream lands.

    P0-2 — A contract cannot be created through the UI. A requester tries to launch a contract and cannot choose a type, cannot reach Submit on a 1080p screen, and cannot attach version 1. All three are platform-console defects, all three sit on the product's primary action. Smallest fix: three upstream issues — action-param dialog must render field-backed lookups as record pickers; the flow/action dialog needs max-height plus an internal scroll region; the file widget must upload and hand back a sys_file id. In this repo, today: nothing to patch, but the flow's header comment recording the 17.3.0 picker measurement should be re-measured and annotated, since it is the kind of (measured) note this repo has been bitten by before.

    P0-3 — The first screen tells a buyer nothing. An evaluator signs in and sees a metadata Studio. Smallest fix that is entirely in this repo: give the clm app one always-visible navigation entry with no requiredPermissions — a type: 'page' welcome/orientation item — so an ungated user lands on something that says what HotCLM is and links to the operator setup. One nav item, no permission model change. It converts navigation: [] from a dead end into a first screen. (This is the one line #11's decision moves; the welcome page is the option that does not need #11 answered.)

    P1-4 — The product refuses in silence. A requester clicks the only button their contract offers and nothing happens. The hook messages are already well written; they are thrown away. Smallest fix: upstream, action failures need to surface as a toast the way flow failures already do, and a hook refusal should carry a 4xx business code rather than 500 INTERNAL_ERROR with a hook '…' threw: prefix. Worth filing as one issue with both halves, because the good message already exists and only the transport is wrong.

    P1-5 — Empty states that contradict the data. A lawyer opens All Contracts and is told to create their first record, next to 123 rows they cannot see. Smallest fix: upstream, distinguish "no rows matched" from "no rows visible to you" and word the second one accordingly. Cheap and high-value — this is the class the card calls "places the product lies", and it is the difference between "the product is empty" and "your access is not set up".

    P1-6 — The reminder layer nudges nobody. Four of six flows select work and notify no one. The legal_owner-shaped hole is a data problem the demo could fix and a product problem the app should decide on. Smallest fix in this repo: the demo dataset should give every contract a legal_owner (54 of 120 have none), so the reminder layer demonstrates itself; separately, a no-recipient sweep should record that it had nobody to tell — a counter on the run summary is already there (skipped), it just never reaches a person.

    P2-7 — pnpm demo's first run reads as a failure. 120 red ERROR lines after a green "Server is ready". The condition is expected and documented; the presentation is not. Smallest fix: the seeder's unresolved-reference lines for a reference the fixture knows cannot resolve yet should be one WARN summarising the count, not 120 ERRORs — and the "create the three requesters" hint should be re-printed after the ready banner, where it will actually be read.

    P2-8 — Small honesty and polish items. README still says M0 · Studio says "All healthy" over 26 author-time warnings · the seeder's dropped/referencesDropped counters are both wrong · the "User Created" dialog asks you to copy a password it does not show · lookups render as raw ids in related lists (position_mtur8hz641l67zpx in Setup → Users → Positions; bsZ9czV… in the obligations Owner column) · the Pipeline funnel mark is wrong for this data shape and loses stage order in zh-CN · view descriptions ignore their zh-CN branch.

    Things an evaluator would have to be told

    Worth naming, since the card asks: that the app is empty because the dev admin holds no clm_* set; that the three requester accounts must be named exactly; that pnpm demo must be run a second time; that 42 contracts sit past in_approval with no approval request behind them; that version_count reads an honest 0 because a seed cannot mint a file. Every one of these is written down truthfully in the README — and none of them is visible from inside the running product. That is the gap card 14's 「陌生人 clone 一条命令跑起」 has to close.


    5. What worked, plainly

    The data model, the audience model, the record page, the three dashboards and the zh-CN bundle are all genuinely good, and the descriptions throughout refuse to overclaim in a way that is rare. Once the permission sets are granted directly, every audience's navigation renders exactly the §05 table, group for group. Nothing in this report is a defect of design; the expensive ones are all a layer below the app, and the app's own honesty rules are visibly holding.

    Environment: worktree hotclm-dogfood-45 (detached at a7b7db5), port 3987, four pnpm demo boots, Chromium 141 headless at /opt/pw-browsers/chromium-1194. Pre-existing platform pair (no such table: _objectstack_sequences, UNIQUE constraint failed: sys_oauth_resource.identifier) observed on boots 1–3 and attributed as the card says. No file in the repository was modified; the only writes were to the local gitignored database.


    Generated by Claude Code

  4. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Review: ACCEPT — with a headline retraction that the seat caught at dedupe, before anything was filed upstream.

    ⛔ Read this before acting on the report comment above. Its §2.2, §2.3, the row-visibility matrix, suspect 1's extension, proposal P0-1 and open question 1 are retracted. The rest stands, and most of what matters is in the rest.

    What was wrong, and how it was caught

    The report's headline was "the README's documented operator setup does not work" — positions granting nothing, sharing never materialising, four audiences rendering correct navigation over zero rows, "one upstream root cause, two symptoms". That was about to become a new objectstack card.

    It did not, because the pre-filing dedupe search hit objectstack#16712, whose title carries "RE-CUT: the originally reported resolution-path defect is disproved", and the closed #13419, which records that permission-set resolution binds by position NAME. The report's own security/explain output showed an id in principal.positions. That mismatch was the whole finding.

    Asked directly, the run confirmed it against its own saved output:

    • All eight position assignments were written id-spelled: {"user_id":"…","position":"position_mtur8hz5jp7ptm3r"}, from a name → id map. Every one returned 201 with no diagnostic.
    • The tell was already in the posted report: principal.positions = ["org_member", "position_mtur8hz5jp7ptm3r", "everyone"] — the two platform-seeded memberships appear as names; only the row the run wrote appears as an id. The list is populated verbatim from sys_user_position.position.
    • The six sharing rules all carry name recipients (recipient position:clm_legal_counsel, clm_legal_head, …). Matching those against an id-spelled row yields expandedUsers: 0 — the expected result, not a defect.
    • ⇒ One bad spelling explains both halves that were reported as one root cause.
    • Nothing instructed the id. README.md:72 says 「create accounts in Setup → Users and assign the seven positions」 and names them by name. The id was chosen by analogy with sys_user_permission_set.permission_set_id after a metadata read was truncated before it reached the position field's definition — and position is the one field on that row without an _id suffix.
    • The documented path was never actually run. README says Setup → Users; the Positions tab was opened and screenshotted, and "Assign position" was never clicked. So the headline claim is not merely wrong, it is unevidenced — the run did not perform the thing it reported as broken.

    The run volunteered all of this, listed precisely what to retract, and named its own process error: a working positive control (a direct set grant) was read as isolating the mechanism when it equally isolated its own payload. That is the right instinct arriving one step late, and saying so is worth more than the finding would have been.

    Why this is still an ACCEPT

    The card asked for observation and judgement about a user's experience, ⛔ not for a defect list, and it delivered that. Nothing that matters was lost to the retraction, because none of the surviving findings rests on those rows:

    • Pass 1's navigation: [] for the dev admin was measured before any setup existed.
    • The three intake dead ends and the silent 500 refusal were all measured as requester1, who reached them through a direct permission-set grant and record ownership — no position involved.
    • Suspects 3, 4, 5 and 6, the dashboards, and the empty-state wording all stand independently.

    40+ screenshots, four boots, server-side readings throughout, and the one case where a post-navigation DOM read looked like a P0 was correctly disproved with a screenshot — which is the discipline the card demanded.

    What survives from the retracted half, and where it goes

    Two smaller, real things:

    1. The platform accepts an unresolvable sys_user_position.position with 201 and no diagnostic. That is plausibly already objectstack#16712's territory; the seat is adding this evidence there rather than filing a duplicate.
    2. README.md names the positions but never says the assignment binds by name. An operator following it through the UI is fine; anyone automating it has nothing to go on. App-side, and the seat is carding it.

    ⚠️ Still unmeasured, and ⛔ not to be assumed either way: whether a name-spelled row actually resolves on 17.4.0. The decisive one-line test — write {"user_id":"…","position":"clm_legal_counsel"}, re-run security/explain, see whether permissionSets gains clm_legal — was correctly not run rather than rebuilding a torn-down world for one row. It goes to a fresh dispatch. Until it is run, ⛔ nobody may state that positions do or do not work on this version.

    Card status

    This card stays open as the carrier for the findings the seat has not yet carded. ⛔ It is not closed on ACCEPT: closing it would strand roughly a dozen named observations with no home, and this repo's own discipline is that a finding without a carrier is a finding that dies.

    Teardown verified by the run: servers and Chromium stopped by recorded PID, git status empty before git worktree remove (exit 0), the sibling hotclm-issue-39 worktree untouched, and no repository file modified — which is what an observation card should look like.


    Generated by Claude Code

  5. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Correction to this pass's record: one finding did not reproduce

    This card's report described the Pipeline by Stage widget on 法务工作台 as "a bowtie spilling outside its card". #48 was cut from that, and its dev re-measured the claim rather than inheriting it. The spilling half is false:

    • On main @ 2807b3b with console 17.4.0, the funnel's ink sat 25px inside the card on every side at 1440 / 1280 / 1024 / 820px viewport widths, with scrollWidth === clientWidth at each — no horizontal overflow at any width.
    • The bowtie half did reproduce and is fixed (PR Draw 「Pipeline by Stage」 as a ranked bar, not a funnel #57, merged as 2b74c4f): band widths 115 · 138 · 138 · 92 · 69 · 692 · 692px, i.e. narrow-wide-narrow then eleven times wider, because a funnel encodes monotonic decline over a distribution that does not decline (draft 10 · submitted 6 · in_review 12 · in_approval 8 · approved 4 · signing 6 · active 60).

    Recorded here rather than left in the successor card, because this card is the observation record and an unretracted claim in it is what the next pass would inherit.

    The lesson is this card's own golden rule pointing at itself. It says: ⛔ do not report a "missing / broken / unreachable" finding from a post-navigation DOM read alone; confirm against a screenshot or the server's own response. The spilling claim was a visual read of a screenshot in which a full-width trapezoid looks like it is breaching its container — and the geometry says it was not. Same failure mode, different sense: the report is what an eye concluded, not what a measurement said. A layout claim needs getBoundingClientRect / scrollWidth, exactly the way a data claim needs the network response.

    Nothing else in this pass's report is affected — the bowtie, the navigation: [] re-measurement, the label-locale findings and the rest stand as written. #48 is closed, #57 is merged, and the platform half is upstream as objectstack-ai/objectstack#17344.


    Generated by Claude Code

  6. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Closing — the report is delivered and every finding is routed

    This card's deliverable was a report, not code, and the report is in. Where each finding went:

    Fixed and merged

    finding card PR squash
    Pipeline by Stage renders as a bowtie #48 #57 2b74c4f
    the README's status line still said M0; the position assignment binds by name and nothing said so #46 #56 2d63324
    pnpm demo's operator banner asserted a census it could not know #49 #51 2807b3b
    clm_contract.legal_owner was the dev admin on every contract #47 #55 d34b225
    the payment_overdue / active → terminated contradiction this pass raised against PR #42's invariant #50 — resolved as measurement: no bypass, the invariant holds

    Filed and in flight or queued

    Routed upstream

    Sitting in the decision box, awaiting the maintainer

    Five of this card's "known suspects" were confirmed to still reproduce and are decisions rather than defects: #28 (navigation: [] out of the box) · #11 (clm_requester cannot bind to everyone) · #38 (21 fields with no consumer) · #43 (backfill invisible to legal — the permission gate is an AND) · #41 (pnpm demo stops being re-runnable once the jobs have run). Plus #53, which this pass produced. Each is its own card with its own four-axis analysis; none of them needs this card to stay open.

    One claim retracted

    The "spilling outside its card" half of the funnel finding did not reproduce — see the correction above. The bowtie half did and is fixed.

    Closing as completed. A future dogfood pass gets a fresh card against the tree it measures; this one's tree (a7b7db5) is six commits behind.


    Generated by Claude Code

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