feat(042): approvable tier changes on active licence assignments - #125
Conversation
…sed) Reconnaissance (7-reader workflow + gap critic) and a 5-lens adversarial challenge pass. The panel proved plan v1's central costing argument wrong and surfaced a live bug: a failed license-request approval leaks a Hub user, because a returned error commits the transaction. v2 is migration-free and roughly half the scope of v1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Approving a licence request for someone who already held that tool failed outright, and the only suggested remedy (revoke first) routed the admin into the worst of three rival tier-change implementations. approveRequest now takes a discriminated-union mode: create - insert a new assignment (unchanged) change_tier - retier the seat they already hold, in place link_existing - approve and link an already-provisioned seat A discriminated union rather than a flat flag because loadToolAndTier runs before the transaction and unconditionally required an API key -- which would have killed every Claude Console upgrade, the only requires_api_key tool and the one with the deepest tier ladder. Tier semantics now live in one pure module (lib/assignments/tier-change), shared by updateAssignment and approveRequest. In-place mutation is deliberate: closing one row and opening another double-counts the switch month, because per-period expected spend sums the flat monthly price by date overlap with no proration. A unit test pins that property, since integration tests do not run in CI. Two live bugs fixed: - A failed approval leaked a Hub user. ensureRequesterUser inserted the requester, then the duplicate-seat refusal was RETURNED from the transaction callback -- which commits in Drizzle; only a throw rolls back. Every such rejection left an abandoned viewer account behind. - loadToolAndTier never checked access_tiers.is_active, so the request path could put a live seat on a deactivated tier. Also: tier changes now revalidate the budget/report surfaces they move (previously only /assignments, /users/[id], /reports); the tier write is guarded on the observed status and tier so a concurrent revoke cannot be retiered; history is written inside the transaction; and the dead getExpectedSpendForPeriod (zero callers) is deleted rather than reconciled. Refs specs/042-assignment-tier-change Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the record UI + tests completing spec 042, plus two corrections to the plan. UI: - Approval dialog derives its mode from a toolId match against the requester's active assignments: create / change_tier / link_existing. Signed monthly-tier-cost delta, no upgrade/downgrade wording (Claude Console's price order is not an entitlement ladder). API key stays optional rather than suppressed on a retier -- suppressing it made the pasted snippet read "Your API key: -". - Assignment detail page: tier Select gated for sync-managed seats (a live bug -- that page never received the assignment's source, so an admin could "change" a Copilot seat and have cron revert it), plus a change_history-derived tier timeline and a one-line disclosure that a tier change restates closed periods. - User detail: Change-tier link per active row, routing to the assignment page rather than duplicating a dialog. New action linkExistingAssignment: the legacy recordAssignment path could not be fixed with approveRequest's link_existing mode as the plan claimed. That dialog only opens for status='approved' rows while approveRequest requires 'pending_review' -- mutually exclusive, so every such call would have failed. It gets its own guarded linker instead. CORRECTION: an earlier commit message and the plan claimed a live bug where a failed approval leaked a Hub user. That was wrong and is retracted. ensureRequesterUser inserts only when no user with that email exists, and a freshly inserted user cannot already hold an active assignment, so the duplicate-seat refusal and a user insert are mutually exclusive. The throw-not-return discipline is kept as hardening -- this spec does add rejections that can follow a real write -- but nothing was ever orphaned. Tests: 658 passing (53 files), up from 629. The existing integration suite's six approveRequest calls needed an explicit mode: "create"; typecheck could not catch that, because the action's parameter is typed as unknown. Refs specs/042-assignment-tier-change Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…invalidating Four review lenses over the branch (reuse, simplification, efficiency, altitude). 10 findings applied, 4 skipped with reasons in the notes. Shared what had been duplicated: - revalidate.ts had its own copy of the five path literals updateTier already revalidated, and the two had already drifted. One revalidateCostSurfaces(), called by both. - The "Managed by sync" badge existed twice with two different explanations of the same condition. Now one component. - fetchPerToolByPeriod still inlined its own overlap predicate, so the CI test pinned the rule for one reader while the other could drift -- which defeated the reason for extracting it. Both share overlapsPeriod now, with the long-standing > vs >= edge case preserved as an explicit revokedBound parameter and pinned by its own test. - The RACE_LOST/ApprovalError catch body was copy-pasted into three transactions. One catchApprovalTx. Stopped over-invalidating: cost paths were busted on every successful update, including workspace/API-key-only edits, a no-op retier, and link_existing -- seven paths each time, and the dashboard alone re-runs ~13 queries. The precedent this claimed to mirror gates on whether cost changed; this did not. Now gated. Dropped a redundant query on three paths: isSyncManagedTool took a tool id and re-queried ai_tools, at three call sites that already held the tool row -- approveRequest even fetched it and threw it away. The API is name-based now (isCopilotSyncActive + isSyncManagedToolName), and getSyncManagedToolId is gone. getRequestContext also runs its two independent queries concurrently. Resolves the deviation logged in the notes: the user detail page gated on assignment.source, the exact check plan section 6 rejects as insufficient, leaving one right and one wrong example of the same decision for the next surface to copy. It is tool-based now, resolved server-side. Tests 659 passing (53 files). Typecheck and eslint clean. Refs specs/042-assignment-tier-change Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the suite
Both found by running tests/integration against a real Neon branch — neither
was reachable from unit tests.
1. Teardown broke because of this spec's own change. change_history.changed_by
is onDelete RESTRICT, and approving as a tier change now writes audit rows
attributed to the test admin, so afterAll's users delete failed with a FK
violation (23503) after every run. The suite reported 12/12 passing tests and
a failed FILE. Audit rows for the run's users are now removed first.
2. My retier assertion was testing the wrong layer. It expected
{{tier.previousName}} to be resolved in the stored approval message, but
approveRequest stores bodyMd verbatim -- rendering happens client-side in the
approval dialog, and the suite already relies on that elsewhere (it asserts
the stored message deliberately KEEPS {{licenseCode}} so the key never lands
in the database). Now asserts verbatim storage and points at the renderer's
own unit test.
Verified: full integration directory green -- 8 files, 56 passed, 7 todo,
2 skipped -- including the budget suites, which exercise the getBudgetWithCosts
refactor onto the shared overlap predicate against real Postgres.
Refs specs/042-assignment-tier-change
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified against a synthetic Neon branch, because the app's own project is not reachable from this session: the connected Neon MCP sees one project whose only branch is empty, and no shared projects. Forked it, migrated 0000-0029, seeded a catalogue mirroring the live taxonomy. Integration suite green: 8 files, 56 passed. Includes the budget suites, so the getBudgetWithCosts refactor onto the shared overlap predicate is exercised against real Postgres. Records the two defects the run found that unit tests could not (both fixed in the previous commit), and states plainly that the UI has no verification at all -- the browser pass could not run because @neondatabase/serverless falls back to Node's undici WebSocket, which connects only intermittently on Node 24. Environmental, not caused by this spec: it also broke db:seed:agent. The documented instrumentation + serverExternalPackages workaround was tried and did not help; both files were reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither was reachable from typecheck, 659 unit tests, or 56 integration tests. Both needed a real browser against real data. 1. The sync-managed badge crashed the whole assignment detail page. "`Tooltip` must be used within `TooltipProvider`" -- the assignments TABLE only works because DataTable happens to wrap its rows in a provider, and the detail page has none. So /assignments/<id> rendered nothing but a heading for every sync-managed seat, which is precisely the case the badge exists to mark. SyncManagedBadge now carries its own provider, so it is safe to drop anywhere; nesting providers is harmless. 2. The tier control rendered EMPTY on every assignment. Radix resolves a Select's displayed value by looking up the selected ITEM's children, and the items arrive asynchronously from loadTiers -- so the control painted blank until the list happened to be ready, and stayed blank whenever it was disabled. A placeholder does not help: it only shows when the value is empty, and here it is a real tier id. The label is now rendered directly, falling back to the assignment's own tier name. Pre-existing (it predates this spec), but it is this feature's primary control and it made the tier look unset everywhere. Verified in a browser against a branch of the real database: 9/9 checks, covering both directions (Standard -> Premium -> Standard on a real Claude seat), the timeline recording both hops, the re-pricing disclosure, and a sync-managed Copilot seat showing "Managed by sync" with its tier control disabled but still legible. Refs specs/042-assignment-tier-change Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Integration suite and a Playwright browser pass against a branch of the real database (223 users, 309 active assignments, 1933 history rows). Supersedes the round-1 synthetic-branch section. Records what the browser pass exercised end to end -- a real Claude seat moved Standard -> Premium -> Standard, the timeline rendering both hops, the database left with exactly one active row and both audit fields written under one timestamp, and a real copilot-sync seat correctly gated -- and states plainly that the pass earned its place by finding two rendering bugs that typecheck, 659 unit tests and 56 integration tests all missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The costing decision is no longer an open assumption: the upgrade month bills entirely at the new tier and closed periods restate, confirmed after the trade-off (1 period x old price vs N closed periods x delta) was laid out. Alternatives stay on record in plan section 3 and deferral D-1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR (spec 042) makes “change the tier of an active license assignment” an approvable, unified operation across existing entry points (admin edit + request approval), without schema changes. It consolidates tier-change semantics into shared pure helpers, introduces an approval-mode discriminated union to make create-only rules unreachable for other flows, and hardens cost attribution + cache invalidation behavior.
Changes:
- Add three approval modes (
create,change_tier,link_existing) and implement in-place tier mutation with correct rollback semantics. - Centralize tier-change and cost-attribution logic (
buildTierChange, sharedoverlapsPeriod/sumExpectedSpendCents) with new unit tests that can gate CI. - Add sync authority gating (tool-based) and UI updates (tier timeline, disabled tier controls for sync-managed seats, “Change tier” affordance).
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/license-requests/render-template.test.ts | Updates template context test data for tier.previousName. |
| tests/unit/assignment-tier-change.test.ts | New pure-logic tests for tier-change semantics and period overlap/cost attribution invariants. |
| tests/unit/actions/assignment-tier-change.test.ts | New mocked-action tests covering updateAssignment retier path + approveRequest mode matrix. |
| tests/integration/actions/license-requests.test.ts | Integration updates for new approval schema discriminator and new 042 tier-change/link behaviors + teardown fixes. |
| src/lib/validators.ts | Converts approveRequestSchema to a discriminated union and adds legacy linkExistingAssignmentSchema. |
| src/lib/license-requests/render-template.ts | Extends template context with always-bound tier.previousName and registers the variable path. |
| src/lib/budget-utils.ts | Extracts and exports overlapsPeriod + sumExpectedSpendCents for shared cost attribution and CI-unit-test coverage. |
| src/lib/assignments/tier-change.ts | New pure module for shared tier-change semantics (buildTierChange, delta helper, sync-managed error message). |
| src/lib/assignments/sync-authority.ts | New tool-based sync authority helpers (isCopilotSyncActive, name-based sync-managed checks). |
| src/lib/assignments/revalidate.ts | New shared cache revalidation helpers for cost-derived pages. |
| src/components/assignments/sync-managed-badge.tsx | New reusable “Managed by sync” badge that safely owns a TooltipProvider. |
| src/app/users/[id]/user-detail-client.tsx | Adds “Change tier” CTA per active assignment with server-provided sync-managed tool gating. |
| src/app/users/[id]/page.tsx | Computes syncManagedToolNames via isCopilotSyncActive() and passes down to client. |
| src/app/settings/license-templates/template-editor-dialog.tsx | Updates sample/template-preview context to include tier.previousName. |
| src/app/requests/[id]/request-detail-client.tsx | Threads activeAssignments into approval + record dialogs. |
| src/app/requests/[id]/record-assignment-dialog.tsx | Adds legacy “link existing seat” path when a duplicate active seat exists. |
| src/app/requests/[id]/approval-dialog.tsx | Implements mode-aware approval UX (create/retier/link), template selection, and tier delta copy. |
| src/app/assignments/assignments-client.tsx | Reuses SyncManagedBadge for table rows instead of duplicating tooltip logic. |
| src/app/assignments/[id]/page.tsx | Adds sync-managed gating + tier-history fetch and passes data to client. |
| src/app/assignments/[id]/assignment-detail-client.tsx | Disables tier select when sync-managed, fixes Select value rendering, and renders tier timeline. |
| src/actions/tools.ts | Reuses shared cost-surface revalidation helper instead of duplicating path list. |
| src/actions/reports.ts | Uses shared overlapsPeriod predicate (with explicit revoked bound behavior preserved). |
| src/actions/license-requests.ts | Core 042 logic: new approval modes, rollback discipline, retier/link flows, validation changes, and cache invalidation gating. |
| src/actions/history.ts | Adds assignment tier timeline reader (getAssignmentTierHistory). |
| src/actions/budget.ts | Removes dead getExpectedSpendForPeriod and switches to shared expected-spend summation helper. |
| src/actions/assignments.ts | Unifies tier-change semantics via shared helper and adds race-safe conditional update + transactional audit. |
| specs/042-assignment-tier-change/implementation-plan.html | Design/plan documentation for 042, including tradeoffs and acceptance criteria. |
| specs/042-assignment-tier-change/implementation-notes.html | Implementation log, deviations, verification notes, and rationale record. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (approval.mode === "change_tier") { | ||
| const outcome = buildTierChange( | ||
| { | ||
| tierId: target.tierId, | ||
| costAtAssignmentCents: target.costAtAssignmentCents, | ||
| // Already refused above; the tool cannot change mid-transaction. | ||
| isSyncManaged: false, | ||
| }, | ||
| { id: tier!.id, monthlyCostCents: tier!.monthlyCostCents }, | ||
| ); |
There was a problem hiding this comment.
Fixed in f4bb80b — you're right, and this was a real defect, not just a hardening gap.
loadToolAndTier proves the tier belongs to approval.toolId, but nothing tied the assignment to it, so the two were free to disagree. Both consequences you name are real: a stale dialog or crafted payload could write another tool's tier onto the seat, and because the sync-managed refusal also keys off the selected tool rather than the row's own, passing a Copilot assignmentId with a non-Copilot toolId would have walked straight past that gate.
Added the explicit target.toolId === approval.toolId check before buildTierChange, which mirrors the same-tool rule updateAssignment already gets for free by scoping its accessTiers lookup to assignment.toolId — splitting that validation across two loads is exactly what opened the hole. Also added a unit regression test that asserts both the refusal and that nothing is written to the assignment.
Worth noting the adversarial challenge pass missed this: it scrutinised the mode matrix and the sync gate separately, but never their interaction. Recorded in implementation-notes.html.
| eq(changeHistory.fieldName, "tierId"), | ||
| ), | ||
| orderBy: desc(changeHistory.createdAt), | ||
| with: { changedByUser: true }, | ||
| }); |
There was a problem hiding this comment.
Fixed in f4bb80b — restricted to with: { changedByUser: { columns: { name: true } } }, since name is the only field rendered.
One clarification on severity, so the record is accurate: the mapped return shape is { id, previousTierName, newTierName, changedByName, createdAt }, so passwordHash never reached the client. It was needless loading of a password hash into server memory rather than an exposure. Still worth fixing, and it now matches the columns: {...} convention used elsewhere in this file.
| // revoke, copilot-sync's removed-seat revoke), and retiering a just-revoked | ||
| // row would corrupt the historical snapshot that revoked rows are supposed to | ||
| // preserve. The tierId predicate doubles as an optimistic lock against a | ||
| // concurrent tier price cascade (updateTier / syncBillingData) leaving the row | ||
| // stranded at a stale price. |
There was a problem hiding this comment.
Fixed in f4bb80b — you're right, the comment was wrong and I've rewritten it.
Confirmed against updateTier (src/actions/tools.ts): the cascade sets costAtAssignmentCents on rows matched by tierId, leaving tier_id itself untouched — so an eq(tierId, observed) predicate cannot see it. Same for syncBillingData.
The comment now states only what the guard actually covers (a status flip to inactive from any of the four revoke paths, and a concurrent retier), then names the price-cascade race it does not cover and why it is tolerable: our cost value is read before the transaction and would win, but the error is narrow and self-correcting — the next cascade or retier overwrites it. A real fix is row-level compare-and-swap on updatedAt, which the plan deliberately scoped out (the guard was scoped to the tier-snapshot invariant); that trade-off is recorded in implementation-notes.html under the simplify pass's skipped findings.
…join, honest comment
All three review comments were correct; one was a real defect.
1. change_tier never checked that the target assignment belongs to the
SELECTED tool. loadToolAndTier proves the tier belongs to approval.toolId,
but nothing tied the assignment to it -- so a stale dialog or crafted
payload could write another tool's tier onto a seat, and slip past the
sync-managed refusal, which also keys off the selected tool rather than the
row's own. Copilot flagged both consequences. Added the explicit
target.toolId === approval.toolId check, mirroring the same-tool guard
updateAssignment already enforces through its accessTiers query, plus a
unit regression test (asserts the refusal AND that nothing is written).
2. getAssignmentTierHistory selected the whole users row via
changedByUser: true -- password_hash included -- to render one name. It
never reached the client, so this is hygiene rather than a leak, but the
relation is now restricted to { name }.
3. My race-guard comment overclaimed. It said the tierId predicate doubles as
an optimistic lock against a tier price cascade; it does not, because
updateTier and syncBillingData rewrite cost_at_assignment_cents while
leaving tier_id alone, so the predicate cannot see them. The comment now
states what the guard actually covers (status flips and concurrent
retiers), names the price-cascade race it does not cover, and points at the
compare-and-swap the plan scoped out.
Tests 660 passing (from 659); integration 8 files / 56 passing; typecheck and
eslint clean.
Refs specs/042-assignment-tier-change
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three Copilot comments, all correct, all adopted. Records that one was a real defect this spec introduced -- change_tier not verifying the target assignment belongs to the selected tool, which also let a crafted payload bypass the sync-managed gate -- and why the challenge pass missed it: it examined the mode matrix and the sync gate separately, never the interaction where the gate's input and the mutation's target are allowed to disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Main advanced with #125 (spec 042, approvable tier changes on active licence assignments) and #126 (spec 044, pool reliability) while 043 was in review. The two specs had independently built three overlapping abstractions. Resolved as follows. 1. TIER-CHANGE SEMANTICS — 042 wins, 043 adapts. updateAssignmentCore no longer has its own tier branch; it calls buildTierChange from @/lib/assignments/tier-change, the same function the UI action and approveRequest use. 043's premise is one implementation per mutation shared by UI and MCP, so keeping a second copy would have broken the thing the refactor exists for. MCP therefore inherits 042's sync-managed refusal for free: without this, an agent could retier a GitHub Copilot seat and have the 06:00 cron silently revert it — the exact failure mode set_tier_price already guards against. 042's ordering is preserved verbatim: sync authority is consulted ONLY when the tier actually differs, because the detail form always submits tierId and checking unconditionally would reject every workspace/API-key edit on a synced seat. 2. SYNC AUTHORITY — 042 wins, 043's duplicate deleted. isSyncOwnedTool and its hardcoded name set are gone; everything now goes through isSyncManagedTool, which additionally verifies the Copilot sync is actually active rather than assuming it from the tool name. 043's caps.syncOwnedFields survives as the UI-vs-MCP distinction on top of it, so UI behaviour is unchanged. revokeLicenseCore gained the same refusal (caps-gated): revoking a sync-managed seat is undone by the next sync with no audit row, so an agent would report a released cost that returns at 06:00. 3. CACHE INVALIDATION — composed, not chosen. New src/lib/assignments/cost-paths.ts holds the single LIST of cost surfaces. There are two TRANSPORTS that replay it: 042's revalidate.ts (direct revalidatePath, for actions that do not go through a write core) and 043's CoreResult.revalidate (for those that do). A given write uses exactly one. The list module imports nothing from next/cache, which keeps it out of the core module graph that the MCP route and the db-mocked unit tests load. Also migrated every history call site 042/044 added to @/lib/history's options-object signature with an explicit source, and repointed the tests that mocked @/actions/history for the write helpers. Migrations untouched — 0030 and 0031 are already applied to production. Verified: pnpm typecheck, pnpm lint, and 751 unit tests across 56 files all pass (043's 703 plus main's new suites). NOT verified: the integration suite, Playwright and a live MCP session — the Neon dev branch credential stopped authenticating partway through this work (password authentication failed for neondb_owner on both the pooled and unpooled URLs). Those must be re-run before this merges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing (#127) Nine admin write tools on the MCP server alongside the existing 14 read-only ones. Every mutation lands a change_history row attributed to the real human behind the OAuth token. Architecture: business logic moved out of the Server Actions into actor-parameterised cores (src/lib/core/*), so there is one implementation of each mutation shared by the UI and MCP. It could not become a parameter on the actions themselves — every export of a "use server" file is a client-callable RPC endpoint whose arguments are attacker-controlled. The audit writers and the invite minter moved out of "use server" files for the same reason; both were forgery endpoints. Guardrails: conjunctive write gate (MCP_WRITE_ENABLED + live admin role + mcp:write scope + token-bound user + non-automation actor); new mcp:write scope because the consent screen promised "No write access"; the unbound shared secret cannot write; HMAC plan tokens on the three destructive tools; a human-readable echo required alongside every numeric id; secrets refused before any DB read; two-sided cents guard. Schema (0030 + 0031, already applied to production): change_history.source, and a partial unique index enforcing one ACTIVE assignment per (user, tool) — previously unenforced. 0031 supplies a DEFAULT purely for deploy safety; 0030 alone breaks every audited write in both deploy directions. Includes the merge with #125 (spec 042): updateAssignmentCore now calls buildTierChange, so MCP inherits the sync-managed refusal; 043's weaker isSyncOwnedTool deleted in favour of isSyncManagedTool; cache invalidation reconciled to one list with two transports. Verified: 751 unit, 75 integration and 5 Playwright tests against a preview branch of production, plus a live MCP session over Streamable HTTP. Spec: specs/043-mcp-write-tools/
Summary
A tier-upgrade request arrived for a licence someone already held, and the Hub had no way to action it. Approving it failed outright:
Revoke-then-recreate wasn't a workaround — it orphans the comment thread, resets
assigned_at, drops the stored API key, and double-bills the person in the switch month.The root problem turned out not to be a missing feature but three unreconciled implementations of the same operation. This unifies them and makes "change the tier of an active assignment" approvable. No schema change, no migration.
Spec, plan and full running log:
specs/042-assignment-tier-change/—implementation-plan.htmlfor the design,implementation-notes.htmlfor the challenge pass, deviations, corrections and verification.Licence-request approval now has three modes
approveRequestSchemabecomes a discriminated union, not a flat flag:createcreatechange_tierchange_tierlink_existingA union rather than a flag because
loadToolAndTierruns before the transaction and unconditionally demanded an API key — so create-only rules had to be unreachable from the other modes, not merely skipped. Claude Console is the onlyrequires_api_keytool and has the deepest tier ladder, i.e. exactly the upgrades a shared shape would have broken.link_existingis what gives GitHub Copilot an approvable outcome at all: all 63 live seats are sync-managed, so a Business→Enterprise request was refused by the create guard and by the sync guard. Now it matches 032-v2's provision-first philosophy — upgrade the plan in GitHub, approve as linked, the tier arrives via sync.Why the tier change mutates in place
Per-period expected spend sums
cost_at_assignment_centsby date overlap, flat monthly price, no proration. Closing one row and opening another therefore double-counts the switch period — one seat, two prices.Both options are wrong, in different places:
1 period × old priceN closed periods × deltaIn-place is the larger total error. It was chosen anyway because it is the convention this codebase already committed to —
actions/tools.tsstates it in a comment and migration0024backfilled production to match — and because the double count corrupts the current month, the figure people act on. A feature whose purpose is to reconcile three rival semantics shouldn't introduce a fourth costing model. Confirmed with the requester after the trade-off was laid out; alternatives (boundary-snapped supersede, proration, freezing expected spend at period close) are on record as deferral D-1.Bugs fixed along the way
Tooltipused without aTooltipProvider; the assignments table only works becauseDataTablehappens to supply one. Found by the browser pass.Select's display value from the selected item's children, which load asynchronously. Pre-existing, but this feature's primary control.<Select>was unguarded against Copilot sync — an admin could change a synced seat's tier, see[SAVED], and have cron silently revert it. The gate is tool-based, notsource-based, because sync takes over manual rows too.loadToolAndTiernever checkedaccess_tiers.is_active— the request path could put a live seat on a deactivated tier, which both other entry points already refuse.statusguard — a concurrent revoke could be retiered, corrupting the historical snapshot that revoked rows are supposed to preserve. Now a conditional UPDATE on observedstatus+tier_id(which doubles as an optimistic lock against the price cascade) with a zero-rows sentinel.updateTier's path list (already drifted) and fired on every edit, including workspace/API-key-only changes and no-ops. Now one shared list, gated on cost actually changing.getExpectedSpendForPeriodhad zero callers and carried astatusfilter the surviving readers don't, inviting a future "reconcile these two" against a function nobody read.Notable non-changes
Tier semantics live in one pure module (
lib/assignments/tier-change.ts) shared byupdateAssignmentandapproveRequest. No new action, no new dialog, and notier_changed_atcolumn —change_historyalready stores old/newtierIdwith actor and timestamp, and now backs a tier timeline on the assignment page.Verification
pnpm typecheckpnpm testpnpm lint/ eslint on changed filespnpm test:integration(full directory)Integration and browser runs were against a branch of the real database — 223 users, 5 tools, 309 active assignments, 27 requests, 1933 change-history rows.
The double-count property is pinned by a unit test. The overlap predicate was extracted from
getBudgetWithCostsspecifically so it runs in CI: theintegration-testsjob in.github/workflows/ci.ymlis commented out, so an integration test could never have gated a merge.fetchPerToolByPeriodnow shares that predicate too, with its long-standing>vs>=edge case preserved as an explicit parameter and its own test.Browser pass, end to end: a real Claude seat moved Standard Seat → Premium Seat → Standard Seat (both directions); header and cost followed ($25.00 ↔ $125.00); feedback as inline
[SAVED], not a toast; the timeline rendered both hops attributed and dated. Confirmed directly in the database afterwards: exactly one active row for that user+tool, withtierId5→6 andcostAtAssignmentCents2500→12500 written under a single timestamp. A realcopilot-syncseat showed "Managed by sync", tier control disabled but still legible, and no re-pricing disclosure.Two integration-test defects the run exposed, both fixed here: this spec's audit rows broke the suite's teardown via
change_history.changed_by'sonDelete RESTRICT(the suite reported 12/12 tests passing and a failed file); and one of my own assertions tested the wrong layer, expectingapproveRequestto render a template it deliberately stores verbatim.Process: plan → 5-lens adversarial challenge pass (2 blocking + several major findings adopted; scope roughly halved, and it disproved the plan's original costing argument) → implement →
/simplify(4 lenses, 10 applied, 4 skipped with reasons) → verify. All logged inimplementation-notes.html, including two claims I had to retract.Known limitations: the approval dialog's three modes are covered server-side but weren't clicked through in a browser. Copilot's sync-managed path was verified as gated, not as an end-to-end GitHub round trip.
🤖 Generated with Claude Code