Matt asked whether idle time would be a better fit than a flat session length. It is, but idle-alone weakens D18's own purpose - a continuously active session would never force a fresh Okta recheck on its own. Decided: both. AUTH_IDLE_MINUTES (new, default 30) slides the session on activity; AUTH_SESSION_HOURS (existing var, meaning changes to an absolute ceiling, default 12 -> proposed 8) caps it regardless of activity. Docs only in this commit - implementation is T13.1, next.
19 KiB
Decisions — September 17, 2026
Three items. Like D11, D15, D16 and D17, these are new scope raised after
R2 closed out (see docs/reference/completion.md, T9.7), not a reopening of
anything already decided there.
CR-019 and CR-020 get CR ids because they are field/product-facing feature
requests — the same kind of thing CR-001-CR-018 were — not internal
engineering calls made mid-build. D18 gets a D id because it is exactly that:
an internal security/architecture call, the same category as D15 (Okta vs.
LDAPS), not a feature a user asked for.
Requested/raised by Matt Mabrey, 2026-09-17.
CR-019 — Usage and activity metrics (admin console)
- Area: Admin Console / Monitoring
- Priority: proposed High — the current admin console has no reliable way to
answer "who is using this and how much," which is the same visibility gap the
original UX review found in
B4(numbers that look authoritative but are not). - Source: Matt Mabrey, 2026-09-17.
Why this is not already built
D5/T7.10 already shipped a usage-analytics feature — html/wp-usage.js, with
a report at admin.js:666-699 — but it reads localStorage, which is scoped to
one browser. The report's own empty state says exactly this: "No usage recorded
in this browser yet." It cannot show who across the team is active or what they
use, because each person's activity exists only on their own machine. This is the
same class of defect B4 named for the pipeline strip — a number that looks
authoritative but is not — just never generalized to this feature. CR-019 is
therefore new server-side work, not a UI addition on top of what exists.
Two things already in the schema are relevant and reused rather than duplicated:
User.last_login_at (one timestamp, no history) and AuditLog (append-only,
already records business mutations — WP created, status changed, role changed —
per user, with a timestamp). Neither captures navigation or feature-open events,
which is the actual gap.
Intent
Give admins a real, server-backed picture of who is using the suite, what parts of it they use, and how active they are — replacing the per-browser report as the thing anyone actually looks at.
Decisions, made 2026-09-17
- Retention: indefinite. No automatic purge of usage-event data. (Separate
from
AuditLog's own retention, which this does not change.) - Scope: suite-wide, filterable. Not per-project by default; the console provides filters (date range, project, user, tool/page) rather than scoping the data itself.
- Export: raw view shows real identities; export supports sanitization. The admin console's own tables and the CSV export both default to real usernames — this is an internal audit tool, not a public one. But the export also offers a "sanitize" toggle that replaces the actor with a stable pseudonymous id (a per-user hash, consistent across rows and across export runs) rather than dropping the identity field outright — so an external system (Power BI or similar) can still group and trend "by user" without ever receiving a real name. Flagged here as the recommended approach rather than the only one Matt confirmed in so many words: if a fully-anonymous (no stable id at all) export turns out to be what's actually wanted, that is a one-line change to the same feature, raise it in the PR rather than treating it as blocking.
- The old per-browser report is retired, not kept alongside the new one.
Once
CR-019ships,admin.js's existingusage-adminpanel (readingWPUsage.load(...)per browser) is removed rather than left next to the real report, where it would show a smaller, misleading number for whoever happens to have it open.html/wp-usage.jsand its two call sites (html/work-package-suite-app.js's wizard dwell-tracking, and the creator's equivalent) are a separate question — the recording code can stay or go independent of the admin report being retired, since dwell events were never reliably tied to a real identity anyway. Default to removing both unless a task finds a reason to keep the recorder; log that reason rather than deciding it here.
Acceptance criteria
- A new admin-only tab in
admin.html(same role gate as the User Directory) shows: active users over a selectable date range (day/week/month), each user's last-active timestamp, and a breakdown of which tools/pages get opened and how often. - Server-side event capture, keyed to the authenticated session (real identity,
not a browser-local guess) — a new table, not an extension of
AuditLog, since page-open/navigation events are not business mutations and mixing them in would makeAuditLognoisy for its existing, narrower purpose. - The console's filters cover date range, project, user, and tool/page, and combine (e.g., "user X, last 30 days, field view only").
- CSV export from the console, in both raw (real usernames) and sanitized (stable pseudonymous id per user) modes.
- Retention is indefinite; nothing in this item purges data.
- View-only, refresh-on-load. No alerting — matches how the rest of the admin console works today; if that changes later it is new scope, not a rider on this item.
- The old per-browser "Usage" report and its admin-console panel are removed in the same wave, not left running alongside the new one.
- Accessible per
CLAUDE.md's standingC1rules (this is a new component, not a legacy one — it ships accessible or it is not done, same as everything else built since wave 7).
Frontend/backend boundary
This needs server work, the same way CR-004/CR-018/B4 did: a real table,
a capture endpoint, an aggregation endpoint, and an export endpoint. If a task
under this item is being built by writing to localStorage, it is rebuilding
the exact defect this item exists to replace — stop and say so, per
CLAUDE.md.
Scheduling
New wave. Wave 10 (Okta) is merged, so this does not wait on anything.
Task breakdown: docs/waves/wave-11.md.
CR-020 — Bulk editing of users in the admin console
- Area: Admin Console / User Directory
- Priority: proposed High — three of the four actions below touch access control (role, project assignment, active/disabled) and the fourth is a hard delete; getting the guardrails right matters more than getting it built fast.
- Source: Matt Mabrey, 2026-09-17.
Why this is not already built
Every user-editing action in html/users.js today is one row, one action:
a role <select> per row, an activate/disable button per row, a
per-user project-membership checklist opened one user at a time, and a
per-row delete. There is no row selection in the User Directory table at all.
Server-side, every corresponding endpoint
(/api/auth/users/{id}/active, /role, /project-role, /projects, and
DELETE /api/auth/users/{id}) takes exactly one user_id. Bulk editing is new
UI (selection) and, for most actions, either a loop over the existing
single-user endpoints or new endpoints that accept a list — the task decides
which, per user count and transaction-safety needs.
Deletion is already a hard delete today (delete_user, server/app.py:1121
— db.delete(u), not a deactivate). Bulk delete inherits that: it is not this
item's job to invent a soft-delete pattern that does not exist for the single
case, but the confirmation step around it has to be sized for the fact that a
bad multi-select now removes more than one account at once, permanently.
Decisions, made 2026-09-17
- All four bulk actions are in scope: role change, activate/disable, project assignment (add to / remove from a project, including the project-role), and delete.
- Selection works two ways: checkboxes (select-all and individual) in the existing User Directory table, respecting whatever filter is already applied (role, active/disabled, project) — and a CSV upload, for a one-off bulk operation against an external list (e.g., an offboarding list that didn't originate in this app). Both are in scope, not a choice between them.
Acceptance criteria
- The User Directory table gains row checkboxes and a select-all that respects the current filter; a bulk-action toolbar appears once at least one row is selected.
- CSV upload as an alternative to checkbox selection: a list of usernames plus
the action to apply. Validates every row, reports rejected ones by row (bad
username, user not found, actor lacks permission over that user) rather than
silently skipping them — the same validate-and-report pattern
CR-005established for list uploads. - Every existing single-user guardrail carries forward unchanged: an actor
cannot include their own account in a bulk action that would disable, demote,
or delete it; a super user's bulk action is scoped to only the users and
projects
require_see_user/require_manage_useralready let them touch today (a super user cannot use a bulk action to reach a user or project outside what they manage, even via CSV);grantable_rolesstill gates which roles an actor may assign in bulk, the same as one at a time. - Every affected row is written to
AuditLogindividually, exactly as the single-user endpoints do today (onerole_changed/user_deleted/ etc. row per user) — a bulk action is many audited changes, not one opaque batch entry, so per-user history stays intact and readable in isolation. - Confirmation before applying, using the
wp-dialogkit (T7.9), not a nativeconfirm(). The dialog names exactly how many users are affected and, for delete specifically, lists the affected usernames before committing. - Partial failure is reported, not hidden: if some rows in a batch fail (scope, already-deleted, bad CSV row), the action applies to what it can and states exactly which rows failed and why. It never reports success on a batch that partly failed.
- Accessible per
C1: real controls, keyboard-operable selection and bulk- action toolbar,aria-liveannouncing the result.
Recommended, not yet confirmed — raise in the PR if this is wrong
- Bulk delete gets an extra confirmation step beyond naming the count —
proposed as typing a confirmation phrase (e.g. the word
DELETE) regardless of how many rows are selected, since one bad multi-select now removes more than one account, permanently, with no soft-delete to fall back on. This is a recommendation, not a confirmed requirement — Matt has not signed off on the exact mechanism. - Project assignment is add/remove, not replace-the-whole-list — a bulk "add these users to project X" or "remove these users from project X" action, rather than a bulk action that overwrites a user's entire project list. Proposed because add/remove is less likely to clobber project memberships the actor didn't intend to touch; a replace-the-whole-list version is a materially different (and riskier) feature if that turns out to be what's actually wanted.
Frontend/backend boundary
Selection state (which rows are checked) is fine as client-side UI state — it is not persisted data. Everything the bulk action actually does (role, active/disabled, project membership, delete) already requires server work today for the single-user case, and bulk does not change that: no new localStorage-derived state, no client-side aggregation of what changed.
Scheduling
New wave. Independent of wave 11 (CR-019) — the two do not touch the same
code and can build in either order or in parallel. Task breakdown:
docs/waves/wave-12.md.
D18 — Detecting and acting on Okta/AD deprovisioning
- Raised by: Matt Mabrey, 2026-09-17, in response to a direct question about what happens when someone's AD account is removed and Okta subsequently drops them.
- Amends: nothing decided, closes a gap
D15/D16left unaddressed. Those items designed how identity flows into this app (Okta authenticates, this app JIT-provisions and owns roles); neither addressed what happens when an identity is withdrawn. This app has never had any deprovisioning signal — push or pull — since Okta went live.
What was found
Traced through server/auth.py and server/okta_auth.py: this app has no
connection back to Okta after initial sign-in. Consequences, confirmed against
the actual code:
- A local
usersrow is never touched by anything Okta-side.is_activestaysTrueindefinitely unless an admin manually disables or deletes the account through the User Directory. There is no flag distinguishing a terminated employee's account from a current one. get_current_user(server/auth.py:237) re-checksis_activeandtoken_versionon every request — so a manual disable takes effect immediately, on the very next request. The gap is not enforcement, it's detection: nothing tells an admin to go flip that switch.- A session already live when someone is deprovisioned keeps working, fully,
for up to
AUTH_SESSION_HOURS(12 hours today,server/auth.py:63), because validity is checked against this app's own JWT and DB state only, never against Okta. - A NEW session can't be established once Okta drops the account —
okta_login/okta_callbackrequires completing Okta's own sign-in, which Okta itself refuses. The front door closes on its own; the back door (an already-open session, and the stale local record) does not.
Decisions, made 2026-09-17
- Build a scheduled sync against Okta's Management API, not an Event Hook.
This app has, since
D15, only ever reached out to Okta — never received anything from it — and a polling design keeps that shape rather than introducing a new inbound, internet-reachable endpoint with its own signature-verification surface. Traded deliberately: this is poll-interval-late rather than real-time, which is judged acceptable for an HR/offboarding-driven event, not a to-the-second requirement. - Revised 2026-09-23, in response to Matt's question about idle time
instead of a flat session length: sessions now slide on activity, with a
hard ceiling underneath. A flat
AUTH_SESSION_HOURSforces a re-check with Okta on a fixed schedule regardless of activity; a pure idle timer with no ceiling does the opposite — a continuously-active session would never force a fresh Okta check on its own, which is a worse fit for the exact threat this item exists to address (someone still clicking around after being deprovisioned). Decided: both.AUTH_IDLE_MINUTES(new, default 30): a session with no request for this long stops being valid. Implemented as a sliding JWT expiry — the token is reissued with a freshexpon activity, throttled so the cookie isn't rewritten on literally every request.AUTH_SESSION_HOURS(existing var, meaning changes to an absolute ceiling): no session survives past this many hours from the original sign-in, no matter how continuously active it is. Default changing from 12 to a proposed 8 — flagged as a recommendation, not confirmed.- Both defaults, and the mechanism itself, should be sanity-checked against the tenant's actual Okta SSO session policy — if Okta's own session silently outlives either number, re-authentication here is likely a fast redirect, not a real re-login screen, so these numbers cost less than they look like they do. Confirm before treating either as final.
- The sync only ever disables an account — it never re-enables one. A
rehire showing active in Okta again does not automatically restore access;
an admin re-enabling the account is a deliberate act, consistent with
D16's posture that this app never auto-grants access on its own initiative. - Fail closed on the side of INACTION, not disablement. This is the
opposite failure direction from
D16's login-time posture ("if Okta is unreachable, the app is unreachable for everyone"). Here, an Okta API error, timeout, empty response, or anything the sync can't confidently parse must result in no change to any account that cycle, plus a logged failure. A sync job that treats "couldn't reach Okta" as "nobody is active" is a far worse outcome than a missed cycle — it would lock out the entire org on an Okta API hiccup. This is the single most important acceptance criterion in this item. - Every auto-disable is audited individually, same as every other
account-state change in this app: an
AuditLogrow per user, with an actor value that's clearly the sync job and not a person (e.g.system:okta_sync), so it reads correctly in the User Directory's history and is never confused with an admin's own action. - New credential required: a read-scoped Okta API token (or an Okta
service-app OAuth2 client), separate from the
OKTA_CLIENT_ID/OKTA_CLIENT_SECRETpair used for sign-in. This needs provisioning by whoever administers the Okta tenant — the same dependency that gated the original OIDC rollout (D15's "security scoping reply").
Recommended, not yet confirmed — raise in the PR if this is wrong
- Sync interval: proposed every 15 minutes. Frequent enough that the detection gap is small, infrequent enough not to hammer Okta's API or need special rate-limit handling. Not confirmed with IT/security.
- Where the job runs: proposed as an in-process background task inside the
existing
apicontainer (it already hasoutboundnetwork access to reach Okta, and already holds the Okta client config) rather than a new sidecar container. Thebackupcontainer (docker-compose.yml) is the existing precedent for a scheduled-interval container in this stack, if isolation from theapiprocess is preferred instead — a reasonable alternative, not the recommendation. - Admin visibility: at minimum, an auto-disable is a normal, readable
AuditLogentry (visible whever admin already reviews audit history, and naturally covered onceCR-019's activity view exists). Whether it should also trigger an email/notification to admins is a genuine open question — proposed as a fast-follow rather than blocking this item, sinceD10already established the pattern for admin-controlled email toggles this could reuse.
Frontend/backend boundary
Entirely server-side and infrastructure. No new localStorage state, no
frontend surface beyond what already reads is_active and AuditLog today
(the User Directory, and eventually CR-019's activity view).
Scheduling
New wave, independent of wave 11 and wave 12 in the sense that nothing here is
blocked by them — but note it touches the same is_active/account-state
surface CR-020's bulk actions touch in server/app.py. Not a hard
dependency; sequence commits to avoid an avoidable merge conflict, per
CLAUDE.md's "one task per PR" spirit. Task breakdown: docs/waves/wave-13.md.