Skip to content

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

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-29-datasets-and-dashboards
Sep 8, 2026
Merged

hotlong merged 3 commits into
mainfrom
claude/issue-29-datasets-and-dashboards

Conversation

@hotlong

@hotlong hotlong commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #29

DESIGN.md §09's four datasets and three dashboards. Every widget binds a dataset and selects
dimensions and measures by name (ADR-0021); no inline query, no hand-written cube.

Zone 2 — the two assumptions, measured

1. Is analytics enterprise-gated like hierarchy-security? No, on both counts.

PLATFORM_CAPABILITY_PROVIDERS.analytics             → { package: '@objectstack/service-analytics', edition: 'open' }
PLATFORM_CAPABILITY_PROVIDERS['hierarchy-security'] → { package: '@objectstack/security-enterprise', edition: 'enterprise' }
PLATFORM_ALWAYS_ON_CAPABILITIES → [queue, job, cache, settings, email, storage, sms, sharing, messaging, analytics]

It is open edition and always-on, so the token has no gate behind it: AnalyticsServicePlugin
was already in the 36-plugin boot roster on main with requires unchanged, and
POST /api/v1/analytics/dataset/query already answered. It is declared anyway, for the same
reason messaging is — the app states what it means. Plugin count is 36 on both sides.

2. Are the issue's fixture counts still accurate on fb549e3? Mostly. The governing-law
split in the issue body is wrong
— and it does not even sum to 120:

issue body measured
US-NY 37 42
Germany 38 41
England and Wales 35 37
total 110 ❌ 120 ✓

Everything else re-derived clean: 820 rows, currencies USD 42 / EUR 41 / GBP 37, the ten
statuses, payment plans (planned 150 · due 30 · partial 18 · paid 90 · overdue 12), obligations
(pending 75 · in_progress 10 · overdue 10 · done 90 · waived 15). Governing law is 1:1 with
currency by construction (plan.ts maps each jurisdiction to one currency), which is the check
that catches it: 42/41/37 both ways.

Predicted vs served — every tile, prediction first

Each number was hand-derived from raw /api/v1/data rows before any dataset was queried,
then verified twice: standalone through POST /analytics/dataset/query, then read out of the
live DOM in Chromium.

法务工作台 — legal_workbench

tile predicted served (browser)
Awaiting Intake (status = submitted) 6 6 ✓
In Review (status = in_review) 12 12 ✓
In Review Over 30 Days 10 10 ✓
Waiting on Counterparty (current_turn = counterparty) 1 1 ✓
Approved This Month + compareTo 2, compare 8 (−75%) 2, ↓75% vs last month ✓
Pipeline by Stage (funnel) draft 10 · submitted 6 · in_review 12 · in_approval 8 · approved 4 · signing 6 · active 60 7 trapezoids, 10/6/12/8/4/6/60 ✓

The comparison window is derived, not asserted: the widget dates itself
{ approved_at: { $gte: '{current_month_start}', $lte: '{current_month_end}' } } = Sep 1–30, so
previousPeriod shifts to Aug 2–31. All eight August approvals fall between Aug 5 and Aug 22,
so none is clipped by the two-day offset — checked before predicting 8.

管理层 — executive_overview

tile predicted served (browser)
Active Contract Value by Currency EUR 18,632,500 · GBP 7,668,500 · USD 12,507,000 identical, read from tooltips ✓
Expiring Within 90 Days 12 12 ✓
High-Risk Contracts 19 19 ✓
Routes: Head of Legal 11 11 ✓
Routes: Finance Controller 41 41 ✓
Routes: Executive 23 23 ✓
Routes: General Manager 4 4 ✓
Contract Value Signed, by Direction 2 series over 2025-09…2026-07, peak 2026-06 sales 11,877,500 2 lines, 11 buckets, tooltip 2026-01 Purchase 868,000 ✓

财务 — finance_overview

tile predicted served (browser)
Falling Due This Month purchase 1,790,425 · sales 1,581,300 identical, read from tooltips ✓
Overdue Amount 2,176,250 2,176,250 ✓
Overdue Instalments 12 12 ✓
Unsettled by Counterparty — Top 10 Corvus 1,267,550 (4) · Port Authority of Hamburg 1,217,000 (2) · Stratus 828,700 (5) · Granite 698,125 (3) · Aurora 687,200 (2) · Verdant 676,100 (1) · Sable 597,200 (1) · Northwind 448,100 (5) · Harborline 398,525 (3) · Halcyon 303,000 (3) 10 rows, all identical ✓
Planned vs Settled by Month 13 buckets 2025-09…2026-09 2 lines, tooltip 2026-07 planned 3,094,575 / settled 2,102,625 ✓

obligation_metrics is delivered as §09 specifies and verified standalone (200 obligations;
overdue 10 · open 85; the five-status split), but no §09 dashboard binds it — §09's three
dashboard rows list no obligation tile. Stated rather than papered over; see the dataset header.

Two §09 tiles could not be delivered as written

Neither was quietly dropped and neither was faked. Both are the same root cause and both are
recorded in the source beside the thing they affect.

1. 平均周转 and contract_cycle_time's 各段时长 need a DURATION, and none is computable.

sqlite> select typeof(submitted_at), submitted_at from clm_contract limit 1;
text|2026-05-19T00:00:00.000Z
sqlite> select avg(submitted_at) from clm_contract;
2025.9166666666667          ← the average YEAR, not a date

Field.datetime persists ISO text, so AVG() over a stage stamp is SQLite's text→numeric
coercion of the leading year. The dataset layer takes no SQL and no expressions, so there is
nowhere to subtract two dates. 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. That is the form that would have shipped, which is why
the dataset carries stage-coverage counts instead (submitted 60 · review started 41 · approved 34 ·
signed 72 · activated 72) and the legal tile shows approval throughput, labelled as such.

The comparison half of §09's requirement is delivered exactly as the card asks: the platform
primitive compareTo: { kind: 'previousPeriod' }, no hardcoded delta.

A duration needs a persisted numeric stamped by a daily job — the shape §12 gap #7 already
prescribes for the other date-arithmetic gap. That is a schema change and belongs to card 09.
Raised as a decision on the issue, not resolved here.

2. 审批瓶颈(各台阶平均停留)has no dwell to read. sys_approval_request holds 0 rows on a
stock pnpm demo (the seed stamps approval_status directly; F5 never ran), so no rung has a start
or an end — and even with rows, the duration problem above applies. The four persisted route_*
booleans F2 stamps carry per-rung volume, so the tiles read those and are titled "Routes: …",
never "bottleneck".

3. 超 SLA is a fixed threshold, and the tile says so. The SLA is per type
(clm_contract_type.review_sla_days = 2/3/5/10 across the nine seeded types) and a breach is a
row-wise comparison against another object's column — no formula filter (§12 gap #10), and a
cross-object filter is refused outright on the strategy every bucketed query lands on. The tile is
titled "In Review Over 30 Days", above every seeded SLA, precisely so it cannot be read as the
per-type breach it is not.

Three defects found by measuring instead of assuming

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; all
four numbers now render.

Two measures on one axis can hide one of them. "Falling Due This Month" carried
planned_total + instalment_count; a count of 10 against an axis reaching 1.8M drew a
zero-height bar. The count moved off that tile.

An unwindowed planned-vs-settled trend reads as a collapse. The schedule runs to 2028, so
every future month showed planned value against a settled line pinned at zero. Windowed to the
trailing twelve months, where both series can exist.

chart-config-missing — suppressed on measurement, not on preference

The rule asserts "the renderer cannot determine which measure to plot, so the series renders
empty."
On a dataset-bound widget that is not what happens: objectui's
buildChartSeries(rows, dimensions, values, fields) derives the bindings from the selection and
chartConfig is merged onto that derivation as presentation only (mergeAuthoredPresentation).
Observed 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 prescribes this suppression
for exactly that case. Reported below.

