Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,40 @@ rejects it. Use `position` (distribution), `permission_set` (capability),
`is_open`. A stored flag needs a writer that runs every midnight; a formula
field is virtual and a filter naming one silently matches nothing. Ask
`status` and `due_date` directly — they are stored and indexed.

**The one exception, and its exact boundary (#52).** A value may be stored
when it is **written once, at the instant it becomes knowable, and never
recomputed** — a historical fact, not a maintained flag. `duly_task` carries
four: `completed_at` and `completed_late` (stamped at completion),
`visible_from` and `late_after` (`due_date + duty.grace_days`, stamped at
dispatch).

The test is not "is it derivable" — every one of these is derivable. It is
**what happens the day nobody writes it**:

| | maintained flag (`is_late`) | write-once fact (`completed_late`) |
|:--|:--|:--|
| needs a writer at midnight | yes — and lies the day it does not run | no |
| changes when config changes | yes, retroactively and silently | no, by design |
| wrong answer looks like | a stale flag nobody can see is stale | nothing: it is what was true then |

So the question to ask of a candidate column is: **would a second write ever
have to happen?** If yes — if it tracks the clock, or has to be refreshed
when a related record is edited — it is the banned shape, whatever it is
called. If no, and it records what was true at a moment that has passed, it
is the same category as `completed_at` and it may be stored.

Two obligations come with using this exception, both load-bearing:

- **`readonly: true` and ONE writer**, which is the hook or the planner. A
column a caller can set is a column that drifts.
- **Say what the write-once cost is, where the reader will hit it.** For
`late_after`: editing a duty's `grace_days` does not move the deadline on
tasks already dispatched. That surprises whoever has just corrected a
misconfiguration, so the `late` view's comment names `duly_catalog_sync`
— the action that already exists to replay duty edits — as the place a
recompute would belong. An undocumented denormalisation is the drift this
rule is really about.
6. **`sharingModel` is mandatory and fail-closed.** Unset means private, and the
publish linter errors on it (ADR-0090 D1/D7). State it deliberately.
7. **A hierarchy scope REQUIRES `requires: ['hierarchy-security']`. Omitting it
Expand Down
28 changes: 23 additions & 5 deletions docs/product/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,32 @@ percentage describes work that already finished. `last_update_at` describes work
that has quietly stopped, and it says so weeks before the due date does. It is
server-owned, stamped on every status change and note edit.

**`late_after` and `completed_late` are the lateness pair, and they are stamped
once each.** `late_after` is `due_date + duty.grace_days`, written by the
dispatcher; `completed_late` is `completed_at > late_after`, written by the
completion hook. Both are server-owned and neither is ever recomputed — so the
Late list is a plain date filter, the on-time rate is a count over a boolean,
and the platform gap that made `completed_at <= due_date + duty.grace_days`
unaskable (objectstack#14104) stops being a blocker rather than being resolved.

The consequence is deliberate and worth stating plainly: **editing a duty's
grace does not re-adjudicate work already dispatched or already completed.** A
task records what was owed when it was owed, and a compliance record that
rewrites itself when configuration changes is worth nothing in front of an
auditor. The place a replay onto open tasks would belong is `duly_catalog_sync`,
which already exists to push duty edits onto instantiated records; it does not
do this today.

**Deliberately absent.** `is_late`, `is_overdue`, `is_open`, `is_completed`,
`progress_percent`.

The first four are derivable from `due_date`, `grace_days` and `status`, all of
which are stored and indexed. A stored copy needs a writer that runs every
midnight, and the night it does not run, every view lies without erroring. A
formula field is worse: it is virtual, so a *filter* naming one silently matches
nothing at all. Consumers ask the stored columns.
The first four are the *maintained-flag* shape, which is a different thing from
the two stamps above: their truth changes with the clock rather than with the
record, so each needs a writer that runs every midnight, and the night it does
not run every view lies without erroring. A formula field is worse: it is
virtual, so a *filter* naming one silently matches nothing at all. The test is
not "is it derivable" — everything here is derivable — but "would a second write
ever have to happen". `AGENTS.md` rule 5 carries the boundary.

`progress_percent` is a number nobody can verify, which is exactly why it becomes
the number everyone reports. Progress lives in `status` and `last_update_at`.
Expand Down
106 changes: 67 additions & 39 deletions src/dashboards/duty-health.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,37 @@ import { Dashboard } from '@objectstack/spec/ui';
* rather than on this file. Nothing on this screen writes — managers read
* here, and assigning is their only write in the product.
*
* ── What is NOT on this dashboard, and why that is deliberate ─────────────
* ── The on-time rate, and what it is a rate OF ───────────────────────────
*
* The card asked for five things in reading order. Two of them — *"Late"* and
* *"On-time rate, current period"* — are the SAME missing comparison, not two
* separate omissions:
* The card asked for five things in reading order, and two of them — *"Late"*
* and *"On-time rate"* — used to be one missing comparison,
* `completed_at <= due_date + duty.grace_days`, which no filter grammar can
* express (no date arithmetic, no column-to-column comparison on the SQL path,
* and the offset is itself a column: objectstack#14104).
*
* completed_at <= due_date + duty.grace_days (on-time)
* now > due_date + duty.grace_days (late)
* #52 answered it by moving the comparison off the query and onto the row:
* `late_after` is stamped at dispatch, `completed_late` at completion, both
* write-once. So the tile below binds `on_time_rate`, a derived ratio of two
* plain counts, and needs no platform change — #14104 stopped being a blocker
* rather than getting resolved. `src/datasets/duty-health.dataset.ts` carries
* the reasoning.
*
* Both need `due_date + duty.grace_days`, and the filter grammar cannot say
* it: there is no date arithmetic in `FILTER_OPERATORS`, the `{N_days_ago}`
* macro vocabulary is relative to NOW and never to another column, and the
* offset here is itself a column. Column-to-column comparison (`$field`) is
* refused by `driver-sql` while the in-memory evaluator resolves it, so even
* the half that parses would be a per-deployment answer. Filed upstream as
* **objectstack-ai/objectstack#14104**; `src/datasets/duty-health.dataset.ts`
* carries the full measurement, and it is why no dataset in this app declares
* an on-time or a late measure for a widget to bind.
* **It is a rate over COMPLETED work** — `tasks_done_on_time / tasks_done` —
* and the description below says so on the screen. That is the number a
* compliance manager is asked for, and it is not the same as "how much of what
* was owed got done": still-open work is not in it. Stagnation is what answers
* for work that has not finished, which is why the not-moving tile keeps the
* headline position rather than being displaced by this one.
*
* A grace-free approximation IS expressible here — a widget `filter` of
* `due_date < {today}` over `tasks_due` needs no platform change at all — and
* it is deliberately not built. It marks late every task still inside the
* grace window its own duty grants, so a customer who configured 7 days of
* grace gets its people listed as late the morning after the due date. That
* is not a rounding error; it is wrong in exactly the direction the customer
* configured against, and it would be wrong invisibly. The product decision is
* open on **#52** (with **#48** on the `late` LIST view, which does carry the
* grace-free definition today); when it lands, the tile lands with it.
* A grace-free approximation is STILL not built here, and is still the thing
* to refuse: a widget `filter` of `due_date < {today}` over `tasks_due` is a
* "late" count in two lines, and it marks late every task inside the grace its
* own duty grants. `test/dashboard.test.ts` pins that shape out.
*
* The reordering is an improvement rather than a loss: the card already wanted
* the not-moving tile to be the visual focus, and with lateness deferred it is
* unambiguously the headline. Stagnation is also the EARLIER signal — a task
* untouched for three weeks and not due for another two is already a problem,
* and no lateness measure can see it until it is too late to act.
*
* The absence is stated on the screen itself, in `description` below, for the
* same reason the caliber note is: a manager reading "not moving: 3" on a
* dashboard that says nothing about lateness will conclude there are three
* problems. An unexplained absence is a wrong number with no digits.
* The ordering rule stands for the same reason it always did: stagnation is
* the EARLIER signal — a task untouched for three weeks and not due for
* another two is already a problem, and no lateness measure can see it until
* it is too late to act.
*
* ── Shapes this file is authored around ──────────────────────────────────
*
Expand Down Expand Up @@ -117,9 +109,9 @@ export const DutyHealthDashboard = Dashboard.create({
*/
description:
'Governed duties only — role-catalog and manager-assigned work. Self-declared duties are '
+ 'excluded from every number here. Lateness is not shown yet: it depends on each duty\'s '
+ 'grace period, which this view cannot apply — the Team → Late list is the interim answer '
+ 'and it does not account for grace.',
+ 'excluded from every number here. On-time is measured against each task\'s own grace '
+ 'period, as it stood when the task was dispatched, and counts completed work only — '
+ 'open work is what the not-moving tiles answer for.',

header: {
showTitle: true,
Expand Down Expand Up @@ -188,6 +180,42 @@ export const DutyHealthDashboard = Dashboard.create({
layout: { x: 9, y: 0, w: 3, h: 4 },
},

/**
* 3b. The on-time rate — the number the product is asked for by name.
*
* `on_time_rate` is `tasks_done_on_time / tasks_done`, both counts over
* `completed_late`, the verdict stamped at completion against the grace in
* force then. It is a RATE OVER COMPLETED WORK; the tile description says
* so, because a rate whose denominator a reader has to guess is a number
* they will guess wrong.
*
* `default` rather than `warning` / `danger`: this is the one number on the
* screen that is good when it is high, and colouring it as an alert would
* read as a problem at 100%. The attention colours stay on the two
* not-moving tiles, which is where action is actually needed.
*
* No `owner` dimension, here or anywhere on this screen: an on-time rate
* split by person is a performance score, and this product does not have
* one. `test/dashboard.test.ts` pins that as a property of the barrel.
*/
{
id: 'on_time_rate',
title: 'On-time rate',
description:
'Governed tasks completed within their own grace period, as a share of governed tasks '
+ 'completed. Open work is not counted here.',
type: 'metric',
dataset: 'duly_duty_health',
values: ['on_time_rate'],
colorVariant: 'default',
// Directly under the headline tile and the same width as it — the second
// number a manager reads — but SHORTER, and that is a rule rather than a
// taste call: no other number on this screen may out-area the not-moving
// tile, because stagnation is the signal that arrives early enough to act
// on. `test/dashboard.test.ts` pins it.
layout: { x: 0, y: 4, w: 6, h: 3 },
},

/**
* 4. By unit. Ordered by the unit DIMENSION, never by the count —
* `sortBy` names a selected dimension, so the bar order is a property of
Expand Down Expand Up @@ -230,7 +258,7 @@ export const DutyHealthDashboard = Dashboard.create({
showDataLabels: false,
},
options: { sortBy: 'business_unit', sortOrder: 'asc' },
layout: { x: 0, y: 4, w: 7, h: 6 },
layout: { x: 0, y: 7, w: 7, h: 6 },
},

/**
Expand Down Expand Up @@ -268,7 +296,7 @@ export const DutyHealthDashboard = Dashboard.create({
},
// Chronological, and — like the chart above — independent of the count.
options: { sortBy: 'due_week', sortOrder: 'asc' },
layout: { x: 7, y: 4, w: 5, h: 6 },
layout: { x: 7, y: 7, w: 5, h: 6 },
},
],
});
21 changes: 21 additions & 0 deletions src/data/demo-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export const DISPATCH_DUTIES: readonly DispatchDuty[] = DUTIES.map((duty) => {
due_anchor: cadence.due_anchor ?? null,
due_offset_days: cadence.due_offset_days ?? null,
lead_days: cadence.lead_days ?? null,
// The grace the catalog authored, so `late_after` on every seeded row is
// the deadline the real dispatcher would have stamped. Omit it and the
// whole fixture reads as zero grace: the demo's Late list would show tasks
// still inside a 7-day window, which is the exact defect #52 removed.
grace_days: cadence.grace_days ?? null,
timezone: timezoneOf(unit),
// The same window `duty.seed.ts` writes onto `duly_duty.effective_from`.
// Stated HERE too, not only there: the planner clips every period whose
Expand Down Expand Up @@ -217,6 +222,18 @@ const NOTES: Readonly<Record<string, string>> = {
export interface SeededTask extends Omit<TaskDraft, 'status'> {
status: 'open' | 'in_progress' | 'done' | 'skipped' | 'cancelled';
completed_at?: string;
/**
* `completed_late` is deliberately NOT on this interface.
*
* The verdict is `completed_at` past `late_after`, and both of those are on
* the row this fixture emits — so `task.hook.ts` stamps it on the way in,
* from the same comparison it makes on a live completion. Computing it here
* as well would be a second spelling of one rule, in a file whose whole
* point is that it re-uses the dispatcher's own arithmetic instead of
* re-deriving it. The drift each row completes with (below) is what decides
* which way the verdict falls; the grace it is judged against comes from the
* catalog, through `late_after`.
*/
skip_reason?: string;
note?: string;
/** Written by a SECOND seed pass — an insert can never carry it. See `task.seed.ts`. */
Expand Down Expand Up @@ -332,6 +349,10 @@ const resolveDraft = (draft: TaskDraft, index: number): SeededTask => {
const completed = iso(
new Date(Math.max(dueInstant.getTime() + drift * DAY + 14 * HOUR, dispatched.getTime())),
);
// The `+1` drift lands a day past the due date, so whether a row is
// stamped late depends on the grace its own duty granted — which is what
// makes the seeded on-time rate a number worth looking at rather than 100%
// by construction. The stamp itself is the hook's, on the way in.
return withNote({ ...draft, status: 'done', completed_at: completed, last_update_at: completed });
}

Expand Down
11 changes: 11 additions & 0 deletions src/data/task.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,23 @@ export const taskHistorySeed = defineSeed(Task, {
period_key: task.period_key,
due_date: task.due_date,
visible_from: task.visible_from,
// From the planner, like `due_date` and `visible_from` beside it — the
// deadline the real dispatcher would have stamped, with the duty's own
// grace applied. Readonly to callers; carried here on the same
// system-context leg as `completed_at` below.
late_after: task.late_after,
status: task.status,
// Carried on the INSERT, from the seed loader's system context — the leg
// that is exempt from the readonly strip. A caller's identical write is
// still refused by `completed_at_required_when_done`, and
// `test/seed-history.test.ts` pins both halves.
completed_at: task.completed_at,
// `completed_late` is NOT written here. A `done` row inserted this way
// makes no completion transition, so the hook's `beforeUpdate` leg never
// sees it — its `beforeInsert` leg does, and stamps the verdict from the
// two values above. The seed states the facts; the engine draws the
// conclusion, which is the only way the demo and a live completion cannot
// disagree.
skip_reason: task.skip_reason,
note: task.note,
})),
Expand Down
Loading
Loading