feat(api): POST /v1/projects — create a project, owned by its creator - #22
Open
marcorivm wants to merge 1 commit into
Open
feat(api): POST /v1/projects — create a project, owned by its creator#22marcorivm wants to merge 1 commit into
marcorivm wants to merge 1 commit into
Conversation
This was referenced Aug 7, 2026
Slice 2 of docs/project-lifecycle.md. Projects could only come into existence implicitly — bootstrapOrganization, joinSharedOrganization and ensureMemberDefaultProject — so a user could never deliberately make one. The owner binding is a NESTED create, not a follow-up write, so it lands in the same statement as the project row. That is Guard G's precondition: a project with no owner binding can only ever be managed by an org admin, so a create that succeeded with a binding that failed would strand an orphan. A test proves the end-to-end consequence — a plain member creates a project and can immediately rename it, with no admin involved. Seeded like every other creation site (defaultProjectSeed: an API key and a default agent) so the project is usable the moment it exists, with best-effort policy seeding for the same reason those sites treat it as best-effort. Slug collisions are resolved silently, not surfaced: project NAMES are deliberately non-unique (projectNameSchema), so two projects called "Alpha" must both be creatable and only the slug has to differ — "alpha" is taken, so they become alpha-2 and alpha-3. A P2002 from a concurrent create retries once with a random tail. MAX_PROJECTS_PER_ORG caps growth at 100. Enforced count-then-create without a lock, so a concurrent burst can overshoot by a few — deliberate: the cap bounds runaway growth rather than being exact, and a lock on a cold path costs more than the overshoot. Authorization lives in the service, not the route, because there is no resource to resolve yet: any active member may create, and a caller with no role is refused. Note that gate is defence-in-depth rather than route-reachable — a suspended session resolves no context and an org key whose user lost membership fails key auth, so both 401 before the handler. The test asserts that real behaviour rather than the 403 I first expected. 12 new cases; 1168 passing across @onecli/api.
marcorivm
force-pushed
the
feat/projects-create
branch
from
August 8, 2026 19:34
264960f to
23a391d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice 2 of #20, stacked on #21. 4 files, ~230 lines.
Why
Projects could only appear implicitly —
bootstrapOrganization,joinSharedOrganization,ensureMemberDefaultProject. There was no way to deliberately make one, which is half of why groups and project access have felt inert.The bit that matters: the owner binding is atomic
A nested create, not a follow-up write, so it lands in the same statement as the project row. That's Guard G's precondition — a project with no owner binding can only ever be managed by an org admin, so a create that succeeded with a binding that failed would strand an orphan nobody could rename or delete.
A test proves the consequence end-to-end rather than just asserting the row exists:
A plain member creates a project and can immediately rename it, no admin involved.
Slug collisions resolve silently
Project names are deliberately non-unique (
projectNameSchemasays so explicitly, becauseensureMemberDefaultProjectnames every member's project "Default"). So two projects called "Alpha" must both be creatable — only the slug has to differ.alphais taken by the seeded project, so they becomealpha-2andalpha-3. A P2002 from a concurrent create retries once with a random tail.Surfacing a 409 here would have been wrong: it would make a naming detail a user-facing error for a name the schema explicitly permits.
Cap
MAX_PROJECTS_PER_ORG = 100. Count-then-create with no lock, so a concurrent burst can overshoot by a few — deliberate, and documented in the constant's comment: the cap bounds runaway growth rather than being exact, and a lock on a cold path costs more than the overshoot. A project isn't a cheap row (it seeds an API key, an agent and a policy generation).Authorization
Lives in the service, not the route — there's no resource to resolve yet. Any active member may create; no role means refused.
Worth knowing: that gate is defence-in-depth, not route-reachable. A suspended session resolves neither project nor org, and an org key whose user lost membership fails key authentication outright (the org-key branch re-checks
role >= adminper request) — both 401 before the handler runs. I originally asserted 403, found it was 401, and the test now asserts the real behaviour with the reasoning in a comment. Same pattern as the equivalent finding in #21.Tests
12 new cases (88 in the file, 1,168 across
@onecli/api). Owner seeding, immediate manageability, appearing in the creator's list, slug derivation and collision, the!!!→projectfallback, 422 on empty/missing/overlong name, the cap counting only this org, audit metadata, and the gateway org-cache flush —ProjectAccessis authorization data the Rust engine reads, so a missed flush is a stale authorization decision.Verification