feat(dashboard-api): project upsert, member sync and user purge - #3442
Conversation
Implements the remaining /v1/management operations except project deletion, which stays 501 with the reason recorded in the handler. Membership writes live in a new internal/management package together with their cache evictions: auth caches a copy of the team per member, and the sweep that would find those keys reads users_teams, so a removal has to name its evictions itself. Adds an optional email to the project upsert contract. It is required to create a project and optional to reconcile one, which a single operation cannot express in the schema.
PR SummaryMedium Risk Overview Reviewed by Cursor Bugbot for commit bf5b488. Bugbot is set up for automated code reviews on this repo. Configure here. |
❌ 1 Tests Failed:
View the top 1 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
…r read Under READ COMMITTED the pre-read and the unconditional delete take different statement snapshots, so a membership committed between them was removed without its cache entry being evicted. DELETE ... RETURNING makes the eviction set the deleted set by construction, matching what the membership sync already does.
…existence The caller synchronizes every property in the upsert body and sends all of them on every push, so email is required rather than optional and a reconcile is a complete statement instead of a patch. project_type leaves the contract. This side has no column for it and no opinion about the caller's plan vocabulary: the tier is assigned once at creation from a local default, and the limits that matter arrive absolute through upsertProjectLimits. Nothing sets additionalProperties, so a caller still sending the field has it ignored rather than rejected. The handler now branches on whether the project exists rather than on an insert failing, which is what makes 'create assigns the tier, reconcile never touches it' visible in the code.
…rvice The upsert grew past what belonged in a handler: three functions, a type, three sentinels and a tier constant, none of them touching gin. Keeping it in the web layer meant that layer importing dberrors and authqueries, and knowing a Postgres constraint name to tell a taken slug from a real failure. The service now reports outcomes as sentinel errors and owns the cache eviction its own write invalidates, the same rule the membership operations already followed. The handler parses, calls, maps sentinels to status codes and responds. Splits the package by feature — service.go for the type, members.go and project.go for the operations — with the tests following the same split.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb2b0667b6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
RedisCache.Delete documents that its caller must pass a context detached from request cancellation when the delete must not be skipped, and InvalidateAPIKeyCache already does. The two team invalidators did not, so an invalidation running after its write had committed was skipped whenever the client had gone away. That matters most for the management purge route, whose eviction set comes from the delete's own RETURNING: a retry finds the rows already gone and evicts nothing, leaving a purged user authenticating until the entry expires. The caller times out by design before retrying, so the cancelled context is the expected path rather than a rare one. Also fixes the same latent gap in the dashboard's own member routes, the limits push, and the api service's team-kill route.
The comment credited the rule to the caller's DNS namespace, which is the M5 reason and implies this side would accept a rename if the caller allowed one. It would not: register_build stamps the team slug onto every template alias it claims and names render as slug/alias, so a rename without rewriting those rows orphans every template the team owns.
A slug was refused on reconcile because template names are stored as '<slug>/<alias>' and a rename would have addressed the project's templates under a slug it no longer had. Repointing env_aliases.namespace in the same transaction removes that reason, so renames are allowed. Two costs stay with the caller and are noted in the contract: the names its users already type change, and the api service resolves aliases through a cache, so the old ones answer for up to its TTL. Aliases predating the namespace column keep their null, which is how they are still resolved. teams_slug_unique now applies on the way out as well as in, so a rename onto a taken slug is the same 409 a create would get.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e498f2e. Configure here.
Template names are unique on (alias, namespace), so repointing them first meant a rename onto a taken slug collided there rather than on teams_slug_unique, reporting a conflict the contract calls 409 as a 500. Only when both projects held a template of the same name, which is why the existing test missed it. Claiming the slug first makes the repoint collision-free by construction: no other project can hold the namespace once the slug is ours, and a team's aliases all share one namespace.
…ames Repointing env_aliases.namespace on a rename broke every template reference a user had already scripted, to fix an inconsistency that is merely untidy: names embed the slug they were built under, so a renamed project keeps its existing template names and only new ones carry the new slug. Dropping the side effect also drops what it dragged in — the ordering constraint against the alias unique index, and the read of the stored slug that decided whether to repoint. The upsert writes the row and nothing else. teams_slug_unique still applies on rename, so a taken slug is the same 409 a create would get.
🤖 I have created a release *beep* *boop* --- ## 0.0.1 (2026-07-30) ### Features * add workspace admin API foundations ([#3314](#3314)) ([0f72030](0f72030)) * **api:** LD-gated ClickHouse read switcher ([#3061](#3061)) ([29e74ca](29e74ca)) * **api:** soft-delete build layers in DB on user delete ([#3121](#3121)) ([ee88776](ee88776)) * **auth:** support admin token team auth ([#2934](#2934)) ([5496666](5496666)) * **auth:** verifiers on one axis, and a reusable authenticator constructor ([#3423](#3423)) ([923b99b](923b99b)) * **dashboard-api:** add internal admin route for deleting a user ([#2986](#2986)) ([ecc1291](ecc1291)) * **dashboard-api:** add internal team creation ([#2824](#2824)) ([375051b](375051b)) * **dashboard-api:** add OIDC admin user bootstrap endpoint ([#2841](#2841)) ([6a7a59e](6a7a59e)) * **dashboard-api:** add Ory user profile provider and auth middleware fix ([#2840](#2840)) ([30d40d2](30d40d2)) * **dashboard-api:** add template tags handlers ([#2885](#2885)) ([bf52a4b](bf52a4b)) * **dashboard-api:** batch member sync route, and unenumerate project_type ([#3427](#3427)) ([cc16acf](cc16acf)) * **dashboard-api:** expose auth profile admin routes ([#2743](#2743)) ([b673a10](b673a10)) * **dashboard-api:** flag sandboxes past data retention ([#3102](#3102)) ([9b162bf](9b162bf)) * **dashboard-api:** implement upsertProjectLimits ([#3438](#3438)) ([ec1ed29](ec1ed29)) * **dashboard-api:** include build resources in /builds response ([#3009](#3009)) ([bf49c32](bf49c32)) * **dashboard-api:** map Ory SSO organizations to E2B teams ([#3094](#3094)) ([dbd098f](dbd098f)) * **dashboard-api:** populate Ory identity external_id on admin bootstrap ([#3062](#3062)) ([6c51232](6c51232)) * **dashboard-api:** project upsert, member sync and user purge ([#3442](#3442)) ([f997c39](f997c39)) * **dashboard-api:** templates list pagination ([#2904](#2904)) ([6882463](6882463)) * **db:** add project_limits, an override the limits owner can write ([#3429](#3429)) ([021c2a4](021c2a4)) * improve templates list sorting ([#2983](#2983)) ([51ad7ff](51ad7ff)) * **otel:** instrument auth service HTTP client with otelhttp ([#2722](#2722)) ([69b085d](69b085d)) * per-team events TTL limit (tier + addons) ([#3181](#3181)) ([f76b2cb](f76b2cb)) ### Bug Fixes * added api and orch ([#3454](#3454)) ([fda5e45](fda5e45)) * **api:** copy auth/internal into api and dashboard-api image builds ([#3323](#3323)) ([bda1fee](bda1fee)) * **api:** invalidate auth cache on API key deletion ([#3324](#3324)) ([8b02910](8b02910)) * correct 3 CVES ([#3218](#3218)) ([076823b](076823b)) * **dashboard-api:** avoid repeated Ory bootstrap provisioning ([#2940](#2940)) ([da5ce59](da5ce59)) * **dashboard-api:** drop removed read-replica accessor in provisioning tests ([#3340](#3340)) ([6addc91](6addc91)) * **dashboard-api:** pass signup metadata to billing provisioning ([#2978](#2978)) ([d0ea5b4](d0ea5b4)) * **dashboard-api:** set Ory external_id only after the bootstrap commit ([#3133](#3133)) ([00ad04b](00ad04b)) * push client-proxy, dashboard-api, and docker-reverse-proxy image… ([#2953](#2953)) ([1d930ee](1d930ee)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: e2b-release-please[bot] <298072688+e2b-release-please[bot]@users.noreply.github.com> Co-authored-by: Charlie Wyse <charlie.wyse@e2b.dev>
🤖 I have created a release *beep* *boop* --- ## [0.1.0](dashboard-api-v0.0.1...dashboard-api-v0.1.0) (2026-07-31) ### Features * add workspace admin API foundations ([#3314](#3314)) ([0f72030](0f72030)) * **api:** LD-gated ClickHouse read switcher ([#3061](#3061)) ([29e74ca](29e74ca)) * **api:** soft-delete build layers in DB on user delete ([#3121](#3121)) ([ee88776](ee88776)) * **auth:** support admin token team auth ([#2934](#2934)) ([5496666](5496666)) * **auth:** verifiers on one axis, and a reusable authenticator constructor ([#3423](#3423)) ([923b99b](923b99b)) * **dashboard-api:** add internal admin route for deleting a user ([#2986](#2986)) ([ecc1291](ecc1291)) * **dashboard-api:** add internal team creation ([#2824](#2824)) ([375051b](375051b)) * **dashboard-api:** add OIDC admin user bootstrap endpoint ([#2841](#2841)) ([6a7a59e](6a7a59e)) * **dashboard-api:** add Ory user profile provider and auth middleware fix ([#2840](#2840)) ([30d40d2](30d40d2)) * **dashboard-api:** add template tags handlers ([#2885](#2885)) ([bf52a4b](bf52a4b)) * **dashboard-api:** batch member sync route, and unenumerate project_type ([#3427](#3427)) ([cc16acf](cc16acf)) * **dashboard-api:** expose auth profile admin routes ([#2743](#2743)) ([b673a10](b673a10)) * **dashboard-api:** flag sandboxes past data retention ([#3102](#3102)) ([9b162bf](9b162bf)) * **dashboard-api:** implement upsertProjectLimits ([#3438](#3438)) ([ec1ed29](ec1ed29)) * **dashboard-api:** include build resources in /builds response ([#3009](#3009)) ([bf49c32](bf49c32)) * **dashboard-api:** map Ory SSO organizations to E2B teams ([#3094](#3094)) ([dbd098f](dbd098f)) * **dashboard-api:** populate Ory identity external_id on admin bootstrap ([#3062](#3062)) ([6c51232](6c51232)) * **dashboard-api:** project upsert, member sync and user purge ([#3442](#3442)) ([f997c39](f997c39)) * **dashboard-api:** templates list pagination ([#2904](#2904)) ([6882463](6882463)) * **db:** add project_limits, an override the limits owner can write ([#3429](#3429)) ([021c2a4](021c2a4)) * improve templates list sorting ([#2983](#2983)) ([51ad7ff](51ad7ff)) * **otel:** instrument auth service HTTP client with otelhttp ([#2722](#2722)) ([69b085d](69b085d)) * per-team events TTL limit (tier + addons) ([#3181](#3181)) ([f76b2cb](f76b2cb)) ### Bug Fixes * added api and orch ([#3454](#3454)) ([fda5e45](fda5e45)) * **api:** copy auth/internal into api and dashboard-api image builds ([#3323](#3323)) ([bda1fee](bda1fee)) * **api:** invalidate auth cache on API key deletion ([#3324](#3324)) ([8b02910](8b02910)) * correct 3 CVES ([#3218](#3218)) ([076823b](076823b)) * creating whitespace to test publish ([#3476](#3476)) ([5158cc9](5158cc9)) * **dashboard-api:** avoid repeated Ory bootstrap provisioning ([#2940](#2940)) ([da5ce59](da5ce59)) * **dashboard-api:** drop removed read-replica accessor in provisioning tests ([#3340](#3340)) ([6addc91](6addc91)) * **dashboard-api:** pass signup metadata to billing provisioning ([#2978](#2978)) ([d0ea5b4](d0ea5b4)) * **dashboard-api:** set Ory external_id only after the bootstrap commit ([#3133](#3133)) ([00ad04b](00ad04b)) * push client-proxy, dashboard-api, and docker-reverse-proxy image… ([#2953](#2953)) ([1d930ee](1d930ee)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: e2b-release-please[bot] <298072688+e2b-release-please[bot]@users.noreply.github.com>
Leaves 501 for every `/v1/management` operation except project deletion.
Completes the cluster-side half of the contract belt's workspace-api
calls (EN-1740, EN-1741, EN-1743).
## What lands
| Route | Behaviour |
|---|---|
| `PUT /projects/{id}` | Creates from a caller-supplied UUID on the
default tier, or reconciles. 201 vs 200 |
| `PUT`·`DELETE /projects/{id}/members/{userId}` | One shared
implementation |
| `POST /projects/{id}/members/batch` | Same implementation, both
directions in one transaction |
| `DELETE /users/{id}` | Memberships and access tokens; `public.users`
survives |
## Contract changes
**`email` is now required.** The caller synchronizes every property in
the upsert body and sends all of them on every push, so a reconcile is a
complete statement of the project rather than a patch. Runtime-breaking
for a caller that omits it.
**`project_type` is gone.** This side has no column for it and no
opinion about the caller's plan vocabulary — the tier is assigned once
at creation from a local default, and the limits that matter arrive
absolute through `upsertProjectLimits`. Nothing sets
`additionalProperties: false`, so a caller still sending the field has
it **ignored rather than rejected**; the break is at their next codegen,
not at runtime.
The handler branches on whether the project exists rather than on an
insert failing, which is what makes "a create assigns the tier, a
reconcile never touches it" visible in the code.
## Why membership writes moved out of the handlers
Auth caches a copy of the team **per member** under `<userID>-<teamID>`,
and `InvalidateTeamCache` discovers those keys by reading `users_teams`
— so a member already removed is one it structurally cannot see. Writing
and evicting from separate call sites leaves a revoked member
authenticating until the entry expires.
`internal/management` makes that unrepresentable: the write and its
evictions are one call. Three routes converge on it, so the per-member
and batch paths cannot answer differently for the same desired state.
Two consequences worth flagging:
- Eviction covers every user the request **named**, not the rows that
moved. A crash before the eviction leaves a stale entry only a retry
clears, and that retry finds the work already done — keying off what the
statement touched would make the recovery path a no-op.
- Membership changes evict pair keys only. Sweeping the team-wide
entries too would evict every API key on the project for a change none
of them observe.
Review caught the inverse of the first point in `PurgeUser`, fixed in
17128ae: it read the user's teams, then ran an unconditional delete,
then evicted for the earlier read. Under READ COMMITTED those are
separate statement snapshots. It is now `DELETE ... RETURNING team_id`,
so the evicted set is the deleted set by construction.
## Project deletion stays 501
`envs`, `snapshots` and `volumes` reference `teams` with `ON DELETE NO
ACTION` and template deletion only stamps `deleted_at`, so any project
that ever built one pins its team row. Releasing it needs the api
service's orchestrator connections, which this process does not have.
Reason and the three ways out are recorded in the handler, with a test
pinning the 501. Belt drops its caller separately.
## Notes
- `ADMIN_AUTH_PROVIDER_CONFIG` is still absent from
`iac/provider-gcp/dashboard-api.tf`, so these routes answer 401 in
production until EN-2007.
- Cross-service ordering gaps found during review are tracked in EN-2012
rather than fixed here — they need workspace-api live to observe, and
several want a decision rather than a fix.
- `internal/management` handles `project`, handlers handle `team` — the
contract's word meeting the cluster's, translated at the call site.
🤖 I have created a release *beep* *boop* --- ## 0.0.1 (2026-07-30) ### Features * add workspace admin API foundations ([#3314](#3314)) ([0f72030](0f72030)) * **api:** LD-gated ClickHouse read switcher ([#3061](#3061)) ([29e74ca](29e74ca)) * **api:** soft-delete build layers in DB on user delete ([#3121](#3121)) ([ee88776](ee88776)) * **auth:** support admin token team auth ([#2934](#2934)) ([5496666](5496666)) * **auth:** verifiers on one axis, and a reusable authenticator constructor ([#3423](#3423)) ([f68e713](f68e713)) * **dashboard-api:** add internal admin route for deleting a user ([#2986](#2986)) ([ecc1291](ecc1291)) * **dashboard-api:** add internal team creation ([#2824](#2824)) ([375051b](375051b)) * **dashboard-api:** add OIDC admin user bootstrap endpoint ([#2841](#2841)) ([6a7a59e](6a7a59e)) * **dashboard-api:** add Ory user profile provider and auth middleware fix ([#2840](#2840)) ([30d40d2](30d40d2)) * **dashboard-api:** add template tags handlers ([#2885](#2885)) ([bf52a4b](bf52a4b)) * **dashboard-api:** batch member sync route, and unenumerate project_type ([#3427](#3427)) ([6d8dc38](6d8dc38)) * **dashboard-api:** expose auth profile admin routes ([#2743](#2743)) ([b673a10](b673a10)) * **dashboard-api:** flag sandboxes past data retention ([#3102](#3102)) ([9b162bf](9b162bf)) * **dashboard-api:** implement upsertProjectLimits ([#3438](#3438)) ([f4ee390](f4ee390)) * **dashboard-api:** include build resources in /builds response ([#3009](#3009)) ([bf49c32](bf49c32)) * **dashboard-api:** map Ory SSO organizations to E2B teams ([#3094](#3094)) ([dbd098f](dbd098f)) * **dashboard-api:** populate Ory identity external_id on admin bootstrap ([#3062](#3062)) ([6c51232](6c51232)) * **dashboard-api:** project upsert, member sync and user purge ([#3442](#3442)) ([8c90702](8c90702)) * **dashboard-api:** templates list pagination ([#2904](#2904)) ([6882463](6882463)) * **db:** add project_limits, an override the limits owner can write ([#3429](#3429)) ([5ab6259](5ab6259)) * improve templates list sorting ([#2983](#2983)) ([51ad7ff](51ad7ff)) * **otel:** instrument auth service HTTP client with otelhttp ([#2722](#2722)) ([69b085d](69b085d)) * per-team events TTL limit (tier + addons) ([#3181](#3181)) ([f76b2cb](f76b2cb)) ### Bug Fixes * added api and orch ([#3454](#3454)) ([d56e0a8](d56e0a8)) * **api:** copy auth/internal into api and dashboard-api image builds ([#3323](#3323)) ([bda1fee](bda1fee)) * **api:** invalidate auth cache on API key deletion ([#3324](#3324)) ([8b02910](8b02910)) * correct 3 CVES ([#3218](#3218)) ([076823b](076823b)) * **dashboard-api:** avoid repeated Ory bootstrap provisioning ([#2940](#2940)) ([da5ce59](da5ce59)) * **dashboard-api:** drop removed read-replica accessor in provisioning tests ([#3340](#3340)) ([6addc91](6addc91)) * **dashboard-api:** pass signup metadata to billing provisioning ([#2978](#2978)) ([d0ea5b4](d0ea5b4)) * **dashboard-api:** set Ory external_id only after the bootstrap commit ([#3133](#3133)) ([00ad04b](00ad04b)) * push client-proxy, dashboard-api, and docker-reverse-proxy image… ([#2953](#2953)) ([1d930ee](1d930ee)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: e2b-release-please[bot] <298072688+e2b-release-please[bot]@users.noreply.github.com> Co-authored-by: Charlie Wyse <charlie.wyse@e2b.dev>
🤖 I have created a release *beep* *boop* --- ## [0.1.0](dashboard-api-v0.0.1...dashboard-api-v0.1.0) (2026-07-31) ### Features * add workspace admin API foundations ([#3314](#3314)) ([0f72030](0f72030)) * **api:** LD-gated ClickHouse read switcher ([#3061](#3061)) ([29e74ca](29e74ca)) * **api:** soft-delete build layers in DB on user delete ([#3121](#3121)) ([ee88776](ee88776)) * **auth:** support admin token team auth ([#2934](#2934)) ([5496666](5496666)) * **auth:** verifiers on one axis, and a reusable authenticator constructor ([#3423](#3423)) ([f68e713](f68e713)) * **dashboard-api:** add internal admin route for deleting a user ([#2986](#2986)) ([ecc1291](ecc1291)) * **dashboard-api:** add internal team creation ([#2824](#2824)) ([375051b](375051b)) * **dashboard-api:** add OIDC admin user bootstrap endpoint ([#2841](#2841)) ([6a7a59e](6a7a59e)) * **dashboard-api:** add Ory user profile provider and auth middleware fix ([#2840](#2840)) ([30d40d2](30d40d2)) * **dashboard-api:** add template tags handlers ([#2885](#2885)) ([bf52a4b](bf52a4b)) * **dashboard-api:** batch member sync route, and unenumerate project_type ([#3427](#3427)) ([6d8dc38](6d8dc38)) * **dashboard-api:** expose auth profile admin routes ([#2743](#2743)) ([b673a10](b673a10)) * **dashboard-api:** flag sandboxes past data retention ([#3102](#3102)) ([9b162bf](9b162bf)) * **dashboard-api:** implement upsertProjectLimits ([#3438](#3438)) ([f4ee390](f4ee390)) * **dashboard-api:** include build resources in /builds response ([#3009](#3009)) ([bf49c32](bf49c32)) * **dashboard-api:** map Ory SSO organizations to E2B teams ([#3094](#3094)) ([dbd098f](dbd098f)) * **dashboard-api:** populate Ory identity external_id on admin bootstrap ([#3062](#3062)) ([6c51232](6c51232)) * **dashboard-api:** project upsert, member sync and user purge ([#3442](#3442)) ([8c90702](8c90702)) * **dashboard-api:** templates list pagination ([#2904](#2904)) ([6882463](6882463)) * **db:** add project_limits, an override the limits owner can write ([#3429](#3429)) ([5ab6259](5ab6259)) * improve templates list sorting ([#2983](#2983)) ([51ad7ff](51ad7ff)) * **otel:** instrument auth service HTTP client with otelhttp ([#2722](#2722)) ([69b085d](69b085d)) * per-team events TTL limit (tier + addons) ([#3181](#3181)) ([f76b2cb](f76b2cb)) ### Bug Fixes * added api and orch ([#3454](#3454)) ([d56e0a8](d56e0a8)) * **api:** copy auth/internal into api and dashboard-api image builds ([#3323](#3323)) ([bda1fee](bda1fee)) * **api:** invalidate auth cache on API key deletion ([#3324](#3324)) ([8b02910](8b02910)) * correct 3 CVES ([#3218](#3218)) ([076823b](076823b)) * creating whitespace to test publish ([#3476](#3476)) ([6b4177f](6b4177f)) * **dashboard-api:** avoid repeated Ory bootstrap provisioning ([#2940](#2940)) ([da5ce59](da5ce59)) * **dashboard-api:** drop removed read-replica accessor in provisioning tests ([#3340](#3340)) ([6addc91](6addc91)) * **dashboard-api:** pass signup metadata to billing provisioning ([#2978](#2978)) ([d0ea5b4](d0ea5b4)) * **dashboard-api:** set Ory external_id only after the bootstrap commit ([#3133](#3133)) ([00ad04b](00ad04b)) * push client-proxy, dashboard-api, and docker-reverse-proxy image… ([#2953](#2953)) ([1d930ee](1d930ee)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: e2b-release-please[bot] <298072688+e2b-release-please[bot]@users.noreply.github.com>

