feat(043): MCP write tools — users, licence assignments and tier pricing - #127
Conversation
Gives an admin connected over OAuth the ability to act, not just read: nine write
tools alongside the existing 14 read-only ones. Every mutation lands a
change_history row attributed to the real human behind the token.
Architecture: the business logic moves 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, so assignLicense(input, ctx) would let any
client forge ctx.caps.
The audit writers also move out of src/actions/history.ts for the same reason: they
took changedBy as a parameter with no auth check of their own, which made them an
audit-forgery endpoint. Only the read side stays behind "use server".
Guardrails, each from a specific failure mode:
- Conjunctive write gate: MCP_WRITE_ENABLED, live admin role, mcp:write scope, a
token-bound user id, and a non-automation actor. No fallback branch.
- New mcp:write scope, because the consent screen promised "No write access."
Existing grants stay read-only until the human re-authorises.
- The shared MCP secret is refused for writes — unbound, so unattributable.
- Plan tokens (HMAC over the resolved plan, 10-min TTL, caller-bound) on the three
destructive tools. A boolean confirm is satisfied by the model in the same turn.
- Every tool taking a numeric id also takes a human-readable echo of that row.
Tier tools need tool name + tier name + current price, because tier names are only
unique per tool and the real ones are generic ("Business", "Enterprise").
- Secrets refused in the handler, before any DB read, so the refusal text provably
cannot contain the submitted value.
- Two-sided cents guard: 19 for "$19" understates spend 100x through propagation.
- No-op returns success, not isError, so a retry after a lost response is not read
as failure (which would push the agent toward deactivate_user).
Schema (0030 + 0031, applied together):
- change_history.source distinguishes an agent write from the same human's UI edit.
- Partial unique index enforcing one ACTIVE assignment per (user, tool). Nothing
enforced that before; MCP refusing rather than replacing widens the race into
duplicate rows that double-bill a seat in every aggregation.
- 0031 supplies a DEFAULT purely for deploy safety: 0030 alone breaks every audited
write in BOTH deploy directions. Provenance is enforced by the required TS field.
Verification: 703 unit, 69 integration and 5 Playwright tests against a branch of
production, plus a live MCP session over Streamable HTTP. Real data caught two bugs
in the migration that no unit test could reach — see specs/043-mcp-write-tools/
implementation-notes.html.
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.
🟡 Human review recommended
It introduces new privileged write surfaces plus DB migrations and broad auth/audit refactors, which warrants final human review despite strong automated test coverage.
Pull request overview
Adds first-class MCP write capabilities (9 mutation tools) gated by a multi-step authorization flow, while refactoring existing UI Server Actions to share a single set of actor-parameterized write cores and ensuring every mutation is attributed in change_history with a provenance source.
Changes:
- Introduces MCP write tool handlers (
src/lib/mcp/write.ts) with guardrails (kill switch,mcp:writeOAuth scope, bound admin actor, secret refusal, plan-token preview/commit for destructive operations). - Refactors user/license/tier mutations into shared cores (
src/lib/core/*) and moves audit writers into a non–Server Action module (src/lib/history.ts) to prevent audit forgery. - Adds DB enforcement + provenance: partial unique index for one active assignment per (user, tool), plus
change_history.source(with deploy-safe default migration), and broad test coverage (unit/integration/e2e).
File summaries
| File | Description |
|---|---|
| tests/unit/oauth/metadata.test.ts | Updates scope metadata expectations to include mcp:write. |
| tests/unit/mcp/write.test.ts | New unit tests for MCP write tool registration, gating, secret refusal, echoes, plan tokens. |
| tests/unit/mcp/tools.test.ts | Updates list_ai_tools call expectations to include includeInactive behavior. |
| tests/unit/actions/tier-cost-propagation.test.ts | Updates tier repricing tests for new transactional + auditing behavior and new history module. |
| tests/integration/mcp-write-cores.test.ts | New integration tests validating write cores against a real DB (constraints, FOR UPDATE, audit rows). |
| tests/e2e/043-write-paths.spec.ts | New Playwright coverage ensuring UI write flows still function after core refactor. |
| src/lib/sync/sources/invoice-matching.ts | Switches audit creation to @/lib/history with source: "sync". |
| src/lib/oauth/validate.ts | Adds mcp:write scope, request validation, grant resolution, and helper checks. |
| src/lib/oauth/metadata.ts | Advertises mcp:write in AS/Protected Resource metadata. |
| src/lib/oauth/authorize.ts | Preserves requested scope for later server-side grant resolution. |
| src/lib/mcp/write.ts | New MCP write tool surface and adapter: gating, secret refusal, plan-token protocol, echo requirements. |
| src/lib/mcp/tools.ts | Extends list_ai_tools tool with optional includeInactive (admin-only) parameter. |
| src/lib/mcp/plan-token.ts | New HMAC/HKDF-signed plan tokens enabling preview→commit for destructive write tools. |
| src/lib/mcp/data.ts | Adds includeInactive support for AI tools + tiers catalog reads. |
| src/lib/mcp/access.ts | Adds centralized write authorization (authorizeWrite) and related refusal messages. |
| src/lib/history.ts | New non–Server Action audit writers requiring source to prevent audit forgery. |
| src/lib/env.ts | Adds env validation entries for MCP write enablement + allowed email domains. |
| src/lib/db/schema.ts | Adds partial unique index on active assignments and adds change_history.source with default + index. |
| src/lib/db/migrations/meta/_journal.json | Records migrations 0030 and 0031 in the Drizzle journal. |
| src/lib/db/migrations/0031_change_history_source_default.sql | Adds deploy-safety default for change_history.source. |
| src/lib/db/migrations/0030_mcp_write_audit_source.sql | Adds change_history.source, remediates duplicate active assignments, creates partial unique index. |
| src/lib/core/users.ts | New user mutation cores (create/update/deactivate) with caps-based refusals and auditing. |
| src/lib/core/context.ts | New actor-parameterized write context, capabilities model, and core result shape. |
| src/lib/core/assignments.ts | New assignment mutation cores (assign/update/revoke) with caps and audit semantics. |
| src/app/oauth/authorize/page.tsx | Updates consent UI to accurately reflect granted write scope when applicable. |
| src/app/api/mcp/[transport]/route.ts | Registers write tools alongside existing read tools (refuse at call time if gated). |
| src/actions/users.ts | Replaces action bodies with calls into shared user cores; updates history calls to require source. |
| src/actions/tools.ts | Replaces action bodies with calls into shared tool/tier cores; routes tier price edits through setTierPriceCore. |
| src/actions/oauth.ts | Resolves granted scopes server-side from requested scope + session role (for write scope). |
| src/actions/invoices.ts | Updates history calls to use @/lib/history with source: "ingest". |
| src/actions/invoice-sync.ts | Updates sync history calls to use @/lib/history with source: "sync". |
| src/actions/invite.ts | Updates history calls to use @/lib/history with explicit source. |
| src/actions/history.ts | Removes write helpers; retains read-side history API and documents why. |
| src/actions/github.ts | Updates history calls to use @/lib/history with explicit source. |
| src/actions/github-sync.ts | Updates sync-side history calls to use @/lib/history with source: "sync". |
| src/actions/forecast-scenarios.ts | Updates history calls to use @/lib/history with source: "ui". |
| src/actions/copilot.ts | Updates history calls to use @/lib/history with source: "ui". |
| src/actions/budget.ts | Updates history calls to use @/lib/history with explicit source. |
| src/actions/budget-extensions.ts | Updates history calls to use @/lib/history with explicit source. |
| src/actions/assignments.ts | Refactors actions into thin wrappers over assignment cores; updates history usage to new module/signature. |
| scripts/preflight-duplicate-assignments.ts | New read-only preflight script to report duplicates before applying migration 0030. |
| .env.local.example | Documents MCP write env flags, allow-list domains, and shared-secret read-only behavior. |
Review details
- Files reviewed: 45/47 changed files
- Comments generated: 2
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| * `source` is REQUIRED on every call and the column has no DB default. A | ||
| * default would silently label a forgetful call site as a human UI edit — the | ||
| * exact forensic lie the column exists to prevent — so the compiler is the | ||
| * enforcement mechanism instead. |
There was a problem hiding this comment.
Fixed in 0b44b01 — you were right, and the comment was describing a design that got reversed late.
change_history.source was originally specified as NOT NULL with no DB default, for exactly the reason that comment gives. That turned out to be undeployable in either direction: migrate-first and the live pre-043 code inserts without a source, so every audited write fails with 23502 (reproduced against a real database, not theorised); deploy-first and the new code writes a column that does not exist yet. Migration 0031 therefore adds DEFAULT 'ui', and this header no longer claims otherwise.
The enforcement argument survives, just relocated: source is a required field on HistoryOptions, so the compiler still forces all ~44 call sites going through these helpers to state it explicitly. The DB default only backstops a raw db.insert(changeHistory) that bypasses them — there are exactly two, both of which pass it explicitly.
| -- NULL, which reaches the same final schema (NOT NULL, no default) while | ||
| -- remaining applicable. The absence of a default is deliberate: a default | ||
| -- would silently label a forgetful call site as a human UI edit, which is the | ||
| -- forensic lie this column exists to prevent. |
There was a problem hiding this comment.
Fixed in 0b44b01. Same root cause as the history.ts comment: this header described the original design, which 0031 reversed for deploy safety. Item 1 now says 0030 deliberately leaves the column without a default and points at 0031_change_history_source_default.sql for why the default is added immediately afterwards.
Worth recording why editing an already-applied migration is safe here, since that is normally a red flag — this migration is already applied to production. Drizzle decides which migrations are pending from the journal's folderMillis timestamp, not from the recorded hash (pg-core/dialect.js:62: Number(lastDbMigration.created_at) < migration.folderMillis). The hash is written to drizzle.__drizzle_migrations but never compared. I verified against production before committing: its newest created_at is 1785849754855 (0031), and 0030's when is 1785835084489, so 0030 is skipped and cannot re-run.
If drizzle had compared hashes instead, this comment edit would have made 0030 look unapplied and the next db:migrate would have tried to re-add an existing column.
Addresses the two Copilot review comments on #127, plus the same class of stale claim in places a code review would not reach. change_history.source was originally designed as NOT NULL with NO default, so that nothing but the compiler could supply provenance. That was reversed during rollout: 0030 alone is undeployable in both directions (migrate-first hits 23502 against the live pre-043 code; deploy-first writes a column that does not exist), so 0031 adds DEFAULT 'ui'. Several comments still asserted the original design. - src/lib/history.ts: header now states the column DOES carry a DB default and that provenance is enforced by the required HistoryOptions field instead. - 0030_mcp_write_audit_source.sql: item 1 now points at 0031 rather than claiming the absence of a default is deliberate. Editing an already-applied migration is safe here: drizzle decides pending by the journal's folderMillis timestamp, not by the recorded hash (pg-core/dialect.js:62), and 0030's timestamp is older than production's newest created_at — verified against production before committing. - implementation-plan.html / implementation-notes.html: §5 and the challenge-pass entry record the reversal rather than the superseded decision. These are running design notes, so the history is kept and annotated rather than rewritten. - Also corrected the notes' claim that the DO block keeps "the highest id" — that rule was likewise reversed to earliest assigned_at after checking real data. - tests/e2e/043-write-paths.spec.ts: comment names 0031 as the source of the default. Comments only — no SQL statement, schema or logic changed. typecheck / lint clean, 703 unit tests pass. 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>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 54 changed files in this pull request and generated 3 comments.
Suppressed comments (4)
src/lib/core/tools.ts:605
- This limit is documented as an MCP guard that should direct large reprices to the Hub UI, but it is unconditional and
updateTiercalls this same core with a UI context. Once a tier exceeds 250 seats, the UI is also refused with instructions to use the UI. Gate both this check and the repeated in-transaction check on the MCP capability/source, while handling the UI audit batch without an unbounded sequence of inserts.
if (seats.length > MAX_REPRICE_ROWS) {
return coreErr(
`${seats.length} active assignments would be repriced (limit ` +
`${MAX_REPRICE_ROWS}). Make a change this large in the Hub UI at ` +
`/tools/${existing.tool.id}.`,
);
src/lib/core/tools.ts:663
SELECT ... FOR UPDATEonly locks rows that already exist; it does not prevent a concurrentassignLicenseCoreinsert for this tier. Such an insert can either be caught by the later bulk update without appearing inlocking(so it is unaudited), or land after the update with the old cost snapshot. Serialize assignment creation on the same tier row (or use equivalent predicate/transaction isolation) so the locked set really is the set rewritten and audited.
// THIS is the row set the update below rewrites, so it is also the row set
// the audit rows describe.
const locking = await tx
.select({
id: licenseAssignments.id,
beforeCents: licenseAssignments.costAtAssignmentCents,
})
.from(licenseAssignments)
.where(
and(
eq(licenseAssignments.tierId, tierId),
eq(licenseAssignments.status, "active"),
),
)
.for("update");
src/lib/core/users.ts:345
- The active-assignment list used for the preview, result, and audit is read before the transaction, but this bulk update can match a newly inserted assignment that is absent from that list. The new row is then revoked without an audit entry, and
revokedCount/released cost can be wrong. Re-read and lock the affected assignments inside the transaction and derive both the update and audit/result from that locked set (while preventing assignment creation for the deactivated user).
if (activeAssignments.length > 0) {
await tx
.update(licenseAssignments)
.set({ status: "inactive", revokedAt: now, updatedAt: now })
.where(
and(
eq(licenseAssignments.userId, input.id),
eq(licenseAssignments.status, "active"),
),
);
src/lib/mcp/write.ts:178
gatedauthorizes the actor and callsisAgentUser(a DB read) before invoking the tool callback, so the secret checks insideassign_licenseandupdate_assignmentare not actually the first operation. This contradicts the documented guardrail that submitted secrets are refused before actor resolution/DB access. Add a pre-authorization input guard to this wrapper (or wrap these two handlers outsidegated) so secret-bearing requests return the controlled refusal immediately.
| changes.isActive = { old: existing.isActive, new: updates.isActive }; | ||
| values.isActive = updates.isActive; | ||
| if (monthlyCostCents !== undefined) { | ||
| const priced = await setTierPriceCore(ctx, { tierId: id, monthlyCostCents }); |
There was a problem hiding this comment.
Fixed in a94e37a. Real bug, introduced when I split the propagating price write out of updateTier — the original did both in one transaction and I did not preserve that.
Both metadata failure modes you can actually hit are pure validation: a duplicate tier name, and deactivating a tier that still has active assignments. Neither needs a write to detect. So updateTier now runs updateTierCore with commit: false first and returns before touching the price, which makes the reported sequence unreachable.
If the commit pass still fails afterwards — a concurrent rename between validation and commit, or a DB fault — the price is already committed, so that path now replays the price's revalidation instead of dropping it. That was the second half of your comment and it was the worse half: an error return that leaves /, /budget and /reports serving the old number is harder to notice than the error itself.
Being explicit about what this does not do: it is not true cross-core atomicity. setTierPriceCore and updateTierCore each open their own transaction, so genuine all-or-nothing needs a shared-transaction parameter threaded through WriteContext. I have not added that here — it changes every core signature and this PR is already carrying a merge with #125. What is fixed is the reachable failure, not the theoretical one, and the residual is now a concurrent-rename race rather than ordinary validation.
| const { createInviteTokenForUser } = await import("@/actions/invite"); | ||
| return createInviteTokenForUser(userId); |
There was a problem hiding this comment.
Fixed in a94e37a. Correct, and this is the most serious of the three — thank you.
createInviteTokenForUser lived in src/actions/invite.ts with a comment on it literally reading "internal helper — no auth check, no user lookup", which is fine for an internal helper and disqualifying for a "use server" export. It took a bare userId and returned a live 72-hour /setup-password URL, so any client could POST an arbitrary id and receive a working password-set link for that account.
You are right that it is the same class of issue this PR fixes for the history writers, which makes it worse rather than better: I identified that exact pattern, moved four functions out for it, and then imported this one into the MCP layer without noticing it had the same shape.
Moved to src/lib/invite.ts — a plain module, and the natural home since the token helpers it already calls (generateToken, buildInviteUrl) live there. src/actions/invite.ts keeps only the authenticated wrappers (generateInviteToken, resetUserPassword, sendInviteEmail, sendBatchInviteEmails), each of which calls it after its own requireAdmin(), and now carries a comment saying why the raw minter is not there. All three importers repointed.
| const verdict = verifyPlanToken(planToken, tool, plan, subject, nowSeconds); | ||
| if (!verdict.ok) return errorResult(planTokenErrorMessage(verdict.reason)); | ||
|
|
||
| return respond(await runCore(mcpContext(actor, true))); |
There was a problem hiding this comment.
Fixed in a94e37a. Partly a correction to the framing, and one genuine bug underneath that is worse than the race.
On the framing: the token is not verified against the original preview. previewOrCommit re-resolves the plan by running the core with commit: false at commit time, then verifies the token against that freshly-resolved plan. So a change between the human's first preview and the commit is already caught — there is an integration test for exactly that ("rejects a token whose plan no longer matches the resolved state"). set_tier_price narrows it further by re-reading the price under FOR UPDATE inside the commit transaction and hard-failing if it moved.
But the window you point at between the re-resolve and the write is real, and chasing it surfaced something concrete in deactivateUserCore. The cascade UPDATE is WHERE user_id = $1 AND status = 'active', so it revokes any seat assigned after the pre-transaction read — while the audit loop iterated that earlier snapshot. A seat appearing in that window was therefore revoked with no change_history row at all. Not a plan mismatch: a silent audit hole in the one operation whose whole justification is that it records what the person held, because nothing restores it.
The seats are now re-read inside the transaction under FOR UPDATE (locking only license_assignments, not the joined ai_tools/access_tiers, which would contend with unrelated tier edits), and the audit is driven from that locked set. Same pattern setTierPriceCore already used. New integration test covers it directly — two seats on different tools, since the partial unique index permits only one active seat per tool.
revokeLicenseCore got your CAS suggestion: its UPDATE is now guarded on status = 'active' and reports a no-op when zero rows match, rather than overwriting an existing revokedAt with a later timestamp and writing a second "active → inactive" row for a transition that already happened.
…scade audit Three findings from the Copilot review of the merge commit. All three were real. 1. createInviteTokenForUser was a client-callable credential minter. It lived in src/actions/invite.ts — a "use server" file, so every export is an RPC endpoint — took a bare userId, performed no auth check (its own comment said so), and returned a live 72-hour /setup-password URL. Any client could POST an arbitrary userId and receive a working password-set link for that account. This is the same class of issue this PR already fixed for the audit writers, and I walked straight past it while importing the function into the MCP layer. Moved to src/lib/invite.ts (a plain module, alongside the token helpers it already uses); the authenticated wrappers in actions/invite.ts call it after their own requireAdmin(). Importers repointed. 2. updateTier committed the price before validating metadata. The tier dialog submits price and metadata together, and each core owns its own transaction, so a metadata failure — duplicate name, or deactivating a tier that still has active assignments — returned an error AFTER the price and every seat snapshot had been rewritten, and skipped revalidation on that path so the UI kept showing the old number. Both failure modes are pure validation, so updateTier now runs a commit:false pass first and returns before touching the price. If the commit pass still fails (concurrent rename, DB fault) the price's invalidation is replayed rather than dropped. 3. The deactivation cascade could revoke a seat with no audit row. The cascade UPDATE is `WHERE status='active'`, so it revokes seats assigned after the pre-transaction read — but the audit loop iterated that earlier snapshot, so such a seat was revoked with NO change_history row at all. The seats are now re-read INSIDE the transaction under FOR UPDATE (locking only the assignment rows, not the joined catalogue) and the audit is driven from that locked set. This also closes the plan-token window the review described: a seat appearing after the token was verified can no longer be swept in silently. revokeLicenseCore got the same treatment as a compare-and-swap: its UPDATE is now guarded on status='active' and reports a no-op when zero rows match, instead of overwriting an existing revokedAt and writing a duplicate transition audit row. New integration test covers the cascade audit gap directly (two seats on different tools, since the partial unique index allows one active seat per tool). Verified: typecheck, lint, 751 unit tests, 75 integration tests against a preview branch of production — all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Gives an admin connected over OAuth the ability to act, not just read: nine write tools alongside the existing 14 read-only ones. Every mutation lands a
change_historyrow attributed to the real human behind the token.Spec, plan and running notes:
specs/043-mcp-write-tools/.Tool surface (9)
create_userviewer; mints the invite but never returns the URLupdate_userexpectedEmailemail/role/status— see belowdeactivate_userexpectedEmail+ plan tokenassign_licenseexpectedUserEmailrequiresApiKeyupdate_assignmentexpectedUserEmailrevoke_licenseexpectedUserEmail+ plan tokencreate_access_tierexpectedToolNameupdate_access_tierset_tier_priceArchitecture
Business logic moves 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, soassignLicense(input, ctx)would let any client forgectx.caps.The audit writers move out of
src/actions/history.tsfor the same reason — they tookchangedByas a parameter with no auth check, which made them an audit-forgery endpoint. Only the read side stays behind"use server".Guardrails, each from a specific failure mode
MCP_WRITE_ENABLED-> liveadminrole ->mcp:writescope -> token-bound user id -> non-automation actor. No fallback branch.mcp:writescope, because the consent screen promised "No write access." Existing grants stay read-only until the human re-authorises.confirmis satisfied by the model in the same turn it invents the call.copilot-synccreates "Business"/"Enterprise").update_usercannot changeemail: every credential-recovery path mails the current address, so that field is an account-takeover primitive the echo cannot defend against.deactivateUsernever clearspasswordHashnor revokes OAuth tokens, so a status flip back to active silently restores an offboarded person's password and re-arms their live grants.isError— a retry after a lost response must not read as failure.Schema (0030 + 0031, applied together)
change_history.sourcedistinguishes an agent write from the same human's UI edit.DEFAULTpurely for deploy safety. 0030 alone breaks every audited write in both deploy directions — proven with23502. Provenance is enforced by the required TS field, not the default.Verification
typecheckandlintclean.@/lib/db): the23505constraint-name match,SELECT … FOR UPDATEthrough neon-serverless (no precedent in this repo), and that the UI retier path does not trip the new unique index.assigned_at), andrevoked_at = now()would have left the double-count in every point-in-time report permanently.specs/043-mcp-write-tools/production-rollback-record.json.Rollout state
Production DB is migrated and backward-compatible, so this can merge at any time.
MCP_WRITE_ENABLED=1is being set on Vercel; it grants nothing on its own — each admin must reconnect their connector once to obtain themcp:writescope.🤖 Generated with Claude Code