Gates — exit codes captured before any pipe

$ pnpm validate  > final-validate.log  2>&1; echo $?   → 0
$ pnpm lint      > final-lint.log      2>&1; echo $?   → 0
$ pnpm typecheck > final-typecheck.log 2>&1; echo $?   → 0

  ✓ Validation passed (1627ms)
  Data: 11 Objects  169 Fields
  UI: 1 Apps  7 Views  1 Pages  3 Dashboards  7 Actions

validate reports 6 warnings — the same 6 that are on main (hierarchy-security + the five
contract_approval approver notes). This branch adds none.

The boot — compared, not assumed

Same tree, same seed, same load, wiped .objectstack/data before each, pnpm demo:

main @ fb549e3 this branch
Plugins loaded 36 36
Boot diagnostics 1 — [Seeder] Inline seed exceeded 8000ms budget 1 — same line
WARN lines per seed pass 23 23
ERROR lines per seed pass 120 120
distinct WARN/ERROR shapes Seeder budget · Seeder completion · owner_id deferred-reference identical set
rows "inserted":820 "inserted":820

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 appears on this branch that is absent from
main.
(The first baseline capture showed 184/960; that server had been left up long enough for
the seeder to re-run 8× idempotently — 23×8 and 120×8. Per-pass counts are the comparable ones.)

Browser evidence

pnpm demo, Chromium at /opt/pw-browsers/chromium-1194/chrome-linux/chrome, all three boards
opened at /_console/apps/clm/dashboard/<name>.

One addition outside the claim's declared file surface — and its REWORK

src/apps/clm.app.ts gains an Analytics navigation group with the three
type: 'dashboard' entries. Without it the dashboards are unreachable from the product's own
sidebar. The app file itself deferred this ("报表 (管理) is card 10").

⚠️ Round 1 shipped this gate wrong, and the reason I could not see it was itself the bug

The first push carried requiredPermissions: ['clm_legal.access'] on the group. Each of the five
permission sets carries exactly ONE clm_*.access and never another's, so that one line hid all
three boards from everyone but legal — the finance controller could not open Finance Overview,
the records manager saw nothing, and the CLM administrator could not open any of the three.

I had flagged the rows "not browser-verified", and the reason I gave was wrong. I wrote that
this container's console serves only the Setup/metadata surface and the runtime app shell was
unreachable. It is reachable. I verified as admin@objectos.ai, which holds no clm_*
permission set at all
, so GET /api/v1/meta/app/clm served it navigation: [] — the entire CLM
sidebar, not just my group. My dashboard verification worked only because I went straight to
/_console/apps/clm/dashboard/<name>, bypassing navigation. Signed in as any CLM account, the app
shell renders and the missing group would have been the first thing on screen.

requiredPermissions is AND, not OR — measured twice

The obvious repair, listing every audience's capability, is worse than the bug. The authoritative
server-side filter is filterAppForUserWithReason (@objectstack/rest 17.3.0):

const req = Array.isArray(e.requiredPermissions) ? e.requiredPermissions : [];
if (req.length > 0 && !req.every((p) => sysPerms.has(p))) continue;   // every(), not some()

Read from source, then proven at runtime by ablation — the group's gate set to
['clm_requester.access', 'clm_admin.access'], artifact rebuilt, served navigation re-read:

account holds requester holds admin Analytics group
legal ✔ ✘ absent
finance ✔ ✘ absent
records ✔ ✘ absent
executive ✔ ✘ absent
clm_admin ✔ ✔ present, 3 rows

Under OR all five would have seen it. Restored byte-identical (git hash-object before cdb5a80
= after cdb5a80).

The fix, and the served-navigation matrix

