diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index cf8aaee61..893b058c5 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -8,7 +8,7 @@ for current product capability. |---|---|---| | [WS-DB-002](initiatives/WS-DB-002/OVERVIEW.md) | Complete | Shared UUIDv7 record generation, native-UUID relationships and fresh v0.1 baseline; natural-owner retry custody and aligned CI/local setup | | [WS-MCP-002](initiatives/WS-MCP-002/OVERVIEW.md) | Planned | Nine tools through WS-MCP-002-03: self-service and administrative reads; 18 tools remain and WS-MCP-002-04 administrative grant mutations are next | -| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, project inspection, manager browsing, contributor discovery, claim/start and governing context/intake requirements delivered through WS-CLI-001-07; hidden submission is not exposed, further public journeys and binary distribution remain | +| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, project inspection, manager browsing, contributor discovery, claim/start, governing context/intake requirements and draft project creation delivered through WS-CLI-001-08; hidden submission is not exposed, guide setup, further public journeys and binary distribution remain | | [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Source storage, inert AUTH contracts, TASK request reservation, hidden exact AUTH preparation and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; TASK-before-CHECKERS reservation/current-read custody and ordered admission INSERTs are delivered by 04E1B-B1; 04E1B-B2 adds exact source preparation without publication; 04E1B-B3 retains inspected ZIP metadata and B4 binds Submission text to checked packet custody; initial Submission/dispatch composition is next under the [first-layer sequence](initiatives/WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone), ending in a public contributor drill before live human review/revision. True admission remains independent of shared acceptance; false activation still requires exact AUTH receipt custody, database complete-set enforcement, currentness race proof and atomic routing activation. | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Exact checker input/output custody and packet foundations are delivered; routing integration, remediation and public intake remain. | | [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | AUTH-19A commitments, TASK request reservation, ARCH-04E2-A hidden strict PREP matching and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; the action remains unavailable, with mandatory exact AUTH receipt input, database closure, audit/outbox and scoped activation still required for the automated path. | diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index 03300eb68..03e02596d 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -11,7 +11,8 @@ [WS-CLI-001-04](WS-CLI-001-04.md), public manager task pagination and detail; [WS-CLI-001-05](WS-CLI-001-05.md), contributor ready-task discovery and instructions; [WS-CLI-001-06](WS-CLI-001-06.md), public contributor claim/start with explicit retry keys; - [WS-CLI-001-07](WS-CLI-001-07.md), contributor governing context and locked intake requirements. + [WS-CLI-001-07](WS-CLI-001-07.md), contributor governing context and locked intake requirements; + [WS-CLI-001-08](WS-CLI-001-08.md), draft project shell creation with explicit caller-owned replay. ## Current boundary @@ -35,6 +36,9 @@ Exact caller keys support manual replay; uncertainty never triggers automatic re `task context TASK_ID` and `task requirements TASK_ID` read governing guide, policy selectors, server action hints and locked intake rules. Hints are not authority; these reads do not expose the hidden submission intake. +`project create --name TEXT --slug TEXT --idempotency-key UUID` creates a draft +shell through the public POST. The API owns system-scoped creation authority +and committed recovery; guide upload, approval and activation are not added. All have text/JSON output and built-binary integration proof. Mutations preserve omitted/null semantics and explicitly report uncertain outcomes without automatic retries. @@ -85,11 +89,14 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. 7. **WS-CLI-001-07:** Inspect contributor work context and exact submission requirements through public reads. Preserve historical selectors, server hints and contributor disclosure without fetching files or evaluating policy. -8. **Later governed-work commands:** Add project setup, submission, +8. **WS-CLI-001-08:** Create a draft project shell through the public POST with + explicit caller-supplied keys, exact 201/full-response validation and unknown + outcomes. Reuse project inspection parsing/output; no guide activation. +9. **Later governed-work commands:** Add further project setup, submission, review, revision, and contribution reads/writes only as their actual public contracts and authority boundaries become available. Split by user journey, not one PR per endpoint or one giant catalogue PR. -9. **Optional TUI:** Add a focused public queue/evidence view after its API +10. **Optional TUI:** Add a focused public queue/evidence view after its API workflow is complete. Never require a TUI for agents or scripts. ## Risks and proof diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-08.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-08.md new file mode 100644 index 000000000..9b28a187f --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-08.md @@ -0,0 +1,121 @@ +# WS-CLI-001-08 — Create a draft project shell + +- Initiative: `WS-CLI-001` +- Durable disposition: `Complete` +- Intended merge outcome: Create a project shell through the existing public + REST operation, preserving explicit replay custody and uncertain outcomes. + +## Intent + +Inspection and contributor preparation are delivered. Begin the already +proposed project-setup journey with `POST /api/v1/projects`, publicly registered +by `projects/create_router.py`. `ProjectCreateService` owns system-scoped +Project Manager authorization, server-generated identity, transaction and +actor/action/key/body replay. The result is a project shell, not an approved +guide or claimable work. No backend implementation or permission is added. + +## Bounded change + +Allowed: `cli/internal/api/project_create.go`, `project.go`, `client.go` and +`task_mutations.go`; `cli/internal/command/project_create.go` and `command.go`; +`cli/tests/integration/test_project_create_http.py`, `project_create_journey.py` +and `test_public_self_service.py`; CLI/root README, affected roadmap claims, +this record, overview and index. No backend, MCP, workflow/dependency, hidden +route, guide creation/upload/activation, grants, local authority/policy engine, +credential persistence, preflight, generated key, automatic retry or TUI work. + +Add `workstream project create --name TEXT --slug TEXT --idempotency-key UUID +[--description TEXT]`. Require both flags, including explicit empty values +where accepted by the API. Validate UTF-8, NUL exclusion and the backend's +200/120-character name/slug bounds. Preserve supplied text, whitespace and +description omission versus explicit empty. Reuse the existing 8KiB encoded +request bound; description has no additional character ceiling. Send exactly +one fixed POST, with unchanged bearer/key and no request preflight. + +Extend the existing private transport with a caller-selected expected success +status: existing operations retain 200, creation requires 201. Never accept an +arbitrary 2xx as success. Preserve one-shot mutation bodies, redirect/proxy +refusal, deadlines, response bounds and credential-safe errors. Reuse the +canonical POST error validator for project creation, naming it for its shared +mutation responsibility rather than copying a task-specific implementation. +Do not alter the profile PATCH error contract. + +Share project response parsing/output with `project show`. Creation requires +the complete seven-field administrative ProjectResponse, valid UUID/timestamps, +nullable description and no unknown/duplicate/null required fields. It cannot +bind a returned ID to a nonexistent input selector. Preserve the exact raw JSON +and show all seven fields safely. Do not require every replay to say `draft`: +the backend recovers the current stored project, which may have progressed. + +Lost, malformed, oversized, redirected, unexpected-status and noncanonical +responses are unknown mutation outcomes. Only a complete canonical 4xx proves +a known denial. Text recovery guidance retains the same name/slug/description +and key; it must not invent a project ID, promise rollback or suggest a new key. +Existing project-create replay deliberately recovers a committed result after +creation-grant revocation (`test_project_create_exact_replay_and_mismatch_are_atomic`). +Do not impose TASK's different replay semantics in the client or claim that +recovery authorizes another creation. Fresh creation still requires authority. + +## Acceptance criteria + +- Named built-process HTTP cases prove one POST and exact 201 response, all + request fields, key spellings, omission/empty description, accepted text + boundaries, complete escaped text/raw JSON, and invalid input without network. +- Accept explicit null response description; reject a missing description member + or a non-string/non-null value. Reject minimal GET-only projection, malformed + UUID/time/required field, unknown/duplicate members and unexpected 200/202. + Exercise known canonical 4xx versus malformed envelope, including null + details, dropped response, redirect and oversized reply; stdout stays empty. +- A real TLS/HTTP2 GOAWAY case proves exactly one transmitted creation body + and an unknown outcome. Simulated rewindable-body variant must fail that case. +- Extend the existing one-bootstrap public FastAPI/PostgreSQL journey via + `project_create_journey.py`, not a second parallel fixture. Assert OpenAPI + exposure, persisted creation/direct-GET parity, exact manual replay and body + mismatch; slug collision is a server conflict, not an overwrite. No private + product writes or fake authorization. Prove a project-scoped manager and + absent/Access Administrator-only authority cannot create; use public grants + and fresh-key creation controls to prove revocation and suspension denial. + Use the spare scoped-manager actor: add system authority for a positive create, + revoke only that system grant for exact recovery/new-key denial, restore system + authority and prove another positive create before suspension. Reactivate and + revoke the test grants afterward so the existing journey keeps its baseline. + Preserve existing read/task tests and backend-owned replay semantics. +- Go build/vet/verify/tidy-diff; Ruff/format; complete CLI process/API suite; + unchanged workflow guards; links/stale scans/Commitrail and full hosted + Backend remain required. No coverage/count quotas or weakened selection. + +## Risk and review routing + +Risk `L1`: shared status handling, duplicate creation after lost responses, +bearer leakage and false authority/recovery claims. Plan feasibility review +before code; three focused assignments for architecture/docs, security and +QA/test-delta. Shared transport proof must retain all existing command behavior. +Lead owns deterministic checks and hosted monitoring, not nine reviewer runs. +Human focus: exact 201, caller-owned key, project shell versus guide readiness, +and replay recovery versus authority for a fresh write. + +## Evidence + +Exact execution and review results belong in the PR. Tests run the installed +binary, not Go functions. Real PostgreSQL proves stored projects and API +authorization; HTTP peers prove wire, malformed responses and transport replay. + +```sh +cd cli +go mod verify +go mod tidy -diff +go vet ./... +go build -trimpath -o /tmp/workstream-cli ./cmd/workstream +cd ../backend +WORKSTREAM_CLI_EXECUTABLE=/tmp/workstream-cli python scripts/run_isolated_tests.py --metadata-json /tmp/workstream-cli-isolation.json --timeout-seconds 240 -- python -m pytest -q ../cli/tests/integration +``` + +Reject a separate create transport/DTO/output renderer, generic API passthrough, +implicit whoami/access preflight, default retries and local grant inference. +Source availability is not binary distribution or deployed-provider evidence. + +Plan correction `PLAN-CLI08-NULL-DESCRIPTION` distinguishes a legitimate null +response description from missing or wrong-type data; only required scalar +fields reject null. Project-create's existing committed recovery returns before +PREP. Suspended/revoked-link replay is an owner safety question to hand off, +not a new client-side guard or a claim of fresh replay authorization. diff --git a/README.md b/README.md index 01443b268..e5a3118d8 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,9 @@ explicit caller retry keys and uncertain-outcome reporting without automatic ret `workstream task context TASK_ID` and `task requirements TASK_ID` inspect the governing guide/policy selectors, server action hints and locked intake rules. Hints do not grant authority; requirements do not expose hidden submission upload. +`workstream project create --name TEXT --slug TEXT --idempotency-key UUID` +creates a draft project shell through the public API, with explicit manual +replay and uncertain-outcome handling; it does not approve or activate a guide. All support human-readable and JSON output, using the caller's Flow token. The first source package is buildable; further workflow commands and published binaries remain planned. diff --git a/cli/README.md b/cli/README.md index c331c3bc5..4045b455f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,7 +2,7 @@ An independent Go client for Workstream's public REST API, for humans and agents using the terminal. It provides human self-profile reads and editing, -plus exact-project inspection, authority reads, manager task browsing and +plus draft project creation, exact-project inspection, authority reads, manager task browsing and contributor work discovery, claim/start and governing context/intake requirements: | Command | Public API | @@ -11,6 +11,7 @@ contributor work discovery, claim/start and governing context/intake requirement | `workstream profile update` | `PATCH /api/v1/actors/me` | | `workstream project access PROJECT_ID` | `GET /api/v1/actors/me/authorization-context?project_id=PROJECT_ID` | | `workstream project show PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID` | +| `workstream project create --name TEXT --slug TEXT --idempotency-key UUID` | `POST /api/v1/projects` | | `workstream project tasks PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/tasks` | | `workstream project task PROJECT_ID TASK_ID` | `GET /api/v1/projects/PROJECT_ID/tasks/TASK_ID` | | `workstream task ready PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/tasks/ready` | @@ -92,6 +93,48 @@ object; text escapes terminal controls. Foreign or revoked authority remains a server denial with empty stdout, not an empty successful project. This command does not list projects, edit setup, activate guides or claim tasks. +## Create a draft project shell + +```sh +workstream project create --name 'Evaluation project' --slug evaluation --idempotency-key CREATE_UUID --output json +workstream project create --name 'Research project' --slug research --description 'Evaluate the supplied evidence.' --idempotency-key ANOTHER_CREATE_UUID +``` + +Both `--name` and `--slug` must be supplied; their explicit empty values and +whitespace are preserved because the API accepts them. Name and slug accept +at most 200 and 120 Unicode characters. All text must be valid UTF-8 without +NUL. Description is optional: omission sends no member, while an explicit empty +flag sends an empty string. The complete encoded JSON request is capped at +8 KiB; description has no additional character limit in the backend. + +Creation requires system-scoped Project Manager authority. Access Administrator +or project-scoped Project Manager authority alone cannot create a project. +The CLI sends one public POST with the unchanged caller bearer and UUID key; +there is no role preflight, generated key or automatic retry. Only HTTP 201 +with the full seven-field ProjectResponse establishes success. Nullable +description is valid, but missing members, malformed identity/timestamps, +unknown or duplicate fields and the minimal contributor GET shape are rejected. +JSON preserves the API response; text shows every field with terminal escaping. + +This creates a draft shell, not an uploaded or approved guide, activated project +or claimable task. Project identity is generated by Workstream, not the key. +Retain the key with the exact name, slug and description omission/value. +An exact manual replay recovers the stored project, whose state may since have +changed. Changed fields conflict; a different key with the same slug conflicts +rather than overwriting a project. + +Unlike task claim/start, the current project-create API recovers a committed +result before fresh creation authorization, including after the creation grant +is revoked. This recovery is not permission for another creation. Do not assume +every mutation has task replay semantics or that a replay certifies current +authority. Fresh creation is denied after revocation or suspension. + +Lost, malformed, oversized, redirected, unexpected-status, server-error and +noncanonical error replies leave an unknown outcome: nonzero exit, empty stdout +and `error.outcome_unknown: true`. Only a complete canonical Workstream 4xx +establishes a known denial. If manually retrying, use the unchanged fields and +key; do not invent a project ID, assume rollback or switch to a fresh key. + ## Browse tasks as a project manager ```sh diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index 92596588a..fc2acfc60 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -305,10 +305,10 @@ func (c *Client) safeMetadata(value string) bool { } func (c *Client) request(ctx context.Context, method, path, query string, body []byte) (json.RawMessage, error) { - return c.requestWithKey(ctx, method, path, query, body, "") + return c.requestWithKey(ctx, method, path, query, body, "", http.StatusOK) } -func (c *Client) requestWithKey(ctx context.Context, method, path, query string, body []byte, key string) (json.RawMessage, error) { +func (c *Client) requestWithKey(ctx context.Context, method, path, query string, body []byte, key string, successStatus int) (json.RawMessage, error) { mutation := method == http.MethodPatch || method == http.MethodPost target := c.origin + path if query != "" { @@ -353,7 +353,7 @@ func (c *Client) requestWithKey(ctx context.Context, method, path, query string, correlation = "" } } - if response.StatusCode != http.StatusOK { + if response.StatusCode != successStatus { code := "api_error" knownEnvelope := false if response.StatusCode >= 300 && response.StatusCode < 400 { @@ -370,7 +370,7 @@ func (c *Client) requestWithKey(ctx context.Context, method, path, query string, } } if method == http.MethodPost { - knownEnvelope = canonicalTaskError(responseBody, response.Header.Get("Content-Type")) + knownEnvelope = canonicalMutationError(responseBody, response.Header.Get("Content-Type")) } // A complete API 4xx envelope is a known denial, independent of code // spelling/redaction. A gateway reply without it cannot prove rollback. @@ -383,3 +383,29 @@ func (c *Client) requestWithKey(ctx context.Context, method, path, query string, } return json.RawMessage(responseBody), nil } + +// POST writes need a complete public ApiError, not a gateway's code-shaped +// JSON. Profile PATCH deliberately retains its existing response contract. +func canonicalMutationError(raw []byte, contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "application/json" { + return false + } + var envelope struct { + Error json.RawMessage `json:"error"` + Detail json.RawMessage `json:"detail"` + } + if decode(raw, &envelope, []string{"error"}, nil) != nil { + return false + } + var value struct { + Code *string `json:"code"` + Message *string `json:"message"` + Details map[string]json.RawMessage `json:"details"` + CorrelationID *string `json:"correlation_id"` + Retryable *bool `json:"retryable"` + } + return decode(envelope.Error, &value, []string{"code", "message", "details", "correlation_id", "retryable"}, nil) == nil && + value.Code != nil && *value.Code != "" && value.Message != nil && value.Details != nil && + value.CorrelationID != nil && validUUID(*value.CorrelationID) && value.Retryable != nil +} diff --git a/cli/internal/api/project.go b/cli/internal/api/project.go index 82f6215fa..a4d707661 100644 --- a/cli/internal/api/project.go +++ b/cli/internal/api/project.go @@ -31,7 +31,7 @@ type Project struct { func (c *Client) Project(ctx context.Context, selector string) (Result[Project], error) { var result Result[Project] - selectedID, valid := uuidIdentity(selector) + _, valid := uuidIdentity(selector) if len(selector) > 100 || !valid { return result, errors.New("PROJECT_ID must be a UUID") } @@ -39,9 +39,18 @@ func (c *Client) Project(ctx context.Context, selector string) (Result[Project], if err != nil { return result, err } + project, err := decodeProject(raw) + if err != nil || !sameUUID(project.ID, selector) { + return result, &Failure{Code: "invalid_api_response"} + } + return Result[Project]{Raw: raw, Value: project}, nil +} + +func decodeProject(raw json.RawMessage) (Project, error) { + var project Project var fields map[string]json.RawMessage if err := jsonv2.Unmarshal(raw, &fields); err != nil || fields == nil { - return result, &Failure{Code: "invalid_api_response"} + return project, &Failure{Code: "invalid_api_response"} } requiredStrings := []string{"id", "name", "status"} if len(fields) != 3 { @@ -50,10 +59,10 @@ func (c *Client) Project(ctx context.Context, selector string) (Result[Project], for _, key := range requiredStrings { var value *string if err := jsonv2.Unmarshal(fields[key], &value); err != nil || value == nil { - return result, &Failure{Code: "invalid_api_response"} + return project, &Failure{Code: "invalid_api_response"} } } - var project Project + var err error if len(fields) == 3 { err = decode(raw, &project.ProjectIdentity, requiredStrings, nil) } else { @@ -63,13 +72,12 @@ func (c *Client) Project(ctx context.Context, selector string) (Result[Project], } err = decode(raw, &full, append(requiredStrings, "description"), nil) if !validTime(full.CreatedAt) || !validTime(full.UpdatedAt) { - return result, &Failure{Code: "invalid_api_response"} + return project, &Failure{Code: "invalid_api_response"} } project = Project{ProjectIdentity: full.ProjectIdentity, Metadata: &full.ProjectMetadata} } - returnedID, valid := uuidIdentity(project.ID) - if err != nil || !valid || returnedID != selectedID { - return result, &Failure{Code: "invalid_api_response"} + if err != nil || !validUUID(project.ID) { + return project, &Failure{Code: "invalid_api_response"} } - return Result[Project]{Raw: raw, Value: project}, nil + return project, nil } diff --git a/cli/internal/api/project_create.go b/cli/internal/api/project_create.go new file mode 100644 index 000000000..7b5f6626b --- /dev/null +++ b/cli/internal/api/project_create.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "unicode/utf8" +) + +// ProjectCreate preserves omission versus explicit empty description. The API +// owns project identity, creation authority, replay and draft lifecycle. +type ProjectCreate struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description,omitempty"` +} + +func (c *Client) CreateProject(ctx context.Context, input ProjectCreate, key string) (Result[Project], error) { + var result Result[Project] + if !validUUID(key) || len(key) > 100 { + return result, errors.New("--idempotency-key must be a UUID") + } + if !validProjectText(input.Name, 200) || !validProjectText(input.Slug, 120) || + (input.Description != nil && (!utf8.ValidString(*input.Description) || strings.ContainsRune(*input.Description, '\x00'))) { + return result, errors.New("project fields must be valid UTF-8 without NUL; name and slug are limited to 200 and 120 characters") + } + body, err := json.Marshal(input) + if err != nil || len(body) > maxUpdateBytes { + return result, errors.New("project request exceeds the request size limit") + } + raw, err := c.requestWithKey(ctx, http.MethodPost, "/api/v1/projects", "", body, key, http.StatusCreated) + if err != nil { + return result, projectCreateFailure(err) + } + project, err := decodeProject(raw) + if err != nil || project.Metadata == nil { + return result, projectCreateFailure(&Failure{Code: "invalid_api_response", OutcomeUnknown: true}) + } + return Result[Project]{Raw: raw, Value: project}, nil +} + +func validProjectText(value string, maxCharacters int) bool { + return utf8.ValidString(value) && !strings.ContainsRune(value, '\x00') && utf8.RuneCountInString(value) <= maxCharacters +} + +func projectCreateFailure(err error) error { + var failure *Failure + if errors.As(err, &failure) && failure.OutcomeUnknown { + failure.RecoveryHint = "project creation outcome unknown; replay only the unchanged name, slug, description and idempotency key" + } + return err +} diff --git a/cli/internal/api/task_mutations.go b/cli/internal/api/task_mutations.go index 59acaf23b..d08204f3c 100644 --- a/cli/internal/api/task_mutations.go +++ b/cli/internal/api/task_mutations.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "mime" "net/http" "net/url" "regexp" @@ -82,7 +81,7 @@ func (c *Client) taskWrite(ctx context.Context, selector, action, key string, re if err != nil || len(body) > maxUpdateBytes { return nil, errors.New("task request exceeds the request size limit") } - raw, err := c.requestWithKey(ctx, http.MethodPost, "/api/v1/tasks/"+url.PathEscape(selector)+"/"+action, "", body, key) + raw, err := c.requestWithKey(ctx, http.MethodPost, "/api/v1/tasks/"+url.PathEscape(selector)+"/"+action, "", body, key, http.StatusOK) return raw, taskWriteFailure(err) } @@ -166,29 +165,3 @@ func sameUUID(left, right string) bool { b, validB := uuidIdentity(right) return validA && validB && a == b } - -// A task write needs a complete public ApiError, not a gateway's code-shaped -// JSON. Profile PATCH deliberately retains its existing response contract. -func canonicalTaskError(raw []byte, contentType string) bool { - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil || mediaType != "application/json" { - return false - } - var envelope struct { - Error json.RawMessage `json:"error"` - Detail json.RawMessage `json:"detail"` - } - if decode(raw, &envelope, []string{"error"}, nil) != nil { - return false - } - var value struct { - Code *string `json:"code"` - Message *string `json:"message"` - Details map[string]json.RawMessage `json:"details"` - CorrelationID *string `json:"correlation_id"` - Retryable *bool `json:"retryable"` - } - return decode(envelope.Error, &value, []string{"code", "message", "details", "correlation_id", "retryable"}, nil) == nil && - value.Code != nil && *value.Code != "" && value.Message != nil && value.Details != nil && - value.CorrelationID != nil && validUUID(*value.CorrelationID) && value.Retryable != nil -} diff --git a/cli/internal/command/command.go b/cli/internal/command/command.go index bd0b21775..7a10bbe95 100644 --- a/cli/internal/command/command.go +++ b/cli/internal/command/command.go @@ -107,8 +107,9 @@ func Run(args []string, stdout, stderr io.Writer, getenv environment, version st profile.AddCommand(update) root.AddCommand(profile) - project := &cobra.Command{Use: "project", Short: "Project-scoped public operations"} + project := &cobra.Command{Use: "project", Short: "Public project operations"} addTaskReads(project, client, &output, stdout) + addProjectCreate(project, client, &output, stdout) project.AddCommand(&cobra.Command{ Use: "show PROJECT_ID", Short: "Inspect the project fields your current authority permits", @@ -122,20 +123,7 @@ func Run(args []string, stdout, stderr io.Writer, getenv environment, version st if err != nil { return err } - if output == "json" { - return writeJSON(stdout, result.Raw) - } - p := result.Value - if _, err := fmt.Fprintf(stdout, "Project: %s\nName: %s\nStatus: %s\n", - safeText(p.ID), safeText(p.Name), safeText(p.Status)); err != nil { - return err - } - if p.Metadata != nil { - _, err = fmt.Fprintf(stdout, "Slug: %s\nDescription: %s\nCreated: %s\nUpdated: %s\n", - safeText(p.Metadata.Slug), optional(p.Metadata.Description), - safeText(p.Metadata.CreatedAt), safeText(p.Metadata.UpdatedAt)) - } - return err + return writeProject(stdout, output, result) }, }) project.AddCommand(&cobra.Command{ diff --git a/cli/internal/command/project_create.go b/cli/internal/command/project_create.go new file mode 100644 index 000000000..615ca8856 --- /dev/null +++ b/cli/internal/command/project_create.go @@ -0,0 +1,57 @@ +package command + +import ( + "fmt" + "io" + + "github.com/Flow-Research/workstream/cli/internal/api" + "github.com/spf13/cobra" +) + +func addProjectCreate(project *cobra.Command, client func() (*api.Client, error), output *string, stdout io.Writer) { + var name, slug, description, key string + create := &cobra.Command{ + Use: "create", Short: "Create a draft project shell (not an approved guide)", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if !cmd.Flags().Changed("name") || !cmd.Flags().Changed("slug") { + return commandError{"invalid_arguments", "--name and --slug are required"} + } + input := api.ProjectCreate{Name: name, Slug: slug} + if cmd.Flags().Changed("description") { + input.Description = &description + } + apiClient, err := client() + if err != nil { + return err + } + result, err := apiClient.CreateProject(cmd.Context(), input, key) + if err != nil { + return err + } + return writeProject(stdout, *output, result) + }, + } + create.Flags().StringVar(&name, "name", "", "Project name (at most 200 characters)") + create.Flags().StringVar(&slug, "slug", "", "Project slug (at most 120 characters; preserved unchanged)") + create.Flags().StringVar(&description, "description", "", "Optional project description") + create.Flags().StringVar(&key, "idempotency-key", "", "Required caller-owned UUID; retain with unchanged fields for manual replay") + project.AddCommand(create) +} + +func writeProject(w io.Writer, output string, result api.Result[api.Project]) error { + if output == "json" { + return writeJSON(w, result.Raw) + } + p := result.Value + if _, err := fmt.Fprintf(w, "Project: %s\nName: %s\nStatus: %s\n", + safeText(p.ID), safeText(p.Name), safeText(p.Status)); err != nil { + return err + } + if p.Metadata != nil { + _, err := fmt.Fprintf(w, "Slug: %s\nDescription: %s\nCreated: %s\nUpdated: %s\n", + safeText(p.Metadata.Slug), optional(p.Metadata.Description), + safeText(p.Metadata.CreatedAt), safeText(p.Metadata.UpdatedAt)) + return err + } + return nil +} diff --git a/cli/tests/integration/project_create_journey.py b/cli/tests/integration/project_create_journey.py new file mode 100644 index 000000000..21c5b1e23 --- /dev/null +++ b/cli/tests/integration/project_create_journey.py @@ -0,0 +1,152 @@ +"""Public creation and replay on the existing isolated real API journey.""" + +from __future__ import annotations + +import json +from uuid import uuid4 + + +async def exercise_project_creation(direct, cli, origin, tokens, profiles, project_id): + admin_headers = {"Authorization": f"Bearer {tokens['cli-admin']}"} + manager_headers = {"Authorization": f"Bearer {tokens['cli-manager']}"} + actor = profiles["cli-project-manager"]["actor_profile_id"] + token = tokens["cli-project-manager"] + body = {"name": "CLI project é", "slug": f"cli-created-{uuid4().hex}"} + key = str(uuid4()) + + def create(caller, fields, replay_key): + flags = ["--name", fields["name"], "--slug", fields["slug"]] + if "description" in fields: + flags += ["--description", fields["description"]] + return cli( + origin, + caller, + "-o", + "json", + "project", + "create", + "--idempotency-key", + replay_key, + *flags, + ) + + def denied(result, status, code=None): + assert result.returncode == 1 and result.stdout == "", result.stderr + error = json.loads(result.stderr)["error"] + assert error["status"] == status and "outcome_unknown" not in error + if code is not None: + assert error["code"] == code + + async def grant(scope): + payload = { + "target_actor_profile_id": actor, + "role": "project_manager", + "scope_type": scope, + "reason": "CLI creation requires system authority, not project scope", + } + if scope == "project": + payload["scope_project_id"] = project_id + result = await direct.post( + "/api/v1/admin-role-grants", + headers=admin_headers | {"Idempotency-Key": str(uuid4())}, + json=payload, + ) + assert result.status_code == 201, result.text + return result.json()["resource_id"] + + async def revoke(grant_id): + result = await direct.post( + f"/api/v1/admin-role-grants/{grant_id}/revoke", + headers=admin_headers | {"Idempotency-Key": str(uuid4())}, + json={"reason": "CLI creation authority proof and fixture restoration"}, + ) + assert result.status_code == 200, result.text + + async def lifecycle(transition): + result = await direct.post( + f"/api/v1/actors/{actor}/{transition}", + headers=admin_headers | {"Idempotency-Key": str(uuid4())}, + json={"reason": "CLI creation lifecycle proof with active system grant"}, + ) + assert result.status_code == 200, result.text + + # Neither first admission nor the access-admin trust root creates projects. + for caller in (tokens["cli-outsider"], tokens["cli-admin"], token): + denied(create(caller, body, str(uuid4())), 403) + scoped_grant = await grant("project") + denied(create(token, body, str(uuid4())), 403) + system_grant = await grant("system") + + result = create(token, body, key.replace("-", "")) + assert result.returncode == 0 and result.stderr == "", result.stderr + created = json.loads(result.stdout) + assert set(created) == { + "id", + "name", + "slug", + "description", + "status", + "created_at", + "updated_at", + } + assert created["name"] == body["name"] and created["slug"] == body["slug"] + assert created["status"] == "draft" and created["description"] is None + saved = await direct.get( + f"/api/v1/projects/{created['id']}", headers=manager_headers + ) + assert saved.status_code == 200 and saved.json() == created + # Compare to direct POST recovery as well as the persisted GET projection. + expected = await direct.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {token}", "Idempotency-Key": key}, + json=body, + ) + assert expected.status_code == 201 and expected.json() == created + replay = create(token, body, key) + assert replay.returncode == 0 and json.loads(replay.stdout) == created + denied(create(token, body | {"description": ""}, key), 409, "idempotency_mismatch") + denied( + create(token, body | {"name": "Must not overwrite"}, str(uuid4())), + 409, + "project_slug_conflict", + ) + unchanged = await direct.get( + f"/api/v1/projects/{created['id']}", headers=manager_headers + ) + assert unchanged.status_code == 200 and unchanged.json() == created + + await revoke(system_grant) + # Existing ProjectCreateService recovers committed writes before fresh PREP. + # TASK replay has different current-authority semantics; don't copy them here. + replay = create(token, body, key) + assert replay.returncode == 0 and json.loads(replay.stdout) == created + denied( + create(token, body | {"slug": f"cli-revoked-{uuid4().hex}"}, str(uuid4())), 403 + ) + + system_grant = await grant("system") + complete = body | { + "slug": f"cli-described-{uuid4().hex}", + "description": "Full description é\n", + } + positive = create(token, complete, str(uuid4())) + assert positive.returncode == 0 and positive.stderr == "", positive.stderr + second = json.loads(positive.stdout) + assert second["id"] != created["id"] + assert all(second[field] == value for field, value in complete.items()) + saved = await direct.get( + f"/api/v1/projects/{second['id']}", headers=manager_headers + ) + assert saved.status_code == 200 and saved.json() == second + await lifecycle("suspend") + # Profile reads and creation PREP expose different canonical denial codes. + denied(cli(origin, token, "-o", "json", "whoami"), 403, "actor_suspended") + denied( + create(token, body | {"slug": f"cli-suspended-{uuid4().hex}"}, str(uuid4())), + 403, + "permission_not_granted", + ) + # Preserve the original manager/task fixture's active, ungranted baseline. + await lifecycle("reactivate") + await revoke(system_grant) + await revoke(scoped_grant) diff --git a/cli/tests/integration/test_project_create_http.py b/cli/tests/integration/test_project_create_http.py new file mode 100644 index 000000000..f42f4b055 --- /dev/null +++ b/cli/tests/integration/test_project_create_http.py @@ -0,0 +1,272 @@ +"""Installed project-create process: wire contract, uncertainty and no replay.""" + +from copy import deepcopy +import json + +from http2_fixture import goaway_fixture +from test_contributor_task_mutations import KEY, canonical_error +from test_http_boundary import PROFILE, PROJECT, TOKEN, assert_failure, http_fixture + +CREATED = { + "id": PROJECT, + "name": "Project é\n\x1b[31m", + "slug": "slug\n\u202e", + "description": "Description\n\x1b[32m", + "status": "draft", + "created_at": PROFILE["created_at"], + "updated_at": PROFILE["updated_at"], +} + + +def invoke(cli, origin, *flags, key=KEY, output="json", token=TOKEN): + return cli( + origin, + token, + "-o", + output, + "project", + "create", + "--name", + "Project é", + "--slug", + " slug ", + "--idempotency-key", + key, + *flags, + ) + + +def test_project_create_preserves_fields_key_and_exact_full_response(cli): + with http_fixture() as (origin, response, requests): + response.update(status=201, body=json.dumps(CREATED).encode()) + for key in (KEY, KEY.replace("-", ""), "{" + KEY + "}", "urn:uuid:" + KEY): + for flags, expected in ( + ((), {"name": "Project é", "slug": " slug "}), + ( + ("--description", ""), + {"name": "Project é", "slug": " slug ", "description": ""}, + ), + ( + ("--description", "Evidence é\n"), + { + "name": "Project é", + "slug": " slug ", + "description": "Evidence é\n", + }, + ), + ): + result = invoke(cli, origin, *flags, key=key) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert result.stdout.strip().encode() == response["body"] + assert requests[-1] == ("POST", "/api/v1/projects", "Bearer " + TOKEN) + assert response["commands"][-1] == ("application/json", [key], expected) + # A committed recovery may return a project which has since progressed. + response["body"] = json.dumps(CREATED | {"status": "active"}).encode() + assert invoke(cli, origin).returncode == 0 + assert len(requests) == len(response["commands"]) == 13 + + +def test_project_create_text_and_nullable_description(cli): + with http_fixture() as (origin, response, requests): + response.update(status=201, body=json.dumps(CREATED).encode()) + result = invoke(cli, origin, output="text") + assert result.returncode == 0 and result.stderr == "" + assert result.stdout == ( + f"Project: {PROJECT}\nName: Project é\\u000A\\u001B[31m\nStatus: draft\n" + "Slug: slug\\u000A\\u202E\nDescription: Description\\u000A\\u001B[32m\n" + f"Created: {PROFILE['created_at']}\nUpdated: {PROFILE['updated_at']}\n" + ) + for description in (None, ""): + response["body"] = json.dumps( + CREATED | {"description": description} + ).encode() + result = invoke(cli, origin) + assert result.returncode == 0 and result.stderr == "" + assert result.stdout.strip().encode() == response["body"] + assert len(requests) == 3 + + +def test_project_create_text_and_encoded_size_boundaries(cli): + with http_fixture() as (origin, response, requests): + response.update(status=201, body=json.dumps(CREATED).encode()) + for name, slug in (("", ""), ("é" * 200, "é" * 120)): + result = invoke(cli, origin, "--name", name, "--slug", slug) + assert result.returncode == 0 and result.stderr == "" + assert response["commands"][-1][2] == {"name": name, "slug": slug} + empty = {"name": "x", "slug": "x", "description": ""} + description = "d" * (8192 - len(json.dumps(empty, separators=(",", ":")))) + result = invoke( + cli, origin, "--name", "x", "--slug", "x", "--description", description + ) + assert result.returncode == 0 and result.stderr == "" + assert response["commands"][-1][2] == empty | {"description": description} + assert_failure( + invoke( + cli, + origin, + "--name", + "x", + "--slug", + "x", + "--description", + description + "d", + ), + "invalid_arguments", + 2, + ) + assert len(requests) == 3 + + +def test_project_create_invalid_input_never_dispatches(cli): + with http_fixture() as (origin, _response, requests): + for flags in ( + ("--name", "é" * 201), + ("--slug", "é" * 121), + ("--name", b"invalid-\xff"), + ("--slug", b"invalid-\xff"), + ("--description", b"invalid-\xff"), + ("--description", "d" * 8192), + ("--idempotency-key", "not-uuid"), + ("--idempotency-key", ""), + ("--actor-id", PROJECT), + ("--endpoint", "/private"), + ("unexpected-positional",), + ): + assert_failure(invoke(cli, origin, *flags), "invalid_arguments", 2) + for flags in ( + (), + ("--name", "x", "--idempotency-key", KEY), + ("--slug", "x", "--idempotency-key", KEY), + ("--name", "x", "--slug", "x"), + ): + assert_failure( + cli(origin, TOKEN, "-o", "json", "project", "create", *flags), + "invalid_arguments", + 2, + ) + assert requests == [] + + +def test_project_create_malformed_success_is_unknown(cli): + with http_fixture() as (origin, response, requests): + response["status"] = 201 + values = [ + CREATED | {key: None} + for key in ("id", "name", "slug", "status", "created_at", "updated_at") + ] + values += [ + CREATED | delta + for delta in ( + {"id": "bad"}, + {"created_at": "bad"}, + {"updated_at": "bad"}, + {"description": 123}, + {"private": "secret"}, + ) + ] + values += [ + {key: value for key, value in CREATED.items() if key != missing} + for missing in CREATED + ] + values.append({key: CREATED[key] for key in ("id", "name", "status")}) + malformed = [json.dumps(value).encode() for value in values] + malformed += [ + b"null", + b"[]", + b"{}", + b"not-json", + json.dumps(CREATED) + .encode() + .replace(b'"id":', b'"id": "' + PROJECT.encode() + b'", "id":', 1), + ] + for body in malformed: + response["body"] = body + result = invoke(cli, origin) + assert_failure(result, "invalid_api_response") + assert json.loads(result.stderr)["error"]["outcome_unknown"] is True + assert len(requests) == len(malformed) + + +def test_project_create_denial_uncertainty_and_no_retry(cli): + with http_fixture() as (origin, response, requests): + null_details = deepcopy(canonical_error()) + null_details["error"]["details"] = None + cases = ( + (403, canonical_error(), "application/json", False), + (409, canonical_error("idempotency_mismatch"), "application/json", False), + (422, canonical_error("validation_error"), "application/json", False), + (403, null_details, "application/json", True), + (403, {"error": {"code": "denied"}}, "application/json", True), + (403, canonical_error(), "text/html", True), + (403, canonical_error() | {"unknown": True}, "application/json", True), + (503, canonical_error(), "application/json", True), + (302, {}, "application/json", True), + (200, CREATED, "application/json", True), + (202, CREATED, "application/json", True), + (201, CREATED, "text/html", True), + ) + for status, value, content_type, unknown in cases: + response.update( + status=status, + body=json.dumps(value).encode(), + headers={"Content-Type": content_type, "Location": origin + "/private"}, + ) + result = invoke(cli, origin) + assert result.returncode == 1 and result.stdout == "" + assert ( + json.loads(result.stderr)["error"].get("outcome_unknown", False) + is unknown + ) + response.update( + status=201, body=b" " * 65537, headers={"Content-Type": "application/json"} + ) + result = invoke(cli, origin) + assert_failure(result, "invalid_api_response") + assert json.loads(result.stderr)["error"]["outcome_unknown"] is True + response["drop"] = True + result = invoke(cli, origin, output="text") + assert result.returncode == 1 and result.stdout == "" + assert "project creation outcome unknown" in result.stderr + assert "unchanged name, slug, description and idempotency key" in result.stderr + assert "whoami" not in result.stderr + assert len(requests) == len(response["commands"]) == len(cases) + 2 + + +def test_project_create_reflected_error_metadata_does_not_leak_bearer(cli): + with http_fixture() as (origin, response, requests): + for token in ("credential_canary_AAA", KEY): + response.update( + status=403, + body=json.dumps(canonical_error(token)).encode(), + headers={"Content-Type": "application/json", "X-Correlation-ID": token}, + ) + result = invoke(cli, origin, token=token) + assert_failure(result, "api_error") + assert "outcome_unknown" not in json.loads(result.stderr)["error"] + assert "correlation_id" not in json.loads(result.stderr)["error"] + assert len(requests) == 2 + + +def test_project_create_is_not_replayed_after_http2_goaway(cli, tmp_path): + with goaway_fixture(tmp_path) as (origin, env, bodies, connections): + result = cli( + origin, + TOKEN, + "-o", + "json", + "project", + "create", + "--name", + "Received before GOAWAY", + "--slug", + "one-shot", + "--idempotency-key", + KEY, + extra_env=env, + ) + assert_failure(result, "service_unavailable") + assert json.loads(result.stderr)["error"]["outcome_unknown"] is True + assert connections == ["h2"] + assert [json.loads(value) for value in bodies] == [ + {"name": "Received before GOAWAY", "slug": "one-shot"} + ] diff --git a/cli/tests/integration/test_public_self_service.py b/cli/tests/integration/test_public_self_service.py index 18cd2618e..80f956b8e 100644 --- a/cli/tests/integration/test_public_self_service.py +++ b/cli/tests/integration/test_public_self_service.py @@ -14,6 +14,7 @@ import pytest from task_journey import exercise_manager_task_reads +from project_create_journey import exercise_project_creation ROOT = Path(__file__).resolve().parents[3] BACKEND = ROOT / "backend" @@ -121,6 +122,7 @@ async def test_installed_cli_uses_only_public_profile_and_project_context( assert ( "get" in specification.json()["paths"]["/api/v1/projects/{project_id}"] ) + assert "post" in specification.json()["paths"]["/api/v1/projects"] for path in ( "/api/v1/projects/{project_id}/tasks", "/api/v1/projects/{project_id}/tasks/{task_id}", @@ -260,6 +262,10 @@ async def test_installed_cli_uses_only_public_profile_and_project_context( assert project.status_code == 201, project.text project_id = project.json()["id"] + await exercise_project_creation( + direct, cli, origin, tokens, profiles, project_id + ) + for selector in (project_id, project_id.replace("-", "")): shown = cli( origin, diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index bef25a00e..004b3ecb9 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -139,7 +139,8 @@ and administrative read tools through WS-MCP-002-03. The reads plus human self-profile editing, exact-project inspection and manager task queue/detail reads, contributor ready-work/instruction reads and public claim/start commands with caller-supplied retry keys, plus governing work-context -and locked intake-requirement reads, with +and locked intake-requirement reads and draft project creation with an explicit +caller-owned retry key, with text/JSON output. Project inspection preserves server-selected full/minimal fields; no public project-list route is invented. CLI write uncertainty is explicit and never automatically @@ -324,6 +325,11 @@ cannot be reused as post-submission review-gate evidence. See the adds the governing guide, exact policy selectors, server hints and locked intake requirements through public reads. Hints are not authority; no files are fetched, no policy is evaluated locally and hidden submission remains hidden. + [CLI project creation](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-08.md) + creates a draft shell through the public POST, with exact 201 response, + caller-owned replay custody and explicit uncertain outcomes. It does not upload, + approve or activate a guide. Committed recovery follows the existing project's + API contract, not task replay authorization semantics. Built-binary HTTP integration and isolated real-API proof accompany the package. Further public commands, optional TUI and binary distribution