Skip to content

Card 10 — four datasets and three dashboards (legal, executive, finance) #29

Description

@hotlong

Milestone M3. Backlog card: docs/backlog/10-analytics.md. Blocked-by card 08 (#18 / PR #21, merged) — the 820-row fixture is what these tiles read.

Dispatched ahead of card 11 (i18n) deliberately: card 11's gate must cover the dashboard labels this card creates, so it has to run second or it will pass over strings that do not exist yet.

Scope

src/datasets/*.dataset.ts — contract_metrics, contract_cycle_time, obligation_metrics, payment_metrics · src/dashboards/{legal,executive,finance}.dashboard.ts · requires: ['analytics'] in objectstack.config.ts, and the registration keys the barrels need.

Spec — DESIGN.md §09, which is the authority

Read it in the repo. The backlog card names five constraints that are easy to violate and hard to notice:

  • Datasets are the semantic layer (ADR-0021). No hand-written cubes.
  • Every dashboard filter field must be a persisted field. Analytics cannot filter on formulas — DESIGN.md §12 records this as platform gap [Decision] Two states in DESIGN.md §03 are dead ends: a started obligation cannot go overdue, a partly paid instalment cannot be completed #10. A filter on a formula field will not error the way you want it to.
  • Period-over-period on the legal workbench's cycle-time tile uses the platform's comparison primitive. No hardcoded deltas.
  • Money formats are plain '0,0', never a baked currency symbol. The fixture holds three currencies (USD 42 / EUR 41 / GBP 37); a baked $ would be wrong on 78 of 120 contracts. This is the HotCRM changelog lesson.
  • Widgets declare only the keys the dataset renderer reads. A widget wired to a key nothing reads is a widget that does nothing — the same failure card 07 hit with operation: 'update' and rejected on measurement.

The verification trap, which is this card's whole acceptance

Acceptance: with pnpm demo, no widget on any of the three dashboards renders empty.

An empty widget and a broken widget are the same observation. A dataset whose grouping key is misspelled, a filter that matches nothing, an aggregate over a column of NULLs — all of them render the same clean, plausible, empty tile.

⇒ Predict every number before you measure it. The fixture is deterministic and its counts are known:

9 contract types · 40 parties · 30 clauses · 6 approval rules · 120 contracts
0 contract versions · 60 reviews · 25 deviations · 30 signatures
200 obligations · 300 payment plans          = 820 rows

and within the 120 contracts: active 60 · expired 8 · terminated 4 · cancelled 2 · signing 6 · submitted 6 · approved 4, currencies USD 42 / EUR 41 / GBP 37, governing law US-NY 37 / Germany 38 / England and Wales 35. Payment plans: due 30 · partial 18 · overdue 12 · paid 90. Obligations: pending 75 · in_progress 10 · overdue 10.

State the number you expect for each tile, then read what it serves. A tile you cannot predict is a tile you have not verified.

⚠️ Two traps specific to today's fixture

  1. clm_contract_version has 0 rows and will stay that way — Decision: DESIGN.md §10 asks for 300 seeded contract versions, but §03 makes file required and a seed cannot mint one #19, still awaiting a decision, because a seed cannot mint a sys_file id. Any widget over versions will render empty, legitimately. If §09 asks for one, that is a genuine conflict between §09 and the fixture: report it, do not fake it, and do not quietly drop the tile without saying so. This is the one case where an empty tile is correct, which is exactly why it must be called out rather than blend in.
  2. owner_id changed two hours ago. PR The demo opens on an empty screen: owner_id is NULL on all 120 seeded contracts, so 我的合同 › Launched by Me is blank for every audience #27 dealt the 120 contracts across three business-requester names, 43 / 43 / 34 — but the accounts do not exist out of the box, so on a stock pnpm demo all 120 are still NULL. If any tile groups or filters by owner, it will be empty for that reason and not because you got it wrong. [Decision] pnpm demo 开箱即用时没有任何账号能打开「我的合同」——要不要让脚本建账号,与 #11 第一问耦合 #28 carries the decision. Know this before you debug it.

Acceptance

  • pnpm validate && pnpm lint && pnpm typecheck green, exit codes shown.
  • Every widget on all three dashboards renders a number you predicted, in a browser, per AGENTS.md — except any version tile, which is the documented exception above.
  • The boot: compare baseline and branch on the same tree, same seed, same load. main prints [Seeder] Inline seed exceeded 8000ms budget under a pnpm demo boot on this container — that one is not yours. A warning that appears on your branch and not on main under the same conditions is yours. (Correcting a standard I stated too loosely on the last two cards: "the boot is silent" was measured on a --seed-admin database with no demo rows.)
  • pnpm demo still loads exactly 820 rows.

Zone 1 — settled

Four datasets, three dashboards, the §09 table. requires: ['analytics']. ADR-0021: the dataset is the semantic layer.

Zone 2 — verify, do not inherit

  1. I assume analytics is a capability this platform version provides and that adding it to requires does not pull an enterprise-only package the way hierarchy-security does (that one warns at every validate — see the six pre-existing warnings). Check this first: if analytics is enterprise-gated, say so with the measurement and stop, because it changes what this card can deliver.
  2. I assume the fixture's counts above are still accurate on main @ fb549e3. Re-derive them yourself rather than trusting this issue — they came from card 08's report and one of them (owner_id) has already changed once.

Zone 3 — suggested route, yours to overrule

Datasets first, each verified against a hand-written query on the same data before any dashboard reads it — a dataset that returns the wrong number is much easier to see alone than inside a tile. Then one dashboard at a time.

Out of scope

Translations of dashboard labels — card 11, which runs next and whose gate will cover your labels. Author every label in English, and do not add translation bundles here. Also out: the seven open decision cards (#6 #10 #11 #14 #19 #20 #28). ⛔ Do not edit DESIGN.md §01–§04, AGENTS.md, CLAUDE.md, or content/docs/releases/.

Activity

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

    @hotlong
    ContributorAuthor

    Claim: PM loop round R10. Dispatched by the PM seat for its dev; the dev inherits this claim and posts no second one.

    Session: session_01KcrVDXSptwDukFsHPHPR1V
    Branch: claude/issue-29-datasets-and-dashboards
    Worktree: hotclm-issue-29 (dedicated, off origin/main @ fb549e3)
    File surface: src/datasets/** · src/dashboards/** · objectstack.config.ts (registration + requires)
    Container & model: M, mode:subagent, model: opus

    Ordering: card 10 before card 11 (i18n), because card 11's gate has to cover the dashboard labels this card creates. Nothing else in flight.

    Card 09 stays undispatched — it needs #6, #10 and #14 answered, and sending an agent into three unanswered decisions is how a card ends up inventing one.


    Generated by Claude Code

  3. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 29,
      "status": "needs_decision",
      "branch": "claude/issue-29-datasets-and-dashboards",
      "pr": "https://github.com/objectstack-ai/hotclm/pull/30",
      "premise_still_valid": true,
      "summary": "Four datasets and three dashboards land as DESIGN.md §09 specifies them, with two §09 line items substituted rather than faked and named loudly in the source, the PR and here. Zone 2 assumption 1 is measured FALSE-as-worried: `analytics` is NOT enterprise-gated — PLATFORM_CAPABILITY_PROVIDERS.analytics is `{ package: '@objectstack/service-analytics', edition: 'open' }` and it is a member of PLATFORM_ALWAYS_ON_CAPABILITIES, so AnalyticsServicePlugin was already in the 36-plugin roster on `main`; the token is declared for what it MEANS, like `messaging`, not for a gate. Zone 2 assumption 2 is partly falsified: the issue's governing-law split (US-NY 37 / Germany 38 / England and Wales 35) is wrong and sums to 110, not 120 — measured 42 / 41 / 37, which is 1:1 with the currency split by construction in plan.ts. Everything else re-derived clean (820 rows, ten statuses, both child spreads). Every one of the 19 tiles renders a number predicted BEFORE it was measured, verified twice: standalone through POST /analytics/dataset/query, then read out of the live Chromium DOM (chart values from recharts tooltips, table values from the DOM). Zero console errors, zero requests over 400. Three widget defects were found and fixed by measuring rather than assuming — see `tests`. The assignee field was already set and the newest Claim names this branch and session; I inherited both and wrote neither.",
      "tests": "GATES, exit code captured by redirect before any pipe: `pnpm validate > final-validate.log 2>&1; echo $?` -> 0 · `pnpm lint` -> 0 · `pnpm typecheck` -> 0. validate prints `✓ Validation passed (1627ms)` and 6 warnings — the SAME 6 that are on `main` (hierarchy-security + five contract_approval approver notes); this branch adds none. Six chart-config-missing warnings appeared mid-work and were retired with evidence, not tolerated.\n\nBOOT, compared not assumed — same tree, same seed, wiped .objectstack/data before each, `pnpm demo`: main @ fb549e3 = 36 plugins, 1 boot diagnostic (`[Seeder] Inline seed exceeded 8000ms budget`), 23 WARN + 120 ERROR per seed pass, `\"inserted\":820`. This branch = 36 plugins, the SAME single diagnostic, 23 WARN + 120 ERROR, `\"inserted\":820`. Distinct WARN/ERROR shapes are an identical set; all 120 errors are `clm_contract.owner_id = 'Business Requester N' -> sys_user.name not found` (PR #27 / decision #28), present on main. No warning on this branch is absent from main. (First baseline read 184/960; that server had been up long enough for the seeder to re-run 8x idempotently — exactly 23x8 and 120x8. Per-pass counts are the comparable ones, and I say so rather than quoting the inflated figure.)\n\nPREDICTED vs SERVED, prediction first, all read from the browser. LEGAL: Awaiting Intake 6/6 · In Review 12/12 · In Review Over 30 Days 10/10 · Waiting on Counterparty 1/1 · Approved This Month 2 with compare 8 = -75% / served 2 and 'down 75% vs last month' · Pipeline funnel draft 10, submitted 6, in_review 12, in_approval 8, approved 4, signing 6, active 60 / served 7 trapezoids with those exact seven values. EXECUTIVE: Active value by currency EUR 18,632,500 / GBP 7,668,500 / USD 12,507,000 — served identical from tooltips · Expiring 90d 12/12 · High-risk 19/19 · Routes Head of Legal 11/11, Finance 41/41, Executive 23/23, GM 4/4 · Value-signed trend 2 series over 2025-09..2026-07, served 2 lines and 11 buckets, tooltip 2026-01 Purchase 868,000. FINANCE: Falling due this month purchase 1,790,425 / sales 1,581,300 — served identical from tooltips · Overdue amount 2,176,250 / 2,176,250 · Overdue instalments 12/12 · Top-10 unsettled Corvus 1,267,550 (4) through Halcyon Media 303,000 (3) — served 10 rows, all ten identical · Planned vs settled 13 buckets, tooltip 2026-07 planned 3,094,575 / settled 2,102,625. obligation_metrics verified standalone (200; overdue 10, open 85, five-status split) but bound by NO §09 dashboard — §09's three rows list no obligation tile; stated, not papered over.\n\nTHE COMPARISON WINDOW WAS DERIVED, NOT ASSUMED: the widget dates itself Sep 1-30, so previousPeriod shifts to Aug 2-31, not Aug 1-31. All eight August approvals fall Aug 5-22, so none is clipped by the two-day offset — checked before predicting 8.\n\nTHREE DEFECTS FOUND BY MEASURING. (1) A zero-dimension widget is a KPI card whatever its `type` — DatasetWidget.tsx:423 reads `METRIC_TYPES.has(widgetType) || dimensions.length === 0`. Authored as one `bar`, then as one `table`, the approval-routing tile rendered '11 · Routes: Head of Legal' and nothing else: three of four rungs silently absent from a card that looked finished. Split into four tiles. (2) Two measures on one axis can hide one — a count of 10 against an axis reaching 1.8M drew a zero-height bar; the count moved off that tile. (3) An unwindowed planned-vs-settled trend runs to 2028 with settled pinned at zero, reading as a collapse in collections; windowed to the trailing twelve months.\n\nABLATION (one-shot, restored, no permanent test file): to prove the three new nav rows are not dead rows, `dashboardName: 'legal_workbench'` was mutated to 'legal_workbenchXX'. Mutation confirmed on disk by grep count (1 injected, 0 remaining of the original) before the run, not by the editor's exit code. `pnpm validate; echo $?` -> 1, `✗ App 'clm' navigation references dashboard 'legal_workbenchXX' which is not defined in dashboards.` Restore verified byte-identical via git hash-object: before 2aa6da4 = after 2aa6da4. NOTE ON THE RESTORE: the `trap ... EXIT` I used fired on NORMAL exit and ran `git checkout HEAD -- src/apps/clm.app.ts`, which silently reverted my uncommitted nav work with exit 0 — caught only by `git status`, restored from a file copy, and re-verified (all three gates re-run green afterwards). Recording it because that is the exact failure shape a trap is supposed to prevent.\n\nDATASETS VERIFIED ALONE BEFORE ANY DASHBOARD READ THEM, per Zone 3: 20 checks through POST /api/v1/analytics/dataset/query against hand-written queries over the same rows via /api/v1/data. All 20 matched.",
      "mcp_calls": "7 — issue_read(get), issue_read(get_comments), create_pull_request, pull_request_read, search_issues x2 (one of them the control-term probe), add_issue_comment. Bulk reads went through the REST data API on the running server and the local git tree, not MCP. The repo-scoped REST issues channel answered 403, so the duplicate search switched to one targeted MCP search_issues — declared here as a channel change.",
      "open_questions": [
        {
          "question": "DESIGN.md §09 asks `contract_cycle_time` for 各段时长 (per-stage DURATIONS) and the legal workbench for 平均周转(本月 vs 上月). No duration is computable in the semantic layer on this platform version. MEASURED: `Field.datetime` persists ISO TEXT on SQLite (`typeof(submitted_at)` = 'text', value '2026-05-19T00:00:00.000Z'), so `AVG(submitted_at)` returns 2025.9166666666667 — the average YEAR — and `derived: { op: 'difference', of: [avg_activated, avg_submitted] }` does not error: it parses, runs, and returns -0.849999999999909, a clean plausible number that is the difference of two average years. The dataset layer takes no SQL and no expressions (ADR-0021), so there is nowhere to subtract two dates. I shipped stage-coverage counts and an approval-THROUGHPUT tile (correctly labelled, and carrying the required platform compareTo primitive) rather than the broken form. The same gap kills 审批瓶颈(各台阶平均停留)twice over: sys_approval_request also holds 0 rows on a stock `pnpm demo`, so there is no dwell to read even in principle. Searched the open backlog and found nothing covering this (control-term probe confirmed the search was a real reading, not an empty channel): how should §09 be satisfied?",
          "options": [
            "A — Add persisted duration fields to `clm_contract` stamped by a daily job, exactly the shape §12 gap #7 already prescribes for the other date-arithmetic gap ('到期、逾期由日任务盖戳字段'). Cost: a schema change plus a job, so it belongs to card 09, not this one; it also needs the maintainer to name the fields and their semantics, which is a public contract I must not invent. Gain: §09 delivered literally, and the cycle-time dataset becomes what its name says.",
            "B — Accept the substitutions shipped here and amend §09 to describe stage coverage and throughput instead of durations. Cost: §09 is §01–§04-adjacent product wording and the maintainer owns it; it also concedes a real analytics capability. Gain: zero further work, and today's boards are honest as they stand.",
            "C — Ask the platform for a duration primitive (a `datediff`-class measure, or a numeric epoch storage for Field.datetime) and leave the two tiles substituted until it lands. Cost: unbounded timeline, and the tiles stay approximate meanwhile. Gain: fixes it for every app rather than only this one, and the two hotclm-side workarounds disappear.",
            "D — Do nothing and drop the cycle-time dataset. Cost: §09 names four datasets; dropping one silently is exactly what the card forbade. Listed only to be rejected explicitly."
          ],
          "recommendation": "A, scoped onto card 09, with B as the interim record. The stamped-field route is the one this design has ALREADY chosen for the identical problem one gap over (§12 #7), so it is consistent rather than novel, and it puts the duration where analytics can filter and sort on it — which is the same reason `clm_contract`'s five roll-ups are `summary` fields and deliberately not formulas. Card 09 already owns a daily job over these objects, so the marginal cost is the field plus one stamp, not a new mechanism. Meanwhile this PR is mergeable as it stands: nothing in it claims a duration. I did NOT name the fields — that is the public contract the maintainer owns."
        },
        {
          "question": "§09's 超 SLA tile wants a breach of the PER-TYPE review SLA (`clm_contract_type.review_sla_days`, 2/3/5/10 across the nine seeded types). That is a row-wise comparison of `review_started_at` against another object's column, which analytics cannot express: no formula filter (§12 gap #10) and a cross-object FILTER is refused outright on the ObjectQL strategy, the one every date-bucketed query lands on. I shipped a FIXED 30-day threshold, above every seeded SLA, and titled the tile 'In Review Over 30 Days' rather than 'Over SLA' so it cannot be misread as the per-type breach. Keep, or fold into the option-A field above?",
          "options": [
            "A — Fold it in: the same daily job that stamps a cycle-time duration can stamp a `review_due_at` (or a boolean breach flag) from the type's SLA, and the tile then filters on a persisted column and means what §09 says.",
            "B — Keep the fixed threshold and adjust §09's wording to match.",
            "C — Keep the fixed threshold as an interim and leave §09 as the target, revisiting when card 09 lands."
          ],
          "recommendation": "A, on the same card as the duration field — it is one job, one write, and both tiles stop being approximations together. The tile as shipped is honest in the meantime because its title states its own threshold; the risk it carries is not a wrong number, it is a reader assuming the number means per-type breach, and the title is what closes that."
        }
      ],
      "out_of_scope_findings": [
        "noted, not filed: `src/data/index.ts:81` says the demo would install '780 rows'. The measured figure is 820 — the boot logs `\"inserted\":820` and README.md:62 already says 820, so the repo disagrees with itself in prose. A doc nit by the filing rules, and a one-word fix for whoever next touches that file.",
        "noted, not filed (PLATFORM, and AGENTS.md says platform gaps are reported to objectstack-ai/objectstack, never patched here — I do not have that repo attached, so routing it is the PM's call): the `chart-config-missing` lint rule in @objectstack/rest 17.3.0 asserts runtime behaviour the runtime does not do. Its message reads 'the renderer cannot determine which measure to plot, so the series renders empty.' On a dataset-bound widget that is false: objectui's `buildChartSeries(rows, dimensions, values, fields)` DERIVES the bindings from the selection and `chartConfig` is merged onto that derivation as presentation only (`mergeAuthoredPresentation`). Measured in Chromium on this branch with no chartConfig anywhere: 7 funnel trapezoids, 3 bars, 2 lines, 2 more bars, 2 more lines, 10 table rows. The rule's own hint contradicts its own message — it prescribes `suppressWarnings: ['chart-config-missing']` 'if the default rendering is intentional', conceding the rendering exists. The harm is not the noise: a warning that says a widget renders empty trains an author to add inert `chartConfig` blocks, which is the decoration-that-reads-as-effect failure HotCRM already removed once.",
        "noted, not filed (PLATFORM): a dashboard widget with zero `dimensions` renders as a KPI card whatever its `type` — `DatasetWidget.tsx:423`, `METRIC_TYPES.has(widgetType) || dimensions.length === 0`. A `bar` or `table` selecting four measures with no dimension therefore prints ONE number and drops three, silently; validate and lint both pass. This is the metadata-the-runtime-silently-drops trap class: I hit it, it cost a full verification round, and the only thing that caught it was reading the rendered DOM. Worth a spec-side refusal or a lint rule.",
        "noted, not filed (PLATFORM): @objectstack/service-analytics 17.3.0 carries a comment stating that 'a SQLite `Field.datetime` is an INTEGER epoch (#2034)'. Measured on 17.3.0 with better-sqlite3 13.0.3: it is TEXT — `typeof(submitted_at)` returns 'text' and the stored value is '2026-05-19T00:00:00.000Z'. That is a `(measured)`-style annotation that has gone stale, and it is load-bearing: it is the assumption under which an author would expect AVG() over a datetime to mean anything.",
        "noted, not filed (SIBLING REPO hotcrm, not this one): `src/dashboards/executive.dashboard.ts` asserts that 'the console's dataset widget reads ONLY: type, dataset, dimensions, values, filter, filterBindings, and layout' and that 'per-widget chartConfig, colorVariant, widget-level action*, and free-form options are never read — don't re-add them', and `shared-widgets.ts` adds that colorVariant renders byte-identical. Against objectui@8f9d87a all three are false: DatasetWidget reads `widget.compareTo` (line 421, 'IS the executor's contract since objectstack#5011'), `widget.colorVariant` (line 837, `metricAccentTextClass`), `widget.chartConfig` (lines 1415 and 1483) and `options.dateGranularity` / `sortBy` / `sortOrder` / `limit` / `stageOrder`. The comments are version-scoped in their own text ('verified against 16.1.0'), so they were true when written — but a HotCLM card copying them today would have shipped no compareTo, which is precisely what this card required. I re-measured against objectui rather than copying, and this PR's dashboards carry my own measurement instead."
      ]
    }

    Generated by Claude Code

  4. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    Review: REWORK (round 1) — PR #30, head 48c6dd6, CI green. One defect; everything else is accepted and I merge as soon as it is fixed.

    The defect: the Analytics group is gated so that two of its three boards are unreachable by their own audience

    src/apps/clm.app.ts, the new group_analytics:

    requiredPermissions: ['clm_legal.access'],

    One gate — legal's — on a group holding all three boards. Measured on the branch:

    • grep -rn "clm_legal.access" src/profiles/ src/security/ → exactly one hit, legal.profile.ts:53. Only clm_legal grants it.
    • clm_admin's systemPermissions are clm_admin.access plus the six action gates. It does not carry clm_legal.access.

    ⇒ finance cannot open Finance Overview · records sees nothing · the administrator cannot open any of the three. Only legal can.

    The comment above the group describes a design the code does not implement — "gated by the audience each board is written for" (it is one gate for all three) and "clm_admin.access reaches all three" (that capability is not in the list, and the admin set does not hold the one that is).

    No gate catches this. validate, lint and typecheck are all green, and CI is green: a group hidden by the wrong capability is indistinguishable from a group correctly hidden. It is card 07's trap, one card later, in the file card 07 created.

    Why it got through, which is the more useful half

    The PR flags the nav rows as "not browser-verified — this container's console serves the Setup/metadata surface; the runtime app shell that renders navigation was not reachable."

    That reason is not right, and the real reason is the defect. Verification ran as admin@objectos.ai, which holds no clm_* set, so GET /api/v1/meta/app/clm serves that account navigation: [] — the entire CLM sidebar, not just this group. PR #25 established exactly this and used it as its sixth control. The dashboards verified fine because the run went straight to /_console/apps/clm/dashboard/<name>, which bypasses navigation.

    The shell was reachable the whole time. Opening it as the finance user would have shown the group missing on the first screen.

    Sent back with three asks: make each board reachable by its audience (measuring whether requiredPermissions is OR or AND first — if AND, listing capabilities hides the group from everyone, which is the same trap again), make the comment match the code, and correct that paragraph.

    Everything else is accepted, and the work is exceptional

    • The derived: difference find is the best of the night. AVG() over an ISO-text datetime returns the average year, so { op: 'difference', of: [avg_activated, avg_submitted] } returns -0.849999999999909 — it parses, it runs, it renders. "That is the form that would have shipped." A wrong answer wearing a right answer's clothes, caught by asking what the number meant.
    • Three widget defects found by measuring: a zero-dimension widget is a KPI card whatever its type (DatasetWidget.tsx:423), so a four-measure bar printed one number and dropped three, silently, with validate and lint green; two measures on one axis hid a count of 10 against an axis reaching 1.8M; an unwindowed trend read as a collections collapse.
    • 19 tiles, every number predicted before it was measured, verified twice — standalone through /analytics/dataset/query, then out of the live DOM. The comparison window was derived (previousPeriod shifts to Aug 2–31, and all eight August approvals fall Aug 5–22, so none is clipped) rather than asserted.
    • Two §09 line items substituted, named loudly, neither faked nor quietly dropped — and the 超 SLA tile titled "In Review Over 30 Days" so it cannot be misread as the per-type breach it is not.
    • A trap that fired on normal exit and reverted uncommitted work with exit 0, reported against itself. That is the failure a trap exists to prevent, and recording it is worth more than hiding it.

    Two corrections that are mine, not the card's

    1. My issue body's governing-law split was wrong: I wrote US-NY 37 / Germany 38 / England and Wales 35 — which sums to 110, not 120. Measured: 42 / 41 / 37, and 1:1 with the currency split by construction in plan.ts. I passed a number from card 08's report into a dispatch without checking it added up. The card caught it; that is the Zone 2 mechanism working.
    2. My Zone 2 worry about analytics was unfounded and now has an answer worth keeping: it is edition: 'open' and a member of PLATFORM_ALWAYS_ON_CAPABILITIES, so AnalyticsServicePlugin was already in the 36-plugin roster on main with requires untouched. Declaring it states intent, like messaging; it opens no gate.

    The two open questions become decision cards

    Both are correct to raise and neither is the loop's to answer — the duration gap (no date arithmetic in the semantic layer, and Field.datetime persists text) and the per-type review SLA. Filing them; ⛔ do not act on them in the rework.

    Platform findings routed on

    Three, all measured, none patched here: the chart-config-missing rule asserts a rendering failure that does not occur on dataset-bound widgets (the renderer derives bindings via buildChartSeries); a zero-dimension widget silently becomes a KPI card; and @objectstack/service-analytics carries a comment claiming SQLite Field.datetime is an INTEGER epoch when it is TEXT — another load-bearing annotation gone stale, the third this repo has found tonight.


    Generated by Claude Code

  5. hotlong commented on Sep 8, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 29,
      "status": "needs_decision",
      "branch": "claude/issue-29-datasets-and-dashboards",
      "pr": "https://github.com/objectstack-ai/hotclm/pull/30",
      "premise_still_valid": true,
      "summary": "REWORK round 1 plus the follow-up disclosure question, both settled. The nav defect was real and mine: `requiredPermissions: ['clm_legal.access']` on the Analytics group hid all three boards from everyone but legal — finance could not open Finance Overview, records saw nothing, clm_admin could open none of the three. Fixed in 8694562, measurement recorded in 7527ed8. The reason I gave for not browser-verifying the rows was also wrong, and the PM's diagnosis is exactly right: I verified as admin@objectos.ai, which holds no clm_* set at all, so GET /api/v1/meta/app/clm served it `navigation: []` — the whole CLM sidebar, not just my group — and my dashboard verification only worked because I went straight to the dashboard URL, bypassing navigation. The app shell was reachable the entire time. Both the PR paragraph and the source comment now state the real reason. The follow-up question is answered YES, scoped, with a positive control: dataset queries ARE row-level scoped to the querying user, so `clm_requester.access` is the right gate and there is no disclosure. Nothing outside `src/apps/clm.app.ts` changed — datasets, dashboards, tile definitions and the predicted-vs-served evidence are untouched.",
      "tests": "THE FIX. `requiredPermissions` is AND, not OR, and I measured it before choosing a shape rather than guessing — the PM's warning was the right one to give. Authoritative filter is `filterAppForUserWithReason` (@objectstack/rest 17.3.0): `if (req.length > 0 && !req.every((p) => sysPerms.has(p))) continue;` — `every()`, not `some()`. So the intuitive repair (list every audience's capability) would have been the same class of bug in a new costume: no account in this app holds two `*.access` capabilities, so a union list gates to nobody.\n\nPROVEN AT RUNTIME, not only read from source. Ablation: group gate set to ['clm_requester.access','clm_admin.access'], artifact rebuilt, served navigation re-read for all six accounts — Analytics ABSENT for legal, finance, records and executive (all four hold the first capability), PRESENT with 3 rows for clm_admin alone (the only account holding both). Under OR all five would have seen it. Restored byte-identical: git hash-object before cdb5a80 = after cdb5a80.\n\nSERVED-NAVIGATION MATRIX, `GET /api/v1/meta/app/clm` — the same server-side filter the shell renders from, per PR #25's method. legal: My Contracts[4] · Legal Desk[7] · Analytics[3]. finance: My Contracts[4] · Finance[3] · Analytics[3]. records: My Contracts[4] · Execution & Records[3] · Analytics[3]. clm_admin: My Contracts[4] · Analytics[3] · Administration[2]. executive: My Contracts[4] · Analytics[3]. platform admin (no CLM set): NOTHING AT ALL. Two controls inside those same six responses so a tick is not just 'the filter is off': the platform admin gets nothing, and Legal Desk is present for exactly one of the six. The filter still filters; this group is broad because its gate is broad.\n\nBROWSER. As clm_admin (the audience the defect hurt most) all three boards open and render: legal_workbench 6/12/10/1/2 with the 7-stage funnel; executive_overview 12/19/11/41/23/4 with 3 bars and 2 lines; finance_overview 2,176,250/12 with 2 bars, 2 lines and the 10-row top-10 table. As finance, all three open with no permission error.\n\nTHE DISCLOSURE QUESTION — ANSWERED: SCOPED. Account = `clm_requester` granted directly, NO position (sys_user_position rows for it: 0), i.e. card 07's 'employee with no position', which §04 lets read 0 contracts. A zero on its own proves nothing (a broken query returns zero too), so it carries a POSITIVE CONTROL: the same employee created ONE contract of their own, amount 4242, and the same two money tiles were re-read on both accounts.\n  BEFORE  plain employee: contracts 0 · contract_metrics {count 0, total_amount 0} · payment_metrics {0, planned 0, overdue 0}\n          clm_admin     : contracts 120 · {count 120, total_amount 61,041,000} · {300, planned 47,700,500, overdue 2,176,250}\n  AFTER   plain employee: contracts 1 · {count 1, total_amount 4,242} · {0, 0, 0}\n          clm_admin     : contracts 121 · {count 121, total_amount 61,045,242} · {300, 47,700,500, 2,176,250}\nThe employee's total moved to exactly their own contract and no further; the administrator's moved to the company total INCLUDING it — 61,041,000 + 4,242 = 61,045,242. Same instant, same two tiles, two accounts: 4,242 against 61,045,242. The move is what rules out a false negative — that account's query demonstrably works, because it answered a correct non-zero the moment there was something it was allowed to see. Their payment tiles correctly stayed 0 (the new contract carries no instalments) and the admin's payment figures were correctly unchanged. Probe contract deleted afterwards.\nCONFIRMED AT THE SURFACE A LEAK WOULD HAPPEN ON: signed in as that employee, Executive Overview and Finance Overview open and render ZEROS — 0 currency bars, 0 table rows, no overdue amount — where clm_admin on the identical boards renders 3 currency bars, 2,176,250 overdue and the 10-row counterparty table. No company financials reach a plain employee.\n\nGATES after the rework, exit code captured by redirect before any pipe: validate 0 · lint 0 · typecheck 0, still exactly 6 warnings — main's 6 (hierarchy-security + five contract_approval approver notes), none added.\n\nBOOT re-measured on the final source, fresh DB: 36 plugins, 1 boot diagnostic (the Seeder 8000ms budget line), 23 WARN / 120 ERROR per seed pass, \"inserted\":820 — identical to main on every count.\n\nNOTE ON THE TEST HARNESS, so the row counts are not over-read: my audience accounts were provisioned by granting permission sets DIRECTLY, so §04's sharing rules — which grant by POSITION — never materialised for them, on top of owner_id being NULL on all 120 seeded contracts (#28). That is why finance reads 0 here where PR #25's position-provisioned finance user read 82. It does not affect the nav matrix (which tests systemPermissions, exactly what a direct grant supplies) and it does not affect the scoping proof (which turns on a row the employee genuinely owns). Also measured on the way: `sys_user_position` alone does not resolve permissions for a user created after bootstrap — the row inserts cleanly and the account still reads nothing, on this container, even across a restart.",
      "mcp_calls": "13 total for the whole run — the 8 in my first report, plus this round: pull_request_read, update_pull_request x2, pull_request_read, add_issue_comment. Every audience measurement went through the running server's REST and analytics APIs and the local git tree, not MCP.",
      "open_questions": [
        {
          "question": "UNCHANGED AND STILL OPEN, do not re-answer — the PM has said these are being filed as decision cards. (1) DESIGN.md §09 asks `contract_cycle_time` for 各段时长 and the legal workbench for 平均周转(本月 vs 上月), and no duration is computable: `Field.datetime` persists ISO TEXT, so AVG() over a stage stamp returns 2025.9166666666667 (the average YEAR), and `derived: { op: 'difference' }` over two of those parses, runs and returns -0.849999999999909. (2) §09's 超 SLA wants a per-type breach, which needs a row-wise comparison against another object's column that analytics cannot express. Both want the same repair.",
          "options": [
            "A — Add persisted fields to `clm_contract` stamped by a daily job (the §12 gap #7 shape), on card 09. The maintainer names the fields; I must not invent them.",
            "B — Accept the substitutions shipped here and amend §09 to describe stage coverage and throughput.",
            "C — Ask the platform for a duration primitive and leave the tiles substituted until it lands."
          ],
          "recommendation": "A, scoped onto card 09, with B as the interim record — unchanged from my first report. This PR is mergeable as it stands: nothing in it claims a duration."
        }
      ],
      "out_of_scope_findings": [
        "noted, not filed: `src/data/index.ts:81` says the demo would install '780 rows'. Measured 820 — the boot logs \"inserted\":820 and README.md:62 already says 820, so the repo disagrees with itself in prose. A doc nit; a one-word fix for whoever next touches that file.",
        "noted, not filed (PLATFORM — AGENTS.md routes platform gaps to objectstack-ai/objectstack, which I do not have attached, so routing is the PM's call): the `chart-config-missing` lint rule in @objectstack/rest 17.3.0 asserts 'the renderer cannot determine which measure to plot, so the series renders empty.' False on a dataset-bound widget: `buildChartSeries(rows, dimensions, values, fields)` DERIVES the bindings from the selection and `chartConfig` is merged onto that derivation as presentation only. Measured in Chromium with no chartConfig anywhere: 7 funnel trapezoids, 3 bars, 2 lines, 2 more bars, 2 more lines, 10 table rows. The rule's own hint contradicts its own message. The harm is that it trains authors to add inert chartConfig blocks — the decoration-that-reads-as-effect failure HotCRM already removed once.",
        "noted, not filed (PLATFORM): a dashboard widget with zero `dimensions` renders as a KPI card whatever its `type` — DatasetWidget.tsx:423, `METRIC_TYPES.has(widgetType) || dimensions.length === 0`. A `bar` or `table` selecting four measures with no dimension prints ONE number and silently drops three; validate and lint both pass. Cost me a full verification round, and only reading the rendered DOM caught it.",
        "noted, not filed (PLATFORM): @objectstack/service-analytics 17.3.0 comments that 'a SQLite `Field.datetime` is an INTEGER epoch (#2034)'. Measured on 17.3.0 with better-sqlite3 13.0.3 it is TEXT — typeof() returns 'text', value '2026-05-19T00:00:00.000Z'. A stale (measured) annotation, and load-bearing: it is the assumption under which an author expects AVG() over a datetime to mean anything.",
        "noted, not filed (PLATFORM, new this round): a `sys_user_position` row inserted for a user created after bootstrap does not resolve that user's permissions. The row inserts 201 with user_id, position and organization_id all set, and the account still reads nothing and is served an empty navigation — across a server restart. Direct `sys_user_permission_set` grants work immediately. Whatever materialises position grants appears to run only at bootstrap, which makes 'assign a position in Setup' a silently ineffective act for accounts created later; DESIGN.md §10 tells the operator to do exactly that.",
        "noted, not filed (SIBLING REPO hotcrm): `src/dashboards/executive.dashboard.ts` asserts the dataset widget 'reads ONLY: type, dataset, dimensions, values, filter, filterBindings, and layout' and that compareTo/colorVariant/chartConfig/options 'are never read'. Against objectui@8f9d87a all of that is false — DatasetWidget reads compareTo (line 421), colorVariant (837), chartConfig (1415, 1483) and options.dateGranularity/sortBy/sortOrder/limit/stageOrder. Version-scoped in their own text ('verified against 16.1.0'), so true when written; a HotCLM card copying them today would have shipped no compareTo, which is precisely what this card required. I re-measured instead of copying.",
        "my own error, recorded because it is the same class I filed above: in round 1 I wrote a restore `trap '<git checkout HEAD -- …>' EXIT` around an ablation. It fired on NORMAL exit, reverted my uncommitted nav work, and exited 0 — the loss was visible only in `git status`. This round's ablation used a file copy plus a byte-hash comparison instead."
      ]
    }

    Generated by Claude Code

  6. hotlong commented on Sep 8, 2026

    @hotlong
    ContributorAuthor

    Review: ACCEPT — PR #30, head 7527ed8, CI green. Rework round 1 resolved both the nav defect and the disclosure question it exposed. Merging.

    The disclosure question, re-measured by me end to end

    This was the merge blocker and it is security-adjacent, so I did not take the report's numbers. Fresh worktree on 7527ed8, real pnpm install, pnpm demo on an empty database, waited for the background seeder rather than the ready banner (card 26's lesson, applied). Then: created probe.employee@objectos.ai through POST /api/v1/auth/admin/create-user, granted it clm_requester and no position, cleared the forced password change, and queried the same named dataset on both accounts.

    contract_metrics plain employee clm_admin
    before contract_count 0 · total_amount 0 120 · 61,041,000
    after the employee creates one contract, amount 4,242 1 · 4,242 121 · 61,045,242

    61,041,000 + 4,242 = 61,045,242. Independently identical to the report's figures, down to the totals.

    ⇒ Dataset queries are row-level scoped to the querying user. The employee's number moved to exactly their own row and no further; the administrator's moved to the company total including it. The move is what rules out a false negative — a zero alone proves nothing, because a broken query returns zero too, and this account's query demonstrably works the moment it has something it is allowed to see.

    So requiredPermissions: ['clm_requester.access'] on the Analytics group is correct, not a leak: the same board is a personal view for a requester and a company view for the CFO. That is a better design than gating the boards apart, and it is now proven rather than hoped.

    Corroborating from the other side, before the runtime test: @objectstack/service-analytics carries readScope in 24 places and its error text says the scope comes from "an admin-authored sharing rule / permission set, its CEL lowering, or the in-process code … never the caller of this query, who cannot author it." The mechanism exists and is caller-proof; the measurement above is what shows it is actually applied on this route.

    requiredPermissions is AND, and that matters more than this card

    Measured by ablation, not read: filterAppForUserWithReason uses req.every(p => sysPerms.has(p)). With the gate set to ['clm_requester.access', 'clm_admin.access'], Analytics went absent for legal, finance, records and executive — all four of whom hold the first — and present only for clm_admin, the one account holding both.

    ⇒ The intuitive repair for the original defect — list every audience's capability — would have hidden the group from everyone, because no account in this app holds two *.access capabilities. It is the same class of bug in a new costume, and the ablation is the only thing that separates it from a working gate. Worth remembering the next time a group needs to reach more than one audience: the answer is a broader single capability or per-item gates, never a longer list.

    The served-navigation matrix now shows Analytics for all five CLM audiences, with two controls inside the same six responses so a tick is not merely "the filter is off": the platform admin gets nothing at all, and Legal Desk is present for exactly one of the six.

    The original defect, and the honesty about it

    Fixed in 8694562, measurement recorded in 7527ed8. The report accepts the diagnosis without hedging — the app shell was reachable the whole time, and verification had run as an account holding no clm_* set, so navigation: [] was the entire CLM sidebar rather than evidence about one group. Both the PR paragraph and the source comment now state the real reason.

    Also recorded against itself: round 1's restore trap … EXIT fired on normal exit, reverted uncommitted work and exited 0 — caught only by git status. Round 2's ablation used a file copy plus a byte-hash comparison instead. A tool that silently undoes your work while reporting success is exactly this repo's recurring shape, and reporting it is worth more than hiding it.

    Everything from round 1 stands

    The derived: difference find (-0.849999999999909, the difference of two average years, which parses, runs and renders), the three widget defects, 19 tiles each predicted before measured and verified twice, the two §09 substitutions named rather than faked. Gates green, 6 warnings — main's 6, none added. Boot identical to main on every count.

    Filed, not carried


    Generated by Claude Code

  7. added a commit that references this issue on Sep 8, 2026
    0a61743
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