The gate is now ['clm_requester.access'] — the one capability every CLM audience holds, because
bind-position-sets.ts binds RequesterSet to every position (confirmed against
sys_position_permission_set on the running database: 13 rows, every clm_* position mapped to
clm_requester).

GET /api/v1/meta/app/clm, the same server-side filter the shell renders from, per PR #25's method:

account groups served Analytics
legal My Contracts[4] · Legal Desk[7] · Analytics[3] 3 rows
finance My Contracts[4] · Finance[3] · Analytics[3] 3 rows
records My Contracts[4] · Execution & Records[3] · Analytics[3] 3 rows
clm_admin My Contracts[4] · Analytics[3] · Administration[2] 3 rows
executive My Contracts[4] · Analytics[3] 3 rows
platform admin (no CLM set) (none) absent

Two controls in those same six responses, so a ✔ is not just "the filter is off": the platform
admin gets nothing at all, and Legal Desk is present for exactly one of the six. The filter is
still filtering; this group is broad because its gate is broad.

In the browser, signed in 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 (1,267,550 · 1,217,000 · 828,700 · 698,125 …). Signed in as
finance, all three boards open — no permission error — and render zeros.

Those zeros are the test harness, not the product: 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).
PR #25's position-provisioned users read 120 / 82 / 78, so a correctly provisioned finance
controller sees real numbers.

The disclosure question this gate raises, settled

clm_requester.access is held by every CLM user including a plain business requester, so the gate
is only correct if dataset queries respect row-level security for the querying user. Scoped and
unscoped look identical from outside — a board renders numbers either way — so it was measured with
a positive control rather than inferred from a zero.

The account: clm_requester granted directly, no position at all (sys_user_position rows for
it: 0), i.e. card 07's "employee with no position", which §04 lets read 0 contracts.

Step 1 — the employee owns nothing:

account clm_contract readable contract_metrics count / total_amount payment_metrics instalments / planned / overdue
plain employee 0 0 / 0 0 / 0 / 0
clm_admin 120 120 / 61,041,000 300 / 47,700,500 / 2,176,250

Step 2 — the positive control. A zero proves nothing on its own: a broken query returns zero
too. So the same employee created one contract of their own, amount: 4242, and the same two
tiles
were re-read on both accounts:

account clm_contract readable contract_metrics count / total_amount payment_metrics instalments / planned / overdue
plain employee 1 1 / 4,242 0 / 0 / 0
clm_admin 121 121 / 61,045,242 300 / 47,700,500 / 2,176,250

Scoped. The employee's tile moved 0 → 4,242 — exactly and only the contract they own, not a
share of the company's — while the administrator's moved 61,041,000 → 61,045,242, the company
total including that same contract. Same instant, same two tiles, two accounts, two different
numbers: 4,242 vs 61,045,242. The move also retires the false-negative: the employee's query is
demonstrably working, because it returned a correct non-zero the moment there was something they
were allowed to see. Their payment tiles stayed 0 correctly — the new contract carries no
instalments — and the admin's payment figures were unchanged for the same reason.

Confirmed at the surface a leak would actually 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.

So the same board is a personal view for a requester and a company view for the CFO, and
clm_requester.access is the right gate. (The probe contract was deleted afterwards.)

The nav rows are not dead rows

A one-shot ablation, restored byte-identical (before 2aa6da4 = after 2aa6da4):

dashboardName: 'legal_workbench' → 'legal_workbenchXX'
$ pnpm validate; echo $?   → 1
  ✗ App 'clm' navigation references dashboard 'legal_workbenchXX' which is not defined in dashboards.

Unchanged by the rework

