From d1aa2ad51ba500ac34e46568f6d5aedda034d930 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Wed, 7 Oct 2026 15:35:44 +0100 Subject: [PATCH 1/8] docs(cli): bound guide declaration and replay proof --- .../initiatives/WS-CLI-001/WS-CLI-001-09.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md 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..8d1e4006e --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md @@ -0,0 +1,98 @@ +# WS-CLI-001-09 — Declare a guide and its required setup inputs + +- Initiative: `WS-CLI-001` +- Durable disposition: `Planned` +- Intended merge outcome: Create guide metadata, illustrative task examples and + declared document targets through the existing public API. + +## Intent and design + +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, 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, declared labels/media types and example values +to the request; default optional example title/labels match their API defaults. +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 and proof + +- 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/nested null fields, substituted project, + version/examples/documents, 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. Restore + fixtures 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, reviewers and human focus + +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. + +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. From c8dcd348382a9e95adc459acb5f3181b202fb1a2 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Wed, 7 Oct 2026 15:40:43 +0100 Subject: [PATCH 2/8] docs(cli): align guide declaration proof with backend normalization --- .../initiatives/WS-CLI-001/WS-CLI-001-09.md | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md index 8d1e4006e..21a93c81b 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md @@ -45,8 +45,16 @@ 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, declared labels/media types and example values -to the request; default optional example title/labels match their API defaults. +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. 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 @@ -57,8 +65,9 @@ the same project, file contents and key, never claims rollback or invents an ID. - 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/nested null fields, substituted project, - version/examples/documents, invalid UUIDs/hashes/times, normalized duplicate +- 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), @@ -67,8 +76,11 @@ the same project, file contents and key, never claims rollback or invents an ID. - 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. Restore - fixtures through public operations. No fake authority or private product writes. + 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, a fresh positive before revocation, and a fresh positive after regrant + and reactivation before suspension. 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. From 4a474a77905851a6da2ec21eca653908cc1ef96b Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Wed, 7 Oct 2026 15:42:19 +0100 Subject: [PATCH 3/8] docs(cli): make lifecycle proof sequence reachable --- .commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md index 21a93c81b..d649313d6 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md @@ -78,8 +78,9 @@ the same project, file contents and key, never claims rollback or invents an ID. 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, a fresh positive before revocation, and a fresh positive after regrant - and reactivation before suspension. Restore the spare actor's active/ungranted + 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. From ac2e6d41c94b3ce2144ddefbb6367b62c814ee59 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Wed, 7 Oct 2026 15:50:32 +0100 Subject: [PATCH 4/8] feat(cli): declare project guides through the public API --- .commitrail/INDEX.md | 2 +- .../initiatives/WS-CLI-001/OVERVIEW.md | 14 +- .../initiatives/WS-CLI-001/WS-CLI-001-09.md | 5 +- README.md | 3 + cli/README.md | 51 ++- cli/internal/api/client.go | 8 +- cli/internal/api/guide_create.go | 206 ++++++++++ cli/internal/command/command.go | 1 + cli/internal/command/guide_create.go | 61 +++ cli/tests/integration/guide_create_journey.py | 175 ++++++++ .../integration/test_guide_create_http.py | 386 ++++++++++++++++++ cli/tests/integration/test_http_boundary.py | 1 + .../integration/test_public_self_service.py | 4 + docs/roadmap_status.md | 9 +- 14 files changed, 915 insertions(+), 11 deletions(-) create mode 100644 cli/internal/api/guide_create.go create mode 100644 cli/internal/command/guide_create.go create mode 100644 cli/tests/integration/guide_create_journey.py create mode 100644 cli/tests/integration/test_guide_create_http.py diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index 893b058c5..67da61225 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; 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 index d649313d6..4e05c9e86 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md @@ -1,7 +1,7 @@ # WS-CLI-001-09 — Declare a guide and its required setup inputs - Initiative: `WS-CLI-001` -- Durable disposition: `Planned` +- Durable disposition: `Complete` - Intended merge outcome: Create guide metadata, illustrative task examples and declared document targets through the existing public API. @@ -29,7 +29,8 @@ or backend authority mechanism is required. 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, CLI/root README, affected roadmap claims, this +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. diff --git a/README.md b/README.md index e5a3118d8..caa2e3ec7 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,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..aac82963b 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` | @@ -93,6 +94,54 @@ 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. +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..6348ff50d --- /dev/null +++ b/cli/internal/api/guide_create.go @@ -0,0 +1,206 @@ +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") + } + input, examples, err := decodeGuideInput(body) + if err != nil || len(body) > MaxGuideInputBytes { + 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 + for _, id := range []*string{value.ContributionPolicyID, value.ContributionPolicyVersionID, value.ActivationOperationID, value.ApprovedBy} { + if id != nil && !validUUID(*id) { + return value, errors.New("invalid guide identity") + } + } + for _, timestamp := range []*string{value.EffectiveAt, value.SupersededAt} { + if timestamp != nil && !validTime(*timestamp) { + return value, errors.New("invalid guide time") + } + } + 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..bffac848a --- /dev/null +++ b/cli/tests/integration/test_guide_create_http.py @@ -0,0 +1,386 @@ +"""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_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 004b3ecb9..6cca7c6ff 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 @@ -330,6 +331,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 From 788119d15c7c5924d46f1330dc102422fb873cb7 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Wed, 7 Oct 2026 15:52:50 +0100 Subject: [PATCH 5/8] docs(cli): use canonical bounded-change sections --- .commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md index 4e05c9e86..b8363d181 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md @@ -5,7 +5,7 @@ - Intended merge outcome: Create guide metadata, illustrative task examples and declared document targets through the existing public API. -## Intent and design +## Intent CLI08 creates a project shell. Its next setup boundary is the public `POST /api/v1/projects/{project_id}/guides`, owned by @@ -61,7 +61,7 @@ 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 and proof +## 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 @@ -87,7 +87,7 @@ the same project, file contents and key, never claims rollback or invents an ID. workflow guards, links/stale/Commitrail and full hosted Backend remain required. No unit-test suite, coverage quota, skipped proof or weakened CI. -## Risk, reviewers and human focus +## 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 @@ -95,6 +95,8 @@ 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 @@ -110,3 +112,7 @@ WORKSTREAM_CLI_EXECUTABLE=/tmp/workstream-cli python scripts/run_isolated_tests. 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. From 2991780e747d6109bec11241166368d6a2e6585d Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Wed, 7 Oct 2026 16:08:31 +0100 Subject: [PATCH 6/8] fix(cli): reject contradictory draft creation receipts --- .../initiatives/WS-CLI-001/WS-CLI-001-09.md | 10 +++- cli/README.md | 5 +- cli/internal/api/guide_create.go | 14 ++---- .../integration/test_guide_create_http.py | 46 +++++++++++++++++++ 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md index b8363d181..172ef091c 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-09.md @@ -54,7 +54,11 @@ 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. Hash validation uses the existing syntax helper, +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 @@ -116,3 +120,7 @@ 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/cli/README.md b/cli/README.md index aac82963b..73ed49f34 100644 --- a/cli/README.md +++ b/cli/README.md @@ -71,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. @@ -132,6 +133,8 @@ The API checks current Project Manager authority for the exact project, includin 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. diff --git a/cli/internal/api/guide_create.go b/cli/internal/api/guide_create.go index 6348ff50d..27fcc68d7 100644 --- a/cli/internal/api/guide_create.go +++ b/cli/internal/api/guide_create.go @@ -152,15 +152,11 @@ func decodeCreatedGuide(raw json.RawMessage, project string, input guideCreateIn return value, errors.New("invalid guide receipt") } value.TaskExamples = returnedExamples - for _, id := range []*string{value.ContributionPolicyID, value.ContributionPolicyVersionID, value.ActivationOperationID, value.ApprovedBy} { - if id != nil && !validUUID(*id) { - return value, errors.New("invalid guide identity") - } - } - for _, timestamp := range []*string{value.EffectiveAt, value.SupersededAt} { - if timestamp != nil && !validTime(*timestamp) { - return value, errors.New("invalid guide time") - } + // 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" { diff --git a/cli/tests/integration/test_guide_create_http.py b/cli/tests/integration/test_guide_create_http.py index bffac848a..7ccde066f 100644 --- a/cli/tests/integration/test_guide_create_http.py +++ b/cli/tests/integration/test_guide_create_http.py @@ -315,6 +315,52 @@ def test_guide_create_rejects_malformed_substituted_or_incomplete_receipt( 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( From 85424d3b8d187c56a059b8a237dcc4f51e96fdce Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Wed, 7 Oct 2026 16:27:44 +0100 Subject: [PATCH 7/8] fix(cli): bound guide input before decoding --- cli/internal/api/guide_create.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/internal/api/guide_create.go b/cli/internal/api/guide_create.go index 27fcc68d7..b77f8a727 100644 --- a/cli/internal/api/guide_create.go +++ b/cli/internal/api/guide_create.go @@ -74,8 +74,11 @@ func (c *Client) CreateGuide(ctx context.Context, project string, body json.RawM 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 || len(body) > MaxGuideInputBytes { + if err != nil { return result, errors.New("--input must contain a bounded public guide declaration") } raw, err := c.requestWithResponseLimit(ctx, http.MethodPost, From 74dd14d35b3b208280122227bd468ea4bc1b8079 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Thu, 8 Oct 2026 08:26:09 +0100 Subject: [PATCH 8/8] docs: preserve CLI guide declaration in merged initiative index --- .commitrail/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. |