Skip to content

feat(042): approvable tier changes on active licence assignments - #125

Merged
studert merged 11 commits into
mainfrom
042-assignment-tier-change
Aug 4, 2026
Merged

feat(042): approvable tier changes on active licence assignments#125
studert merged 11 commits into
mainfrom
042-assignment-tier-change

Conversation

@studert

@studert studert commented Aug 4, 2026

Copy link
Copy Markdown
Member

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:

Requester already has an active assignment for this tool (assignment #N). Revoke the existing assignment first, or cancel this request.

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.html for the design, implementation-notes.html for the challenge pass, deviations, corrections and verification.

Licence-request approval now has three modes

approveRequestSchema becomes a discriminated union, not a flat flag:

mode Requester already holds the tool Outcome
create no Insert, exactly as before
create yes Refused (advice updated: retier instead of revoke)
change_tier yes, manual seat New — retier in place, link the request
change_tier yes, same tier No-op on the row, but still approves and links
link_existing yes New — approve and link, mutate nothing

A union rather than a flag because loadToolAndTier runs 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 only requires_api_key tool and has the deepest tier ladder, i.e. exactly the upgrades a shared shape would have broken.

link_existing is 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_cents by 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:

Error Where
Close old + open new 1 period × old price The switch month only
In place (chosen) N closed periods × delta Every closed period the row spans

In-place is the larger total error. It was chosen anyway because it is the convention this codebase already committed to — actions/tools.ts states it in a comment and migration 0024 backfilled 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

  • The assignment detail page crashed for every sync-managed seatTooltip used without a TooltipProvider; the assignments table only works because DataTable happens to supply one. Found by the browser pass.
  • The tier control rendered empty on every assignment — radix resolves a Select's display value from the selected item's children, which load asynchronously. Pre-existing, but this feature's primary control.
  • The detail page's tier <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, not source-based, because sync takes over manual rows too.
  • loadToolAndTier never checked access_tiers.is_active — the request path could put a live seat on a deactivated tier, which both other entry points already refuse.
  • The tier write had no status guard — a concurrent revoke could be retiered, corrupting the historical snapshot that revoked rows are supposed to preserve. Now a conditional UPDATE on observed status + tier_id (which doubles as an optimistic lock against the price cascade) with a zero-rows sentinel.
  • Cost invalidation was both wrong and wasteful — it duplicated 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.
  • Dead code removedgetExpectedSpendForPeriod had zero callers and carried a status filter 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 by updateAssignment and approveRequest. No new action, no new dialog, and no tier_changed_at column — change_history already stores old/new tierId with actor and timestamp, and now backs a tier timeline on the assignment page.

Verification

Gate Result
pnpm typecheck clean
pnpm test 659 passed, 53 files (from 629)
pnpm lint / eslint on changed files clean
pnpm test:integration (full directory) 8 files, 56 passed, 7 todo, 2 skipped
Playwright browser pass 9/9 checks

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 getBudgetWithCosts specifically so it runs in CI: the integration-tests job in .github/workflows/ci.yml is commented out, so an integration test could never have gated a merge. fetchPerToolByPeriod now 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, with tierId 5→6 and costAtAssignmentCents 2500→12500 written under a single timestamp. A real copilot-sync seat 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's onDelete RESTRICT (the suite reported 12/12 tests passing and a failed file); and one of my own assertions tested the wrong layer, expecting approveRequest to 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 in implementation-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

studert and others added 9 commits August 3, 2026 12:13
…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>
Copilot AI lite review requested due to automatic review settings August 4, 2026 08:18
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ai-developer-hub Ready Ready Preview Aug 4, 2026 8:31am

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, shared overlapsPeriod/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.

Comment on lines +547 to +556
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 },
);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/actions/history.ts
Comment on lines +142 to +146
eq(changeHistory.fieldName, "tierId"),
),
orderBy: desc(changeHistory.createdAt),
with: { changedByUser: true },
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/actions/assignments.ts Outdated
Comment on lines +322 to +326
// 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

studert and others added 2 commits August 4, 2026 10:29
…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>
@studert
studert merged commit ea50404 into main Aug 4, 2026
7 checks passed
@studert
studert deleted the 042-assignment-tier-change branch August 4, 2026 08:52
studert added a commit that referenced this pull request Aug 7, 2026
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>
studert added a commit that referenced this pull request Aug 7, 2026
…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/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants