Repository navigation
Card 10 — four datasets and three dashboards (legal, executive, finance) - #30
Merged
Merged
Conversation
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
This was referenced Sep 7, 2026
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
analyticsenterprise-gated likehierarchy-security? No, on both counts.It is open edition and always-on, so the token has no gate behind it:
AnalyticsServicePluginwas already in the 36-plugin boot roster on
mainwithrequiresunchanged, andPOST /api/v1/analytics/dataset/queryalready answered. It is declared anyway, for the samereason
messagingis — 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-lawsplit in the issue body is wrong — and it does not even sum to 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.tsmaps each jurisdiction to one currency), which is the checkthat catches it: 42/41/37 both ways.
Predicted vs served — every tile, prediction first
Each number was hand-derived from raw
/api/v1/datarows before any dataset was queried,then verified twice: standalone through
POST /analytics/dataset/query, then read out of thelive DOM in Chromium.
法务工作台 —
legal_workbenchstatus = submitted)status = in_review)current_turn = counterparty)compareToThe comparison window is derived, not asserted: the widget dates itself
{ approved_at: { $gte: '{current_month_start}', $lte: '{current_month_end}' } }= Sep 1–30, sopreviousPeriodshifts 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财务 —
finance_overviewobligation_metricsis 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.Field.datetimepersists ISO text, soAVG()over a stage stamp is SQLite's text→numericcoercion 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 thatis 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_requestholds 0 rows on astock
pnpm demo(the seed stampsapproval_statusdirectly; F5 never ran), so no rung has a startor 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 arow-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:423readsMETRIC_TYPES.has(widgetType) || dimensions.length === 0. Authored as onebar, then as onetable, 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 azero-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 preferenceThe 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 andchartConfigis merged onto that derivation as presentation only (mergeAuthoredPresentation).Observed in Chromium on this branch with no
chartConfiganywhere: 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
validatereports 6 warnings — the same 6 that are onmain(hierarchy-security + the fivecontract_approvalapprover notes). This branch adds none.The boot — compared, not assumed
Same tree, same seed, same load, wiped
.objectstack/databefore each,pnpm demo:main@fb549e3[Seeder] Inline seed exceeded 8000ms budgetowner_iddeferred-reference"inserted":820"inserted":820All 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 frommain. (The first baseline capture showed 184/960; that server had been left up long enough forthe 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 boardsopened at
/_console/apps/clm/dashboard/<name>.shell additionally requests
sys_activityand gets 403 — that is the console's own "recentactivity" panel on every page of the shell, not a widget on any of these three boards.)
tooltips and table values from the DOM, not inferred from pixels.
clm_contract_versionhas 0 rows (Decision: DESIGN.md §10 asks for 300 seeded contract versions, but §03 makesfilerequired and a seed cannot mint one #19) — §09 asks for no version tile, so no widget on anyof the three boards reads it. Trap 1 does not fire; stated so it is on the record.
owner_idis NULL on all 120 contracts ([Decision]pnpm demo开箱即用时没有任何账号能打开「我的合同」——要不要让脚本建账号,与 #11 第一问耦合 #28), confirmed — no widget groups or filters byowner on any of the three boards, for that reason.
One addition outside the claim's declared file surface — and its REWORK
src/apps/clm.app.tsgains an Analytics navigation group with the threetype: 'dashboard'entries. Without it the dashboards are unreachable from the product's ownsidebar. The app file itself deferred this ("报表 (管理) is card 10").
The first push carried
requiredPermissions: ['clm_legal.access']on the group. Each of the fivepermission sets carries exactly ONE
clm_*.accessand never another's, so that one line hid allthree 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 noclm_*permission set at all, so
GET /api/v1/meta/app/clmserved itnavigation: []— the entire CLMsidebar, 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 appshell renders and the missing group would have been the first thing on screen.
requiredPermissionsis AND, not OR — measured twiceThe obvious repair, listing every audience's capability, is worse than the bug. The authoritative
server-side filter is
filterAppForUserWithReason(@objectstack/rest17.3.0):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:Under OR all five would have seen it. Restored byte-identical (
git hash-objectbeforecdb5a80= after
cdb5a80).The fix, and the served-navigation matrix
The gate is now
['clm_requester.access']— the one capability every CLM audience holds, becausebind-position-sets.tsbindsRequesterSetto every position (confirmed againstsys_position_permission_seton the running database: 13 rows, everyclm_*position mapped toclm_requester).GET /api/v1/meta/app/clm, the same server-side filter the shell renders from, per PR #25's method: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 boardsopen 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_idbeing 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.accessis held by every CLM user including a plain business requester, so the gateis 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_requestergranted directly, no position at all (sys_user_positionrows forit: 0), i.e. card 07's "employee with no position", which §04 lets read 0 contracts.
Step 1 — the employee owns nothing:
clm_contractreadablecontract_metricscount / total_amountpayment_metricsinstalments / planned / overdueStep 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 twotiles were re-read on both accounts:
clm_contractreadablecontract_metricscount / total_amountpayment_metricsinstalments / planned / overdueScoped. The employee's tile moved
0 → 4,242— exactly and only the contract they own, not ashare of the company's — while the administrator's moved
61,041,000 → 61,045,242, the companytotal 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_adminon the identical boards renders 3 currency bars, 2,176,250 overdue, andthe 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.accessis 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= after2aa6da4):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.tsonly. Gates re-run green (validate/lint/typecheckall 0, still exactlymain's 6 warnings), and the boot re-measured on thefinal 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