Skip to content

Commit e7b56da

Browse files
The legal workbench shows how long each contract stage takes (#168)
Part of #31 — ruling 6396322580 (C): contract_cycle_time carries five average-days duration measures (18.2.0, objectstack-ai/objectstack#25778); the legal workbench gains four stage tiles and an average approval turnaround in place of the throughput count. Per-type over-SLA stays blocked (objectstack-ai/objectstack#26601); the fixed 30-day tile stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn
1 parent 2cbcb8b commit e7b56da

5 files changed

Lines changed: 240 additions & 149 deletions

File tree

‎src/dashboards/executive.dashboard.ts‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,23 +160,22 @@ export const ExecutiveDashboard: Dashboard = {
160160
//
161161
// The average DWELL per rung that §09 listed BEFORE 2026-09-09 is not
162162
// deliverable, and §09 no longer asks for it: the maintainer ruling in
163-
// PR #40 (`1127e52`) placed it outside the V1.0 surface, and §09 now gives
164-
// these two measurements — both taken on this board — as the reasons it
165-
// declines:
163+
// PR #40 (`1127e52`) placed it outside the V1.0 surface, and §09 gave two
164+
// reasons, both measured on this board:
166165
//
167166
// 1. There is no dwell to read. `sys_approval_request` holds 0 rows on a
168167
// stock `pnpm demo` — the seed stamps `approval_status` directly and
169168
// the ladder (F5) never ran — so no rung has a start or an end.
170-
// 2. Even with rows, a duration is not expressible in the semantic layer:
171-
// see the measurement in `cycle-time.dataset.ts`.
169+
// 2. A duration was not expressible in the semantic layer. That one no
170+
// longer holds: platform 18.2.0 added the `duration` measure
171+
// (objectstack#25778), which `contract_cycle_time` now uses for the
172+
// stage durations on the legal workbench.
172173
//
173-
// ⚠️ Neither reads as a TODO. Fact 1 is independent of the platform and
174-
// survives any upgrade; and the in-flight platform work
175-
// (`objectstack-ai/objectstack#16737`) makes the wrong path ERROR rather
176-
// than adding date arithmetic, so the dwell does not arrive when it closes
177-
// either. ⛔ Do not re-promise the metric on the strength of that issue, and
178-
// ⛔ do not stamp it from an application-side daily job — §09 forbids that
179-
// route in as many words, as replicating a platform rule in the app.
174+
// ⚠️ Fact 1 is independent of the platform and survives any upgrade, so the
175+
// dwell still has nothing to read. ⛔ Do not re-promise the metric until
176+
// approval requests exist, and ⛔ do not stamp it from an
177+
// application-side daily job — §09 forbids that route in as many words, as
178+
// replicating a platform rule in the app.
180179
//
181180
// What IS persisted is the routing: F2 stamps four booleans on the contract,
182181
// one per rung. These four tiles read them — how much traffic each rung

‎src/dashboards/legal.dashboard.ts‎

Lines changed: 111 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type { Dashboard } from '@objectstack/spec/ui';
2626
*
2727
* `DashboardRenderer` merges the date-range filter into EVERY widget's query,
2828
* and `DatasetWidget` lowers each bounded `{ $gte, $lte }` window it finds into
29-
* a `timeDimensions` entry. `approval_throughput` below dates its own window on
29+
* a `timeDimensions` entry. `approval_turnaround` below dates its own window on
3030
* `approved_at` so that `compareTo` has something to shift; a second dashboard
3131
* window on another field would make two dated dimensions, and the executor
3232
* refuses that with `compareTo.dimension is ambiguous` — a widget that renders
@@ -73,15 +73,17 @@ import type { Dashboard } from '@objectstack/spec/ui';
7373
export const LegalDashboard: Dashboard = {
7474
name: 'legal_workbench',
7575
label: 'Legal Workbench',
76-
description: 'Intake queue, review load, negotiation stalls and pipeline shape for the legal team.',
76+
description: 'Intake queue, review load, negotiation stalls, pipeline shape and stage durations for the legal team.',
7777

7878
columns: 12,
7979
gap: 4,
8080

8181
// Both are persisted columns on `clm_contract` (`category` a select,
8282
// `department` a lookup), and every widget on this dashboard is bound to
83-
// `contract_metrics` (base object `clm_contract`), so neither filter can
84-
// reach a widget whose object lacks the column.
83+
// `contract_metrics` or `contract_cycle_time` — both on base object
84+
// `clm_contract` — so neither filter can reach a widget whose object lacks
85+
// the column. Both apply a literal value, which a duration measure accepts
86+
// (it refuses only a field-to-field comparison).
8587
globalFilters: [
8688
{
8789
name: 'category',
@@ -157,13 +159,20 @@ export const LegalDashboard: Dashboard = {
157159
* merged, PR #70 `30fd863`) and asks in its place for exactly this: a
158160
* fixed threshold, self-declared in the brick's title.
159161
*
160-
* The measurement behind that ruling was taken here, which is why it is
161-
* recorded here. The SLA is PER TYPE (`clm_contract_type.review_sla_days`:
162-
* 2, 3, 5 or 10 days across the nine seeded types), and a breach is a
163-
* row-wise comparison of `review_started_at` against another object's
164-
* column. Analytics cannot express that: there is no formula filter
165-
* (§12 gap #10), and a cross-object filter is refused outright on the
166-
* strategy every bucketed query lands on.
162+
* The SLA is PER TYPE (`clm_contract_type.review_sla_days`: 2, 3, 5 or
163+
* 10 days across the nine seeded types), and a breach is a row-wise
164+
* comparison of `review_started_at` against "now minus that type's SLA".
165+
* 18.2.0 added the now-anchored comparand
166+
* (`{ $now: true, addDays: { $field } }`, objectstack#25808), but its
167+
* offset reads a numeric column of the SAME record only. Measured on
168+
* 18.2.0 as the head of legal, `addDays: { $field:
169+
* 'contract_type.review_sla_days' }` answers `400 INVALID_FILTER` — as a
170+
* runtime filter "the addDays offset … is a dotted path, and SQL
171+
* push-down compiles same-table column references only", and as a
172+
* measure filter the same code with the reason withheld — while
173+
* `os validate` passes it. The same anchor with a literal `-30` answers
174+
* 200. So the per-type breach is still not expressible, and this tile
175+
* stays.
167176
*
168177
* ⛔ The remaining route — a persisted `review_due_at` stamped by a daily
169178
* job, the §12 gap-#7 shape — is closed for this metric, and not merely
@@ -200,47 +209,54 @@ export const LegalDashboard: Dashboard = {
200209
layout: { x: 9, y: 0, w: 3, h: 2 },
201210
},
202211

203-
// ─── Row 2: throughput, period over period ───────────────────────────
212+
// ─── Row 2: turnaround, period over period ───────────────────────────
204213
{
205214
/**
206-
* §09's 审批吞吐 brick: how many contracts legal got to `approved` in the
207-
* window, against the previous period — and the period comparison uses
208-
* the platform primitive §09 names (`compareTo`, the one shape
209-
* `DatasetSelection.compareTo` implements) rather than a hardcoded delta.
210-
* This tile IS that brick; it is not a substitute for a missing one.
215+
* §09's 平均周转 brick: the average number of days from submission to
216+
* approval for the contracts approved in the window, against the
217+
* previous period. The comparison is the platform's `compareTo`, not a
218+
* hardcoded delta.
211219
*
212-
* §09 did ask for 平均周转 — an AVERAGE TURNAROUND — until 2026-09-09,
213-
* when the maintainer ruling in PR #40 (`1127e52`) placed 各段时长 outside
214-
* the V1.0 delivery surface and rewrote this brick to throughput (§09 as
215-
* merged: PR #70, `30fd863`). The measurement behind that ruling was
216-
* taken here: a duration cannot be computed in the semantic layer on this
217-
* platform version — `Field.datetime` persists ISO text, so `AVG()` over
218-
* a stage stamp answers the average YEAR (2025.9166…), and the dataset
219-
* layer has no expression in which to subtract two dates. The full
220-
* measurement is in `cycle-time.dataset.ts`. A number that is honest and
221-
* says so beats a plausible one that is not, which is the reasoning §09
222-
* itself now carries.
220+
* The measure is `avg_days_to_approval` on `contract_cycle_time`, a
221+
* `duration` measure (platform 18.2.0, objectstack#25778): the average of
222+
* `approved_at - submitted_at` per contract, in days. It replaces the
223+
* 审批吞吐 count this slot carried while a duration could not be written
224+
* (decision #31, 1C: the stand-in held until the platform shipped the
225+
* measure). Submission → approval is the part of the cycle legal owns;
226+
* signing and activation are the next four tiles' business.
223227
*
224-
* ⚠️ This tile does not become an average turnaround when
225-
* `objectstack-ai/objectstack#16737` closes. That in-flight fix makes the
226-
* wrong path ERROR instead of returning a plausible fake number; it does
227-
* not add date arithmetic, so the duration stays uncomputable after it
228-
* lands. ⛔ Do not read the tracker as a queue this metric is waiting in.
228+
* Contracts approved in the window with no `submitted_at` (the demo's
229+
* backfilled in-force book) do not enter the average — a duration needs
230+
* both stamps. The tile is an average of the contracts that have them.
229231
*
230232
* The window is stated HERE, on the widget, and it is bounded on both
231233
* ends on purpose: `DatasetWidget` lowers only a `{ $gte, $lte }` pair
232234
* into the `timeDimensions` entry the executor shifts. An open-ended
233-
* `$gte` alone comes back "compareTo needs a dated window to shift".
235+
* `$gte` alone comes back "compareTo needs a dated window to shift". A
236+
* bounded window is not a `granularity` bucket, so the duration measure
237+
* is served by the native-SQL strategy (a bucketed one would be refused).
238+
*
239+
* `previousPeriod` is the EQUAL-LENGTH window immediately before, as the
240+
* platform documents it — not the previous calendar month. Measured on
241+
* 2026-10-27: the October window [10-01, 10-31] was compared with
242+
* [08-31, 09-30], so August 31st's approval was in the comparison. The
243+
* description says "the equal-length period", not "last month", for
244+
* that reason.
234245
*/
235-
id: 'approval_throughput',
236-
title: 'Approved This Month',
237-
description: 'Contracts approved this month, compared with last month. A count, not an average turnaround time.',
246+
id: 'approval_turnaround',
247+
title: 'Avg Days to Approval (This Month)',
248+
description: 'Average days from submission to approval, for contracts approved this month, compared with the equal-length period just before it.',
238249
type: 'metric',
239-
dataset: 'contract_metrics',
240-
values: ['contract_count'],
250+
dataset: 'contract_cycle_time',
251+
values: ['avg_days_to_approval'],
241252
filter: { approved_at: { $gte: '{current_month_start}', $lte: '{current_month_end}' } },
242253
compareTo: { kind: 'previousPeriod' },
243-
colorVariant: 'success',
254+
// No `colorVariant`: for a duration, a bigger number is the WORSE one,
255+
// so the success green the throughput count carried would praise a
256+
// slowdown. ⚠️ The delta beside it is still drawn green when it rises:
257+
// the renderer colours "up" emerald for every measure and the platform
258+
// has no key to say lower-is-better (measured: "↑ 23%" in green for a
259+
// turnaround that got 23% longer).
244260
layout: { x: 0, y: 2, w: 4, h: 4 },
245261
},
246262
{
@@ -262,9 +278,9 @@ export const LegalDashboard: Dashboard = {
262278
*
263279
* The criterion that does discriminate, and the one this filter now
264280
* states: WHETHER LEGAL HAS WORK TO DO ON THE STAGE. This is a work-queue
265-
* board — the other five widgets are `awaiting_intake`, `in_review`,
266-
* `review_ageing`, `negotiation_stalled` and `approval_throughput`, each
267-
* one a queue or a throughput. `draft` … `signing` are contracts legal is
281+
* board — its first five widgets are `awaiting_intake`, `in_review`,
282+
* `review_ageing`, `negotiation_stalled` and `approval_turnaround`, each
283+
* one a queue or a turnaround. `draft` … `signing` are contracts legal is
268284
* moving; `active`, `expired`, `terminated`, `cancelled` and `rejected`
269285
* are contracts legal has finished moving. Legal's work on an in-force
270286
* book is renewal and expiry, and that is a DATED slice of it (the 到期日历
@@ -332,7 +348,8 @@ export const LegalDashboard: Dashboard = {
332348
* sits, and `active` holds sixty contracts because sixty contracts are
333349
* in force. A real conversion funnel is a DIFFERENT widget over a
334350
* DIFFERENT measure (contracts entering vs leaving each stage over a
335-
* window) and needs the stage-duration data #31 is blocked on.
351+
* window); the stage DURATIONS are the row of tiles below, not a
352+
* funnel.
336353
*
337354
* A bar over the stage dimension says exactly what the data says and
338355
* cannot bowtie at ANY distribution — which is the requirement, not a
@@ -395,5 +412,55 @@ export const LegalDashboard: Dashboard = {
395412
suppressWarnings: ['chart-config-missing'],
396413
layout: { x: 4, y: 2, w: 8, h: 4 },
397414
},
415+
416+
// ─── Row 3: 各段时长 — average days per stage ────────────────────────
417+
//
418+
// DESIGN.md §09's chain submitted → review started → approved → signed →
419+
// active, one tile per stage, each a `duration` measure on
420+
// `contract_cycle_time` (average days between the two stamps, over every
421+
// contract that has both). FOUR TILES, not one four-measure chart, for the
422+
// reason measured on the executive board's routing row: a zero-dimension
423+
// dataset widget renders as a KPI card showing its FIRST measure only, so
424+
// one chart over four measures would silently show one stage.
425+
//
426+
// Not windowed: these read the whole book the viewer can see, and the
427+
// dashboard's two filters narrow them like every other tile. Each title
428+
// says "days" because the result field carries no unit.
429+
{
430+
id: 'days_to_review',
431+
title: 'Submitted → Review Started (Avg Days)',
432+
description: 'Average days from submission to a lawyer accepting the contract for review.',
433+
type: 'metric',
434+
dataset: 'contract_cycle_time',
435+
values: ['avg_days_to_review'],
436+
layout: { x: 0, y: 6, w: 3, h: 2 },
437+
},
438+
{
439+
id: 'days_in_review',
440+
title: 'Review Started → Approved (Avg Days)',
441+
description: 'Average days from the start of legal review to approval.',
442+
type: 'metric',
443+
dataset: 'contract_cycle_time',
444+
values: ['avg_days_in_review'],
445+
layout: { x: 3, y: 6, w: 3, h: 2 },
446+
},
447+
{
448+
id: 'days_to_signature',
449+
title: 'Approved → Signed (Avg Days)',
450+
description: 'Average days from approval to the contract being signed.',
451+
type: 'metric',
452+
dataset: 'contract_cycle_time',
453+
values: ['avg_days_to_signature'],
454+
layout: { x: 6, y: 6, w: 3, h: 2 },
455+
},
456+
{
457+
id: 'days_to_activation',
458+
title: 'Signed → Active (Avg Days)',
459+
description: 'Average days from signature to the contract coming into force.',
460+
type: 'metric',
461+
dataset: 'contract_cycle_time',
462+
values: ['avg_days_to_activation'],
463+
layout: { x: 9, y: 6, w: 3, h: 2 },
464+
},
398465
],
399466
};

0 commit comments

Comments
 (0)