diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index f3495dadf..8398974ad 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, 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-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, inspection, task browsing/claim/start, governing context, project creation and guide declaration delivered through WS-CLI-001-09; document upload, further public journeys and binary distribution remain; hidden submission is not exposed | | [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 and B5 validates bounded evaluation content before durable admission; 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 03e02596d..d96f3f311 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -12,7 +12,8 @@ [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-08](WS-CLI-001-08.md), draft project shell creation with explicit caller-owned replay. + [WS-CLI-001-08](WS-CLI-001-08.md), draft project shell creation with explicit caller-owned replay; + [WS-CLI-001-09](WS-CLI-001-09.md), guide declaration, illustrative tasks and document upload selectors. ## Current boundary @@ -39,6 +40,10 @@ 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. +`project guide create PROJECT_ID --input FILE --idempotency-key UUID` declares +a draft guide, task examples and document targets; setup awaits document upload. +This is a stored creation receipt, not live readiness. The API reauthorizes +guide creation replay, unlike project-shell committed recovery. All have text/JSON output and built-binary integration proof. Mutations preserve omitted/null semantics and explicitly report uncertain outcomes without automatic retries. @@ -92,11 +97,14 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. 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, +9. **WS-CLI-001-09:** Declare a guide, required illustrative tasks and source + document targets through public POST using an exact bounded JSON file. + Actual document upload, setup inspection, approval and activation remain. +10. **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. -10. **Optional TUI:** Add a focused public queue/evidence view after its API +11. **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-09.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md new file mode 100644 index 000000000..172ef091c --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md @@ -0,0 +1,126 @@ +# WS-CLI-001-09 — Declare a guide and its required setup inputs + +- Initiative: `WS-CLI-001` +- Durable disposition: `Complete` +- Intended merge outcome: Create guide metadata, illustrative task examples and + declared document targets through the existing public API. + +## Intent + +CLI08 creates a project shell. Its next setup boundary is the public +`POST /api/v1/projects/{project_id}/guides`, owned by +`GuideMutationService.create_guide`. Add `workstream project guide create +PROJECT_ID --input FILE --idempotency-key UUID`. The UTF-8 JSON file contains +the exact public ProjectGuideCreate body: version, optional change_summary, +required task_examples and required documents. Preserve field omission, null +where supported, text and order; reject malformed/duplicate/unknown fields. +The backend retains semantic validation, authority, immutable source membership, +setup identity, transaction and replay. No source text is extracted by the CLI. + +This step intentionally ends at declared upload selectors and awaiting_documents, +not an approved or usable guide. Binary document upload needs a separate bounded +file-stream/transport proof, rather than hiding that risk in JSON creation. +Prefer one fixed typed command over generic HTTP passthrough, shell argument +arrays of large example text, or a second setup engine. No new human decision +or backend authority mechanism is required. + +## Bounded change + +Allowed: new `cli/internal/api/guide_create.go` and +`cli/internal/command/guide_create.go`; existing client.go and command.go; +new `cli/tests/integration/test_guide_create_http.py` and guide_create_journey.py; +test_public_self_service.py and the existing HTTP fixture's raw-body capture, +CLI/root README, affected roadmap claims, this +record, initiative overview and index. No backend, MCP, dependencies/workflows, +hidden routes, upload, guide edits/approval/activation, setup polling, grants, +local authorization/policy decisions, generated keys, retries or TUI. + +Reuse the shared one-shot keyed POST and canonical error contract, adding +explicit response bounds to that private transport: existing commands keep +64KiB; guide creation alone permits 2MiB because 100 examples and document +declarations can exceed 64KiB. Bound the input file to 1MiB, require a regular +file, and never include its path/content or an OS error in diagnostics. Other +mutations keep their 8KiB bound. Require exact 201, unchanged bearer/key, +safe UUID selectors, no preflight, redirects, ambient proxy or transport replay. + +Validate the complete closed ProjectGuideCreateResponse and nested examples, +documents and setup, including UUIDs/timestamps/hashes, non-null required fields, +nullable fields, unique document UUID identity and ordered declaration indices. +Bind project identity, version, change_summary, declared media types and example +values/order to the request. Bind document labels to the backend's existing +whitespace normalization (`" ".join(label.split())`), preserving original request +bytes; test Unicode whitespace and normalization collisions. Omitted and null +change_summary both return null; text remains exact. Optional example title +defaults to null, labels to an empty array. Null change_summary and example title +are valid; required fields, arrays and array members cannot be null. Creation +returns guide status `draft` and setup status `awaiting_documents` even on replay +of the stored creation receipt. contribution_policy_id, +contribution_policy_version_id, activation_operation_id, approved_by, +effective_at and superseded_at must all be null for this fixed receipt; validate +each syntactically valid non-null substitution and partial/full policy binding +as an unknown invalid response. Hash validation uses the existing syntax helper, +not a new implementation of backend canonical hashing. +Do not infer approval or active authority from a stored replay. Preserve raw +JSON in JSON mode; safely render all response fields in text mode. Invalid or +unexpected responses remain unknown mutation outcomes. Recovery guidance keeps +the same project, file contents and key, never claims rollback or invents an ID. + +## Acceptance criteria + +- Built-process HTTP tests prove exact body/field omissions and selectors, + nullable values and Unicode, complete safe text/raw JSON, and input/response + bounds (including a legitimate response over 64KiB). +- Reject malformed/duplicate/unknown fields and null required fields/members, + substituted project, version/summary/examples/documents/lifecycle constants, + invalid UUIDs/hashes/times, normalized duplicate + document identities, and unexpected success status. Invalid local input sends + no request and emits no file content/path or credential. +- Canonical 4xx is a known denial; malformed envelopes (including null details), + drop, redirect, oversized response and HTTP/2 GOAWAY are unknown outcomes; + one invocation transmits at most one POST body. +- Extend the one-bootstrap real FastAPI/PostgreSQL CLI journey. Assert OpenAPI + registration, declared targets and awaiting_documents, direct exact replay, + unchanged-body manual replay, mismatch denial and fresh creation denial for + absent/foreign-project/revoked manager authority and suspended actor. Prove a + fresh positive on the scoped grant's own project before a foreign-project + denial and a fresh positive before revocation. Revocation leaves the actor + active: regrant, prove a fresh positive, suspend, prove denial, reactivate, + then revoke the restored grant. Restore the spare actor's active/ungranted + baseline through public operations. No fake authority or private product writes. +- Existing process/API suite, Go verify/tidy-diff/vet/build, Ruff/format, + workflow guards, links/stale/Commitrail and full hosted Backend remain required. + No unit-test suite, coverage quota, skipped proof or weakened CI. + +## Risk and review routing + +Risk L1: larger untrusted response, file input, bearer handling, false setup +success or duplicate mutation after lost replies. Plan feasibility review before +code; architecture/docs, security and QA/test-delta focused assignments after +shared checks and a clean target. Human focus: declaration is not upload or +approval, exact file/body replay custody, full response identity and safe output. + +## Evidence + +Verification uses the existing built-process suite and isolated database runner: + +```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 300 -- python -m pytest -q ../cli/tests/integration +``` + +Execution and exact-head review evidence belong in the PR, not durable active +state. Assess all affected roadmap claims in this PR; upload, policy approval, +activation and binary distribution remain separate boundaries. + +Plan review corrected label normalization, nullable field exceptions, complete +response relationships and reachable authority/lifecycle controls. The intended +creation receipt binds inputs; it is not a live setup-state observation. +Implementation review additionally reproduced acceptance of valid-but-impossible +approval/activation facts. The creation parser now rejects those facts rather +than accepting them merely because UUID/time syntax is valid; a focused process +regression distinguishes the defective binary from the repair. diff --git a/README.md b/README.md index b282bd901..0a732c2cd 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,9 @@ Hints do not grant authority; requirements do not expose hidden submission uploa `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. +`workstream project guide create PROJECT_ID --input FILE --idempotency-key UUID` +declares a draft guide, task examples and document upload targets. It does not +upload documents or approve the guide; returned setup waits for those documents. 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 4045b455f..73ed49f34 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 draft project creation, exact-project inspection, authority reads, manager task browsing and +plus draft project/guide declaration, exact-project inspection, authority reads, manager task browsing and contributor work discovery, claim/start and governing context/intake requirements: | Command | Public API | @@ -12,6 +12,7 @@ contributor work discovery, claim/start and governing context/intake requirement | `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 guide create PROJECT_ID --input FILE --idempotency-key UUID` | `POST /api/v1/projects/PROJECT_ID/guides` | | `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` | @@ -70,7 +71,8 @@ and preserving the successful API JSON. Exit status is `0` for success, `1` for API/network/response failure, and `2` for invalid arguments or configuration. A request times out after 12 seconds; -responses are bounded to 64 KiB and requests are not automatically retried. +responses default to a 64 KiB bound (guide declaration uses the documented +2MiB exception below), and requests are not automatically retried. Use `--help`, `--version` and `completion bash|zsh|fish|powershell` without a credential or network connection. @@ -93,6 +95,56 @@ 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. +## Declare a guide and its setup inputs + +```sh +workstream project guide create PROJECT_ID --input guide.json --idempotency-key GUIDE_CREATE_UUID --output json +``` + +The regular UTF-8 JSON file contains the public guide-creation request, not the +guide document's contents or a storage URL. For example: + +```json +{ + "version": "evaluation-guide", + "change_summary": "Initial project instructions", + "task_examples": [ + {"content": "Evaluate the supplied experiment's evidence.", "title": "Evidence evaluation", "labels": ["research"]} + ], + "documents": [ + {"label": "Guide.pdf", "media_type": "application/pdf"} + ] +} +``` + +`version`, `task_examples` and `documents` are required. `change_summary` and +example `title` may be omitted or null; example `labels` defaults to an empty +array. Unknown/duplicate members, malformed JSON and null required fields or +array members are rejected. The file is sent unchanged; the API owns semantic +limits and validation. PDF, DOCX and PPTX declarations use the media types in +OpenAPI. The API normalizes document-label whitespace; example text is preserved. + +The CLI bounds this input to 1MiB and this response to 2MiB because declarations +include example text and document selectors. These are client wire envelopes, +not backend policy limits. Existing operations retain their 64KiB response +limit and small mutations their 8KiB input limit. + +The API checks current Project Manager authority for the exact project, including +manual replay. A successful HTTP 201 returns the complete draft creation receipt, +declared document IDs and `setup.status = awaiting_documents`. JSON output +preserves the response; text renders every field with terminal escaping. +Policy, activation, approval, effective and supersession fields must be null +in this fixed creation receipt, including stored replay. +The command does not upload files, poll setup, approve policies or activate a +guide. The stored creation receipt is not a live readiness or authority snapshot. + +Retain the project selector, exact input contents and caller-owned UUID key. +There is no preflight, generated key or automatic retry, including HTTP/2 replay. +Changed input conflicts. Lost, malformed, redirected or unexpected replies +report an unknown outcome, not rollback; manually replay only the unchanged +input and key. A complete canonical 4xx is a known denial. Local file errors +never echo file paths, contents or OS error details. + ## Create a draft project shell ```sh diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index fc2acfc60..f607733e9 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -309,6 +309,10 @@ func (c *Client) request(ctx context.Context, method, path, query string, body [ } func (c *Client) requestWithKey(ctx context.Context, method, path, query string, body []byte, key string, successStatus int) (json.RawMessage, error) { + return c.requestWithResponseLimit(ctx, method, path, query, body, key, successStatus, maxResponseBytes) +} + +func (c *Client) requestWithResponseLimit(ctx context.Context, method, path, query string, body []byte, key string, successStatus, responseLimit int) (json.RawMessage, error) { mutation := method == http.MethodPatch || method == http.MethodPost target := c.origin + path if query != "" { @@ -342,8 +346,8 @@ func (c *Client) requestWithKey(ctx context.Context, method, path, query string, return nil, &Failure{Code: "service_unavailable", OutcomeUnknown: mutation} } defer response.Body.Close() - responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1)) - if err != nil || len(responseBody) > maxResponseBytes { + responseBody, err := io.ReadAll(io.LimitReader(response.Body, int64(responseLimit)+1)) + if err != nil || len(responseBody) > responseLimit { return nil, &Failure{Code: "invalid_api_response", Status: response.StatusCode, OutcomeUnknown: mutation} } correlation := response.Header.Get("X-Correlation-ID") diff --git a/cli/internal/api/guide_create.go b/cli/internal/api/guide_create.go new file mode 100644 index 000000000..b77f8a727 --- /dev/null +++ b/cli/internal/api/guide_create.go @@ -0,0 +1,205 @@ +package api + +import ( + "context" + "encoding/json" + jsonv2 "encoding/json/v2" + "errors" + "net/http" + "net/url" + "reflect" + "strings" + "unicode" +) + +// These are CLI wire envelopes, not backend policy limits. Guide declarations +// include examples and up to 100 document selectors, unlike small mutations. +const MaxGuideInputBytes = 1024 * 1024 +const maxGuideResponseBytes = 2 * 1024 * 1024 + +type GuideExample struct { + Content string `json:"content"` + Title *string `json:"title"` + Labels []string `json:"labels"` +} + +type GuideDocumentInput struct { + Label string `json:"label"` + MediaType string `json:"media_type"` +} + +type GuideDocument struct { + DocumentID string `json:"document_id"` + Label string `json:"label"` + MediaType string `json:"media_type"` + Order int `json:"order"` +} + +type GuideWaitingSetup struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// GuideCreated is the immutable creation receipt, not current guide readiness. +type GuideCreated struct { + ContributionPolicyID *string `json:"contribution_policy_id"` + ContributionPolicyVersionID *string `json:"contribution_policy_version_id"` + ActivationOperationID *string `json:"activation_operation_id"` + ID string `json:"id"` + ProjectID string `json:"project_id"` + Version string `json:"version"` + Status string `json:"status"` + ChangeSummary *string `json:"change_summary"` + TaskExamples []GuideExample `json:"task_examples"` + TaskExamplesHash *string `json:"task_examples_hash"` + ApprovedBy *string `json:"approved_by"` + EffectiveAt *string `json:"effective_at"` + CreatedBy string `json:"created_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + SupersededAt *string `json:"superseded_at"` + Documents []GuideDocument `json:"documents"` + Setup GuideWaitingSetup `json:"setup"` +} + +type guideCreateInput struct { + Version string `json:"version"` + ChangeSummary *string `json:"change_summary"` + TaskExamples []json.RawMessage `json:"task_examples"` + Documents []GuideDocumentInput `json:"documents"` +} + +func (c *Client) CreateGuide(ctx context.Context, project string, body json.RawMessage, key string) (Result[GuideCreated], error) { + var result Result[GuideCreated] + if !validUUID(project) || len(project) > 100 || !validUUID(key) || len(key) > 100 { + return result, errors.New("PROJECT_ID and --idempotency-key must be UUIDs") + } + if len(body) > MaxGuideInputBytes { + return result, errors.New("--input must contain a bounded public guide declaration") + } + input, examples, err := decodeGuideInput(body) + if err != nil { + return result, errors.New("--input must contain a bounded public guide declaration") + } + raw, err := c.requestWithResponseLimit(ctx, http.MethodPost, + "/api/v1/projects/"+url.PathEscape(project)+"/guides", "", body, key, http.StatusCreated, maxGuideResponseBytes) + if err != nil { + return result, guideCreateFailure(err) + } + value, err := decodeCreatedGuide(raw, project, input, examples) + if err != nil { + return result, guideCreateFailure(&Failure{Code: "invalid_api_response", OutcomeUnknown: true}) + } + return Result[GuideCreated]{Raw: raw, Value: value}, nil +} + +func decodeGuideInput(raw json.RawMessage) (guideCreateInput, []GuideExample, error) { + var input guideCreateInput + fields, err := contextObject(raw, &input, []string{"version", "task_examples", "documents"}, nil) + if err != nil { + return input, nil, err + } + examples, err := decodeGuideExamples(fields["task_examples"]) + if err != nil { + return input, nil, err + } + var documents []json.RawMessage + if jsonv2.Unmarshal(fields["documents"], &documents) != nil || len(documents) == 0 || len(documents) > 100 { + return input, nil, errors.New("invalid documents") + } + for _, document := range documents { + var value GuideDocumentInput + if _, err := contextObject(document, &value, []string{"label", "media_type"}, nil); err != nil || !guideMediaType(value.MediaType) { + return input, nil, errors.New("invalid document declaration") + } + } + return input, examples, nil +} + +func decodeGuideExamples(raw json.RawMessage) ([]GuideExample, error) { + var members []json.RawMessage + if jsonv2.Unmarshal(raw, &members) != nil || len(members) == 0 || len(members) > 100 { + return nil, errors.New("invalid examples") + } + examples := make([]GuideExample, 0, len(members)) + for _, member := range members { + value := GuideExample{Labels: []string{}} + if _, err := contextObject(member, &value, []string{"content"}, []string{"labels"}); err != nil { + return nil, err + } + examples = append(examples, value) + } + return examples, nil +} + +func decodeCreatedGuide(raw json.RawMessage, project string, input guideCreateInput, examples []GuideExample) (GuideCreated, error) { + var value GuideCreated + fields, err := contextObject(raw, &value, []string{ + "id", "project_id", "version", "status", "created_by", "created_at", "updated_at", "documents", "setup", "task_examples", + }, nil) + if err != nil { + return value, err + } + // Nullable but required fields must still be present; the public schema's + // three optional policy/activation UUIDs may be omitted with null defaults. + for _, field := range []string{"change_summary", "task_examples_hash", "approved_by", "effective_at", "superseded_at"} { + if _, ok := fields[field]; !ok { + return value, errors.New("missing guide field") + } + } + returnedExamples, err := decodeGuideExamples(fields["task_examples"]) + if err != nil || !reflect.DeepEqual(examples, returnedExamples) || !reflect.DeepEqual(input.ChangeSummary, value.ChangeSummary) || + !validUUID(value.ID) || !sameUUID(value.ProjectID, project) || value.Version != input.Version || value.Status != "draft" || + !validUUID(value.CreatedBy) || !validTime(value.CreatedAt) || !validTime(value.UpdatedAt) || + value.TaskExamplesHash == nil || !contextPolicyDigest.MatchString(*value.TaskExamplesHash) { + return value, errors.New("invalid guide receipt") + } + value.TaskExamples = returnedExamples + // Creation and its stored exact replay precede approval/activation. Valid + // UUIDs and times are still invalid here when they imply those later facts. + if value.ContributionPolicyID != nil || value.ContributionPolicyVersionID != nil || + value.ActivationOperationID != nil || value.ApprovedBy != nil || value.EffectiveAt != nil || value.SupersededAt != nil { + return value, errors.New("contradictory draft guide receipt") + } + if _, err := contextObject(fields["setup"], &value.Setup, []string{"id", "status"}, nil); err != nil || + !validUUID(value.Setup.ID) || value.Setup.Status != "awaiting_documents" { + return value, errors.New("invalid setup receipt") + } + var documents []json.RawMessage + if jsonv2.Unmarshal(fields["documents"], &documents) != nil || len(documents) != len(input.Documents) { + return value, errors.New("invalid document membership") + } + identities := make(map[[16]byte]bool, len(documents)) + for index, document := range documents { + var item GuideDocument + _, err := contextObject(document, &item, []string{"document_id", "label", "media_type", "order"}, nil) + identity, valid := uuidIdentity(item.DocumentID) + if err != nil || !valid || identities[identity] || item.Order != index || + item.MediaType != input.Documents[index].MediaType || item.Label != normalizedGuideLabel(input.Documents[index].Label) { + return value, errors.New("invalid document selector") + } + identities[identity] = true + } + return value, nil +} + +func normalizedGuideLabel(label string) string { + // Python str.split(), used by the public owner, includes U+001C–U+001F + // in addition to the Unicode whitespace accepted by Go's Fields. + return strings.Join(strings.FieldsFunc(label, func(r rune) bool { + return unicode.IsSpace(r) || (r >= '\x1c' && r <= '\x1f') + }), " ") +} + +func guideMediaType(value string) bool { + return value == "application/pdf" || value == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || + value == "application/vnd.openxmlformats-officedocument.presentationml.presentation" +} + +func guideCreateFailure(err error) error { + var failure *Failure + if errors.As(err, &failure) && failure.OutcomeUnknown { + failure.RecoveryHint = "guide declaration outcome unknown; replay only the unchanged project, input file contents and idempotency key" + } + return err +} diff --git a/cli/internal/command/command.go b/cli/internal/command/command.go index 7a10bbe95..ba8484c70 100644 --- a/cli/internal/command/command.go +++ b/cli/internal/command/command.go @@ -110,6 +110,7 @@ func Run(args []string, stdout, stderr io.Writer, getenv environment, version st project := &cobra.Command{Use: "project", Short: "Public project operations"} addTaskReads(project, client, &output, stdout) addProjectCreate(project, client, &output, stdout) + addGuideCreate(project, client, &output, stdout) project.AddCommand(&cobra.Command{ Use: "show PROJECT_ID", Short: "Inspect the project fields your current authority permits", diff --git a/cli/internal/command/guide_create.go b/cli/internal/command/guide_create.go new file mode 100644 index 000000000..c8e33cfb7 --- /dev/null +++ b/cli/internal/command/guide_create.go @@ -0,0 +1,61 @@ +package command + +import ( + "io" + "os" + + "github.com/Flow-Research/workstream/cli/internal/api" + "github.com/spf13/cobra" +) + +func addGuideCreate(project *cobra.Command, client func() (*api.Client, error), output *string, stdout io.Writer) { + var input, key string + guide := &cobra.Command{Use: "guide", Short: "Public guide setup operations"} + create := &cobra.Command{ + Use: "create PROJECT_ID", Short: "Declare a draft guide, examples and document upload targets", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := client() + if err != nil { + return err + } + body, err := readGuideInput(input) + if err != nil { + return err + } + result, err := apiClient.CreateGuide(cmd.Context(), args[0], body, key) + if err != nil { + return err + } + if *output == "json" { + return writeJSON(stdout, result.Raw) + } + return writeTaskContextFields(stdout, []contextField{{"Guide declaration (documents still need uploading)", result.Value}}) + }, + } + create.Flags().StringVar(&input, "input", "", "Required regular UTF-8 JSON file containing the public guide declaration (at most 1MiB)") + create.Flags().StringVar(&key, "idempotency-key", "", "Required caller-owned UUID; retain with unchanged input for manual replay") + guide.AddCommand(create) + project.AddCommand(guide) +} + +func readGuideInput(path string) ([]byte, error) { + bad := commandError{"invalid_arguments", "--input must be a readable regular JSON file of at most 1MiB"} + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > api.MaxGuideInputBytes { + return nil, bad + } + file, err := os.Open(path) + if err != nil { + return nil, bad + } + defer file.Close() + info, err = file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > api.MaxGuideInputBytes { + return nil, bad + } + body, err := io.ReadAll(io.LimitReader(file, api.MaxGuideInputBytes+1)) + if err != nil || len(body) == 0 || len(body) > api.MaxGuideInputBytes { + return nil, bad + } + return body, nil +} diff --git a/cli/tests/integration/guide_create_journey.py b/cli/tests/integration/guide_create_journey.py new file mode 100644 index 000000000..7094d2f5e --- /dev/null +++ b/cli/tests/integration/guide_create_journey.py @@ -0,0 +1,175 @@ +"""Guide declaration through the real public API and existing bootstrap journey.""" + +import json +from uuid import uuid4 + + +async def exercise_guide_creation( + direct, cli, origin, tokens, profiles, project_id, tmp_path +): + specification = (await direct.get("/openapi.json", timeout=60)).json() + assert "post" in specification["paths"]["/api/v1/projects/{project_id}/guides"] + actor = profiles["cli-project-manager"]["actor_profile_id"] + token = tokens["cli-project-manager"] + admin = {"Authorization": f"Bearer {tokens['cli-admin']}"} + manager = {"Authorization": f"Bearer {tokens['cli-manager']}"} + caller = {"Authorization": f"Bearer {token}"} + path = tmp_path / "guide-declaration.json" + body = { + "version": "cli-guide-" + uuid4().hex, + "change_summary": "First source declaration é", + "task_examples": [ + {"content": "Review the experiment results é\n", "title": None}, + {"content": "Replicate the reported calculation", "labels": ["analysis"]}, + ], + "documents": [ + { + "label": " Study\u0085\u00a0guide.pdf\u001e ", + "media_type": "application/pdf", + }, + { + "label": "Examples.docx", + "media_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + ], + } + + def create(fields, key, project=project_id, presented=token): + path.write_text( + json.dumps(fields, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return cli( + origin, + presented, + "-o", + "json", + "project", + "guide", + "create", + project, + "--input", + str(path), + "--idempotency-key", + key, + ) + + def positive(result): + assert result.returncode == 0 and result.stderr == "", result.stderr + return json.loads(result.stdout) + + def denial(result, statuses=(403, 404), code=None): + assert result.returncode == 1 and result.stdout == "", result.stderr + error = json.loads(result.stderr)["error"] + assert error["status"] in statuses and "outcome_unknown" not in error + if code: + assert error["code"] == code + + async def grant(): + response = await direct.post( + "/api/v1/admin-role-grants", + headers=admin | {"Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": actor, + "role": "project_manager", + "scope_type": "project", + "scope_project_id": project_id, + "reason": "CLI guide declaration scoped authority proof", + }, + ) + assert response.status_code == 201, response.text + return response.json()["resource_id"] + + async def revoke(grant_id): + response = await direct.post( + f"/api/v1/admin-role-grants/{grant_id}/revoke", + headers=admin | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Restore CLI guide fixture"}, + ) + assert response.status_code == 200, response.text + + async def lifecycle(transition): + response = await direct.post( + f"/api/v1/actors/{actor}/{transition}", + headers=admin | {"Idempotency-Key": str(uuid4())}, + json={"reason": "CLI guide lifecycle proof"}, + ) + assert response.status_code == 200, response.text + + for presented in (token, tokens["cli-outsider"], tokens["cli-admin"]): + denial(create(body, str(uuid4()), presented=presented)) + grant_id = await grant() + key = str(uuid4()) + created = positive(create(body, key.replace("-", ""), project_id.replace("-", ""))) + assert created["project_id"] == project_id and created["version"] == body["version"] + assert ( + created["status"] == "draft" + and created["change_summary"] == body["change_summary"] + ) + assert created["created_by"] == actor + assert created["setup"]["status"] == "awaiting_documents" + assert len({item["document_id"] for item in created["documents"]}) == 2 + assert [ + (item["label"], item["media_type"], item["order"]) + for item in created["documents"] + ] == [ + (" ".join(item["label"].split()), item["media_type"], index) + for index, item in enumerate(body["documents"]) + ] + assert created["task_examples"] == [ + {"title": None, "labels": [], **example} for example in body["task_examples"] + ] + + # Stored public setup facts and direct replay prove server custody; HTTP + # fixtures alone cannot prove guide/source/identity persistence. + root = f"/api/v1/projects/{project_id}/guides/{created['id']}" + setup = await direct.get(root + "/setup-runs/latest", headers=caller) + assert setup.status_code == 200, setup.text + assert setup.json()["id"] == created["setup"]["id"] + assert setup.json()["status"] == "awaiting_documents" + assert ( + setup.json()["project_id"] == project_id + and setup.json()["guide_id"] == created["id"] + ) + recovered = await direct.post( + f"/api/v1/projects/{project_id}/guides", + headers=caller | {"Idempotency-Key": key}, + json=body, + ) + assert recovered.status_code == 201 and recovered.json() == created, recovered.text + assert positive(create(body, key)) == created + denial( + create(body | {"change_summary": "Different"}, key), + (409,), + "idempotency_mismatch", + ) + denial(create(body, str(uuid4())), (409,)) + + foreign = await direct.post( + "/api/v1/projects", + headers=manager | {"Idempotency-Key": str(uuid4())}, + json={"name": "Foreign guide target", "slug": "foreign-guide-" + uuid4().hex}, + ) + assert foreign.status_code == 201, foreign.text + denial(create(body, str(uuid4()), foreign.json()["id"])) + collision = body | { + "version": "collision-" + uuid4().hex, + "documents": [ + {"label": "Same.pdf", "media_type": "application/pdf"}, + {"label": " Same.pdf\u00a0", "media_type": "application/pdf"}, + ], + } + denial(create(collision, str(uuid4())), (422,)) + + # A positive immediately before revocation discriminates a real authority + # change from a never-authorized caller. Guide replay rechecks authority. + positive(create(body | {"version": "before-revoke-" + uuid4().hex}, str(uuid4()))) + await revoke(grant_id) + denial(create(body, key)) + denial(create(body | {"version": "revoked-" + uuid4().hex}, str(uuid4()))) + grant_id = await grant() + positive(create(body | {"version": "before-suspend-" + uuid4().hex}, str(uuid4()))) + await lifecycle("suspend") + denial(create(body, key)) + denial(create(body | {"version": "suspended-" + uuid4().hex}, str(uuid4()))) + await lifecycle("reactivate") + await revoke(grant_id) diff --git a/cli/tests/integration/test_guide_create_http.py b/cli/tests/integration/test_guide_create_http.py new file mode 100644 index 000000000..7ccde066f --- /dev/null +++ b/cli/tests/integration/test_guide_create_http.py @@ -0,0 +1,432 @@ +"""Installed guide declaration: exact JSON input, response identity and recovery.""" + +from copy import deepcopy +import json +import os +from urllib.parse import quote + +from http2_fixture import goaway_fixture +from test_contributor_task_mutations import KEY, canonical_error +from test_http_boundary import ( + ACTOR, + PROFILE, + PROJECT, + TOKEN, + assert_failure, + http_fixture, +) + +GUIDE = "018f0ebc-7966-7e8d-bc4d-1cae1e000007" +SETUP = "018f0ebc-7966-7e8d-bc4d-1cae1e000008" +DOCUMENT = "018f0ebc-7966-7e8d-bc4d-1cae1e000009" +DECLARATION = { + "version": "guide é\n\x1b[31m", + "task_examples": [{"content": "Evaluate this work é\n\x1b[32m"}], + "documents": [ + {"label": " Guide\u0085\u00a0file.pdf\u001e ", "media_type": "application/pdf"} + ], +} + + +def receipt(body=DECLARATION): + return { + "contribution_policy_id": None, + "contribution_policy_version_id": None, + "activation_operation_id": None, + "id": GUIDE, + "project_id": PROJECT, + "version": body["version"], + "status": "draft", + "change_summary": body.get("change_summary"), + "task_examples": [ + {"title": None, "labels": [], **item} for item in body["task_examples"] + ], + "task_examples_hash": "sha256:" + "a" * 64, + "approved_by": None, + "effective_at": None, + "created_by": ACTOR, + "created_at": PROFILE["created_at"], + "updated_at": PROFILE["updated_at"], + "superseded_at": None, + "documents": [ + { + "document_id": DOCUMENT, + "label": " ".join(item["label"].split()), + "media_type": item["media_type"], + "order": index, + } + for index, item in enumerate(body["documents"]) + ], + "setup": {"id": SETUP, "status": "awaiting_documents"}, + } + + +def invoke(cli, origin, path, *, selector=PROJECT, key=KEY, output="json", **kwargs): + return cli( + origin, + TOKEN, + "-o", + output, + "project", + "guide", + "create", + selector, + "--input", + str(path), + "--idempotency-key", + key, + **kwargs, + ) + + +def write_input(tmp_path, body=DECLARATION): + path = tmp_path / "guide.json" + path.write_text(json.dumps(body, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def test_guide_create_preserves_body_selectors_defaults_and_safe_text(cli, tmp_path): + with http_fixture() as (origin, response, requests): + for summary in ("omit", None, "", "Changed é\n\x1b[33m"): + body = deepcopy(DECLARATION) + if summary != "omit": + body["change_summary"] = summary + body["task_examples"] += [ + {"content": "Second", "title": None, "labels": ["tag é"]} + ] + path = write_input(tmp_path, body) + response.update( + status=201, body=json.dumps(receipt(body), ensure_ascii=False).encode() + ) + for selector in ( + PROJECT, + PROJECT.replace("-", ""), + "{" + PROJECT + "}", + "urn:uuid:" + PROJECT, + ): + for key in (KEY, KEY.replace("-", "")): + result = invoke(cli, origin, path, selector=selector, key=key) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert result.stdout.strip().encode() == response["body"] + assert response["raw_commands"][-1] == path.read_bytes() + assert response["commands"][-1] == ("application/json", [key], body) + assert requests[-1] == ( + "POST", + "/api/v1/projects/" + quote(selector, safe=":") + "/guides", + "Bearer " + TOKEN, + ) + text = invoke(cli, origin, path, output="text") + assert text.returncode == 0 and text.stderr == "", text.stderr + assert text.stdout.startswith( + "Guide declaration (documents still need uploading): " + ) + for key in receipt(body): + assert f'"{key}"' in text.stdout + assert "\x1b" not in text.stdout and text.stdout.count("\n") == 1 + assert len(requests) == 36 + + +def test_guide_create_input_bounds_and_large_valid_response(cli, tmp_path): + body = deepcopy(DECLARATION) + body["task_examples"] = [{"content": "é" * 32768}] + path = write_input(tmp_path, body) + raw = path.read_bytes() + path.write_bytes(raw + b" " * (1024 * 1024 - len(raw))) + with http_fixture() as (origin, response, requests): + response.update( + status=201, body=json.dumps(receipt(body), ensure_ascii=False).encode() + ) + assert len(response["body"]) > 65536 + result = invoke(cli, origin, path) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert result.stdout.strip().encode() == response["body"] + assert len(response["raw_commands"][0]) == 1024 * 1024 + # The per-operation response envelope accepts its exact bound, but not + # one additional byte; unrelated command limits remain unchanged. + response["body"] += b" " * (2 * 1024 * 1024 - len(response["body"])) + assert invoke(cli, origin, path).returncode == 0 + response["body"] += b" " + rejected = invoke(cli, origin, path) + assert_failure(rejected, "invalid_api_response") + assert json.loads(rejected.stderr)["error"]["outcome_unknown"] is True + path.write_bytes(path.read_bytes() + b" ") + assert_failure(invoke(cli, origin, path), "invalid_arguments", 2) + assert len(requests) == 3 + + +def test_guide_create_invalid_local_input_has_no_network_or_file_disclosure( + cli, tmp_path +): + invalid = [ + b"", + b"null", + b"[]", + b"{}", + b"{} {}", + b'{"version":"bad\xff"}', + b'{"version":"a","version":"b","task_examples":[],"documents":[]}', + ] + for field in ("version", "task_examples", "documents"): + body = deepcopy(DECLARATION) + body[field] = None + invalid.append(json.dumps(body).encode()) + for update in ( + {"endpoint": "/private"}, + {"actor_id": ACTOR}, + {"task_examples": [None]}, + {"task_examples": [{"content": None}]}, + {"task_examples": [{"content": "x", "labels": [None]}]}, + {"task_examples": [{"content": "x", "labels": None}]}, + {"task_examples": [{"content": "x", "title": 42}]}, + {"task_examples": [{"content": "x", "unknown": True}]}, + {"documents": [None]}, + {"documents": [{"label": None, "media_type": "application/pdf"}]}, + {"documents": [{"label": "x", "media_type": "text/plain"}]}, + { + "documents": [ + { + "label": "x", + "media_type": "application/pdf", + "url": "https://private", + } + ] + }, + ): + invalid.append(json.dumps(DECLARATION | update).encode()) + path = tmp_path / "private_input_credential_canary.json" + with http_fixture() as (origin, _response, requests): + for raw in invalid: + path.write_bytes(raw) + result = invoke(cli, origin, path) + assert_failure(result, "invalid_arguments", 2) + assert str(path) not in result.stdout + result.stderr + path.write_bytes(b" " * (1024 * 1024 + 1)) + assert_failure(invoke(cli, origin, path), "invalid_arguments", 2) + path.unlink() + for source in (path, tmp_path): + result = invoke(cli, origin, source) + assert_failure(result, "invalid_arguments", 2) + assert str(source) not in result.stderr + fifo = tmp_path / "guide_fifo" + os.mkfifo(fifo) + assert_failure(invoke(cli, origin, fifo), "invalid_arguments", 2) + path = write_input(tmp_path) + for selector, key in (("../../private", KEY), (PROJECT, "not-a-key")): + assert_failure( + invoke(cli, origin, path, selector=selector, key=key), + "invalid_arguments", + 2, + ) + assert requests == [] + + +def test_guide_create_rejects_malformed_substituted_or_incomplete_receipt( + cli, tmp_path +): + path = write_input(tmp_path) + good = receipt() + variants = [] + for field in ( + "id", + "project_id", + "version", + "status", + "created_by", + "created_at", + "updated_at", + "documents", + "setup", + "task_examples", + "task_examples_hash", + ): + variants.append(good | {field: None}) + for field in good: + variants.append({key: value for key, value in good.items() if key != field}) + # Policy/activation UUIDs are optional in the public response schema. + optional = { + "contribution_policy_id", + "contribution_policy_version_id", + "activation_operation_id", + } + variants = [ + item + for item in variants + if set(good) - set(item) not in [{field} for field in optional] + ] + for update in ( + {"id": "bad"}, + {"project_id": ACTOR}, + {"version": "different"}, + {"status": "active"}, + {"change_summary": "different"}, + {"created_by": "bad"}, + {"created_at": "yesterday"}, + {"updated_at": "today"}, + {"task_examples_hash": "bad"}, + {"approved_by": "bad"}, + {"effective_at": "bad"}, + {"superseded_at": "bad"}, + {"contribution_policy_id": "bad"}, + {"unknown": True}, + {"task_examples": [None]}, + {"task_examples": [{"content": "different"}]}, + { + "task_examples": [ + { + "content": DECLARATION["task_examples"][0]["content"], + "labels": [None], + } + ] + }, + { + "task_examples": [ + {"content": DECLARATION["task_examples"][0]["content"], "labels": None} + ] + }, + {"setup": {"id": SETUP, "status": "queued"}}, + {"setup": {"id": None, "status": "awaiting_documents"}}, + {"setup": {"id": SETUP}}, + {"documents": [None]}, + ): + variants.append(good | update) + for update in ( + {"document_id": "bad"}, + {"label": "different"}, + {"media_type": "text/plain"}, + {"order": None}, + {"order": 1}, + {"order": True}, + {"unknown": True}, + ): + variants.append(good | {"documents": [good["documents"][0] | update]}) + with http_fixture() as (origin, response, requests): + response["status"] = 201 + for value in variants: + response["body"] = json.dumps(value).encode() + result = invoke(cli, origin, path) + assert_failure(result, "invalid_api_response") + assert json.loads(result.stderr)["error"]["outcome_unknown"] is True + response["body"] = ( + json.dumps(good) + .replace('"status": "draft"', '"status": "draft", "status": "draft"') + .encode() + ) + assert_failure(invoke(cli, origin, path), "invalid_api_response") + assert len(requests) == len(variants) + 1 + + +def test_guide_create_rejects_valid_activation_facts_on_draft(cli, tmp_path): + path = write_input(tmp_path) + facts = { + "contribution_policy_id": ACTOR, + "contribution_policy_version_id": PROJECT, + "activation_operation_id": KEY, + "approved_by": ACTOR, + "effective_at": PROFILE["created_at"], + "superseded_at": PROFILE["updated_at"], + } + bindings = ( + *(dict([item]) for item in facts.items()), + { + key: facts[key] + for key in ("contribution_policy_id", "contribution_policy_version_id") + }, + { + key: facts[key] + for key in ("contribution_policy_id", "activation_operation_id") + }, + { + key: facts[key] + for key in ("contribution_policy_version_id", "activation_operation_id") + }, + { + key: facts[key] + for key in ( + "contribution_policy_id", + "contribution_policy_version_id", + "activation_operation_id", + ) + }, + facts, + ) + with http_fixture() as (origin, response, requests): + response.update(status=201, body=json.dumps(receipt()).encode()) + positive = invoke(cli, origin, path) + assert positive.returncode == 0 and positive.stderr == "", positive.stderr + for binding in bindings: + response["body"] = json.dumps(receipt() | binding).encode() + result = invoke(cli, origin, path) + assert_failure(result, "invalid_api_response") + assert json.loads(result.stderr)["error"]["outcome_unknown"] is True + assert len(requests) == len(bindings) + 1 + + +def test_guide_create_normalized_duplicate_document_identity_and_order(cli, tmp_path): + body = deepcopy(DECLARATION) + body["documents"].append( + { + "label": "Second.docx", + "media_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + } + ) + path = write_input(tmp_path, body) + good = receipt(body) + good["documents"][1]["document_id"] = ACTOR + with http_fixture() as (origin, response, _requests): + response.update(status=201, body=json.dumps(good).encode()) + assert invoke(cli, origin, path).returncode == 0 + for second_id in (DOCUMENT, DOCUMENT.replace("-", "")): + value = deepcopy(good) + value["documents"][1]["document_id"] = second_id + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin, path), "invalid_api_response") + value = deepcopy(good) + value["documents"].reverse() + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin, path), "invalid_api_response") + + +def test_guide_create_denial_versus_uncertain_outcome(cli, tmp_path): + path = write_input(tmp_path) + with http_fixture() as (origin, response, requests): + for status in (403, 404, 409, 422): + response.update(status=status, body=json.dumps(canonical_error()).encode()) + result = invoke(cli, origin, path) + assert_failure(result, "authorization_denied") + assert "outcome_unknown" not in json.loads(result.stderr)["error"] + response["body"] = json.dumps( + canonical_error() + | {"error": canonical_error()["error"] | {"details": None}} + ).encode() + result = invoke(cli, origin, path) + assert json.loads(result.stderr)["error"]["outcome_unknown"] is True + response.update(status=500, body=json.dumps(canonical_error()).encode()) + assert ( + json.loads(invoke(cli, origin, path).stderr)["error"]["outcome_unknown"] + is True + ) + for status in (200, 202): + response.update(status=status, body=json.dumps(receipt()).encode()) + result = invoke(cli, origin, path) + assert result.returncode == 1 and result.stdout == "" + assert json.loads(result.stderr)["error"]["outcome_unknown"] is True + response.update(status=307, headers={"Location": origin + "/private"}) + assert_failure(invoke(cli, origin, path), "redirect_refused") + response["drop"] = True + result = invoke(cli, origin, path, output="text") + assert result.returncode == 1 and result.stdout == "" + assert ( + "unchanged project, input file contents and idempotency key" + in result.stderr + ) + assert len(requests) == 10 + + +def test_guide_create_http2_never_replays_a_transmitted_body(cli, tmp_path): + path = write_input(tmp_path) + with goaway_fixture(tmp_path) as (origin, trust, bodies, connections): + result = invoke(cli, origin, path, extra_env=trust) + assert_failure(result, "service_unavailable") + assert json.loads(result.stderr)["error"]["outcome_unknown"] is True + assert bodies == [path.read_bytes()] + assert len(connections) == 1 diff --git a/cli/tests/integration/test_http_boundary.py b/cli/tests/integration/test_http_boundary.py index 5db183157..7dfb95121 100644 --- a/cli/tests/integration/test_http_boundary.py +++ b/cli/tests/integration/test_http_boundary.py @@ -77,6 +77,7 @@ def do_PATCH(self): # noqa: N802 - standard HTTP handler interface def do_POST(self): # noqa: N802 - standard HTTP handler interface body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + response.setdefault("raw_commands", []).append(body) response["commands"].append( ( self.headers.get("Content-Type"), diff --git a/cli/tests/integration/test_public_self_service.py b/cli/tests/integration/test_public_self_service.py index 80f956b8e..af58447a1 100644 --- a/cli/tests/integration/test_public_self_service.py +++ b/cli/tests/integration/test_public_self_service.py @@ -15,6 +15,7 @@ from task_journey import exercise_manager_task_reads from project_create_journey import exercise_project_creation +from guide_create_journey import exercise_guide_creation ROOT = Path(__file__).resolve().parents[3] BACKEND = ROOT / "backend" @@ -265,6 +266,9 @@ async def test_installed_cli_uses_only_public_profile_and_project_context( await exercise_project_creation( direct, cli, origin, tokens, profiles, project_id ) + await exercise_guide_creation( + direct, cli, origin, tokens, profiles, project_id, tmp_path + ) for selector in (project_id, project_id.replace("-", "")): shown = cli( diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 6930ed380..9f42d2415 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -139,8 +139,9 @@ 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 and draft project creation with an explicit -caller-owned retry key, with +and locked intake-requirement reads, draft project creation and guide declaration +with illustrative tasks and document upload selectors, using explicit +caller-owned retry keys, 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 @@ -332,6 +333,10 @@ cannot be reused as post-submission review-gate evidence. See the 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. + [CLI guide declaration](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md) + creates draft guide metadata, required illustrative tasks and document targets + through public POST. The API reauthorizes exact replay and setup waits for + document upload; the CLI does not upload, approve or activate the guide. Built-binary HTTP integration and isolated real-API proof accompany the package. Further public commands, optional TUI and binary distribution