diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index 2bff78185..9eaa0cca1 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, inspection, task browsing/claim/start, governing context, project creation/declaration, original upload, setup diagnostics, exact proposal inspection, intake/evaluation approval, guide/evaluation correction, explicit saved-successor dispatch, exact guide activation, separate manager task creation/screening/release, explicit Submitter/Reviewer issuance/revocation and filtered grant/qualification history delivered through WS-CLI-001-21; PILOT-13 assigned-guide listing/download is delivered; further public journeys and binary distribution remain; hidden submission is not exposed | +| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, inspection, task browsing/claim/start, governing context, project creation/declaration, original upload, setup diagnostics, exact proposal inspection, intake/evaluation approval, guide/evaluation correction, explicit saved-successor dispatch, exact guide activation, separate manager task creation/screening/release, explicit Submitter/Reviewer issuance/revocation, filtered grant/qualification history and Finance policy discovery/version inspection delivered through WS-CLI-001-22; PILOT-13 assigned-guide listing/download is delivered; Finance policy writes, contribution evidence, further public journeys and binary distribution remain; hidden submission is not exposed | | [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Hidden authorized outcomes now bind exact phase/routing receipts and replay. B8 supplies completion delivery; 04F1 supplies verified failure evidence; 04F2 supplies the exact current checker-remediation TASK/AUTH handoff under both policy modes without REV/CON; 04F3 supplies hidden exact-cause replacement through canonical intake and writer; 04F4 supplies hidden reason-bound Operator retry with exact receipt, audit and request-event custody. Manager-visible setup-fault hold is next, followed by live composition, public intake and the [first-layer drill](initiatives/WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone). False-guide activation remains unavailable. | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Exact checker input/output custody, packet foundations and the 04F2 hidden remediation handoff through existing TASK/AUTH owners are delivered. 04F3 hidden exact-cause replacement through existing ART ports and 04F4 hidden Operator retry are delivered; manager-visible setup-fault hold, production composition/readiness, public intake and the drill remain. | | [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | ARCH-04E2-B supplies canonical fixed-router custody for hidden success outcomes; B8 supplies completion delivery and 04F2 supplies the exact policy-independent remediation consequence with no REV/CON effect. 04F3 hidden exact-cause replacement and 04F4 hidden Operator retry are delivered; manager-visible setup-fault hold, production registration/readiness, public intake and the drill remain; live human acceptance authority stays deferred. | diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index a948196af..ee8754c64 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -26,6 +26,7 @@ [WS-CLI-001-19](WS-CLI-001-19.md), separate manager draft creation, screening and release; [WS-CLI-001-20](WS-CLI-001-20.md), explicit exact-project Submitter/Reviewer issuance and revocation; [WS-CLI-001-21](WS-CLI-001-21.md), filtered grant browsing and exact qualification/revocation history; + [WS-CLI-001-22](WS-CLI-001-22.md), Finance current policy discovery and exact retained-version inspection; [PILOT-13](../../changes/pilot13-assigned-task-guide-documents.md), assigned-task locked-guide listing/download. ## Current boundary @@ -111,6 +112,12 @@ owned; no role cache, automatic grant or AdminRole command is added. explicit opaque continuation; `project role-grant show PROJECT_ID GRANT_ID` observes exact public qualification, provenance and revocation history. Neither read grants authority or verifies permission for a later write. +`project contribution-policy current PROJECT_ID` and +`project contribution-policy show PROJECT_ID POLICY_ID [--version UUID]` expose +the complete authorized Finance policy graph and current selectors. The backend +selects the published version ahead of an open draft unless explicitly overridden +by a version selector; current Finance authority governs historical reads too. +PM-only authority does not suffice. These reads never publish, retire or pay. All have text/JSON output and built-binary integration proof. Mutations preserve omitted/null semantics and explicitly report uncertain outcomes without automatic retries. @@ -203,11 +210,15 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. 21. **WS-CLI-001-21:** Filtered project-grant pagination and exact grant history through public GETs, complete qualification/provenance disclosure and fresh authority on each call. No actor discovery or automatic decisions. -22. **Later governed-work commands:** Add further project setup, submission, +22. **WS-CLI-001-22:** Current Finance policy discovery and backend-selected or + exact retained-version inspection, including empty drafts and complete award + definitions. No Finance mutation, publication or local readiness decisions. +23. **Later governed-work commands:** Add explicit Finance policy editing, + publication/retirement and contribution-evidence reads, then 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. -23. **Optional TUI:** Add a focused public queue/evidence view after its API +24. **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-22.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-22.md new file mode 100644 index 000000000..7faac4657 --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-22.md @@ -0,0 +1,119 @@ +# WS-CLI-001-22 — Inspect contribution-policy selectors and versions + +- Initiative: `WS-CLI-001` +- Durable disposition: `Complete` +- Intended merge outcome: Finance Authorities discover current policy selectors and inspect exact retained policy versions through public REST reads. + +## Intent + +Complete policy inspection before adding Finance editing/publication commands. +`project contribution-policy current PROJECT_ID` recovers the current aggregate, +published version and open draft. `project contribution-policy show PROJECT_ID +POLICY_ID [--version UUID]` inspects the owner's selected version or one explicit +historical version. Observations do not grant write authority or prove readiness. + +## Current behavior + +CLI01–21 cover 32 documented public command surfaces through merged #545. +`backend/app/api/routes/contribution_policies.py` already exposes public GET +`.../contribution-policies/current` and `.../contribution-policies/{policy_id}`, +with optional `version_id`. The CON service/repository own default selection and +exact project/policy/version scoping; current discovery excludes retired +aggregates. Exact retired versions remain readable with current Finance +Authority. Project Manager authority alone does not authorize these operations. +Guide activation is a separate manager action binding an exact published version. + +## Bounded change + +Allowed: new `cli/internal/api/contribution_policy_reads.go` and +`cli/internal/command/contribution_policy_reads.go`; registration in command.go; +extract the existing scalar award validation in contributor_tasks.go so ready +work and policy reads reuse it without changing existing semantics; new +`test_contribution_policy_reads.py` and `contribution_policy_read_journey.py`; +small hook/fixture updates in the retained `test_guide_activation_public_api.py`; +README, CLI README, affected roadmap sections, overview/index and this record. + +Prohibited: backend owners, policy/schema/migration/economic rules, adapter +bindings, dependencies, CI configuration/budgets/selection, Finance granting +commands, draft editing/publication/retirement CLI writes, hidden submission, +bulk task work, automatic selection/retries or a generic administration layer. +No copied currency catalogue, rounding or local payment eligibility decisions. + +## Design and decisions + +Use existing closed/null-aware JSON parsing, UUID identity, finite JSON transport, +decoded credential protection and complete escaped text/raw JSON output. Preserve +the original path/query UUID spelling. Current selector response has four fields; +both version selectors are nullable, but a successful selection cannot have +both null or the same normalized UUID in both roles. Version response retains +complete rules and +definitions, including Finance binding identifiers and exact string quantities, +without treating an incomplete draft or historical version as ready. Reuse scalar +award wire validation; do not copy backend publication/compensation business rules. +Default show is explicitly backend-selected; an explicit --version must bind the +returned version. No preflight, auto-current fetch or cached selectors. + +## Acceptance criteria + +- One public current or show GET; optional `version_id` is forwarded only when + requested. Invalid selectors/empty explicit version fail without HTTP. +- Complete text/JSON projection for empty draft, unpaid rules, compensated + definitions and both nullable current selectors. Quantities retain exact text; + floats, null members, duplicate/unknown/missing fields and malformed identities + do not become successful facts. Unique normalized rule/definition identities, + exact requested project/policy/version hold. Close `contribution_type` to + `accepted_submission|completed_review`, `compensation_mode` to + `unpaid|compensated`, and `instrument_type` to `money|project_points`, with + otherwise-valid unknown-token regression cases. + Policy status is `draft|active|retired`; version status is + `draft|published|retired`. Preserve independently valid historical combinations, + not a local readiness matrix. `version_number` is a present positive integer + using the existing `big.Int` JSON pattern, without machine-integer narrowing. +- Otherwise-valid current responses with both nullable selectors null, or the + same UUID under alternate spellings in both selectors, fail. Both one-null + variants and distinct both-present selectors remain valid. Invalid/missing/null + status/version facts fail after positive controls; oversized positive integers + retain exact JSON/text instead of overflowing the client. +- Valid controls precede otherwise-valid foreign project/policy/version replies, + normalized duplicate IDs, malformed nested definitions and credential reflection. + Every invalid read leaves stdout empty; it is not an unknown write outcome. +- Extend existing real public-socket/Flow/PREP/PostgreSQL guide activation proof, + not a second broad daemon fixture. Public setup exposes the existing unpaid + policy's empty draft, published graph, simultaneous published/open-draft + selectors and historical retirement. PM-only, other callers, a stored foreign + policy/version and a foreign-project Finance actor are denied. Suspend/reactivate + and revoke/restore Finance authority through existing public admin setup; + positive controls precede denials. Existing activation/replay/retirement checks + remain intact. Read snapshots cover all CON policy tables/lifecycle events, + excluding legitimate AUTH read evidence. Reads do not publish/edit/activate. +- Compensated quantity precision is process-boundary proof, not a new native + compensated-policy or fulfillment certification. Existing backend economics + tests remain hosted; no payout or readiness claim is made by this client. +- A target-binding or duplicate-normalization mutant must fail the specific + regression after its valid control. Full hosted execution remains blocking. + +## Risk and review routing + +- Risk class: `L1` — Finance-visible policy and binding facts, client reads only. +- Plan review before code; security; QA/test delta; architecture/docs/reuse in + one bounded assignment. No CI changes and no ceremonial nine-agent fanout. +- Human focus: Finance authority stays backend-owned; exact/default/historical + selection is explicit; no approval, payout or hidden capability is introduced. +- No additional human design decision is needed. + +## Evidence + +Verification commands: independent Go build/vet/module verification/tidy-diff; +`test_contribution_policy_reads.py` process suite plus retained contributor +read proof for the scalar-validator refactor; isolated PostgreSQL execution of +`test_guide_activation_public_api.py`; Ruff, links/stale scans, Commitrail/diff +and complete hosted Backend/CLI collection. This disposition records the intended +durable merge outcome, not transient CI/approval state. Exact execution and +review freshness remain in the PR; source availability is not deployment. + +## Reconciliation + +- Main: #545 merged. PILOT-02 batch CLI and PILOT-04 intake are separate owners. +- Next usable boundary: Explicit public Finance draft/update/publication commands; + contribution evidence reads; public submission/review/revision only when ready. +- Remaining: Binary distribution and optional TUI; no exact chunk-count or delivery promise. diff --git a/README.md b/README.md index 1236f8768..5acf7685a 100644 --- a/README.md +++ b/README.md @@ -430,6 +430,11 @@ and fresh-authorized replay; receipts are observations, not cached permission. page; `project role-grant show PROJECT_ID GRANT_ID` inspects complete public qualification/provenance and revocation history. Pagination is explicit and fresh authority is backend-owned; a read never authorizes a later write. +`project contribution-policy current PROJECT_ID` discovers the published and +open-draft selectors; `project contribution-policy show PROJECT_ID POLICY_ID +[--version UUID]` inspects backend-selected or exact historical policy versions, +including complete rules and exact quantities. These require current Finance +Authority, not PM-only permission; they do not edit, publish, retire or pay. 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 4ae35781d..f72a50cb6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -33,6 +33,8 @@ context/intake requirements: | `workstream project role-grant revoke PROJECT_ID GRANT_ID --input FILE --idempotency-key UUID` | `POST /api/v1/projects/PROJECT_ID/role-grants/GRANT_ID/revoke` | | `workstream project role-grant list PROJECT_ID [--status active\|revoked] [--role submitter\|reviewer]` | `GET /api/v1/projects/PROJECT_ID/role-grants` | | `workstream project role-grant show PROJECT_ID GRANT_ID` | `GET /api/v1/projects/PROJECT_ID/role-grants/GRANT_ID` | +| `workstream project contribution-policy current PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/contribution-policies/current` | +| `workstream project contribution-policy show PROJECT_ID POLICY_ID [--version UUID]` | `GET /api/v1/projects/PROJECT_ID/contribution-policies/POLICY_ID` with optional `version_id` | | `workstream task ready PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/tasks/ready` | | `workstream task show TASK_ID` | `GET /api/v1/tasks/TASK_ID` | | `workstream task claim TASK_ID --idempotency-key UUID` | `POST /api/v1/tasks/TASK_ID/claim` | @@ -202,6 +204,43 @@ and credential reflection fail with empty stdout. These reads require the backend's covered Project Manager or Audit Authority, not a contributor grant. They do not cache permission, authorize a later write, or establish rollback. +## Inspect contribution policies + +```sh +workstream project contribution-policy current PROJECT_ID -o json +workstream project contribution-policy show PROJECT_ID POLICY_ID -o json +workstream project contribution-policy show PROJECT_ID POLICY_ID --version VERSION_ID -o json +``` + +These are Finance Authority reads, not Project Manager permissions. Workstream +requires current covering Finance authority and active admission for each call; +PM-only, foreign-project or withdrawn authority remains a concealed server denial. +Each command sends one public GET, without a preflight or automatic selector fetch. + +`current` returns the aggregate ID and nullable published/open-draft version IDs. +`show` without `--version` uses backend selection: the published version takes +precedence over an open successor draft. Supply `--version` to inspect that draft +or an exact retained historical version. Retired aggregates are excluded from +current discovery but exact historical inspection remains authorized separately. +An explicitly empty or invalid selector fails before HTTP; UUID spelling is +preserved in the request and compared by identity in the response. + +Both output formats retain all authorized fields: policy/version identity and +status, positive integer version number, complete rules and award definitions, +including binding IDs and exact decimal-string quantities. Empty drafts are +valid observations, not readiness. JSON preserves the API object; complete text +escapes terminal controls. Missing, null, unknown, duplicate, substituted or +credential-reflecting facts fail with empty stdout. These commands never publish, +retire, pay, activate a guide or authorize a later write. + +Native proof extends the existing public guide-activation journey with empty +drafts, unpaid publication, simultaneous published/draft selection, exact history, +foreign stored policies/versions, and public Finance suspension/revocation with +restored positive controls. Policy/guide SQL snapshots remain unchanged by the +reads; legitimate AUTH read evidence is excluded. Compensated precision and +malformed nested definitions are process-boundary proof, not a new native payout +or compensated-policy certification. Full backend integration proof remains in CI. + ## Declare a guide and its setup inputs ```sh diff --git a/cli/internal/api/contribution_policy_reads.go b/cli/internal/api/contribution_policy_reads.go new file mode 100644 index 000000000..3e709cda2 --- /dev/null +++ b/cli/internal/api/contribution_policy_reads.go @@ -0,0 +1,145 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "net/http" + "net/url" + "strings" +) + +type ContributionPolicySelection struct { + ProjectID string `json:"project_id"` + ContributionPolicyID string `json:"contribution_policy_id"` + CurrentPublishedVersionID *string `json:"current_published_version_id"` + OpenDraftVersionID *string `json:"open_draft_version_id"` +} + +// A view is retained Finance history, not permission, publication or readiness. +type ContributionPolicyVersion struct { + ProjectID string `json:"project_id"` + ContributionPolicyID string `json:"contribution_policy_id"` + Name string `json:"name"` + PolicyStatus string `json:"policy_status"` + ContributionPolicyVersionID string `json:"contribution_policy_version_id"` + VersionNumber *big.Int `json:"version_number"` + VersionStatus string `json:"version_status"` + Rules []ContributionPolicyRule `json:"rules"` +} + +type ContributionPolicyRule struct { + RuleID string `json:"rule_id"` + ContributionType string `json:"contribution_type"` + CompensationMode string `json:"compensation_mode"` + Definitions []ContributionPolicyDefinition `json:"definitions"` +} + +type ContributionPolicyDefinition struct { + DefinitionID string `json:"definition_id"` + InstrumentType string `json:"instrument_type"` + UnitCode string `json:"unit_code"` + Quantity string `json:"quantity"` + AdapterBindingID string `json:"adapter_binding_id"` +} + +func contributionPolicyPath(project string) (string, error) { + if !validUUID(project) || len(project) > 100 { + return "", errors.New("PROJECT_ID must be a UUID") + } + return "/api/v1/projects/" + url.PathEscape(project) + "/contribution-policies", nil +} + +func (c *Client) CurrentContributionPolicy(ctx context.Context, project string) (Result[ContributionPolicySelection], error) { + var result Result[ContributionPolicySelection] + path, err := contributionPolicyPath(project) + if err != nil { + return result, err + } + raw, err := c.request(ctx, http.MethodGet, path+"/current", "", nil) + if err != nil { + return result, err + } + var value ContributionPolicySelection + fields, err := contextObject(raw, &value, []string{"project_id", "contribution_policy_id"}, nil) + if err != nil || !requiredNullable(fields, "current_published_version_id", "open_draft_version_id") || + !sameUUID(value.ProjectID, project) || !validUUID(value.ContributionPolicyID) || !c.safeMutationResponse(raw) { + return result, &Failure{Code: "invalid_api_response"} + } + for _, id := range []*string{value.CurrentPublishedVersionID, value.OpenDraftVersionID} { + if id != nil && !validUUID(*id) { + return result, &Failure{Code: "invalid_api_response"} + } + } + if (value.CurrentPublishedVersionID == nil && value.OpenDraftVersionID == nil) || + (value.CurrentPublishedVersionID != nil && value.OpenDraftVersionID != nil && sameUUID(*value.CurrentPublishedVersionID, *value.OpenDraftVersionID)) { + return result, &Failure{Code: "invalid_api_response"} + } + return Result[ContributionPolicySelection]{Raw: raw, Value: value}, nil +} + +func (c *Client) ContributionPolicy(ctx context.Context, project, policy string, version *string) (Result[ContributionPolicyVersion], error) { + var result Result[ContributionPolicyVersion] + path, err := contributionPolicyPath(project) + if err != nil { + return result, err + } + if !validUUID(policy) || len(policy) > 100 || (version != nil && (!validUUID(*version) || len(*version) > 100)) { + return result, errors.New("POLICY_ID and an explicit --version must be UUIDs") + } + query := "" + if version != nil { + query = url.Values{"version_id": {*version}}.Encode() + } + raw, err := c.request(ctx, http.MethodGet, path+"/"+url.PathEscape(policy), query, nil) + if err != nil { + return result, err + } + var value ContributionPolicyVersion + fields, err := contextObject(raw, &value, []string{ + "project_id", "contribution_policy_id", "name", "policy_status", "contribution_policy_version_id", "version_number", "version_status", "rules", + }, nil) + if err != nil || !sameUUID(value.ProjectID, project) || !sameUUID(value.ContributionPolicyID, policy) || + !validUUID(value.ContributionPolicyVersionID) || (version != nil && !sameUUID(value.ContributionPolicyVersionID, *version)) || + strings.TrimSpace(value.Name) == "" || value.VersionNumber == nil || value.VersionNumber.Sign() <= 0 || + (value.PolicyStatus != "draft" && value.PolicyStatus != "active" && value.PolicyStatus != "retired") || + (value.VersionStatus != "draft" && value.VersionStatus != "published" && value.VersionStatus != "retired") || + value.Rules == nil || len(value.Rules) > 2 || !c.safeMutationResponse(raw) { + return result, &Failure{Code: "invalid_api_response"} + } + var rules []json.RawMessage + if err := json.Unmarshal(fields["rules"], &rules); err != nil { + return result, &Failure{Code: "invalid_api_response"} + } + seenRules, seenDefinitions := make(map[[16]byte]bool), make(map[[16]byte]bool) + seenTypes := make(map[string]bool) + for index, rawRule := range rules { + rule := &value.Rules[index] + members, err := contextObject(rawRule, rule, []string{"rule_id", "contribution_type", "compensation_mode", "definitions"}, nil) + id, valid := uuidIdentity(rule.RuleID) + if err != nil || !valid || seenRules[id] || seenTypes[rule.ContributionType] || + (rule.ContributionType != "accepted_submission" && rule.ContributionType != "completed_review") || + (rule.CompensationMode != "unpaid" && rule.CompensationMode != "compensated") || rule.Definitions == nil || len(rule.Definitions) > 2 { + return result, &Failure{Code: "invalid_api_response"} + } + seenRules[id], seenTypes[rule.ContributionType] = true, true + var definitions []json.RawMessage + if err := json.Unmarshal(members["definitions"], &definitions); err != nil { + return result, &Failure{Code: "invalid_api_response"} + } + seenInstruments := make(map[string]bool) + for index, rawDefinition := range definitions { + definition := &rule.Definitions[index] + _, err := contextObject(rawDefinition, definition, []string{"definition_id", "instrument_type", "unit_code", "quantity", "adapter_binding_id"}, nil) + id, valid := uuidIdentity(definition.DefinitionID) + award := CompensationAward{Instrument: definition.InstrumentType, Unit: definition.UnitCode, Quantity: definition.Quantity} + if err != nil || !valid || seenDefinitions[id] || !validUUID(definition.AdapterBindingID) || + !validCompensationAward(award) || seenInstruments[definition.InstrumentType] { + return result, &Failure{Code: "invalid_api_response"} + } + seenDefinitions[id], seenInstruments[definition.InstrumentType] = true, true + } + } + return Result[ContributionPolicyVersion]{Raw: raw, Value: value}, nil +} diff --git a/cli/internal/api/contributor_tasks.go b/cli/internal/api/contributor_tasks.go index 4bf1fc786..d97ac32b0 100644 --- a/cli/internal/api/contributor_tasks.go +++ b/cli/internal/api/contributor_tasks.go @@ -53,6 +53,12 @@ type ContributorTaskDetail struct { var exactQuantity = regexp.MustCompile(`^(0|[1-9][0-9]*)(\.[0-9]+)?$`) +func validCompensationAward(award CompensationAward) bool { + return (award.Instrument == "money" || award.Instrument == "project_points") && + award.Unit != "" && exactQuantity.MatchString(award.Quantity) && + strings.ContainsAny(award.Quantity, "123456789") +} + func compensationAwards(raw json.RawMessage) ([]CompensationAward, bool) { var unpaid string if err := jsonv2.Unmarshal(raw, &unpaid); err == nil { @@ -64,9 +70,7 @@ func compensationAwards(raw json.RawMessage) ([]CompensationAward, bool) { } seen := make(map[string]bool) for _, award := range awards { - validInstrument := award.Instrument == "money" || award.Instrument == "project_points" - if !validInstrument || award.Unit == "" || !exactQuantity.MatchString(award.Quantity) || - !strings.ContainsAny(award.Quantity, "123456789") || seen[award.Instrument] { + if !validCompensationAward(award) || seen[award.Instrument] { return nil, false } seen[award.Instrument] = true diff --git a/cli/internal/command/command.go b/cli/internal/command/command.go index b816a049c..3b3e17f38 100644 --- a/cli/internal/command/command.go +++ b/cli/internal/command/command.go @@ -111,6 +111,7 @@ func Run(args []string, stdout, stderr io.Writer, getenv environment, version st addTaskReads(project, client, &output, stdout) addProjectCreate(project, client, &output, stdout) addProjectRoleMutations(project, client, &output, stdout) + addContributionPolicyReads(project, client, &output, stdout) addGuideCreate(project, client, &output, stdout) project.AddCommand(&cobra.Command{ Use: "show PROJECT_ID", diff --git a/cli/internal/command/contribution_policy_reads.go b/cli/internal/command/contribution_policy_reads.go new file mode 100644 index 000000000..9315cc37f --- /dev/null +++ b/cli/internal/command/contribution_policy_reads.go @@ -0,0 +1,54 @@ +package command + +import ( + "io" + + "github.com/Flow-Research/workstream/cli/internal/api" + "github.com/spf13/cobra" +) + +func addContributionPolicyReads(project *cobra.Command, client func() (*api.Client, error), output *string, stdout io.Writer) { + policy := &cobra.Command{Use: "contribution-policy", Short: "Public Finance-authorized policy observations, not decisions"} + policy.AddCommand(&cobra.Command{ + Use: "current PROJECT_ID", Short: "Discover the current published and open draft selectors", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := client() + if err != nil { + return err + } + result, err := apiClient.CurrentContributionPolicy(cmd.Context(), args[0]) + if err != nil { + return err + } + if *output == "json" { + return writeJSON(stdout, result.Raw) + } + return writeTaskContextFields(stdout, []contextField{{"Current selectors (not authority)", result.Raw}}) + }, + }) + var version string + show := &cobra.Command{ + Use: "show PROJECT_ID POLICY_ID", Short: "Inspect the backend-selected version or an explicit historical version", Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := client() + if err != nil { + return err + } + var selected *string + if cmd.Flags().Changed("version") { + selected = &version + } + result, err := apiClient.ContributionPolicy(cmd.Context(), args[0], args[1], selected) + if err != nil { + return err + } + if *output == "json" { + return writeJSON(stdout, result.Raw) + } + return writeTaskContextFields(stdout, []contextField{{"Contribution policy (not authority or readiness)", result.Raw}}) + }, + } + show.Flags().StringVar(&version, "version", "", "Optional exact version UUID; omitted means backend selection, not auto-discovery") + policy.AddCommand(show) + project.AddCommand(policy) +} diff --git a/cli/tests/integration/contribution_policy_read_journey.py b/cli/tests/integration/contribution_policy_read_journey.py new file mode 100644 index 000000000..43c787410 --- /dev/null +++ b/cli/tests/integration/contribution_policy_read_journey.py @@ -0,0 +1,263 @@ +"""CLI Finance reads inside the retained real public guide-activation journey.""" + +import json +from uuid import uuid4 + +from sqlalchemy import text + +from tests.contributions.public_policy.support import UNPAID + + +async def publish_with_policy_reads( + direct, + cli, + origin, + factory, + project, + foreign_project, + finance_token, + finance_id, + finance_grant, + manager_token, + other_token, + foreign_finance_token, + admin, +): + root = f"/api/v1/projects/{project}/contribution-policies" + finance = {"Authorization": "Bearer " + finance_token} + tables = ( + "contribution_policies", + "contribution_policy_versions", + "contribution_rules", + "contribution_award_definitions", + "project_compensation_units", + "iso_4217_currency_codes", + "contribution_policy_transition_custody", + "contribution_policy_lifecycle_events", + "project_guides", + "projects", + ) + + async def stored(): + async with factory() as session: + return { + table: [ + dict(row) + for row in ( + await session.execute(text(f"SELECT * FROM {table} ORDER BY 1")) + ).mappings() + ] + for table in tables + } + + async def write(method, path, body, headers=finance): + response = await direct.request( + method, path, headers=headers | {"Idempotency-Key": str(uuid4())}, json=body + ) + assert response.status_code in (200, 201), response.text + return response.json() + + def call( + action, + *, + policy=None, + version=None, + selected_project=project, + token=finance_token, + status=None, + ): + args = (str(selected_project).replace("-", ""),) + if action == "show": + args += (str(policy).replace("-", ""),) + if version is not None: + args += ("--version", str(version).replace("-", "")) + result = cli( + origin, token, "project", "contribution-policy", action, *args, "-o", "json" + ) + if status is not None: + assert result.returncode == 1 and result.stdout == "", result.stderr + error = json.loads(result.stderr)["error"] + assert error["status"] == status and "outcome_unknown" not in error, error + if status == 404: + assert error["code"] == "contribution_policy_not_found" + return error + assert result.returncode == 0 and result.stderr == "", result.stderr + return json.loads(result.stdout) + + async def inspect( + receipt, published_id, draft_id, *, explicit=None, expected_status=None + ): + before = await stored() + current = call("current") + assert current == { + "project_id": str(project), + "contribution_policy_id": receipt["contribution_policy_id"], + "current_published_version_id": published_id, + "open_draft_version_id": draft_id, + } + expected = await direct.get(root + "/current", headers=finance) + assert expected.status_code == 200 and current == expected.json() + viewed = call( + "show", policy=receipt["contribution_policy_id"], version=explicit + ) + expected = await direct.get( + root + "/" + receipt["contribution_policy_id"], + headers=finance, + params={} if explicit is None else {"version_id": explicit}, + ) + assert expected.status_code == 200 and viewed == expected.json() + if explicit is not None: + assert viewed["contribution_policy_version_id"] == explicit + if expected_status is not None: + assert viewed["version_status"] == expected_status + assert ( + await stored() == before + ) # AUTH read evidence is intentionally outside these tables. + return viewed + + spec = (await direct.get("/openapi.json")).json()["paths"] + for route in ( + "/api/v1/projects/{project_id}/contribution-policies/current", + "/api/v1/projects/{project_id}/contribution-policies/{policy_id}", + ): + assert ( + spec[route]["get"]["x-workstream-action-id"] == "contribution.policy.read" + ) + first = await write("POST", root + "/drafts", {"name": "Governed project policy"}) + first_id = first["contribution_policy_version_id"] + version_path = root + f"/{first['contribution_policy_id']}/versions/{first_id}" + empty = await inspect( + first, None, first_id, explicit=first_id, expected_status="draft" + ) + assert empty["rules"] == [] and empty["version_number"] == 1 + await write("PUT", version_path, UNPAID) + published = await write("POST", version_path + "/publication", {}) + view = await inspect(first, first_id, None, expected_status="published") + assert {rule["contribution_type"] for rule in view["rules"]} == { + "accepted_submission", + "completed_review", + } + assert all( + rule["compensation_mode"] == "unpaid" and rule["definitions"] == [] + for rule in view["rules"] + ) + successor = await write( + "POST", root + "/drafts", {"name": "Governed project policy"} + ) + next_id = successor["contribution_policy_version_id"] + default = await inspect(first, first_id, next_id, expected_status="published") + assert default["contribution_policy_version_id"] == first_id + next_view = await inspect( + first, first_id, next_id, explicit=next_id, expected_status="draft" + ) + assert next_view["rules"] == [] and next_view["version_number"] == 2 + version_path = root + f"/{first['contribution_policy_id']}/versions/{next_id}" + await write("PUT", version_path, UNPAID) + published = await write("POST", version_path + "/publication", {}) + old = await inspect( + first, next_id, None, explicit=first_id, expected_status="retired" + ) + assert old["policy_status"] == "active" and old["rules"] == view["rules"] + await inspect(first, next_id, None, expected_status="published") + + # Stored foreign aggregate/version, created by real foreign-project Finance. + foreign = await write( + "POST", + f"/api/v1/projects/{foreign_project}/contribution-policies/drafts", + {"name": "Foreign policy"}, + {"Authorization": "Bearer " + foreign_finance_token}, + ) + before = await stored() + for presented in (manager_token, other_token, foreign_finance_token): + call("current", token=presented, status=404) + call( + "show", + policy=first["contribution_policy_id"], + version=next_id, + token=presented, + status=404, + ) + call( + "show", + policy=foreign["contribution_policy_id"], + version=foreign["contribution_policy_version_id"], + status=404, + ) + call( + "show", + policy=first["contribution_policy_id"], + version=foreign["contribution_policy_version_id"], + status=404, + ) + call("current", selected_project=foreign_project, status=404) + call( + "show", + selected_project=foreign_project, + policy=foreign["contribution_policy_id"], + status=404, + ) + call("current", token="invalid-token", status=401) + assert await stored() == before + + async def denied(): + before = await stored() + call("current", status=404) + call( + "show", policy=first["contribution_policy_id"], version=next_id, status=404 + ) + assert await stored() == before + + await write( + "POST", + f"/api/v1/actors/{finance_id}/suspend", + {"reason": "Finance read admission proof"}, + admin, + ) + await denied() + await write( + "POST", + f"/api/v1/actors/{finance_id}/reactivate", + {"reason": "Restore Finance read admission"}, + admin, + ) + await inspect(first, next_id, None, expected_status="published") + await write( + "POST", + f"/api/v1/admin-role-grants/{finance_grant}/revoke", + {"reason": "Finance read current authority proof"}, + admin, + ) + await denied() + await write( + "POST", + "/api/v1/admin-role-grants", + { + "target_actor_profile_id": str(finance_id), + "role": "finance_authority", + "scope_type": "project", + "scope_project_id": str(project), + "reason": "Restore exact-project Finance authority", + }, + admin, + ) + await inspect(first, next_id, None, expected_status="published") + + async def inspect_retired(): + before = await stored() + historical = call( + "show", policy=first["contribution_policy_id"], version=next_id + ) + expected = await direct.get( + root + "/" + first["contribution_policy_id"], + headers=finance, + params={"version_id": next_id}, + ) + assert expected.status_code == 200 and historical == expected.json() + assert (historical["policy_status"], historical["version_status"]) == ( + "retired", + "retired", + ) + call("current", status=404) + assert await stored() == before + + return published, version_path, inspect_retired diff --git a/cli/tests/integration/test_contribution_policy_reads.py b/cli/tests/integration/test_contribution_policy_reads.py new file mode 100644 index 000000000..b564744d2 --- /dev/null +++ b/cli/tests/integration/test_contribution_policy_reads.py @@ -0,0 +1,325 @@ +"""Built-binary public Finance reads and hostile wire response regressions.""" + +from copy import deepcopy +import json +from urllib.parse import parse_qs, quote, urlsplit + +from test_http_boundary import ACTOR, PROJECT, TOKEN, assert_failure, http_fixture +from test_contributor_task_mutations import KEY, POLICY, canonical_error + +VERSION = "018f0ebc-7966-7e8d-bc4d-1cae1e000077" + + +def selection(published=VERSION, draft=None): + return { + "project_id": PROJECT, + "contribution_policy_id": POLICY, + "current_published_version_id": published, + "open_draft_version_id": draft, + } + + +def policy(*, empty=False, compensated=False): + rules = [] + if not empty: + for kind, identifier in ( + ("accepted_submission", ACTOR), + ("completed_review", KEY), + ): + rules.append( + { + "rule_id": identifier, + "contribution_type": kind, + "compensation_mode": "unpaid", + "definitions": [], + } + ) + if compensated: + rules[0].update( + compensation_mode="compensated", + definitions=[ + { + "definition_id": PROJECT, + "instrument_type": "money", + "unit_code": "USD", + "quantity": "99999999999999999999.123456789012345678", + "adapter_binding_id": KEY, + }, + { + "definition_id": VERSION, + "instrument_type": "project_points", + "unit_code": "research_points", + "quantity": "9007199254740993.000000000000000000", + "adapter_binding_id": ACTOR, + }, + ], + ) + return { + "project_id": PROJECT, + "contribution_policy_id": POLICY, + "name": "Governed project policy", + "policy_status": "draft" if empty else "active", + "contribution_policy_version_id": VERSION, + "version_number": 1, + "version_status": "draft" if empty else "published", + "rules": rules, + } + + +def invoke( + cli, + origin, + action, + *flags, + project=PROJECT, + selected=POLICY, + output="json", + token=TOKEN, +): + selectors = (project,) if action == "current" else (project, selected) + return cli( + origin, + token, + "project", + "contribution-policy", + action, + *selectors, + *flags, + "-o", + output, + ) + + +def test_policy_reads_exact_wire_selection_history_and_complete_text(cli): + with http_fixture() as (origin, response, requests): + for value in (selection(None, VERSION), selection(), selection(VERSION, KEY)): + response["body"] = json.dumps(value).encode() + for spelling in ( + PROJECT, + PROJECT.replace("-", ""), + "{" + PROJECT + "}", + "urn:uuid:" + PROJECT, + ): + before = len(requests) + result = invoke(cli, origin, "current", project=spelling) + assert result.returncode == 0 and json.loads(result.stdout) == value, ( + result.stderr + ) + assert len(requests) == before + 1 + assert requests[-1] == ( + "GET", + f"/api/v1/projects/{quote(spelling, safe=':')}/contribution-policies/current", + "Bearer " + TOKEN, + ) + text = invoke(cli, origin, "current", output="text") + assert ( + text.returncode == 0 + and json.loads( + text.stdout.removeprefix("Current selectors (not authority): ") + ) + == value + ) + for value in ( + policy(empty=True), + policy(), + policy(compensated=True), + policy() | {"policy_status": "retired", "version_status": "retired"}, + policy(empty=True) | {"policy_status": "active"}, + policy() | {"version_status": "retired"}, + policy() | {"version_number": 2**100}, + ): + response["body"] = json.dumps(value).encode() + for flags in ((), ("--version", VERSION.replace("-", ""))): + before = len(requests) + result = invoke( + cli, + origin, + "show", + *flags, + project=PROJECT.replace("-", ""), + selected=POLICY.replace("-", ""), + ) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert json.loads(result.stdout) == value + assert len(requests) == before + 1 + path = urlsplit(requests[-1][1]) + assert ( + path.path + == f"/api/v1/projects/{PROJECT.replace('-', '')}/contribution-policies/{POLICY.replace('-', '')}" + ) + assert parse_qs(path.query) == ( + {"version_id": [VERSION.replace("-", "")]} if flags else {} + ) + text = invoke(cli, origin, "show", output="text") + assert ( + text.returncode == 0 + and json.loads( + text.stdout.removeprefix( + "Contribution policy (not authority or readiness): " + ) + ) + == value + ) + value = policy() | {"name": "Guide \u202e\u001b[31m policy"} + response["body"] = json.dumps(value).encode() + text = invoke(cli, origin, "show", output="text") + assert ( + text.returncode == 0 + and "\u202e" not in text.stdout + and "\u001b" not in text.stdout + ) + assert ( + json.loads( + text.stdout.removeprefix( + "Contribution policy (not authority or readiness): " + ) + ) + == value + ) + + +def test_policy_invalid_selectors_send_no_request_and_current_relations_fail(cli): + with http_fixture() as (origin, response, requests): + for action in ("current", "show"): + assert invoke(cli, origin, action, project="../foreign").returncode == 2 + assert invoke(cli, origin, "show", selected="bad").returncode == 2 + for version in ("", "bad", "../other"): + result = invoke(cli, origin, "show", "--version", version) + assert result.returncode == 2 and result.stdout == "" + assert requests == [] + good = selection() + response["body"] = json.dumps(good).encode() + assert invoke(cli, origin, "current").returncode == 0 + invalid = [ + selection(None, None), + selection(VERSION, VERSION.replace("-", "")), + good | {"project_id": ACTOR}, + good | {"contribution_policy_id": "bad"}, + good | {"current_published_version_id": "bad"}, + good | {"open_draft_version_id": "bad"}, + good | {"extra": 1}, + ] + for key in good: + invalid.append({k: v for k, v in good.items() if k != key}) + if key in ("project_id", "contribution_policy_id"): + invalid.append(good | {key: None}) + for value in invalid: + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin, "current"), "invalid_api_response") + + +def test_policy_view_binds_full_nested_facts_and_normalized_identities(cli): + good = policy(compensated=True) + invalid = [ + good | {"project_id": ACTOR}, + good | {"contribution_policy_id": KEY}, + good | {"contribution_policy_version_id": KEY}, + good | {"policy_status": "ready"}, + good | {"version_status": "active"}, + good | {"version_number": 0}, + good | {"version_number": -1}, + good | {"version_number": 1.5}, + good | {"version_number": "1"}, + good | {"name": " "}, + good | {"private": 1}, + good | {"rules": [None]}, + ] + for key in good: + invalid += [{k: v for k, v in good.items() if k != key}, good | {key: None}] + for key in good["rules"][0]: + for value in (None, "unknown" if key != "definitions" else [None]): + altered = deepcopy(good) + altered["rules"][0][key] = value + invalid.append(altered) + altered = deepcopy(good) + del altered["rules"][0][key] + invalid.append(altered) + for key in good["rules"][0]["definitions"][0]: + altered = deepcopy(good) + del altered["rules"][0]["definitions"][0][key] + invalid.append(altered) + altered = deepcopy(good) + altered["rules"][0]["definitions"][0][key] = None + invalid.append(altered) + for change in ( + {"definition_id": "bad"}, + {"adapter_binding_id": "bad"}, + {"instrument_type": "token"}, + {"quantity": 9.3}, + {"quantity": "0"}, + {"quantity": "NaN"}, + {"quantity": "1e3"}, + {"unit_code": ""}, + {"extra": 1}, + ): + altered = deepcopy(good) + altered["rules"][0]["definitions"][0].update(change) + invalid.append(altered) + for change in ( + {"rule_id": ACTOR.replace("-", "")}, + {"contribution_type": "accepted_submission"}, + {"extra": 1}, + ): + altered = deepcopy(good) + altered["rules"][1].update(change) + invalid.append(altered) + for change in ( + {"definition_id": PROJECT.replace("-", "")}, + {"instrument_type": "money"}, + ): + altered = deepcopy(good) + altered["rules"][0]["definitions"][1].update(change) + invalid.append(altered) + with http_fixture() as (origin, response, requests): + response["body"] = json.dumps(good).encode() + assert invoke(cli, origin, "show", "--version", VERSION).returncode == 0 + for value in invalid: + response["body"] = json.dumps(value).encode() + before = len(requests) + result = invoke(cli, origin, "show", "--version", VERSION) + assert_failure(result, "invalid_api_response") + assert "outcome_unknown" not in json.loads(result.stderr)["error"] + assert len(requests) == before + 1 + for action, good in (("current", selection()), ("show", policy())): + response["body"] = ( + b'{"project_id":"' + + PROJECT.encode() + + b'",' + + json.dumps(good)[1:].encode() + ) + assert_failure(invoke(cli, origin, action), "invalid_api_response") + + +def test_policy_read_credential_safety_bounds_and_backend_denial(cli): + with http_fixture() as (origin, response, requests): + for action, value in (("current", selection()), ("show", policy())): + response["body"] = json.dumps(value).encode() + assert_failure( + invoke(cli, origin, action, token=POLICY.replace("-", "")), + "invalid_api_response", + ) + response["body"] = b" " * (64 * 1024 + 1) + assert_failure(invoke(cli, origin, action), "invalid_api_response") + response.update( + status=404, + body=json.dumps( + canonical_error() + | { + "error": canonical_error()["error"] + | {"code": "contribution_policy_not_found"} + } + ).encode(), + ) + response["headers"] = { + "Content-Type": "application/json", + "X-Correlation-ID": ACTOR, + } + before = len(requests) + result = invoke(cli, origin, action, token=ACTOR.replace("-", "")) + assert_failure(result, "contribution_policy_not_found") + assert ( + ACTOR not in result.stderr + and "outcome_unknown" not in json.loads(result.stderr)["error"] + ) + assert len(requests) == before + 1 + response["status"] = 200 diff --git a/cli/tests/integration/test_guide_activation_public_api.py b/cli/tests/integration/test_guide_activation_public_api.py index 7705973a9..efc14419c 100644 --- a/cli/tests/integration/test_guide_activation_public_api.py +++ b/cli/tests/integration/test_guide_activation_public_api.py @@ -32,7 +32,8 @@ from tests.projects.guide_compilation.proposals.pg_support import ( seed_selected_review_revision_inputs, ) -from tests.projects.public_activation.support import activation_body, publish +from tests.projects.public_activation.support import activation_body +from contribution_policy_read_journey import publish_with_policy_reads @pytest.mark.asyncio @@ -65,7 +66,7 @@ def token(actor): grant, ): await seed_selected_review_revision_inputs(factory, command, actor) - finance, _ = await seed_review_actor( + finance, finance_grant = await seed_review_actor( factory, command.project_id, role="finance_authority" ) outsider, _ = await seed_review_actor( @@ -83,6 +84,9 @@ def token(actor): slug="foreign-activation-" + uuid4().hex, ) foreign, _ = await seed_review_actor(factory, foreign_id) + foreign_finance, _ = await seed_review_actor( + factory, foreign_id, role="finance_authority" + ) origin = f"http://127.0.0.1:{find_free_port()}" log = (tmp_path / "activation-api.log").open("wb") process = subprocess.Popen( # noqa: S603 - fixed production API @@ -281,15 +285,25 @@ async def stored(expected, saved=None): json={"target": response.json()["target"]}, ) assert approved.status_code == 200, approved.text - async with httpx.AsyncClient( - base_url=origin, - trust_env=False, - timeout=20, - headers={"Authorization": "Bearer " + token(finance)}, - ) as finance_client: - published, version_path = await publish( - finance_client, command.project_id - ) + ( + published, + version_path, + inspect_retired_policy, + ) = await publish_with_policy_reads( + direct, + cli, + origin, + factory, + command.project_id, + foreign_id, + token(finance), + finance.actor_profile_id, + finance_grant, + caller, + token(outsider), + token(foreign_finance), + admin, + ) response = await direct.get(policy_path, headers=headers) assert response.status_code == 200, response.text body = activation_body(response.json()) @@ -364,6 +378,7 @@ async def stored(expected, saved=None): json={}, ) assert retired.status_code == 200, retired.text + await inspect_retired_policy() assert success(call()) == saved suspended = await direct.post( f"/api/v1/actors/{actor.actor_profile_id}/suspend", diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 4ff3a94d4..d197d96e5 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -212,6 +212,11 @@ CLI grant list/detail now expose the existing authorized qualification and revocation history: one filtered count-free page with explicit continuation, or one exact project/grant. Current authority remains backend-owned; no actor discovery, role cache or automatic decisions are added. +CLI contribution-policy current/exact-version reads now expose the authorized +Finance policy graph, including empty drafts, published/open-draft selectors and +retained historical versions. Backend selection and current Finance authority +remain decisive; PM-only permission does not suffice. Explicit policy editing, +publication/retirement and contribution-evidence CLI consumption remain separate. No public project-list route is invented. CLI write uncertainty is explicit and never automatically @@ -481,6 +486,12 @@ cannot be reused as post-submission review-gate evidence. See the qualification/provenance and revocation facts. Cursors remain opaque, continuation is explicit and each request requires fresh backend authority. Observed history does not authorize subsequent writes. + [CLI contribution-policy inspection](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-22.md) + discovers current published/open-draft selectors and reads backend-selected or + explicit retained versions with complete rules and exact award quantities. + Real API proof covers unpaid policy history, current Finance authority, + suspension/revocation and project/version isolation; compensated precision is + process-boundary proof. Reads do not publish, activate, pay or infer readiness. [PILOT-13 assigned-guide access](../.commitrail/changes/pilot13-assigned-task-guide-documents.md) lists exact locked originals in assigned contributor context and streams authorized, fully verified ART bytes. `task guide` lists or safely downloads