Repository navigation
Analytics datasets — duty health, on-time rate, stagnation #9
Description
Activity
Metadata-first — maintainer instruction, applies to this card before it is dispatched. PM seat, round 2.
"这是一个元数据应用,应该利用平台的元数据能力开发,如果平台有问题可以报 issue."
This card is one of the two where the instruction bites hardest, because analytics is the surface most likely to be answered with hand-written aggregation code.
Datasets are the platform's semantic layer (ADR-0021). Express the measures declaratively. On-time rate, stagnation buckets and workload are dimension/measure definitions, not a TypeScript reduce over query results. If you find yourself computing a rate in application code, stop and work out why the dataset cannot say it.
Two places where the answer may genuinely be "it cannot", and both are worth knowing rather than papering over:
- On time honours
grace_days, which lives onduly_duty, not on the task. The comparison iscompleted_at <= due_date + duty.grace_days. If a dataset measure cannot reach a related object's field to do date arithmetic, that is a real limitation of the semantic layer and it should be filed upstream, not worked around with a denormalised copy onduly_task. (A denormalisedgrace_dayswould also be a second writer that drifts, whichAGENTS.mdrule 5 already forbids.) - Stagnation buckets are relative-to-now (
>7d,>14d,>30dsincelast_update_at). Check whether date macros are available inside a dataset measure the way they are in a view filter.
If the platform cannot express something, file an issue against
objectstack-ai/objectstackand name it in your report — do not quietly write around the gap. Three such issues came out of round 1 (#14087, #14088, #14089) and all three were worth having.The constraints already on this card are unchanged and are not negotiable: every governed measure filtered to
source IN ('catalog','assigned'); no measure that ranks or compares item counts across people; no dataset touchesduly_log_entry.
Generated by Claude Code
- On time honours
Claim: PM loop round 3
Session:session_01SqkTcrxUFci7nqXdbBSe2p
Branch:claude/issue-9-analytics-datasets
Worktree:duly-issue-9
File surface:src/datasets/,test/datasets.test.ts
Container & model: L,mode:subagent,model: opus
Clause-②: no
Serial constraints cleared: round 3 is #42 / #8 / #9 / #12, file surfaces disjoint. #10 (the dashboard that binds these datasets) is held atpm:blockedbehind this card.
Generated by Claude Code
- added a commit that references this issue
on Sep 1, 2026 { "issue": 9, "status": "done", "branch": "claude/issue-9-analytics-datasets", "pr": "https://github.com/objectstack-ai/duly/pull/45", "premise_still_valid": true, "summary": "Three datasets shipped declaratively into dulyDatasets — duly_duty_health (5 dims / 3 measures), duly_stagnation (2 dims / 5 measures), duly_workload (4 dims / 1 measure) — plus test/datasets.test.ts (24 tests). No TypeScript aggregation anywhere: every measure is an aggregate+filter or a dimension definition. THE ON-TIME MEASURES ARE ABSENT AND FILED UPSTREAM, which is the headline for triage: 'done on time', 'late' and 'on_time_rate' all reduce to `completed_at <= due_date + duty.grace_days`, which the semantic layer cannot say. Reaching the related field DOES work (`include: ['duty']` + `duty.frequency` ships and is the proof); what fails is (a) column-to-column comparison, declared as `$field` but refused by driver-sql with INVALID_FILTER/400 while the in-memory evaluator resolves it — a per-deployment answer, which is worse than a uniform gap for a dataset — and (b) date arithmetic, which does not exist in the filter grammar at all, and here the offset is itself a column. Filed as objectstack#14104. All three workarounds were rejected on the card's terms (denormalised grace = drifting second writer; TS reduce = number outside the layer no widget can bind; grace-free rate = marks late every task completed inside its own grace, invisibly). Note the gap's current cost: grace_days is authored on duly_catalog_item, propagated to duly_duty, and read by NOTHING — this dataset was its only intended consumer. Stagnation's relative-to-now buckets, by contrast, ARE expressible: date macros are wired into the analytics dataset executor and an unknown token is refused at author time (measured). One judgement call for review: I put the governed filter on EVERY measure with no exception list and no `ungoverned()` counterpart, rather than adding a deliberately-ungoverned volume measure — an exception list is the erosion path, and any 'ungoverned' tag would be a distinction I invented. `source` stays a dimension so the catalog-vs-assigned split is visible; self-declared work is surfaced in the task views, never in the metric layer. If the maintainer wants an explicitly ungoverned surfacing measure, that is a product decision.", "tests": "All four gates run on the FINAL commit 634a5be, tree clean vs HEAD, exit codes captured before any pipe: `pnpm validate` EXIT=0 ('✓ Validation passed (304ms)'), `pnpm typecheck` EXIT=0, `pnpm test` EXIT=0 ('Test Files 9 passed (9) / Tests 302 passed (302)', 24 new in test/datasets.test.ts), `pnpm build` EXIT=0 ('✓ Build complete'). Artifact verified to actually contain the work — dist/objectstack.json parsed: duly_duty_health dims=5 measures=3, duly_stagnation dims=2 measures=5, duly_workload dims=4 measures=1. ABLATIONS — validator reach (each mutation confirmed on disk by grepping the injected AND removed text before running, each reverted by a trap; two early probes hit ANCHOR MISS and their readings were discarded and re-run correctly rather than reported): duplicate measure name => EXIT=1 'datasets.2.measures: duplicate measure name \"tasks_due\"'; bad macro token '{7_fortnights_ago}' => EXIT=1 'rule: filter-token-unknown at datasets[1].measures[1].filter.last_update_at.$lt'. Those two are the controls proving datasets are genuinely in the validate path. Against that, SIX dangling-binding mutations ALL passed EXIT=0: dimension base field period_key->period_kee; joined field duty.frequency->duty.frequenci; measure field last_update_at->last_update_att; filter KEY last_update_at->last_update_attt; include ['duty']->['dutee']; and base object duly_task->duly_tsk. Filed as objectstack#14105. ABLATIONS — guard liveness (4 mutations, each disk-confirmed and trap-reverted): drop governed() from one measure => 2 tests fail; add 'self' to GOVERNED_SOURCES => 1 fails; add due_date to a stagnation bucket => 1 fails; add a tasks_logged/'Most active' measure => 2 fail. IMPORTANT — the due_date probe came back GREEN on the first run: a phantom check. My walker used Object.values, but in a filter condition the column is the KEY, so the single most important assertion in the file ('stagnation never looks at due_date') was asserting nothing. Fixed in a separate commit (74d1ba6) so the defect and fix stay legible; the readings above are the re-run after the fix, where all four turn red. Control-byte scan clean (grep -naP over all changed files, exit 1).", "open_questions": [], "out_of_scope_findings": [ "filed as objectstack-ai/objectstack#14104: a dataset measure cannot express a deadline that is another column plus an offset held in a third column — no date arithmetic in the filter grammar, and $field column-to-column is refused by driver-sql (#5222). Blocks the on-time rate.", "filed as objectstack-ai/objectstack#14105: objectstack validate AND build both exit 0 on a dataset whose base object, include path and every dimension/measure field path name nothing — while a bad date-macro token on the SAME node is caught path-precisely. One level below #7529/#8902, which guards the widget->dataset binding above it.", "filed as #47 (finding): duly_task.source carries no index although it is now the predicate of every governed analytics measure; duly_duty.source IS indexed. Recommended to wait for #10's real group-by patterns and add a composite rather than a standalone index now.", "filed as #48: the 'Late' task list view drops grace_days, so a task shows as late the morning after its due date even when its duty grants a week of grace. Same root gap as #14104 — a view filter has no column+offset comparison either — but the dataset omits the measure and says why, while the view ships the approximation silently. Left as a product decision (should 'Late' mean past-due or past-grace on the operational screen)." ] }
Generated by Claude Code
os-dev-report
{ "issue": 9, "status": "done", "branch": "claude/issue-9-analytics-datasets", "pr": "https://github.com/objectstack-ai/duly/pull/45", "premise_still_valid": true, "summary": "Three datasets shipped declaratively into dulyDatasets — duly_duty_health (5 dims / 3 measures), duly_stagnation (2 dims / 5 measures), duly_workload (4 dims / 1 measure) — plus test/datasets.test.ts (24 tests). No TypeScript aggregation anywhere: every measure is an aggregate+filter or a dimension definition. THE ON-TIME MEASURES ARE ABSENT AND FILED UPSTREAM, which is the headline for triage: 'done on time', 'late' and 'on_time_rate' all reduce to `completed_at <= due_date + duty.grace_days`, which the semantic layer cannot say. Reaching the related field DOES work (`include: ['duty']` + `duty.frequency` ships and is the proof); what fails is (a) column-to-column comparison, declared as `$field` but refused by driver-sql with INVALID_FILTER/400 while the in-memory evaluator resolves it — a per-deployment answer, which is worse than a uniform gap for a dataset — and (b) date arithmetic, which does not exist in the filter grammar at all, and here the offset is itself a column. Filed as objectstack#14104. All three workarounds were rejected on the card's terms (denormalised grace = drifting second writer; TS reduce = number outside the layer no widget can bind; grace-free rate = marks late every task completed inside its own grace, invisibly). Note the gap's current cost: grace_days is authored on duly_catalog_item, propagated to duly_duty, and read by NOTHING — this dataset was its only intended consumer. Stagnation's relative-to-now buckets, by contrast, ARE expressible: date macros are wired into the analytics dataset executor and an unknown token is refused at author time (measured). One judgement call for review: I put the governed filter on EVERY measure with no exception list and no `ungoverned()` counterpart, rather than adding a deliberately-ungoverned volume measure — an exception list is the erosion path, and any 'ungoverned' tag would be a distinction I invented. `source` stays a dimension so the catalog-vs-assigned split is visible; self-declared work is surfaced in the task views, never in the metric layer. If the maintainer wants an explicitly ungoverned surfacing measure, that is a product decision.", "tests": "All four gates run on the FINAL commit 634a5be, tree clean vs HEAD, exit codes captured before any pipe: `pnpm validate` EXIT=0 ('✓ Validation passed (304ms)'), `pnpm typecheck` EXIT=0, `pnpm test` EXIT=0 ('Test Files 9 passed (9) / Tests 302 passed (302)', 24 new in test/datasets.test.ts), `pnpm build` EXIT=0 ('✓ Build complete'). Artifact verified to actually contain the work — dist/objectstack.json parsed: duly_duty_health dims=5 measures=3, duly_stagnation dims=2 measures=5, duly_workload dims=4 measures=1. ABLATIONS — validator reach (each mutation confirmed on disk by grepping the injected AND removed text before running, each reverted by a trap; two early probes hit ANCHOR MISS and their readings were discarded and re-run correctly rather than reported): duplicate measure name => EXIT=1 'datasets.2.measures: duplicate measure name tasks_due'; bad macro token '{7_fortnights_ago}' => EXIT=1 'rule: filter-token-unknown at datasets[1].measures[1].filter.last_update_at.$lt'. Those two are the controls proving datasets are genuinely in the validate path. Against that, SIX dangling-binding mutations ALL passed EXIT=0: dimension base field period_key->period_kee; joined field duty.frequency->duty.frequenci; measure field last_update_at->last_update_att; filter KEY last_update_at->last_update_attt; include ['duty']->['dutee']; and base object duly_task->duly_tsk. Filed as objectstack#14105. ABLATIONS — guard liveness (4 mutations, each disk-confirmed and trap-reverted): drop governed() from one measure => 2 tests fail; add 'self' to GOVERNED_SOURCES => 1 fails; add due_date to a stagnation bucket => 1 fails; add a tasks_logged/'Most active' measure => 2 fail. IMPORTANT — the due_date probe came back GREEN on the first run: a phantom check. My walker used Object.values, but in a filter condition the column is the KEY, so the single most important assertion in the file ('stagnation never looks at due_date') was asserting nothing. Fixed in a separate commit (74d1ba6) so the defect and fix stay legible; the readings above are the re-run after the fix, where all four turn red. Control-byte scan clean (grep -naP over all changed files, exit 1).", "open_questions": [], "out_of_scope_findings": [ "filed as objectstack-ai/objectstack#14104: a dataset measure cannot express a deadline that is another column plus an offset held in a third column — no date arithmetic in the filter grammar, and $field column-to-column is refused by driver-sql (#5222). Blocks the on-time rate.", "filed as objectstack-ai/objectstack#14105: objectstack validate AND build both exit 0 on a dataset whose base object, include path and every dimension/measure field path name nothing — while a bad date-macro token on the SAME node is caught path-precisely. One level below #7529/#8902, which guards the widget-to-dataset binding above it.", "filed as #47 (finding): duly_task.source carries no index although it is now the predicate of every governed analytics measure; duly_duty.source IS indexed. Recommended to wait for #10's real group-by patterns and add a composite rather than a standalone index now.", "filed as #48: the 'Late' task list view drops grace_days, so a task shows as late the morning after its due date even when its duty grants a week of grace. Same root gap as #14104 — a view filter has no column+offset comparison either — but the dataset omits the measure and says why, while the view ships the approximation silently. Left as a product decision (should 'Late' mean past-due or past-grace on the operational screen)." ] }
Housekeeping for the PM scan: this supersedes the identical report posted a moment ago in
#issuecomment-5489363388,
whose leadingos-dev-reportHTML-comment marker was eaten by the GitHub body
sanitizer — the stored body there begins with the code fence, so a marker scan cannot
see it. The JSON is byte-identical apart from two nested-quote escapes dropped for
readability. Using the literal-text marker form here, per the documented fallback. Worth
knowing generally: the sanitizer strips short<...>fragments from comment bodies too,
not just PR bodies.
Generated by Claude Code
ACCEPT — PM review of #45, round 3. This card found the most consequential product fact in the project, and the right answer to it was to not ship a number.
Gates re-run with
origin/mainmerged: all fourEXIT=0, 303 tests. CIverifysuccesson634a5be. No file-surface breach —governed.tsis a helper inside the owned directory, which is fine.Not shipping the on-time rate was correct
Every workaround you rejected would have been worse than the gap:
- a denormalised
grace_daysonduly_taskis a second writer that drifts — rule 5, and the exact defect the object header already warns about; - a TypeScript reduce produces a number living outside the semantic layer that no widget can bind, which is the layer's whole purpose;
- a grace-free rate marks every task completed inside its own grace window as late, invisibly — the worst of the three, because it produces a plausible number that is wrong in a direction the customer has explicitly configured against.
Shipping nothing with the reason attached beats shipping a number nobody can defend. objectstack#14104 is the right escalation, and the detail that
$fieldis refused by driver-sql while the in-memory evaluator resolves it makes it worse than a uniform gap — a measure that works in dev and 400s in production is a per-deployment answer.objectstack#14105 — I verified it, and it is severe
Renaming
last_update_attolast_update_atttin 7 places acrossstagnation.dataset.ts— measure field, filter keys, dimension:pnpm validate EXIT=0 pnpm build EXIT=0The dataset layer has no field-path integrity checking at all, while a bad date-macro token on the same node is caught path-precisely. That asymmetry makes it look guarded when it is not.
Because nothing in the toolchain could tell me these three datasets bind to anything, I checked them myself — walked every dimension field, measure field and filter key in the built artifact against the declared fields of each base object. All resolve. That is why this is merging rather than being held.
The stopgap belongs in this repo the same way #29's does, and I have filed it as its own card rather than reworking yours — #10 binds these datasets and must not go out before it.
The phantom check
Finding that your own
due_dateprobe came back green because the walker usedObject.valueswhile in a filter condition the column is the key — so the single most important assertion in the file was asserting nothing — and then fixing it in a separate commit so the defect and the fix stay legible, is the best piece of work in this PR. A test that passes for the wrong reason is worse than no test, and it is invisible precisely because it is green.Judgement call: no
ungoverned(), confirmedAgreed, and your reasoning is the correct one: an exception list is the erosion path. If a deliberately-ungoverned surfacing measure is ever wanted, that is a product decision with a card, not a helper that already exists waiting to be used. Ablation confirms the gate is defended — adding
selftoGOVERNED_SOURCESreds× the governed set is exactly catalog + assigned — self is never in it.#47 (no index on
duly_task.source, now the predicate of every governed measure) — queued, and I agree with waiting for #10's real group-by patterns before choosing a composite. #48 (the Late view dropsgrace_dayssilently) goes to the maintainer with the grace question; the inconsistency you spotted is the real defect there — the dataset omits the measure and says why, while the view ships the approximation quietly.Merging.
Generated by Claude Code
- a denormalised
The semantic layer (ADR-0021) everything on the manager side reads. Dashboards bind to datasets, never to objects directly — a widget with a dangling binding renders an empty chart and reports success.
Files you own
src/datasets/*.dataset.ts(new), pushed intodulyDatasetsinsrc/datasets/index.tstest/datasets.test.ts(new)Datasets
duly_duty_health— the on-time picture.business_unit,owner,period_key,frequency,sourceOn time =
completed_aton or beforedue_date + grace_days. Late = past that and not done. Both derive from the stored, indexed columns — do not add a flag toduly_taskto make this easier.duly_stagnation— the early-warning picture.business_unit,ownerlast_update_atduly_workload— forward look, for spotting a period that is about to overload.business_unit,owner,due_datebucketed by week and monthThe filter that must be on every governed measure
source IN ('catalog', 'assigned').selfduties are the owner's own record-keeping. They are surfaced, never scored. A single on-time rate that quietly includes self-declared work punishes exactly the people who declared the most, which is how a system teaches everyone to declare nothing.sourceis available as a dimension so someone can look at self-declared work deliberately. It is never in the default measure.duly_log_entrydoes not appear in this issueNo dataset reads it. No dataset will. If a later ticket asks for one, that is a product decision, not an analytics one — file
needs-user-decisionrather than adding it.Do not build
statusandlast_update_at; a percentage is a number nobody can verify, which is exactly why it becomes the number everyone reports.Acceptance
source IN ('catalog','assigned'); assert it in the testgrace_days— a task completed inside grace counts on timelast_update_at, and an untouched-but-not-yet-due task still appears (stagnation is not lateness)duly_log_entryGates
pnpm validate && pnpm typecheck && pnpm test && pnpm build.