The datasets, the dashboards, the tile definitions and the predicted-vs-served evidence above are
untouched — the rework diff is src/apps/clm.app.ts only. Gates re-run green (validate /
lint / typecheck all 0, still exactly main's 6 warnings), and the boot re-measured on the
final source is unchanged: 36 plugins, 1 diagnostic, 23 WARN / 120 ERROR per seed pass,
"inserted":820.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V

The ADR-0021 semantic layer and the three boards §09 names: 法务工作台,
管理层, 财务. Every widget binds a dataset and selects dimensions and
measures by name; there is no inline query and no hand-written cube.

`requires: ['analytics']` is declared for what it MEANS, not for a gate:
measured on 17.3.0, `analytics` is an OPEN-edition capability and a member
of PLATFORM_ALWAYS_ON_CAPABILITIES, so AnalyticsServicePlugin was already
in the 36-plugin boot roster without it.

Two §09 tiles could not be delivered as written, and both are named in the
code rather than approximated in silence:

  - 平均周转 and the cycle-time dataset's 各段时长 need a DURATION.
    `Field.datetime` persists ISO text on SQLite, so AVG() over a stage
    stamp answers the average YEAR (2025.9166…), and a derived difference
    of two of those returns -0.85 — a clean, plausible, meaningless number.
    The dataset carries stage-coverage counts instead, the legal tile shows
    approval throughput, and the comparison half is delivered with the
    platform primitive (compareTo), as the card requires.
  - 审批瓶颈(各台阶平均停留)has no dwell to read: sys_approval_request
    holds 0 rows on a stock demo. The four persisted route_* flags F2
    stamps carry the per-rung volume, and the tiles say "Routing Load".

Money formats are plain '0,0' with no `currency`: the fixture holds three
currencies, so the executive board splits contract value BY currency rather
than summing unlike units into one headline number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
`requiredPermissions: ['clm_legal.access']` on `group_analytics` hid all
three boards from everyone but legal — the finance controller could not
open Finance Overview, the records manager saw nothing, and the CLM
administrator could not open any of the three. Each of the five permission
sets carries exactly ONE `clm_*.access` and never another's, so a single
audience's capability is a gate for that audience alone.

The obvious repair — list every audience's capability — is worse, and this
is the measurement that says so. `requiredPermissions` is AND, not OR:
`filterAppForUserWithReason` (@objectstack/rest 17.3.0) filters nav with
`!req.every((p) => sysPerms.has(p))`. Proven at runtime, not just read:
with `['clm_requester.access', 'clm_admin.access']` on this group, the
served navigation showed it to clm_admin ALONE and withheld it from legal,
finance, records and executive — every one of which holds the first
capability. A union list ships a group almost nobody can see.

The gate is `clm_requester.access`, the one capability every CLM audience
holds: `bind-position-sets.ts` binds `RequesterSet` to every position.
Measured on `GET /api/v1/meta/app/clm` — the same server-side filter the
shell renders from — the Analytics group is now served with all three rows
to legal, finance, records, clm_admin and executive, and withheld from the
platform admin, which holds no CLM set at all. That last one is the control
that proves the gate still gates; so is Legal Desk, present for exactly one
of the six in the same responses.

The comment above the group now describes what ships, including the
measured limit: per-audience narrowing of the NAV needs an OR the platform
does not have, so the rows are narrowed by RLS instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
`clm_requester.access` reaches every CLM user, a plain business requester
included, so the gate is only correct if a dataset query is row-level
scoped to the person asking. Scoped and unscoped render identically, so
the comment now carries the measurement instead of the claim — and the
measurement carries a POSITIVE CONTROL, because a zero on its own proves
nothing: a broken query returns zero too.

An account holding clm_requester and no position (§04: 0 contracts
readable), against clm_admin, on the same two measures, before and after
that employee created one contract of their own worth 4,242:

  before  employee    0 contracts /          0      clm_admin 120 / 61,041,000
  after   employee    1 contract  /      4,242      clm_admin 121 / 61,045,242

Scoped. The employee's total moved to exactly their own contract; the
administrator's moved to the company total including it. The move is also
what rules out a false negative — that account's query answered a correct
non-zero the moment there was something it was allowed to see.

No behaviour change; the gate and the three rows are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants