Found accidentally, while driving the grid in a browser to verify something else — which is the only reason it was noticed at all.
What was measured
Clicking the Status column header to sort the All tasks grid issued, per click:
PUT /api/v1/meta/view/duly_task.default
{"label":"All tasks","type":"grid", … ,"sort":[{"field":"status","order":"asc"}], …}
→ 200 {"success":true,"message":"Saved customization overlay (org=org_mtigfrnu5rygymk5, state=active) …"}
Two clicks, two persisted writes. No save gesture, no confirmation, no indication in the UI that anything was stored — it looks exactly like an ephemeral sort.
The stored row, read straight out of sys_metadata:
type: view name: duly_task.default
scope: "platform" owner: null organization_id: org_… state: active
owner is null and the scope is org-wide. This is not a per-user preference. The overlay replaces the app's declared duly_task.default view for the whole organization until someone deletes it, and it carries the entire view definition — columns, bulk actions, everything — not just the sort. So it also freezes the rest of the view at whatever the app shipped on the day the click happened: a later release that adds a column to this view would be silently ignored in any org where someone once clicked a header.
Cleaned up afterwards with DELETE /api/v1/meta/view/duly_task.default → "Customization overlay deleted — reset to artifact default", and confirmed sys_metadata is empty again.
Why this matters for this product specifically
Duly's views are shared surfaces by design — "Late", "Not moving", "By business unit" are the manager's org lens, and "My week" is every member's daily screen. A view whose definition any one person can overwrite for everyone, by doing the most ordinary thing there is to do to a table, is a different product from the one the view files describe.
The stagnation views are the sharpest case: Not moving is defined by sort: last_update_at asc plus a filter. If a member re-sorts it and that persists org-wide, the manager's headline signal quietly changes meaning for the whole tenant.
What is NOT established, and how to establish it
This was measured as the dev admin, who plausibly holds a customization permission an ordinary member does not. The whole severity turns on that:
- if view customization requires a platform permission our permission sets do not grant, this is a note in
AGENTS.md and nothing more; - if any authenticated user can do it, it is a p0 for a multi-user product.
src/security/permission-sets.ts grants object-level access only — it says nothing about metadata or view customization either way, so the answer is the platform's default, and we do not know it.
Blocked on #73: the seeded people have no credentials, so there is currently no way to log in as an ordinary member and try. Once pnpm demo yields a loginable non-admin, this is a two-minute test — sort a column as a member, then check whether sys_metadata gained a row.
If it turns out members can do it
Two directions, not mutually exclusive:
- Deny the customization permission to
duly_member and duly_manager explicitly, the same way the security model already denies everything else it does not grant. That fits this repo's fail-closed posture. - Raise it upstream — a transient sort persisting org-wide with no save gesture is surprising regardless of who can do it, and the two actions deserve different affordances. Not filed yet, because a report that cannot say who is affected is a report the platform team cannot act on.
No pm:queue — recording it. Restart when #73 lands and the member half can be measured.
Found accidentally, while driving the grid in a browser to verify something else — which is the only reason it was noticed at all.
What was measured
Clicking the Status column header to sort the All tasks grid issued, per click:
Two clicks, two persisted writes. No save gesture, no confirmation, no indication in the UI that anything was stored — it looks exactly like an ephemeral sort.
The stored row, read straight out of
sys_metadata:owneris null and the scope is org-wide. This is not a per-user preference. The overlay replaces the app's declaredduly_task.defaultview for the whole organization until someone deletes it, and it carries the entire view definition — columns, bulk actions, everything — not just the sort. So it also freezes the rest of the view at whatever the app shipped on the day the click happened: a later release that adds a column to this view would be silently ignored in any org where someone once clicked a header.Cleaned up afterwards with
DELETE /api/v1/meta/view/duly_task.default→ "Customization overlay deleted — reset to artifact default", and confirmedsys_metadatais empty again.Why this matters for this product specifically
Duly's views are shared surfaces by design — "Late", "Not moving", "By business unit" are the manager's org lens, and "My week" is every member's daily screen. A view whose definition any one person can overwrite for everyone, by doing the most ordinary thing there is to do to a table, is a different product from the one the view files describe.
The stagnation views are the sharpest case:
Not movingis defined bysort: last_update_at ascplus a filter. If a member re-sorts it and that persists org-wide, the manager's headline signal quietly changes meaning for the whole tenant.What is NOT established, and how to establish it
This was measured as the dev admin, who plausibly holds a customization permission an ordinary member does not. The whole severity turns on that:
AGENTS.mdand nothing more;src/security/permission-sets.tsgrants object-level access only — it says nothing about metadata or view customization either way, so the answer is the platform's default, and we do not know it.Blocked on #73: the seeded people have no credentials, so there is currently no way to log in as an ordinary member and try. Once
pnpm demoyields a loginable non-admin, this is a two-minute test — sort a column as a member, then check whethersys_metadatagained a row.If it turns out members can do it
Two directions, not mutually exclusive:
duly_memberandduly_managerexplicitly, the same way the security model already denies everything else it does not grant. That fits this repo's fail-closed posture.No
pm:queue— recording it. Restart when #73 lands and the member half can be measured.