+ Running log of decisions, deviations, tradeoffs, and open questions captured while implementing
+ specs/032-user-disciplines/plan.html. Entries are appended in chronological order; each is
+ tagged so you can skim by category.
+
+
+
+
Decisions
+
+
+ setup
+ decision
+ Use existing main-repo secrets verbatim in the worktree's .env.local.
+ The plan does not specify how to bootstrap env vars for the worktree. I copied every secret from
+ C:\Repos\ai-developer-hub\.env.local verbatim and only swapped DATABASE_URL /
+ DATABASE_URL_UNPOOLED to the new Neon branch host
+ ep-fragrant-fire-alm1dt17 (branch wt/introduce-roles, id
+ br-empty-voice-alx69zhl). The new branch is a copy-on-write fork of the main Neon branch
+ (br-quiet-brook-al03w27g, 184 users at branch time), so schema work is safely isolated.
+
+
+
+ verify
+ decision
+ Browser verification — all surfaces green.
+ Walked through the dev server (localhost:3000) authenticated as the Nighthawk agent. Verified:
+
+
/users table renders Discipline column with icons; faceted filter present.
+
/users/new — submit without picking discipline shows "Please select a discipline"
+ error and blocks the submit. Picking "Conception" + submitting creates the user with
+ discipline = "conception" in the DB (verified via SQL).
+
/users/[id] — pre-fills the discipline select, the read-only header carries the
+ Conception/Business badge, and editing to "Business" saves and records a change-history row
+ "discipline from "conception" to "business"".
+
/api/export/users CSV header reads
+ name,email,circle,discipline,role,github_username,profile; all 184 backfilled users
+ export as discipline=developer.
+
/profile renders the agent user's Developer badge alongside the Admin role badge.
+
+ Test user id=185 was cleaned up after verification (along with its invite-token and change-history
+ rows). Screenshots in screenshot-users-table.png and screenshot-profile.png.
+
+
+
Deviations
+
+
+ github-sync
+ deviation
+ GitHub sync — "import as-is" path does NOT get a discipline picker.
+ The plan said both confirmGitHubSync insert sites should pull discipline from input.
+ But there are two distinct flows:
+
+
Inline-create (newUsers in ConfirmSyncInput) —
+ admin types name + email in inline-user-form.tsx. Here discipline IS captured from
+ the form (per plan).
+
Import as-is (importGitHubLogins in ConfirmSyncInput) —
+ admin just ticks a checkbox on the unmatched-member card; there is no form. No place to capture
+ discipline.
+
+ For the "import as-is" path I hardcoded discipline: "developer" (matches the existing
+ role: "viewer" hardcoding on the same insert). Adding a discipline picker to the bulk
+ "tick to import" checkbox UI would balloon scope (it's a multi-select grid, not a per-row form).
+ Admins can reclassify any mis-attributed user from /users after the sync. Same fallback
+ the plan uses for the seeded agent user.
+
+
+
Tradeoffs
+
+
+ validators
+ tradeoff
+ Zod 4 message: over errorMap:.
+ The plan's Zod sketch used z.enum(values, { errorMap: () => ({ message: "..." }) }),
+ which is Zod 3 syntax. The project is on Zod 4.3.6 where errorMap was renamed.
+ Switched to { message: "Please select a discipline" } — same UX, current API.
+
+
+
+ disciplines.ts
+ tradeoff
+ Added isDiscipline() type guard not in the plan.
+ The plan only listed the const arrays + icon map. I added a one-line type-guard
+ isDiscipline(value) for safe CSV value narrowing. Trivial; kept because the
+ bulk-import path needs to narrow string to UserDiscipline when
+ forwarding to the typed action.
+
+
+
+ change-history
+ tradeoff
+ Discipline diffs go through the same changeHistory table as role/circle.
+ The plan said "matches existing role/circle behavior". Confirmed: updateUser now records
+ a discipline change-history row when the value changes, with old/new values. The
+ user-detail page's history section already renders fieldName + previousValue + newValue
+ generically, so no UI work needed to surface discipline changes there.
+
+
+
+ simplify-review
+ tradeoff
+ Defensive asDiscipline() on every icon-lookup site.
+ The /simplify code review (5 independent finder angles) flagged that
+ DISCIPLINE_ICON[value] and DISCIPLINE_LABEL[value] would crash with
+ "Element type is invalid" if a row ever surfaced an unexpected enum value (future migration
+ landing before the matching frontend bundle, manual SQL insert, stale SSR cache). Today the DB enum
+ constrains values to the three known members, so the bug is latent — but the fix is cheap:
+ a new asDiscipline(value) helper in src/lib/disciplines.ts falls back to
+ DEFAULT_DISCIPLINE for unrecognized values. Applied at four sites: users-table cell,
+ user-detail-client header, profile-header badge, assignment-detail subline.
+ The other review findings were either pre-existing patterns (naive CSV split, updateUser race),
+ already-documented deviations (GitHub "import as-is" defaults to developer), or behavioral changes
+ already noted in the release-note recommendation (CSV column-order shift). Single defensive fix
+ landed; the rest carry forward as known follow-ups.
+
+
+
Open questions
+
+
+ analytics
+ open
+ Claude analytics pages still hide discipline.
+ Per plan §8 "out of scope this PR", I left the following unchanged:
+
The UserListRow & UserDetail types in src/types/index.ts — both still
+ carry circle + profile but not discipline.
+
+ Confirm this is fine, or queue a follow-up spec to surface discipline as a filter/breakdown in
+ Claude reports. Recommend the follow-up — it's the natural payoff of capturing the data.
+
+
+
+ github-sync
+ open
+ "Import as-is" GitHub members default silently to developer.
+ Detailed in the deviation above. If "import as-is" is heavily used at Unic and most GitHub members
+ are NOT developers, we should add a per-row discipline picker to that flow too — or change
+ the default to nothing and force admins through the inline-create form. Today's behaviour: defaults
+ to developer, admin reclassifies in /users after the fact.
+
Implementation Plan — Introduce discipline on users
+
+ Add a mandatory discipline field to every user record so the tool can correctly attribute
+ Conception and Business colleagues alongside Developers. This plan walks every input mask, display
+ surface, automated user creation path, and test fixture that must change, in a safe rollout order.
+
+
+
Status
Plan · v1
+
Field name
discipline
+
Values
developer / conception / business
+
Backfill
developer (DB default)
+
+
+
+
1
New enum
+
1
New column
+
~25
Files touched
+
0
Breaking changes
+
+
+
+
+
Scope & locked-in decisions
+
+
+ The proposal in specs/032-user-disciplines/proposal.html settled the naming question. This
+ plan assumes the following decisions are locked:
+
+
+
+
+
Decision
Locked answer
Rationale
+
+
+
+
Field name
+
discipline
+
Distinguishes cleanly from security role; agency-native vocabulary.
+
+
+
Enum values
+
developer · conception · business
+
Closed set; extend later via ALTER TYPE … ADD VALUE if needed.
+
+
+
Nullability
+
NOT NULL
+
Every user must have a discipline. DB default developer for backfill safety.
+
+
+
Required in UI?
+
Yes on create; editable on update
+
Force admin to make a conscious choice in the form. DB default catches edge cases only.
+
+
+
Required in CSV import?
+
Optional column
+
New rows fall back to developer; upsert preserves existing value when column is blank.
+
+
+
NextAuth session?
+
Not added to session
+
Discipline is descriptive, not authorizing. Keep JWT lean; fetch from DB where needed.
+
+
+
Profile API response?
+
Yes, include discipline
+
External consumers (Profile API preview, downstream tooling) will want it.
+
+
+
Reports breakdown?
+
Out of scope for this PR
+
Land data first; reporting follow-up gets its own spec.
+
+
+
GitHub sync inline form?
+
Add discipline picker
+
Cheap to add; GitHub members are not all developers (PMs, designers).
+
+
+
Agent user discipline?
+
developer (default)
+
Keeps NOT NULL contract simple; hidden from UI.
+
+
+
+
+
Files in scope (high-level)
+
+
+
+
Area
Files
Impact
+
+
+
Schema & migration
2
High
+
Types & validators
3
High
+
Server actions & API
5
High
+
UI forms & dialogs
5
High
+
UI tables & display
4
Medium
+
Automated paths (sync, seeds)
3
Medium
+
Tests & fixtures
5+
Medium
+
Docs / CLAUDE.md
1
Low
+
+
+
+
+
Schema & migration
+
+
+ Goal: add enum + column with safe backfill
+ Files: 2
+ Risk: low (additive, NOT NULL with default)
+
+
+
Edit: src/lib/db/schema.ts
+
+
Add the new enum next to userProfileEnum (currently lines 39–43), then add the column to
+ the users table.
+ Why this is safe
+ On Postgres 11+ ADD COLUMN … NOT NULL DEFAULT 'developer' is a metadata-only operation —
+ no row rewrite, no table lock beyond the brief AccessExclusive needed for the catalog
+ update. Safe to apply on any production-sized users table.
+
+
+
Database review
+
+
+ Use the drizzle-migration-reviewer agent on the generated SQL before merging. The migration
+ should pass cleanly — no destructive operations, no FK changes.
+
+
+
+
Types & constants
+
+
+ Goal: expose a typed discipline value across the codebase
+ Files: 1 + 1 new helper
+
+
+
Edit: src/types/index.ts
+
+
Existing User is InferSelectModel<typeof users>, so it inherits the new
+ column automatically. Add an explicit UserDiscipline alias for downstream consumers.
+
+
export type UserRole = "admin" | "viewer";
+ export type UserStatus = "active" | "inactive";
+ export type UserProfile = "boost" | "maxed" | "indie";
++export type UserDiscipline = "developer" | "conception" | "business";
+
+
New: src/lib/disciplines.ts
+
+
+ Single source of truth for the human-readable labels and icon mapping. Keeps display strings out of the
+ schema and out of individual components.
+
+ Why a const tuple?
+ Defining disciplineValues once and reusing it in every z.enum() keeps the four
+ schemas in sync. If we ever add a fourth value, only one line changes.
+
+
+
+
Server actions & API
+
+
+ Goal: persist and return the new field correctly
+ Files: 5
+
+
+
Edit: src/actions/users.ts
+
+
+
+ createUser(input) — pass discipline into the db.insert(users)
+ payload alongside circle, role, profile. The Zod-validated
+ input now requires it.
+
+
+ updateUser(input) — extend the diff-and-update logic to detect discipline
+ changes; emit a changeHistory row when it changes (matches existing role/circle
+ behavior).
+
+
+ bulkImportUsers(rows) — for each row:
+
+
Insert path: if CSV omits discipline, write "developer" explicitly (don't rely on DB default — be loud and traceable).
+
Update path (upsert by email): if CSV omits discipline, do NOT include it in the SET clause. Existing value preserved.
+
+
+
+ checkExistingUsers() — extend the returned ExistingUserFields shape so the
+ bulk-import preview can show "Update will change discipline: developer → conception".
+
+ Backwards compatibility
+ Anyone re-importing an old exported CSV (without the new column) will land in the optional path:
+ existing users keep their value, new rows default to developer. Document this in the
+ release notes so admins aren't surprised when historic exports re-import cleanly.
+
+
+
Edit: src/app/api/profile/route.ts
+
+
The Bearer-token-authed profile API consumed by external tooling. Add discipline to the
+ JSON shape returned per email.
+ Do not add discipline to the session token. Discipline does not gate any route or action;
+ adding it to the JWT bloats every request and creates a stale-data problem (admin edits a user's
+ discipline → that user's session keeps the old value until next login). If a client component truly
+ needs the current user's discipline, fetch it from /api/profile or via a Server Component.
+
+ The dedicated /users/new form. Add a required Select field. Place it
+ after circle and before role — disciplines describe what the person
+ does (closer to circle); role is security (separate concern).
+
+
+
+
Default value: none (force a conscious pick — do not pre-fill developer).
+
Validation message: "Please select a discipline".
+
Order in form: name → email → circle → discipline → role → profile → githubUsername.
+ The compact form used inside the GitHub member sync flow ("create unmatched user inline"). Today it
+ captures only githubLogin / name / email and the server action hardcodes role =
+ "viewer". Add the discipline picker — it is required.
+
+
+
+ Why not just default to developer here?
+ GitHub members do not all map to developers (PMs, designers, account managers all use GitHub at Unic).
+ Auto-defaulting to developer from the sync flow would silently mis-classify these users
+ and pollute downstream reports. A required select keeps the data clean for ~1 second more of admin
+ effort per unmatched member.
+
+ The modal opened from the users-table row kebab. Add a Select field for discipline,
+ pre-filled from user.discipline, placed between circle and role to match the create form's
+ layout. Update the defaultValues and the form.reset() call in the
+ useEffect.
+
Also update the form.reset() block inside useEffect(open) to include the same
+ field.
+
+
Edit: src/app/users/[id]/user-detail-client.tsx
+
+
The full-page edit form on the user detail screen. Same change pattern as the dialog — add the field
+ in the same position. Update the diff-summary section ("changed: …") to include discipline.
+
+
Profile self-edit?
+
+
+ src/app/profile/profile-client.tsx is read-only today. We do not let a
+ user edit their own discipline — that is an admin-managed attribute. We do display it (see next
+ section).
+
+
+
+
User display UI
+
+
+ Goal: show discipline everywhere a user appears
+ Files: 4
+
+
+
Edit: src/app/users/users-table.tsx
+
+
Add a new column Discipline, sortable, with a faceted filter (multi-select), placed between
+ Circle and Role.
+ Match the existing pattern used by circle and profile filters in the same
+ table. Use outlined / muted styling — discipline should not compete visually with the colored role
+ badge.
+
Add a discipline badge to the read-only header alongside role/status/profile. Outlined badge with
+ lucide icon. Suggested ordering of badges: role · status · discipline · profile — security
+ first, then identity attributes.
+
+
Edit: src/components/profile/profile-header.tsx
+
+
The logged-in user's own profile view at /profile. Add discipline as a badge alongside
+ circle/profile. Read-only; admin-managed.
The single-assignment detail page shows "assigned to X" with name. Add discipline as a
+ sub-line next to circle so the budget owner can see at a glance who they're allocating to.
+
+
Out of scope this PR
+
+
+
src/components/claude/users-table.tsx — Claude analytics view. Skip for now; reporting follow-up will add discipline as a filter/breakdown.
+
src/app/claude/users/[userId]/page.tsx — Claude user detail. Skip; analytics scope.
+ Goal: keep pickers focused; no discipline noise
+ Files: 0 (no changes)
+
+
+
+ Existing user pickers (src/components/user-combobox.tsx,
+ src/components/user-search-combobox.tsx) display name + email. They should
+ not show discipline by default — picker UX wants minimum visual noise. If a future
+ task needs disambiguation by discipline (e.g. assigning a Claude Maxed seat only to developers),
+ reconsider per-use-site.
+
+
+
+
CSV import & export
+
+
+ Goal: round-trip the field through CSV
+ Files: 2
+
+
+
Export (already covered above)
+
+
New CSV header: name,email,circle,discipline,role,github_username,profile.
+
+
Edit: src/app/users/import/bulk-import-form.tsx
+
+
+
Header parsing: recognize a discipline column. Tolerate missing column for backwards compat with old exports.
+
Per-row validation: if the column is present, normalize to lowercase and validate against the enum. Reject the row with a clear error if it's not one of the three values.
+
Preview table: add a Discipline column to the preview grid.
+
+
For new rows with discipline supplied → show the value as a normal cell.
+
For new rows with discipline missing → show developer in muted text with a tooltip "Defaulted — change before import or in the user record".
+
For update rows where the CSV value differs from existing → highlight the cell (consistent with how circle/profile updates are highlighted today).
+
+
+
Summary report: the "X created, Y updated, Z skipped" summary at the end should mention how many rows were classified into each discipline.
+
+
+
CSV header migration
+
+
+ Existing CSVs in the wild (e.g. an admin's local export from yesterday) do not have
+ this column. They must still import cleanly. The implementation MUST:
+
+
+
Detect the column by header name, not position.
+
Treat discipline as optional in the import schema.
+
Skip discipline from the upsert SET clause when the column is absent or blank.
+
+
+
+
GitHub sync & auto-create paths
+
+
+ Goal: automated paths can't sneak in NULL or default-only users
+ Files: 1
+
+
+
Edit: src/actions/github-sync.ts
+
+
+ confirmGitHubSync() creates users in two places (the report flagged lines ~305–316 and
+ ~472–481). Both pass role = "viewer" hardcoded. We need both to also pass
+ discipline.
+
+
+
+ Source of the discipline value: the inline form (per decision above) now collects it as part of each
+ newUsers entry in the ConfirmSyncInput. The server action just reads it from
+ the input, with no hardcoding required.
+
+ The invite flow only writes passwordHash and mustChangePassword; it does not
+ create users. Discipline is set when the admin creates the user record, then the invite token is issued
+ for that already-classified record. No change needed.
+
+
+
No change: Copilot sync
+
+
+ Copilot sync writes copilot_usage_metrics and copilot_seats; it does not
+ create or modify users rows directly. No change.
+
+
+
No change: NextAuth authorize()
+
+
Reads user fields; does not write. No change.
+
+
+
Seed scripts
+
+
+ Goal: seeded users have a discipline
+ Files: 2
+
Same shape — seed the agent user with discipline: "developer". Hidden from UI; this is
+ only to keep the NOT NULL contract satisfied.
+
+
+
Tests & fixtures
+
+
+ Goal: all tests construct valid users; new behaviors covered
+ Files: 5+
+
+
+
Existing fixtures to update
+
+
+
File
Change
+
+
tests/unit/agent-auth.test.ts
Add discipline: "developer" to mockAgentRow.
+
tests/unit/api/profile.test.ts
Add field to mockUser; assert discipline appears in the JSON response.
+
tests/unit/api/assignments-export.test.ts
Add field to mockAdminUser.
+
tests/unit/reports/circle-report.test.ts
Add field to all user fixtures.
+
tests/unit/anthropic-users-phase2.test.ts
Add field to user objects in the test setup.
+
+
+
+
New tests to add
+
+
+
Unitvitest · userSchema rejects payloads without discipline; updateUserSchema accepts payloads without it.
+
Unit · bulkImportUsers: row with no discipline on insert → defaults to developer; row with no discipline on upsert → existing value preserved.
+
Unit · updateUser: changing discipline records a changeHistory entry.
+
Integration · /api/export/users returns the new column in the header and row data.
+
Integration · /api/profile includes discipline in the response shape.
+
E2Eplaywright · Admin creates a user via /users/new with discipline = "conception"; the new row shows in the users table with the Conception column value; opening edit dialog shows the same value.
+
E2E · CSV import with a row missing discipline → preview shows muted "developer (default)"; importing succeeds; user has discipline = developer.
+
E2E · GitHub sync inline-create flow requires picking a discipline before submit is enabled.
+
+
+
CSV fixture updates
+
+
If tests/ contains a sample CSV used by the bulk-import test (search:
+ *.csv), produce one with and one without the discipline column — both must import
+ cleanly.
+
+
+
Documentation
+
+
+ Goal: the next contributor knows the field exists
+ Files: 1–2
+
+
+
+
CLAUDE.md — no change required; it doesn't document individual user fields.
+
spec.md / tasks.md — generate via the standard speckit flow after this plan is approved.
+
Release note — short bullet for the admin team:
+ "Users now have a Discipline (Developer / Conception / Business). All existing users have been
+ defaulted to Developer — please reclassify Conception and Business colleagues at
+ /users."
+
+
+
+
Verification checklist
+
+
Before marking the PR done, walk through this list with the dev server running.
+
+
Schema & data
+
+
Migration runs cleanly on a fresh DB (pnpm db:migrate).
+
Migration runs cleanly on a populated DB; all existing rows now have discipline = 'developer'.
+
SELECT discipline, COUNT(*) FROM users GROUP BY 1 returns sensible numbers post-migration.
+
+
+
Backend
+
+
pnpm typecheck passes.
+
pnpm lint passes with zero warnings.
+
Unit tests pass (pnpm test).
+
Integration tests pass (pnpm test:integration).
+
GET /api/export/users CSV contains discipline column with values.
+
GET /api/profile?email=<known> returns discipline field.
+
+
+
UI — create
+
+
Visit /users/new — discipline select is visible, required, no default pre-selected.
+
Submit without picking → form shows error "Please select a discipline".
+
Submit with each of the three values → user is created with the right value.
+
+
+
UI — edit
+
+
Open users table → click kebab → Edit. Dialog pre-fills the user's current discipline.
+
Change discipline + save → toast confirms, table reflects the change after refresh.
+
Visit /users/[id] edit form → same behavior.
+
User detail header shows the discipline badge alongside role/status.
+
+
+
UI — display
+
+
Users table shows the new Discipline column with icons.
+
Faceted filter for Discipline narrows the table correctly.
+
/profile shows the logged-in user's discipline as a read-only badge.
+
Assignment detail page shows the assignee's discipline.
+
+
+
CSV
+
+
Export from /users → CSV has the new column with values for every row.
+
Re-import that CSV → all rows match as "no changes".
+
Edit one row's discipline in the CSV → import shows that row as "update", change history records the diff.
+
Import an old CSV without the column → all rows still process correctly.
+
Import a CSV with an invalid value (e.g. devloper) → that row rejects with a clear error.
+
+
+
GitHub sync
+
+
Trigger a sync with an unmatched member → inline form shows discipline picker.
+
Submit is disabled until a discipline is chosen.
+
Confirmed sync creates the user with the chosen discipline.
+
+
+
+
Risks & mitigations
+
+
+
+
Risk
Likelihood
Impact
Mitigation
+
+
+
+
Misclassification: existing Conception/Business users sit at developer after backfill until reclassified.
+
High
+
Med
+
Send the release-note ping to admins immediately after deploy; expose a "discipline = developer with no GitHub username" quick-filter so reclassification candidates are easy to spot.
+
+
+
Old CSV imports break because admins assume the column is required.
+
Low
+
Med
+
Bulk import schema makes the column optional; preview UI surfaces "defaulted" rows clearly.
+
+
+
External profile API consumers can't parse the new field.
+
Low
+
Low
+
Additive change to the JSON response — old consumers ignoring new fields keep working.
+
+
+
Forgotten display surface (e.g. an unexpected place where users render).
+
Low
+
Low
+
Audit completed via subagents — all known surfaces in §6–§9. Search regression: grep -r "user\.role" src as a sanity check.
+
+
+
Enum gap discovered post-launch (e.g. need a "Design" value).
+
Med
+
Low
+
ALTER TYPE user_discipline ADD VALUE 'design' is non-blocking; UI label map is one line.
+
+
+
NextAuth session becomes a foot-gun if someone later adds discipline to the JWT and forgets it goes stale.
+
Low
+
Med
+
Code-level comment in src/lib/auth.ts explaining the deliberate omission.
+
+
+
+
+
+
Rollout sequence
+
+
Single PR. Commits in this order to keep the diff reviewable:
+
+
+
Schema + migration. Add enum + column to schema.ts; run pnpm db:generate; commit the generated SQL. Run drizzle-migration-reviewer.
CSV import. Update bulk-import-form with parsing, preview, and validation.
+
Tests. Update fixtures; add new unit/integration/e2e tests.
+
Verification. Run through the checklist in §15 against a local dev server.
+
Release note. Draft the admin ping; ship after merge to main & production deploy.
+
+
+
+ Done definition
+ Migration applied, all four Zod schemas validate, every UI surface in §6–§9 shows discipline, the
+ verification checklist in §15 is fully green, and the admin release note has been sent.
+
+ Today every user is implicitly a developer. As the team expands the tool to cover Conception and
+ Business colleagues, we need a way to record what kind of work a person does — without colliding with
+ the existing security role column. This document proposes naming, schema, UI, and a
+ migration approach as a starting point for the implementation plan.
+
+
+
Status
Proposal · v0
+
Author
tobias.studer@unic.com
+
Schema impact
1 enum · 1 column
+
Breaking
No (backfilled)
+
+
+
+
+
Context & goal
+
+
+ The user record in src/lib/db/schema.ts currently captures four orthogonal facets of a
+ person:
+
+
+
+
+
Column
Type
Purpose today
+
+
+
+
role
+
user_role enum (admin | viewer)
+
Security / authorization. Drives what the user can do in the app.
+
+
+
circle
+
varchar(100), nullable, free-text
+
Organizational grouping (a team or circle inside the company). Used for filtering/reporting.
+
+
+
profile
+
user_profile enum (boost | maxed | indie)
+
Anthropic API tier the user is on. Drives cost attribution.
+
+
+
github_username
+
varchar(255), nullable
+
GitHub identity for Copilot sync.
+
+
+
+
+
+ None of these capture what kind of work the person does. Up to now that has been irrelevant
+ because every tracked user was a developer. With Conception and Business now in scope, the budget /
+ reporting story needs to slice spend along that axis — e.g. "how much do our Conception colleagues
+ cost on Claude this month?"
+
+
+
+ Goal: add a single mandatory categorical field on every user that records whether they
+ are Developer, Conception, or Business. Pick a name that does not collide
+ with the existing security role.
+
+
+
+ Non-goal
+ We are not redesigning role (security) or profile (Anthropic tier). Those stay
+ as they are. This proposal only adds the new categorical field.
+
+
+
+
The naming problem
+
+
+ “Role” is already taken — using it again for the new field would force every conversation
+ and code-review to disambiguate which role. The candidates below were evaluated against:
+
+
+
Clarity — does it obviously mean "kind of work", not authorization?
+
Industry fit — does the term match how digital-agency teams (Unic, peers) actually
+ talk about Conception / Development / Business?
+
Codebase fit — does it read well next to role, circle,
+ profile?
+
i18n — does it translate cleanly to German (the team's working language alongside
+ English)?
+
+
+
+
+
+
Name
+
Pros
+
Cons
+
+
+
+
+
disciplineRecommended
+
+ Standard agency vocabulary for "Engineering / Conception / Business / Design". Translates 1:1 to
+ German (Disziplin). Zero overlap with role. Reads naturally:
+ user.discipline = "conception".
+
+
Slightly formal. Some readers may first think of "academic discipline".
+
+
+
practice
+
+ Also agency-native ("design practice", "engineering practice"). Implies a community of expertise.
+
+
+ Overloaded in software (e.g. "best practices"). German translation (Praxis) is awkward
+ for an individual person — practices are usually groups, not labels.
+
+
+
+
function
+
HR-standard. Unambiguous as "job function".
+
+ Word also means a code function — high collision in a TypeScript codebase. Reads corporate /
+ cold.
+
+
+
+
track
+
Short. Common in career-ladder talk.
+
+ Implies a progression / level (IC1 → IC5), which is not what we mean. Likely to cause
+ confusion.
+
+
+
+
craft
+
Warm, agency-ish. Used at design-heavy shops.
+
Strongly biased toward "design/build" connotation — fits Conception & Dev but not Business.
+
+
+
category / type
+
Generic; nobody will misread it.
+
+ Too generic — gives the reader no hint of what's inside. Future contributors will need to read the
+ enum to find out.
+
+
+
+
department
+
HR-standard, obvious.
+
+ We already had a department column in migration 0000 that was renamed to
+ circle. Reintroducing it inverts that decision and re-creates ambiguity with
+ circle.
+
+
+
+
+
+
+ Recommendation
+ Adopt discipline. It is unambiguous next to role, matches how Unic and similar
+ agencies already describe the Dev/Conception/Business split, and survives translation. Runner-up:
+ practice if the team feels "discipline" is too formal.
+
+
+
+
Discipline values
+
+
Proposed enum, stored lowercase to match existing conventions (admin, viewer,
+ boost, …):
+
+
+
+
Enum value
Display label
Who
+
+
+
+
developer
+
Developer
+
Engineers — frontend, backend, mobile, DevOps. The current population.
+
+
+
conception
+
Conception
+
UX, IA, content strategy, service design — the strategic / conceptual work upstream of build.
+
+
+
business
+
Business
+
Project management, sales, account, leadership — non-craft roles that still use AI tools.
+
+
+
+
+
+ Decision needed
+ Should the enum stay closed (these three only) or be extensible (e.g. Design, Data,
+ QA later)? Adding values to a Postgres enum is cheap (ALTER TYPE … ADD VALUE),
+ removing them is hard. Recommend: start closed at these three, add more only when a real population
+ shows up. See open questions.
+
+
+
+
Data model
+
+
Schema change
+
+
+ Add a new enum and a non-null column on users. The column is required from day one — every
+ user must have a discipline — and the migration backfills existing rows to developer.
+
Drizzle will emit something close to this. The default + backfill makes it safe on a live table:
+
+
src/lib/db/migrations/00XX_add_user_discipline.sqlCREATE TYPEuser_disciplineAS ENUM ('developer', 'conception', 'business');
+
+ALTER TABLEusers
+ ADD COLUMNdisciplineuser_discipline
+ NOT NULLDEFAULT'developer';
+
+-- No backfill UPDATE needed: the DEFAULT applies to existing rows during ADD COLUMN.
+-- After the release, admins reclassify Conception / Business users via the UI.
+
+
+ Why default to developer?
+ It matches today's reality — every existing user is a developer — so the backfill is correct
+ data, not a placeholder. New users created after the migration must pick a value explicitly via the UI
+ (we'll surface the field as required in the form, even though the DB default exists as a safety net).
+
+
+
+
Validators
+
+
+ Mirror the enum into src/lib/validators.ts and require it on creation; allow partial
+ updates on edit.
+
The new field touches every screen that displays or edits a user.
+
+
+
+
+
Surface
+
Change
+
Effort
+
+
+
+
+
src/app/users/new/new-user-form.tsx
+
Add a Select with the three options. Required field. Default selection blank (force a conscious choice).
+
S
+
+
+
src/app/users/[id]/user-detail-client.tsx
+
Editable Select. Show current value prominently next to the security role badge.
+
S
+
+
+
src/app/users/users-table.tsx
+
New column Discipline, sortable, with a column filter (multi-select). Place between Circle and Role.
+
S
+
+
+
src/app/users/import/bulk-import-form.tsx
+
Accept a discipline column in the CSV. Show it in the preview table. Validate against the enum; reject rows with unknown values.
+
M
+
+
+
src/app/api/export/users/route.ts
+
Add discipline column to CSV export (between circle and role to match the table).
+
S
+
+
+
Reports (spec 005, 028)
+
Add a discipline filter / breakdown to budget & cost reports — secondary follow-up, not part of this proposal's first cut.
+
L (later)
+
+
+
+
+
Visual treatment
+
+
+ Discipline should read as a calm, neutral attribute — not a status. Suggest a subtle outlined badge
+ next to the user's name, using lucide-react icons for visual scent:
+
+
+
+
developer — Code2 icon, slate
+
conception — Lightbulb icon, slate
+
business — Briefcase icon, slate
+
+
+
+ Avoid colored badges that compete with the existing admin / viewer role pills — those
+ already use color and should stay the eye-catcher for authorization.
+
+
+
+
CSV import & export
+
+
+ The team relies on the export → edit → re-import workflow (spec 011). Both ends need to learn about the
+ new column.
+
+
+
Export
+
// Current header
+name,email,circle,role,github_username,profile
+
+// New header
+name,email,circle,discipline,role,github_username,profile
+
+
Import
+
+
New rows — discipline is optional in the CSV (falls back to developer via DB default), but the UI preview should warn when the column is missing or blank to nudge admins to set it explicitly.
+
Existing rows (upsert) — if the CSV omits the column, leave the existing value untouched (don't overwrite to "developer"). Only update when the column is present and non-empty.
+
Invalid value — fail the row with a clear error: "discipline must be one of: developer, conception, business".
+
+
+
+
Migration & backfill
+
+
The rollout is mechanical and low-risk:
+
+
+
Add enum + column to schema.ts; pnpm db:generate to produce the migration file.
+
Run pnpm db:migrate — existing rows get developer via the column default.
+
Update Zod validators, server actions, and UI in one PR.
+
Update CSV import/export endpoints.
+
Communicate to admins: "new field on every user; defaulted to Developer; please reclassify Conception & Business colleagues at /users".
+
+
+
+ Migration safety
+ Using ADD COLUMN … NOT NULL DEFAULT 'developer' on Postgres 11+ is a metadata-only
+ operation (no table rewrite). Safe even on a large users table.
+
+
+
+
Relationship to circle & profile
+
+
+ It is worth being explicit that discipline is orthogonal to the existing fields. They answer
+ different questions:
+
+
+
+
+
Field
Answers
Example
+
+
+
+
role
+
What can you do in this app?
+
admin
+
+
+
disciplineNew
+
What kind of work do you do?
+
conception
+
+
+
circle
+
Which team / circle do you belong to?
+
"Team Phoenix"
+
+
+
profile
+
Which Anthropic API tier are you on?
+
maxed
+
+
+
+
+
+ Two Conception people in different circles will share a discipline but not a circle. A developer and a
+ conception person in the same circle will share a circle but not a discipline. Keeping the columns
+ separate is the right model — do not repurpose circle for this.
+
+
+
+
Open questions
+
+
+
+ Q1. Lock in discipline as the name, or go with
+ practice / something else?
+ Affects column name, validator key, CSV header, and all UI labels. Cheap to change
+ now, expensive after launch.
+
+
+ Q2. Are the three values (developer / conception / business) the
+ complete list, or do we want to seed with design, data, qa while we're at
+ it?
+ Adding later via ALTER TYPE … ADD VALUE is easy; the question is
+ whether the team already knows others are coming.
+
+
+ Q3. Should existing reports (budget by month, Copilot usage, Claude spend)
+ get a "group by discipline" breakdown as part of this feature, or as a follow-up?
+ Recommended follow-up — keep the first PR focused on the data model + user-management
+ UI, then layer reporting on top once the data is populated.
+
+
+ Q4. Does the agent user (is_agent = true, seeded by spec
+ agent-browser-session) need a discipline at all?
+ Suggestion: yes, defaulted to developer, hidden from the UI. Keeps the
+ NOT NULL contract simple. Alternative: make the column nullable for agent rows only — adds
+ complexity for little gain.
+
+
+ Q5. Display ordering for the dropdown — alphabetical
+ (Business / Conception / Developer) or by team size (Developer / Conception / Business)?
+ Suggest by team size, so the most common pick is at the top and saves a click.
+
+
+
+
+
Out of scope
+
+
+
Renaming the existing security role column. (It stays as-is.)
+
Permissions or policy changes based on discipline. Discipline is descriptive, not authorizing.
+
Per-discipline budgets or cost caps. (Possible future feature; this proposal only adds the data.)
+
Per-discipline default Anthropic profile assignment. (Possible follow-up once we see
+ usage patterns.)
+
Historical attribution: existing billed costs are not retroactively tagged by discipline. Future
+ billing rows can be joined to users.discipline at query time.
+
+
+
+
+
+
diff --git a/specs/032-user-disciplines/screenshot-profile.png b/specs/032-user-disciplines/screenshot-profile.png
new file mode 100644
index 0000000..f2ff4bf
Binary files /dev/null and b/specs/032-user-disciplines/screenshot-profile.png differ
diff --git a/specs/032-user-disciplines/screenshot-users-table.png b/specs/032-user-disciplines/screenshot-users-table.png
new file mode 100644
index 0000000..16335bf
Binary files /dev/null and b/specs/032-user-disciplines/screenshot-users-table.png differ
diff --git a/src/actions/github-sync.ts b/src/actions/github-sync.ts
index 3f109dd..4922047 100644
--- a/src/actions/github-sync.ts
+++ b/src/actions/github-sync.ts
@@ -312,6 +312,9 @@ export async function confirmGitHubSync(
passwordHash: tempPasswordHash,
githubUsername: member.githubLogin,
role: "viewer",
+ // "Import as-is" has no inline form for the admin to pick
+ // a discipline — default to developer; reclassify in /users.
+ discipline: "developer",
status: "active",
})
.returning({ id: users.id });
@@ -477,6 +480,7 @@ export async function confirmGitHubSync(
passwordHash: tempPasswordHash,
githubUsername: nu.githubLogin,
role: "viewer",
+ discipline: nu.discipline,
status: "active",
})
.returning({ id: users.id });
diff --git a/src/actions/users.ts b/src/actions/users.ts
index 61acce2..9beacf3 100644
--- a/src/actions/users.ts
+++ b/src/actions/users.ts
@@ -38,7 +38,7 @@ export async function createUser(
};
}
- const { name, email, circle, role, githubUsername, profile } = parsed.data;
+ const { name, email, circle, role, discipline, githubUsername, profile } = parsed.data;
const normalizedEmail = email.toLowerCase();
// Check email uniqueness
@@ -60,6 +60,7 @@ export async function createUser(
passwordHash,
circle: circle ?? null,
role,
+ discipline,
githubUsername: githubUsername ?? null,
profile: profile ?? null,
mustChangePassword: true,
@@ -124,6 +125,13 @@ export async function updateUser(
changes.role = { old: existing.role, new: updates.role };
values.role = updates.role;
}
+ if (
+ updates.discipline !== undefined &&
+ updates.discipline !== existing.discipline
+ ) {
+ changes.discipline = { old: existing.discipline, new: updates.discipline };
+ values.discipline = updates.discipline;
+ }
if (
updates.githubUsername !== undefined &&
updates.githubUsername !== existing.githubUsername
@@ -216,8 +224,22 @@ export async function deactivateUser(input: {
/** Compare CSV row fields against existing user, return changed fields with old/new values.
* Only considers a field changed if the CSV explicitly provides a value (not undefined). */
function computeUserDiff(
- row: { name: string; circle?: string; role?: string; githubUsername?: string; profile?: string },
- existing: { name: string; circle: string | null; role: string; githubUsername: string | null; profile: string | null }
+ row: {
+ name: string;
+ circle?: string;
+ role?: string;
+ discipline?: string;
+ githubUsername?: string;
+ profile?: string;
+ },
+ existing: {
+ name: string;
+ circle: string | null;
+ role: string;
+ discipline: string;
+ githubUsername: string | null;
+ profile: string | null;
+ }
): Record {
const changes: Record = {};
@@ -235,6 +257,9 @@ function computeUserDiff(
if (row.role !== undefined && row.role !== existing.role) {
changes.role = { old: existing.role, new: row.role };
}
+ if (row.discipline !== undefined && row.discipline !== existing.discipline) {
+ changes.discipline = { old: existing.discipline, new: row.discipline };
+ }
if (row.githubUsername !== undefined) {
const newGithubUsername = normalizeField(row.githubUsername);
if (newGithubUsername !== existing.githubUsername) {
@@ -275,6 +300,7 @@ export async function checkExistingUsers(input: {
role: u.role,
githubUsername: u.githubUsername,
profile: u.profile,
+ discipline: u.discipline,
};
}
@@ -316,7 +342,8 @@ export async function bulkImportUsers(input: {
continue;
}
- const { name, email, circle, role, githubUsername, profile } = parsed.data;
+ const { name, email, circle, role, discipline, githubUsername, profile } =
+ parsed.data;
const lowerEmail = email.toLowerCase();
// Detect duplicate emails within the same file
@@ -330,9 +357,11 @@ export async function bulkImportUsers(input: {
const existing = existingMap.get(lowerEmail);
if (existing) {
- // Upsert: update existing user (never touch password or status)
+ // Upsert: update existing user (never touch password or status).
+ // discipline is omitted from the diff input when the CSV did not
+ // supply it, so the existing discipline is preserved on upsert.
const diff = computeUserDiff(
- { name, circle, role, githubUsername, profile },
+ { name, circle, role, discipline, githubUsername, profile },
existing
);
@@ -360,6 +389,9 @@ export async function bulkImportUsers(input: {
passwordHash,
circle: circle ?? null,
role: role ?? "viewer",
+ // New rows default to "developer" when CSV omits the column,
+ // matching the DB default but explicit for traceability.
+ discipline: discipline ?? "developer",
githubUsername: githubUsername ?? null,
profile: profile ?? null,
mustChangePassword: true,
diff --git a/src/app/api/export/users/route.ts b/src/app/api/export/users/route.ts
index d1bb0e5..ba7d882 100644
--- a/src/app/api/export/users/route.ts
+++ b/src/app/api/export/users/route.ts
@@ -15,6 +15,7 @@ export async function GET() {
name: users.name,
email: users.email,
circle: users.circle,
+ discipline: users.discipline,
role: users.role,
githubUsername: users.githubUsername,
profile: users.profile,
@@ -26,13 +27,14 @@ export async function GET() {
row.name,
row.email,
row.circle,
+ row.discipline,
row.role,
row.githubUsername ?? "",
row.profile ?? "",
]);
const csv = toCsv(
- ["name", "email", "circle", "role", "github_username", "profile"],
+ ["name", "email", "circle", "discipline", "role", "github_username", "profile"],
csvRows
);
diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts
index 9bfa0b0..dbbb3ed 100644
--- a/src/app/api/profile/route.ts
+++ b/src/app/api/profile/route.ts
@@ -77,6 +77,7 @@ export async function GET(request: NextRequest) {
role: profileData.user.role,
circle: profileData.user.circle,
profile: profileData.user.profile,
+ discipline: profileData.user.discipline,
status: user.status,
},
assignments: profileData.assignments.map((a) => ({
diff --git a/src/app/assignments/[id]/assignment-detail-client.tsx b/src/app/assignments/[id]/assignment-detail-client.tsx
index d34a835..c80e561 100644
--- a/src/app/assignments/[id]/assignment-detail-client.tsx
+++ b/src/app/assignments/[id]/assignment-detail-client.tsx
@@ -18,7 +18,8 @@ import {
type UpdateAssignmentInput,
} from "@/lib/validators";
import { formatCurrency, cn, formatDateOnly } from "@/lib/utils";
-import type { AccessTier } from "@/types";
+import type { AccessTier, UserDiscipline } from "@/types";
+import { DISCIPLINE_ICON, DISCIPLINE_LABEL, asDiscipline } from "@/lib/disciplines";
import {
Card,
CardContent,
@@ -69,7 +70,7 @@ interface AssignmentData {
assignedAt: string | null;
revokedAt: string | null;
workspace: string | null;
- user: { id: number; name: string };
+ user: { id: number; name: string; discipline: UserDiscipline };
tool: { id: number; name: string };
tier: { id: number; name: string };
hasApiKey: boolean;
@@ -242,8 +243,21 @@ export function AssignmentDetailClient({
{" "}
→ {assignment.tool.name} at {assignment.tier.name}
-
diff --git a/src/app/assignments/[id]/page.tsx b/src/app/assignments/[id]/page.tsx
index 1c6480d..dc09274 100644
--- a/src/app/assignments/[id]/page.tsx
+++ b/src/app/assignments/[id]/page.tsx
@@ -49,7 +49,11 @@ export default async function AssignmentDetailPage({
assignedAt: assignment.assignedAt?.toISOString() ?? null,
revokedAt: assignment.revokedAt?.toISOString() ?? null,
workspace: assignment.workspace,
- user: { id: assignment.user.id, name: assignment.user.name },
+ user: {
+ id: assignment.user.id,
+ name: assignment.user.name,
+ discipline: assignment.user.discipline,
+ },
tool: { id: assignment.tool.id, name: assignment.tool.name },
tier: { id: assignment.tier.id, name: assignment.tier.name },
hasApiKey,
diff --git a/src/app/settings/sync/github-member-sync-sheet.tsx b/src/app/settings/sync/github-member-sync-sheet.tsx
index b677f55..0332397 100644
--- a/src/app/settings/sync/github-member-sync-sheet.tsx
+++ b/src/app/settings/sync/github-member-sync-sheet.tsx
@@ -49,6 +49,7 @@ import type {
SyncUnmatchedMember,
SyncUnmatchedSystemUser,
PendingResolution,
+ UserDiscipline,
} from "@/types";
interface GitHubMemberSyncSheetProps {
@@ -185,6 +186,7 @@ export function GitHubMemberSyncSheet({
githubLogin: string;
name: string;
email: string;
+ discipline: UserDiscipline;
}> = [];
for (const r of resolutions.values()) {
@@ -200,6 +202,7 @@ export function GitHubMemberSyncSheet({
githubLogin: r.githubLogin,
name: r.name,
email: r.email,
+ discipline: r.discipline,
});
break;
// skip: nothing to send
@@ -635,6 +638,7 @@ function UnmatchedGitHubResolutionList({
githubLogin: data.githubLogin,
name: data.name,
email: data.email,
+ discipline: data.discipline,
})
}
onCancel={onCollapse}
diff --git a/src/app/users/[id]/user-detail-client.tsx b/src/app/users/[id]/user-detail-client.tsx
index 44e7c4a..83861fd 100644
--- a/src/app/users/[id]/user-detail-client.tsx
+++ b/src/app/users/[id]/user-detail-client.tsx
@@ -13,6 +13,7 @@ import { getTools, getToolWithTiers } from "@/actions/tools";
import { formatCurrency, formatDate } from "@/lib/utils";
import type { User, ChangeHistoryRecord, CostData, AiTool, AccessTier } from "@/types";
import { AdminCostSection } from "@/components/profile/admin-cost-section";
+import { DISCIPLINES, DISCIPLINE_ICON, DISCIPLINE_LABEL, asDiscipline } from "@/lib/disciplines";
import { Github, ExternalLink, BookOpen, KeyRound, Plus, RotateCcw, Eye, EyeOff } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
@@ -123,11 +124,15 @@ export function UserDetailClient({
email: user.email,
circle: user.circle ?? undefined,
role: user.role as "admin" | "viewer",
+ discipline: user.discipline,
githubUsername: user.githubUsername ?? "",
profile: user.profile ?? null,
},
});
+ const userDiscipline = asDiscipline(user.discipline);
+ const DisciplineIcon = DISCIPLINE_ICON[userDiscipline];
+
async function onSubmit(data: EditUserInput) {
const result = await updateUser({ id: user.id, ...data });
if (result.success) {
@@ -237,6 +242,10 @@ export function UserDetailClient({
>
{user.status}
+
+
+ {DISCIPLINE_LABEL[userDiscipline]}
+
{user.profile && (
{user.profile}
@@ -358,6 +367,33 @@ export function UserDetailClient({
)}
/>
+ (
+
+ Discipline
+
+
+
+ )}
+ />
0,
githubUsername: row.github_username || row.githubusername || "",
profile: (row.profile || "").trim().toLowerCase(),
valid: true,
@@ -80,6 +94,9 @@ function parseCSV(text: string): ParsedUser[] {
} else if (rawProfile && !validProfiles.includes(rawProfile)) {
user.valid = false;
user.error = "Profile must be 'boost', 'maxed', or 'indie'";
+ } else if (rawDiscipline && !validDisciplines.includes(rawDiscipline)) {
+ user.valid = false;
+ user.error = `Discipline must be one of: ${DISCIPLINES.join(", ")}`;
}
return user;
@@ -141,11 +158,14 @@ export function BulkImportForm() {
async function handleImport() {
const validUsers = parsedUsers
.filter((u) => u.valid)
- .map(({ name, email, circle, role, githubUsername, profile }) => ({
+ .map(({ name, email, circle, role, discipline, disciplineProvided, githubUsername, profile }) => ({
name,
email,
circle: circle || undefined,
role,
+ // Pass discipline only when CSV explicitly provided a value so the
+ // server-side upsert preserves the existing value on update.
+ discipline: disciplineProvided ? (discipline as UserDiscipline) : undefined,
githubUsername: githubUsername || undefined,
profile: profile || undefined,
}));
@@ -187,7 +207,9 @@ export function BulkImportForm() {
Bulk Import Users
Upload a CSV file with columns: name, email (required); circle (or
- department), role, github_username, profile (optional)
+ department), discipline, role, github_username, profile (optional).
+ New rows without a discipline default to developer;
+ existing rows keep their value when the column is blank.