Leaves 501 for every
/v1/managementoperation except project deletion. Completes the cluster-side half of the contract belt's workspace-api calls (EN-1740, EN-1741, EN-1743).What lands
PUT /projects/{id}PUT·DELETE /projects/{id}/members/{userId}POST /projects/{id}/members/batchDELETE /users/{id}public.userssurvivesContract changes
emailis now required. The caller synchronizes every property in the upsert body and sends all of them on every push, so a reconcile is a complete statement of the project rather than a patch. Runtime-breaking for a caller that omits it.project_typeis gone. This side has no column for it and no opinion about the caller's plan vocabulary — the tier is assigned once at creation from a local default, and the limits that matter arrive absolute throughupsertProjectLimits. Nothing setsadditionalProperties: false, so a caller still sending the field has it ignored rather than rejected; the break is at their next codegen, not at runtime.The handler branches on whether the project exists rather than on an insert failing, which is what makes "a create assigns the tier, a reconcile never touches it" visible in the code.
Why membership writes moved out of the handlers
Auth caches a copy of the team per member under
<userID>-<teamID>, andInvalidateTeamCachediscovers those keys by readingusers_teams— so a member already removed is one it structurally cannot see. Writing and evicting from separate call sites leaves a revoked member authenticating until the entry expires.internal/managementmakes that unrepresentable: the write and its evictions are one call. Three routes converge on it, so the per-member and batch paths cannot answer differently for the same desired state.Two consequences worth flagging:
Review caught the inverse of the first point in
PurgeUser, fixed in 17128ae: it read the user's teams, then ran an unconditional delete, then evicted for the earlier read. Under READ COMMITTED those are separate statement snapshots. It is nowDELETE ... RETURNING team_id, so the evicted set is the deleted set by construction.Project deletion stays 501
envs,snapshotsandvolumesreferenceteamswithON DELETE NO ACTIONand template deletion only stampsdeleted_at, so any project that ever built one pins its team row. Releasing it needs the api service's orchestrator connections, which this process does not have.Reason and the three ways out are recorded in the handler, with a test pinning the 501. Belt drops its caller separately.
Notes
ADMIN_AUTH_PROVIDER_CONFIGis still absent fromiac/provider-gcp/dashboard-api.tf, so these routes answer 401 in production until EN-2007.internal/managementhandlesproject, handlers handleteam— the contract's word meeting the cluster's, translated at the call site.