diff --git a/README.md b/README.md index 82b670f..a15e7e9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ pins the same module, a `lib` bump is a constellation-wide rebuild (KV-C6a). | `app/service-helpers.go` | Pointer helpers (`String`/`Bool`/`Int64`/…), `GetFieldsAndValues` reflection helper (unit-tested) | | `app/user.go`, `user-helpers.go`, `tenantuser.go`, `userrole.go`, `address.go` | Compatibility principal/domain structs. ADR-KV-024 adds `PrincipalKey`, `PrincipalType`, exact `Scopes`, `HasScope`, and `IsServicePrincipal`; no native response carries its bearer secret. | | `app/logger/logger.go` | Thin `zap.SugaredLogger` wrapper (`New(level)`, level constants) | -| `api/{auth,crm,members,plex,research,sfgate,stash}/` | go-swagger–generated typed clients + models, one directory per sibling service. Stash is intentionally generated from the read-only PDF metadata projection described below. Research CompanyProduct/CompanyService operations are pinned to provider PR #32 as documented in `docs/RESEARCH_COMPANY_CATALOG_CLIENT.md`. | +| `api/{auth,crm,members,plex,research,sfgate,stash}/` | go-swagger–generated typed clients + models, one directory per sibling service. Stash is intentionally generated from the read-only PDF metadata projection described below. Research CompanyProduct/CompanyService operations are pinned to PR #57 source `c3127f32872051b842c74ae7139b1c6ef71e2c53`, merge `cc7bf6191dc105969c804e123823186f1ea26cc3`, and spec SHA-256 `1e5b34b5c7b4230cfd507995ab1f837aabc7b4dcdf61b114f37012167afdf224` as documented in `docs/RESEARCH_COMPANY_CATALOG_CLIENT.md`. | | `swagger/*.yaml`, `swagger/defs/`, `swagger/external/` | Copies of each sibling service's spec (the KV-C6b spec-copy problem — no sync mechanism besides `make regen`), shared component defs, and host/path-rewritten copies for external doc publishing; also carries legacy vendored Kazoo/Clerk specs from `lib`'s pre-mesh history | ## Build, run, test @@ -89,13 +89,16 @@ fields; it cannot generate the provider's deprecated, fail-closed create compatibility route. See `docs/STASH_PDF_METADATA_CLIENT.md` for the provider commit and release blockers. -The Research specification is synchronized from provider PR #32 at commit -`ca6a2911ce79d053f9aa338e6d9bcfe3357b0c77`. Its generated company catalog -surface exposes Product and Service list/detail/create plus Service CAS update. -Product direct update remains proposal-only, and both catalog deletes are -absent. See `docs/RESEARCH_COMPANY_CATALOG_CLIENT.md` for the pinned spec hash, -exact scope requirements, database assumptions, and Research-before-Lib-before- -Studio release gate. +The Research specification is synchronized from PR #57 source +`c3127f32872051b842c74ae7139b1c6ef71e2c53`, merged as +`cc7bf6191dc105969c804e123823186f1ea26cc3`. Its generated company catalog +surface exposes Product and Service list/detail/create plus optimistic- +concurrency Product and Service updates. Published Product changes remain +proposal-only, legacy proposal candidates are never silently converted to +direct writes, and both catalog deletes are absent. See +`docs/RESEARCH_COMPANY_CATALOG_CLIENT.md` for the pinned spec hash, exact scope +requirements, database assumptions, and Research-before-Lib-before-Studio +release gate. **Known gap (resolved for the current spec set, but can recur):** if any sibling spec has an operation with no top-level `tags:`, go-swagger routes diff --git a/api/research/research_client/company_catalog_contract_test.go b/api/research/research_client/company_catalog_contract_test.go index bf617c9..34a014d 100644 --- a/api/research/research_client/company_catalog_contract_test.go +++ b/api/research/research_client/company_catalog_contract_test.go @@ -36,6 +36,8 @@ func (transport *catalogCaptureTransport) SubmitContext( return company_products.NewGetCompanyProductsOK(), nil case "postCompanyProducts": return company_products.NewPostCompanyProductsOK(), nil + case "putCompanyProduct": + return company_products.NewPutCompanyProductOK(), nil case "getCompanyServices": return company_services.NewGetCompanyServicesOK(), nil case "postCompanyServices": @@ -74,6 +76,15 @@ func TestGeneratedCompanyCatalogOperationContract(t *testing.T) { return err }, }, + { + name: "CAS update product", wantID: "putCompanyProduct", + wantMethod: "PUT", wantPath: "/companyproducts", + call: func(transport openapiruntime.ContextualTransport) error { + _, err := company_products.New(transport, strfmt.Default). + PutCompanyProduct(company_products.NewPutCompanyProductParams(), nil) + return err + }, + }, { name: "list or get service", wantID: "getCompanyServices", wantMethod: "GET", wantPath: "/companyservices", @@ -143,6 +154,10 @@ func TestGeneratedCompanyCatalogListGetAndMutationShapes(t *testing.T) { WithCompanyProductRequest(productRequest).CompanyProductRequest; got != productRequest { t.Fatal("CompanyProduct create request body is absent") } + if got := company_products.NewPutCompanyProductParams(). + WithCompanyProductRequest(productRequest).CompanyProductRequest; got != productRequest { + t.Fatal("CompanyProduct CAS update request body is absent") + } serviceRequest := &research_models.CompanyServiceRequest{ Data: []*research_models.CompanyService{{ @@ -161,24 +176,32 @@ func TestGeneratedCompanyCatalogListGetAndMutationShapes(t *testing.T) { func TestGeneratedCompanyCatalogNegativeMutationResponses(t *testing.T) { for name, response := range map[string]interface{ IsCode(int) bool }{ - "product unauthorized": company_products.NewPostCompanyProductsUnauthorized(), - "product forbidden": company_products.NewPostCompanyProductsForbidden(), - "product not found": company_products.NewPostCompanyProductsNotFound(), - "product invalid": company_products.NewPostCompanyProductsUnprocessableEntity(), - "service unauthorized": company_services.NewPostCompanyServicesUnauthorized(), - "service forbidden": company_services.NewPostCompanyServicesForbidden(), - "service invalid": company_services.NewPostCompanyServicesUnprocessableEntity(), - "update unauthorized": company_services.NewPutCompanyServiceUnauthorized(), - "update forbidden": company_services.NewPutCompanyServiceForbidden(), - "update not found": company_services.NewPutCompanyServiceNotFound(), - "update conflict": company_services.NewPutCompanyServiceConflict(), - "update invalid": company_services.NewPutCompanyServiceUnprocessableEntity(), + "product unauthorized": company_products.NewPostCompanyProductsUnauthorized(), + "product forbidden": company_products.NewPostCompanyProductsForbidden(), + "product not found": company_products.NewPostCompanyProductsNotFound(), + "product invalid": company_products.NewPostCompanyProductsUnprocessableEntity(), + "product update unauthorized": company_products.NewPutCompanyProductUnauthorized(), + "product update forbidden": company_products.NewPutCompanyProductForbidden(), + "product update not found": company_products.NewPutCompanyProductNotFound(), + "product update conflict": company_products.NewPutCompanyProductConflict(), + "product update invalid": company_products.NewPutCompanyProductUnprocessableEntity(), + "service unauthorized": company_services.NewPostCompanyServicesUnauthorized(), + "service forbidden": company_services.NewPostCompanyServicesForbidden(), + "service invalid": company_services.NewPostCompanyServicesUnprocessableEntity(), + "update unauthorized": company_services.NewPutCompanyServiceUnauthorized(), + "update forbidden": company_services.NewPutCompanyServiceForbidden(), + "update not found": company_services.NewPutCompanyServiceNotFound(), + "update conflict": company_services.NewPutCompanyServiceConflict(), + "update invalid": company_services.NewPutCompanyServiceUnprocessableEntity(), } { t.Run(name, func(t *testing.T) { expected := map[string]int{ "product unauthorized": 401, "product forbidden": 403, "product not found": 404, "product invalid": 422, - "service unauthorized": 401, "service forbidden": 403, + "product update unauthorized": 401, "product update forbidden": 403, + "product update not found": 404, "product update conflict": 409, + "product update invalid": 422, + "service unauthorized": 401, "service forbidden": 403, "service invalid": 422, "update unauthorized": 401, "update forbidden": 403, "update not found": 404, "update conflict": 409, "update invalid": 422, @@ -193,7 +216,6 @@ func TestGeneratedCompanyCatalogNegativeMutationResponses(t *testing.T) { func TestGeneratedCompanyCatalogDoesNotExposeUnsafeMutations(t *testing.T) { productContract := reflect.TypeOf((*company_products.ClientService)(nil)).Elem() for _, method := range []string{ - "PutCompanyProduct", "PutCompanyProductContext", "DeleteCompanyProduct", "DeleteCompanyProductContext", } { if _, exists := productContract.MethodByName(method); exists { @@ -217,11 +239,18 @@ func TestCompanyCatalogProviderProjectionRequiresCompoundAuth(t *testing.T) { } products := document.Spec().Paths.Paths["/companyproducts"] services := document.Spec().Paths.Paths["/companyservices"] + if products.Put == nil { + t.Fatal("provider projection is missing CompanyProduct CAS update") + } + if services.Put == nil { + t.Fatal("provider projection is missing CompanyService CAS update") + } operations := []*struct { Security []map[string][]string }{ {Security: products.Get.Security}, {Security: products.Post.Security}, + {Security: products.Put.Security}, {Security: services.Get.Security}, {Security: services.Post.Security}, {Security: services.Put.Security}, @@ -237,8 +266,21 @@ func TestCompanyCatalogProviderProjectionRequiresCompoundAuth(t *testing.T) { t.Fatal("catalog operation is missing kvSessionCookie") } } - if products.Put != nil || products.Delete != nil || services.Delete != nil { - t.Fatal("provider projection reintroduced Product PUT or a catalog DELETE") + for _, fragment := range []string{ + "optimistic concurrency", + "preserving its immutable Account binding", + "publication state", + "enrichment audit", + "media ownership", + "Published Products require the governed proposal workflow", + "research:company-product:update", + } { + if !strings.Contains(products.Put.Description, fragment) { + t.Errorf("CompanyProduct PUT description is missing %q", fragment) + } + } + if products.Delete != nil || services.Delete != nil { + t.Fatal("provider projection exposes a catalog DELETE") } } diff --git a/api/research/research_client/company_products/company_products_client.go b/api/research/research_client/company_products/company_products_client.go index 741859b..256c2c2 100644 --- a/api/research/research_client/company_products/company_products_client.go +++ b/api/research/research_client/company_products/company_products_client.go @@ -78,6 +78,12 @@ type ClientService interface { // PostCompanyProductsContext create one tenant owned company product. PostCompanyProductsContext(ctx context.Context, params *PostCompanyProductsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PostCompanyProductsOK, error) + // PutCompanyProduct update one unpublished shared catalog company product. + PutCompanyProduct(params *PutCompanyProductParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PutCompanyProductOK, error) + + // PutCompanyProductContext update one unpublished shared catalog company product. + PutCompanyProductContext(ctx context.Context, params *PutCompanyProductParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PutCompanyProductOK, error) + SetTransport(transport runtime.ContextualTransport) } @@ -217,7 +223,7 @@ func (a *Client) GetPublishedCompanyProductsContext(ctx context.Context, params // PostCompanyProducts creates one tenant owned company product. // -// Create one unpublished CompanyProduct beneath an Account owned by the machine tenant; requires research:company-product:create and same-tenant Owner or Manager. Existing Product updates remain proposal-only.. +// Create one unpublished CompanyProduct beneath an Account owned by the machine tenant; requires research:company-product:create and same-tenant Owner or Manager. // // This method does not support injected context. // However, timeout and opentracing contexts are honored whenever enabled. @@ -236,7 +242,7 @@ func (a *Client) PostCompanyProducts(params *PostCompanyProductsParams, authInfo // PostCompanyProductsContext creates one tenant owned company product. // -// Create one unpublished CompanyProduct beneath an Account owned by the machine tenant; requires research:company-product:create and same-tenant Owner or Manager. Existing Product updates remain proposal-only.. +// Create one unpublished CompanyProduct beneath an Account owned by the machine tenant; requires research:company-product:create and same-tenant Owner or Manager. // // Do not use the deprecated [PostCompanyProductsParams.Context] with this method: it would be ignored. func (a *Client) PostCompanyProductsContext(ctx context.Context, params *PostCompanyProductsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PostCompanyProductsOK, error) { @@ -282,6 +288,73 @@ func (a *Client) PostCompanyProductsContext(ctx context.Context, params *PostCom panic(msg) } +// PutCompanyProduct updates one unpublished shared catalog company product. +// +// Replace the editable fields of one shared-catalog CompanyProduct using optimistic concurrency while preserving its immutable Account binding, publication state, enrichment audit, and media ownership. Published Products require the governed proposal workflow. Requires research:company-product:update and a same-tenant Owner or Manager.. +// +// This method does not support injected context. +// However, timeout and opentracing contexts are honored whenever enabled. +// +// If you need to pass a specific context, use [Client.PutCompanyProductContext] instead. +func (a *Client) PutCompanyProduct(params *PutCompanyProductParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PutCompanyProductOK, error) { + var ctx context.Context + if params.inner.ctx != nil { + ctx = params.inner.ctx + } else { + ctx = context.Background() + } + + return a.PutCompanyProductContext(ctx, params, authInfo, opts...) +} + +// PutCompanyProductContext updates one unpublished shared catalog company product. +// +// Replace the editable fields of one shared-catalog CompanyProduct using optimistic concurrency while preserving its immutable Account binding, publication state, enrichment audit, and media ownership. Published Products require the governed proposal workflow. Requires research:company-product:update and a same-tenant Owner or Manager.. +// +// Do not use the deprecated [PutCompanyProductParams.Context] with this method: it would be ignored. +func (a *Client) PutCompanyProductContext(ctx context.Context, params *PutCompanyProductParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PutCompanyProductOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewPutCompanyProductParams() + } + + op := &runtime.ClientOperation{ + ID: "putCompanyProduct", + Method: "PUT", + PathPattern: "/companyproducts", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &PutCompanyProductReader{formats: a.formats}, + AuthInfo: authInfo, + Client: params.HTTPClient, + } + + for _, opt := range opts { + opt(op) + } + + result, err := a.transport.SubmitContext(ctx, op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*PutCompanyProductOK) + if ok { + return success, nil + } + + // unexpected success response. + + // no default response is defined. + // + // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue + msg := fmt.Sprintf("unexpected success response for putCompanyProduct: API contract not enforced by server. Client expected to get an error, but got: %T", result) + panic(msg) +} + // SetTransport changes the transport on the client func (a *Client) SetTransport(transport runtime.ContextualTransport) { a.transport = transport diff --git a/api/research/research_client/company_products/put_company_product_parameters.go b/api/research/research_client/company_products/put_company_product_parameters.go new file mode 100644 index 0000000..3ef96f6 --- /dev/null +++ b/api/research/research_client/company_products/put_company_product_parameters.go @@ -0,0 +1,159 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// (c) 2012-2020 by Taxnexus, Inc. +// All rights reserved worldwide. +// Proprietary product; unlicensed use is not allowed + +package company_products + +import ( + "context" + "net/http" + "time" + + "code.tnxs.net/vernonkeenan/lib/api/research/research_models" + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewPutCompanyProductParams creates a new PutCompanyProductParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewPutCompanyProductParams() *PutCompanyProductParams { + return NewPutCompanyProductParamsWithTimeout(cr.DefaultTimeout) +} + +// NewPutCompanyProductParamsWithTimeout creates a new PutCompanyProductParams object +// with the ability to set a timeout on a request. +func NewPutCompanyProductParamsWithTimeout(timeout time.Duration) *PutCompanyProductParams { + return &PutCompanyProductParams{ + inner: innerParams{ + timeout: timeout, + }, + } +} + +// NewPutCompanyProductParamsWithContext creates a new PutCompanyProductParams object +// with the ability to set a context for a request. +// +// Deprecated: use the operation call with context to pass the context instead of [PutCompanyProductParams]. +func NewPutCompanyProductParamsWithContext(ctx context.Context) *PutCompanyProductParams { + return &PutCompanyProductParams{ + inner: innerParams{ + ctx: ctx, + }, + } +} + +// NewPutCompanyProductParamsWithHTTPClient creates a new PutCompanyProductParams object +// with the ability to set a custom HTTPClient for a request. +func NewPutCompanyProductParamsWithHTTPClient(client *http.Client) *PutCompanyProductParams { + return &PutCompanyProductParams{ + HTTPClient: client, + } +} + +/* +PutCompanyProductParams contains all the parameters to send to the API endpoint + + for the put company product operation. + + Typically these are written to a http.Request. +*/ +type PutCompanyProductParams struct { + + // CompanyProductRequest. + // + // An array of new CompanyProduct records + CompanyProductRequest *research_models.CompanyProductRequest + + HTTPClient *http.Client + + inner innerParams +} + +// WithDefaults hydrates default values in the put company product params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *PutCompanyProductParams) WithDefaults() *PutCompanyProductParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the put company product params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *PutCompanyProductParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the put company product params. +func (o *PutCompanyProductParams) WithTimeout(timeout time.Duration) *PutCompanyProductParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the put company product params. +func (o *PutCompanyProductParams) SetTimeout(timeout time.Duration) { + o.inner.timeout = timeout +} + +// WithContext adds the context to the put company product params. +// +// Deprecated: use the operation call with context to pass the context instead of [PutCompanyProductParams]. +func (o *PutCompanyProductParams) WithContext(ctx context.Context) *PutCompanyProductParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the put company product params. +// +// Deprecated: use the operation call with context to pass the context instead of [PutCompanyProductParams]. +func (o *PutCompanyProductParams) SetContext(ctx context.Context) { + o.inner.ctx = ctx +} + +// WithHTTPClient adds the HTTPClient to the put company product params. +func (o *PutCompanyProductParams) WithHTTPClient(client *http.Client) *PutCompanyProductParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the put company product params. +func (o *PutCompanyProductParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithCompanyProductRequest adds the companyProductRequest to the put company product params. +func (o *PutCompanyProductParams) WithCompanyProductRequest(companyProductRequest *research_models.CompanyProductRequest) *PutCompanyProductParams { + o.SetCompanyProductRequest(companyProductRequest) + return o +} + +// SetCompanyProductRequest adds the companyProductRequest to the put company product params. +func (o *PutCompanyProductParams) SetCompanyProductRequest(companyProductRequest *research_models.CompanyProductRequest) { + o.CompanyProductRequest = companyProductRequest +} + +// WriteToRequest writes these params to a [runtime.ClientRequest]. +func (o *PutCompanyProductParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + if err := r.SetTimeout(o.inner.timeout); err != nil { + return err + } + var res []error + if o.CompanyProductRequest != nil { + if err := r.SetBodyParam(o.CompanyProductRequest); err != nil { + return err + } + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/api/research/research_client/company_products/put_company_product_responses.go b/api/research/research_client/company_products/put_company_product_responses.go new file mode 100644 index 0000000..b54e992 --- /dev/null +++ b/api/research/research_client/company_products/put_company_product_responses.go @@ -0,0 +1,603 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// (c) 2012-2020 by Taxnexus, Inc. +// All rights reserved worldwide. +// Proprietary product; unlicensed use is not allowed + +package company_products + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "io" + + "code.tnxs.net/vernonkeenan/lib/api/research/research_models" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" +) + +// PutCompanyProductReader is a Reader for the PutCompanyProduct structure. +type PutCompanyProductReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *PutCompanyProductReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewPutCompanyProductOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 401: + result := NewPutCompanyProductUnauthorized() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 403: + result := NewPutCompanyProductForbidden() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 404: + result := NewPutCompanyProductNotFound() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 409: + result := NewPutCompanyProductConflict() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 422: + result := NewPutCompanyProductUnprocessableEntity() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 500: + result := NewPutCompanyProductInternalServerError() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + default: + return nil, runtime.NewAPIError("[PUT /companyproducts] putCompanyProduct", response, response.Code()) + } +} + +// NewPutCompanyProductOK creates a PutCompanyProductOK with default headers values +func NewPutCompanyProductOK() *PutCompanyProductOK { + return &PutCompanyProductOK{} +} + +// PutCompanyProductOK describes a response with status code 200, with default header values. +// +// Response with CompanyProduct objects +type PutCompanyProductOK struct { + Payload *research_models.CompanyProductResponse +} + +// IsSuccess returns true when this put company product o k response has a 2xx status code +func (o *PutCompanyProductOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this put company product o k response has a 3xx status code +func (o *PutCompanyProductOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this put company product o k response has a 4xx status code +func (o *PutCompanyProductOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this put company product o k response has a 5xx status code +func (o *PutCompanyProductOK) IsServerError() bool { + return false +} + +// IsCode returns true when this put company product o k response a status code equal to that given +func (o *PutCompanyProductOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the put company product o k response +func (o *PutCompanyProductOK) Code() int { + return 200 +} + +func (o *PutCompanyProductOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductOK %s", 200, payload) +} + +func (o *PutCompanyProductOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductOK %s", 200, payload) +} + +func (o *PutCompanyProductOK) GetPayload() *research_models.CompanyProductResponse { + return o.Payload +} + +func (o *PutCompanyProductOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(research_models.CompanyProductResponse) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewPutCompanyProductUnauthorized creates a PutCompanyProductUnauthorized with default headers values +func NewPutCompanyProductUnauthorized() *PutCompanyProductUnauthorized { + return &PutCompanyProductUnauthorized{} +} + +// PutCompanyProductUnauthorized describes a response with status code 401, with default header values. +// +// Access unauthorized, invalid API-KEY was used +type PutCompanyProductUnauthorized struct { + AccessControlAllowOrigin string + + Payload *research_models.Error +} + +// IsSuccess returns true when this put company product unauthorized response has a 2xx status code +func (o *PutCompanyProductUnauthorized) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this put company product unauthorized response has a 3xx status code +func (o *PutCompanyProductUnauthorized) IsRedirect() bool { + return false +} + +// IsClientError returns true when this put company product unauthorized response has a 4xx status code +func (o *PutCompanyProductUnauthorized) IsClientError() bool { + return true +} + +// IsServerError returns true when this put company product unauthorized response has a 5xx status code +func (o *PutCompanyProductUnauthorized) IsServerError() bool { + return false +} + +// IsCode returns true when this put company product unauthorized response a status code equal to that given +func (o *PutCompanyProductUnauthorized) IsCode(code int) bool { + return code == 401 +} + +// Code gets the status code for the put company product unauthorized response +func (o *PutCompanyProductUnauthorized) Code() int { + return 401 +} + +func (o *PutCompanyProductUnauthorized) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductUnauthorized %s", 401, payload) +} + +func (o *PutCompanyProductUnauthorized) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductUnauthorized %s", 401, payload) +} + +func (o *PutCompanyProductUnauthorized) GetPayload() *research_models.Error { + return o.Payload +} + +func (o *PutCompanyProductUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // hydrates response header Access-Control-Allow-Origin + hdrAccessControlAllowOrigin := response.GetHeader("Access-Control-Allow-Origin") + + if hdrAccessControlAllowOrigin != "" { + o.AccessControlAllowOrigin = hdrAccessControlAllowOrigin + } + + o.Payload = new(research_models.Error) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewPutCompanyProductForbidden creates a PutCompanyProductForbidden with default headers values +func NewPutCompanyProductForbidden() *PutCompanyProductForbidden { + return &PutCompanyProductForbidden{} +} + +// PutCompanyProductForbidden describes a response with status code 403, with default header values. +// +// Access forbidden, account lacks access +type PutCompanyProductForbidden struct { + AccessControlAllowOrigin string + + Payload *research_models.Error +} + +// IsSuccess returns true when this put company product forbidden response has a 2xx status code +func (o *PutCompanyProductForbidden) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this put company product forbidden response has a 3xx status code +func (o *PutCompanyProductForbidden) IsRedirect() bool { + return false +} + +// IsClientError returns true when this put company product forbidden response has a 4xx status code +func (o *PutCompanyProductForbidden) IsClientError() bool { + return true +} + +// IsServerError returns true when this put company product forbidden response has a 5xx status code +func (o *PutCompanyProductForbidden) IsServerError() bool { + return false +} + +// IsCode returns true when this put company product forbidden response a status code equal to that given +func (o *PutCompanyProductForbidden) IsCode(code int) bool { + return code == 403 +} + +// Code gets the status code for the put company product forbidden response +func (o *PutCompanyProductForbidden) Code() int { + return 403 +} + +func (o *PutCompanyProductForbidden) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductForbidden %s", 403, payload) +} + +func (o *PutCompanyProductForbidden) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductForbidden %s", 403, payload) +} + +func (o *PutCompanyProductForbidden) GetPayload() *research_models.Error { + return o.Payload +} + +func (o *PutCompanyProductForbidden) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // hydrates response header Access-Control-Allow-Origin + hdrAccessControlAllowOrigin := response.GetHeader("Access-Control-Allow-Origin") + + if hdrAccessControlAllowOrigin != "" { + o.AccessControlAllowOrigin = hdrAccessControlAllowOrigin + } + + o.Payload = new(research_models.Error) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewPutCompanyProductNotFound creates a PutCompanyProductNotFound with default headers values +func NewPutCompanyProductNotFound() *PutCompanyProductNotFound { + return &PutCompanyProductNotFound{} +} + +// PutCompanyProductNotFound describes a response with status code 404, with default header values. +// +// Resource was not found +type PutCompanyProductNotFound struct { + AccessControlAllowOrigin string + + Payload *research_models.Error +} + +// IsSuccess returns true when this put company product not found response has a 2xx status code +func (o *PutCompanyProductNotFound) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this put company product not found response has a 3xx status code +func (o *PutCompanyProductNotFound) IsRedirect() bool { + return false +} + +// IsClientError returns true when this put company product not found response has a 4xx status code +func (o *PutCompanyProductNotFound) IsClientError() bool { + return true +} + +// IsServerError returns true when this put company product not found response has a 5xx status code +func (o *PutCompanyProductNotFound) IsServerError() bool { + return false +} + +// IsCode returns true when this put company product not found response a status code equal to that given +func (o *PutCompanyProductNotFound) IsCode(code int) bool { + return code == 404 +} + +// Code gets the status code for the put company product not found response +func (o *PutCompanyProductNotFound) Code() int { + return 404 +} + +func (o *PutCompanyProductNotFound) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductNotFound %s", 404, payload) +} + +func (o *PutCompanyProductNotFound) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductNotFound %s", 404, payload) +} + +func (o *PutCompanyProductNotFound) GetPayload() *research_models.Error { + return o.Payload +} + +func (o *PutCompanyProductNotFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // hydrates response header Access-Control-Allow-Origin + hdrAccessControlAllowOrigin := response.GetHeader("Access-Control-Allow-Origin") + + if hdrAccessControlAllowOrigin != "" { + o.AccessControlAllowOrigin = hdrAccessControlAllowOrigin + } + + o.Payload = new(research_models.Error) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewPutCompanyProductConflict creates a PutCompanyProductConflict with default headers values +func NewPutCompanyProductConflict() *PutCompanyProductConflict { + return &PutCompanyProductConflict{} +} + +// PutCompanyProductConflict describes a response with status code 409, with default header values. +// +// Conflict +type PutCompanyProductConflict struct { + AccessControlAllowOrigin string + + Payload *research_models.Error +} + +// IsSuccess returns true when this put company product conflict response has a 2xx status code +func (o *PutCompanyProductConflict) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this put company product conflict response has a 3xx status code +func (o *PutCompanyProductConflict) IsRedirect() bool { + return false +} + +// IsClientError returns true when this put company product conflict response has a 4xx status code +func (o *PutCompanyProductConflict) IsClientError() bool { + return true +} + +// IsServerError returns true when this put company product conflict response has a 5xx status code +func (o *PutCompanyProductConflict) IsServerError() bool { + return false +} + +// IsCode returns true when this put company product conflict response a status code equal to that given +func (o *PutCompanyProductConflict) IsCode(code int) bool { + return code == 409 +} + +// Code gets the status code for the put company product conflict response +func (o *PutCompanyProductConflict) Code() int { + return 409 +} + +func (o *PutCompanyProductConflict) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductConflict %s", 409, payload) +} + +func (o *PutCompanyProductConflict) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductConflict %s", 409, payload) +} + +func (o *PutCompanyProductConflict) GetPayload() *research_models.Error { + return o.Payload +} + +func (o *PutCompanyProductConflict) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // hydrates response header Access-Control-Allow-Origin + hdrAccessControlAllowOrigin := response.GetHeader("Access-Control-Allow-Origin") + + if hdrAccessControlAllowOrigin != "" { + o.AccessControlAllowOrigin = hdrAccessControlAllowOrigin + } + + o.Payload = new(research_models.Error) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewPutCompanyProductUnprocessableEntity creates a PutCompanyProductUnprocessableEntity with default headers values +func NewPutCompanyProductUnprocessableEntity() *PutCompanyProductUnprocessableEntity { + return &PutCompanyProductUnprocessableEntity{} +} + +// PutCompanyProductUnprocessableEntity describes a response with status code 422, with default header values. +// +// Unprocessable Entity, likely a bad parameter +type PutCompanyProductUnprocessableEntity struct { + AccessControlAllowOrigin string + + Payload *research_models.Error +} + +// IsSuccess returns true when this put company product unprocessable entity response has a 2xx status code +func (o *PutCompanyProductUnprocessableEntity) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this put company product unprocessable entity response has a 3xx status code +func (o *PutCompanyProductUnprocessableEntity) IsRedirect() bool { + return false +} + +// IsClientError returns true when this put company product unprocessable entity response has a 4xx status code +func (o *PutCompanyProductUnprocessableEntity) IsClientError() bool { + return true +} + +// IsServerError returns true when this put company product unprocessable entity response has a 5xx status code +func (o *PutCompanyProductUnprocessableEntity) IsServerError() bool { + return false +} + +// IsCode returns true when this put company product unprocessable entity response a status code equal to that given +func (o *PutCompanyProductUnprocessableEntity) IsCode(code int) bool { + return code == 422 +} + +// Code gets the status code for the put company product unprocessable entity response +func (o *PutCompanyProductUnprocessableEntity) Code() int { + return 422 +} + +func (o *PutCompanyProductUnprocessableEntity) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductUnprocessableEntity %s", 422, payload) +} + +func (o *PutCompanyProductUnprocessableEntity) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductUnprocessableEntity %s", 422, payload) +} + +func (o *PutCompanyProductUnprocessableEntity) GetPayload() *research_models.Error { + return o.Payload +} + +func (o *PutCompanyProductUnprocessableEntity) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // hydrates response header Access-Control-Allow-Origin + hdrAccessControlAllowOrigin := response.GetHeader("Access-Control-Allow-Origin") + + if hdrAccessControlAllowOrigin != "" { + o.AccessControlAllowOrigin = hdrAccessControlAllowOrigin + } + + o.Payload = new(research_models.Error) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewPutCompanyProductInternalServerError creates a PutCompanyProductInternalServerError with default headers values +func NewPutCompanyProductInternalServerError() *PutCompanyProductInternalServerError { + return &PutCompanyProductInternalServerError{} +} + +// PutCompanyProductInternalServerError describes a response with status code 500, with default header values. +// +// Server Internal Error +type PutCompanyProductInternalServerError struct { + AccessControlAllowOrigin string + + Payload *research_models.Error +} + +// IsSuccess returns true when this put company product internal server error response has a 2xx status code +func (o *PutCompanyProductInternalServerError) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this put company product internal server error response has a 3xx status code +func (o *PutCompanyProductInternalServerError) IsRedirect() bool { + return false +} + +// IsClientError returns true when this put company product internal server error response has a 4xx status code +func (o *PutCompanyProductInternalServerError) IsClientError() bool { + return false +} + +// IsServerError returns true when this put company product internal server error response has a 5xx status code +func (o *PutCompanyProductInternalServerError) IsServerError() bool { + return true +} + +// IsCode returns true when this put company product internal server error response a status code equal to that given +func (o *PutCompanyProductInternalServerError) IsCode(code int) bool { + return code == 500 +} + +// Code gets the status code for the put company product internal server error response +func (o *PutCompanyProductInternalServerError) Code() int { + return 500 +} + +func (o *PutCompanyProductInternalServerError) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductInternalServerError %s", 500, payload) +} + +func (o *PutCompanyProductInternalServerError) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /companyproducts][%d] putCompanyProductInternalServerError %s", 500, payload) +} + +func (o *PutCompanyProductInternalServerError) GetPayload() *research_models.Error { + return o.Payload +} + +func (o *PutCompanyProductInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // hydrates response header Access-Control-Allow-Origin + hdrAccessControlAllowOrigin := response.GetHeader("Access-Control-Allow-Origin") + + if hdrAccessControlAllowOrigin != "" { + o.AccessControlAllowOrigin = hdrAccessControlAllowOrigin + } + + o.Payload = new(research_models.Error) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} diff --git a/api/research/research_client/insight_contract_test.go b/api/research/research_client/insight_contract_test.go index 5d5bbca..8f06e58 100644 --- a/api/research/research_client/insight_contract_test.go +++ b/api/research/research_client/insight_contract_test.go @@ -21,7 +21,7 @@ import ( "github.com/go-openapi/strfmt" ) -const researchProviderSpecSHA256 = "7385c249c9f24d4b1dd4bfd5c1c067c8288bb5b590c72ac9daef5009dc5db0ac" +const researchProviderSpecSHA256 = "1e5b34b5c7b4230cfd507995ab1f837aabc7b4dcdf61b114f37012167afdf224" type insightCaptureTransport struct { operation *openapiruntime.ClientOperation diff --git a/api/research/research_client/portfolio_enrichment_runs/portfolio_enrichment_runs_client.go b/api/research/research_client/portfolio_enrichment_runs/portfolio_enrichment_runs_client.go index ecc9255..dd55531 100644 --- a/api/research/research_client/portfolio_enrichment_runs/portfolio_enrichment_runs_client.go +++ b/api/research/research_client/portfolio_enrichment_runs/portfolio_enrichment_runs_client.go @@ -290,7 +290,7 @@ func (a *Client) PostPortfolioEnrichmentRunContext(ctx context.Context, params * // PostPortfolioReconciliation reconciles one portfolio candidate in discovery mode. // -// Create exactly one authoritative Product or Service, or compare-and-swap update one authoritative Service, from one immutable direct-write candidate in a completed same-tenant portfolio_enrichment.v1 run. This Discovery-only operation requires an active server-side tenant policy for the exact entity action, the matching create or update scope, an active Owner or Manager session, explicit acknowledgement, and an idempotency key. Operator-selected field values remain distinct from immutable candidate provenance. Existing Product changes remain governed proposals. It never deletes, creates relationships, proposes, reviews, applies, or publishes.. +// Create or compare-and-swap update exactly one authoritative Product or Service from one immutable direct-write candidate in a completed same-tenant portfolio_enrichment.v1 run. This Discovery-only operation requires an active server-side tenant policy for the exact entity action, the matching create or update scope, an active Owner or Manager session, explicit acknowledgement, and an idempotency key. Operator-selected field values remain distinct from immutable candidate provenance. Legacy Product governed-update candidates are not silently converted to direct writes and must be rerun. It never deletes, creates relationships, proposes, reviews, applies, or publishes.. // // This method does not support injected context. // However, timeout and opentracing contexts are honored whenever enabled. @@ -309,7 +309,7 @@ func (a *Client) PostPortfolioReconciliation(params *PostPortfolioReconciliation // PostPortfolioReconciliationContext reconciles one portfolio candidate in discovery mode. // -// Create exactly one authoritative Product or Service, or compare-and-swap update one authoritative Service, from one immutable direct-write candidate in a completed same-tenant portfolio_enrichment.v1 run. This Discovery-only operation requires an active server-side tenant policy for the exact entity action, the matching create or update scope, an active Owner or Manager session, explicit acknowledgement, and an idempotency key. Operator-selected field values remain distinct from immutable candidate provenance. Existing Product changes remain governed proposals. It never deletes, creates relationships, proposes, reviews, applies, or publishes.. +// Create or compare-and-swap update exactly one authoritative Product or Service from one immutable direct-write candidate in a completed same-tenant portfolio_enrichment.v1 run. This Discovery-only operation requires an active server-side tenant policy for the exact entity action, the matching create or update scope, an active Owner or Manager session, explicit acknowledgement, and an idempotency key. Operator-selected field values remain distinct from immutable candidate provenance. Legacy Product governed-update candidates are not silently converted to direct writes and must be rerun. It never deletes, creates relationships, proposes, reviews, applies, or publishes.. // // Do not use the deprecated [PostPortfolioReconciliationParams.Context] with this method: it would be ignored. func (a *Client) PostPortfolioReconciliationContext(ctx context.Context, params *PostPortfolioReconciliationParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PostPortfolioReconciliationOK, error) { diff --git a/api/research/research_client/portfolio_reconciliation_contract_test.go b/api/research/research_client/portfolio_reconciliation_contract_test.go index f240bce..6c792c4 100644 --- a/api/research/research_client/portfolio_reconciliation_contract_test.go +++ b/api/research/research_client/portfolio_reconciliation_contract_test.go @@ -182,7 +182,21 @@ func TestGeneratedServiceUpdateReceiptCarriesBeforeAndResultVersions(t *testing. } } -func TestPortfolioReconciliationContractKeepsProductUpdatesGoverned(t *testing.T) { +func TestGeneratedProductUpdateReconciliationRequestShape(t *testing.T) { + request := validProductUpdateReconciliationRequest() + if err := request.Validate(strfmt.Default); err != nil { + t.Fatalf("valid Product update request failed generated validation: %v", err) + } + if got := *request.Action; got != research_models.PortfolioReconciliationRequestActionUpdate { + t.Errorf("Action = %q, want update", got) + } + if got := *request.EntityType; got != + research_models.PortfolioReconciliationRequestEntityTypeCompanyProduct { + t.Errorf("EntityType = %q, want company_product", got) + } +} + +func TestPortfolioReconciliationSupportsProductUpdatesWithoutConvertingLegacyRuns(t *testing.T) { document, err := loads.Spec(filepath.Join( portfolioReconciliationRepoRoot(t), "swagger", "research-vernonkeenan.yaml", )) @@ -195,15 +209,21 @@ func TestPortfolioReconciliationContractKeepsProductUpdatesGoverned(t *testing.T } if !strings.Contains( reconciliation.Post.Description, - "Existing Product changes remain governed proposals", + "Create or compare-and-swap update exactly one authoritative Product or Service", ) { - t.Fatal("reconciliation contract does not preserve Product update rejection") + t.Fatal("reconciliation contract does not expose Product direct update") } - if document.Spec().Paths.Paths["/companyproducts"].Put != nil { - t.Fatal("provider projection exposes a direct CompanyProduct PUT") + if !strings.Contains( + reconciliation.Post.Description, + "Legacy Product governed-update candidates are not silently converted to direct writes", + ) { + t.Fatal("reconciliation contract does not keep legacy Product runs fail-closed") + } + if document.Spec().Paths.Paths["/companyproducts"].Put == nil { + t.Fatal("provider projection is missing direct CompanyProduct PUT") } if _, ok := reconciliation.Post.Responses.StatusCodeResponses[422]; !ok { - t.Fatal("reconciliation contract does not expose invalid Product update rejection") + t.Fatal("reconciliation contract does not expose invalid or legacy Product update rejection") } if !portfolio_enrichment_runs. NewPostPortfolioReconciliationUnprocessableEntity(). @@ -256,6 +276,15 @@ func validServiceUpdateReconciliationRequest() *research_models.PortfolioReconci } } +func validProductUpdateReconciliationRequest() *research_models.PortfolioReconciliationRequest { + request := validServiceUpdateReconciliationRequest() + entityType := research_models.PortfolioReconciliationRequestEntityTypeCompanyProduct + idempotencyKey := "studio-product-update-0001" + request.EntityType = &entityType + request.IdempotencyKey = &idempotencyKey + return request +} + func portfolioReconciliationRepoRoot(t *testing.T) string { t.Helper() _, testFile, _, ok := runtime.Caller(0) diff --git a/docs/RESEARCH_COMPANY_CATALOG_CLIENT.md b/docs/RESEARCH_COMPANY_CATALOG_CLIENT.md index f124e7a..d73f7ab 100644 --- a/docs/RESEARCH_COMPANY_CATALOG_CLIENT.md +++ b/docs/RESEARCH_COMPANY_CATALOG_CLIENT.md @@ -1,9 +1,9 @@ # Research company catalog client -This slice synchronizes the full Research provider specification from -`vernonkeenan/research` draft PR #32 at provider commit -`ca6a2911ce79d053f9aa338e6d9bcfe3357b0c77`. The pinned internal spec SHA-256 -is `c213fe19077453fcdc8f8ce3f6d1671cbbb5074f348c9e4ee0739f64ab0ebba3`; +This slice synchronizes the full Research provider specification from PR #57 +source commit `c3127f32872051b842c74ae7139b1c6ef71e2c53`, merged to Research `main` +as `cc7bf6191dc105969c804e123823186f1ea26cc3`. The pinned internal spec +SHA-256 is `1e5b34b5c7b4230cfd507995ab1f837aabc7b4dcdf61b114f37012167afdf224`; the external copy differs only by Lib's conventional scheme, host, and base path rewrite. @@ -11,7 +11,9 @@ The generated CompanyProduct surface contains: - shared list/detail through `GET /companyproducts`; - safe tenant-attributed create through `POST /companyproducts`; and -- no direct update or delete. +- optimistic-concurrency update of unpublished Products through + `PUT /companyproducts`; and +- no delete. The generated CompanyService surface contains: @@ -26,26 +28,33 @@ Every operation retains one compound security alternative containing both forwards only the `kvSession` cookie. Credential values do not enter the generated models, source, tests, or logs. -Product update remains intentionally proposal-only. The separate generated -proposal client records, submits, and independently reviews Product changes; -review does not itself apply or publish the authoritative Product. This Lib +Published Product update remains intentionally proposal-only. The separate +generated proposal client records, submits, and independently reviews Product +changes; review does not itself apply or publish the authoritative Product. +Discovery reconciliation may update an unpublished Product, but legacy +governed-update candidates remain fail-closed and must be rerun rather than +silently converted. The Product PUT contract preserves immutable Account +binding, publication state, enrichment audit, and media ownership. This Lib slice does not invent application, publication, bulk, media-ownership, or delete operations. ## Dependency and release gate -This Lib PR may be reviewed while Research PR #32 is open, but it must not be -released until: +This Lib PR may be reviewed while the Research Product-update change is open, +but it must not be released until: -1. Research PR #32 is merged, released, and deployed; +1. Research merge `cc7bf6191dc105969c804e123823186f1ea26cc3` is + released and deployed; 2. the KV Studio native service principal has the exact governed scopes `research:company-product:read`, `research:company-product:create`, + `research:company-product:update`, `research:company-service:read`, `research:company-service:create`, and `research:company-service:update`; 3. Research production smoke tests prove the native catalog paths and zero Salesforce reads or writes; and 4. KV Studio is ready to pin the new Lib tag, preserve the Product proposal - workflow, and run its Playwright list/detail/create/Service-edit coverage. + workflow for governed and published changes, and run its Playwright + list/detail/create/Product-edit/Service-edit coverage. There is no database migration in this client slice. Research remains responsible for proving ownership through the existing diff --git a/swagger/external/research-vernonkeenan.yaml b/swagger/external/research-vernonkeenan.yaml index 6698179..0090b7f 100644 --- a/swagger/external/research-vernonkeenan.yaml +++ b/swagger/external/research-vernonkeenan.yaml @@ -585,7 +585,7 @@ paths: tags: - CompanyProducts post: - description: Create one unpublished CompanyProduct beneath an Account owned by the machine tenant; requires research:company-product:create and same-tenant Owner or Manager. Existing Product updates remain proposal-only. + description: Create one unpublished CompanyProduct beneath an Account owned by the machine tenant; requires research:company-product:create and same-tenant Owner or Manager operationId: postCompanyProducts parameters: - $ref: "#/parameters/companyProductRequest" @@ -608,6 +608,32 @@ paths: summary: Create one tenant-owned CompanyProduct tags: - CompanyProducts + put: + description: Replace the editable fields of one shared-catalog CompanyProduct using optimistic concurrency while preserving its immutable Account binding, publication state, enrichment audit, and media ownership. Published Products require the governed proposal workflow. Requires research:company-product:update and a same-tenant Owner or Manager. + operationId: putCompanyProduct + parameters: + - $ref: "#/parameters/companyProductRequest" + responses: + "200": + $ref: "#/responses/CompanyProductResponse" + "401": + $ref: "#/responses/Unauthorized" + "403": + $ref: "#/responses/AccessForbidden" + "404": + $ref: "#/responses/NotFound" + "409": + $ref: "#/responses/Conflict" + "422": + $ref: "#/responses/UnprocessableEntity" + "500": + $ref: "#/responses/ServerError" + security: + - ApiKeyAuth: [] + kvSessionCookie: [] + summary: Update one unpublished shared-catalog CompanyProduct + tags: + - CompanyProducts /publishedcompanyproducts: get: description: Return only published CompanyProducts whose owning CRM Account is also published; requires a tenant-bound native service principal with the exact research:company-product:public-read scope and does not accept a human session @@ -1008,7 +1034,7 @@ paths: - PortfolioEnrichmentRuns /portfolioenrichmentruns/{portfolioEnrichmentRunId}/reconciliations: post: - description: Create exactly one authoritative Product or Service, or compare-and-swap update one authoritative Service, from one immutable direct-write candidate in a completed same-tenant portfolio_enrichment.v1 run. This Discovery-only operation requires an active server-side tenant policy for the exact entity action, the matching create or update scope, an active Owner or Manager session, explicit acknowledgement, and an idempotency key. Operator-selected field values remain distinct from immutable candidate provenance. Existing Product changes remain governed proposals. It never deletes, creates relationships, proposes, reviews, applies, or publishes. + description: Create or compare-and-swap update exactly one authoritative Product or Service from one immutable direct-write candidate in a completed same-tenant portfolio_enrichment.v1 run. This Discovery-only operation requires an active server-side tenant policy for the exact entity action, the matching create or update scope, an active Owner or Manager session, explicit acknowledgement, and an idempotency key. Operator-selected field values remain distinct from immutable candidate provenance. Legacy Product governed-update candidates are not silently converted to direct writes and must be rerun. It never deletes, creates relationships, proposes, reviews, applies, or publishes. operationId: postPortfolioReconciliation parameters: - $ref: "#/parameters/portfolioEnrichmentRunIdPath" diff --git a/swagger/research-vernonkeenan.yaml b/swagger/research-vernonkeenan.yaml index 3b53552..b9b1cb8 100644 --- a/swagger/research-vernonkeenan.yaml +++ b/swagger/research-vernonkeenan.yaml @@ -585,7 +585,7 @@ paths: tags: - CompanyProducts post: - description: Create one unpublished CompanyProduct beneath an Account owned by the machine tenant; requires research:company-product:create and same-tenant Owner or Manager. Existing Product updates remain proposal-only. + description: Create one unpublished CompanyProduct beneath an Account owned by the machine tenant; requires research:company-product:create and same-tenant Owner or Manager operationId: postCompanyProducts parameters: - $ref: "#/parameters/companyProductRequest" @@ -608,6 +608,32 @@ paths: summary: Create one tenant-owned CompanyProduct tags: - CompanyProducts + put: + description: Replace the editable fields of one shared-catalog CompanyProduct using optimistic concurrency while preserving its immutable Account binding, publication state, enrichment audit, and media ownership. Published Products require the governed proposal workflow. Requires research:company-product:update and a same-tenant Owner or Manager. + operationId: putCompanyProduct + parameters: + - $ref: "#/parameters/companyProductRequest" + responses: + "200": + $ref: "#/responses/CompanyProductResponse" + "401": + $ref: "#/responses/Unauthorized" + "403": + $ref: "#/responses/AccessForbidden" + "404": + $ref: "#/responses/NotFound" + "409": + $ref: "#/responses/Conflict" + "422": + $ref: "#/responses/UnprocessableEntity" + "500": + $ref: "#/responses/ServerError" + security: + - ApiKeyAuth: [] + kvSessionCookie: [] + summary: Update one unpublished shared-catalog CompanyProduct + tags: + - CompanyProducts /publishedcompanyproducts: get: description: Return only published CompanyProducts whose owning CRM Account is also published; requires a tenant-bound native service principal with the exact research:company-product:public-read scope and does not accept a human session @@ -1008,7 +1034,7 @@ paths: - PortfolioEnrichmentRuns /portfolioenrichmentruns/{portfolioEnrichmentRunId}/reconciliations: post: - description: Create exactly one authoritative Product or Service, or compare-and-swap update one authoritative Service, from one immutable direct-write candidate in a completed same-tenant portfolio_enrichment.v1 run. This Discovery-only operation requires an active server-side tenant policy for the exact entity action, the matching create or update scope, an active Owner or Manager session, explicit acknowledgement, and an idempotency key. Operator-selected field values remain distinct from immutable candidate provenance. Existing Product changes remain governed proposals. It never deletes, creates relationships, proposes, reviews, applies, or publishes. + description: Create or compare-and-swap update exactly one authoritative Product or Service from one immutable direct-write candidate in a completed same-tenant portfolio_enrichment.v1 run. This Discovery-only operation requires an active server-side tenant policy for the exact entity action, the matching create or update scope, an active Owner or Manager session, explicit acknowledgement, and an idempotency key. Operator-selected field values remain distinct from immutable candidate provenance. Legacy Product governed-update candidates are not silently converted to direct writes and must be rerun. It never deletes, creates relationships, proposes, reviews, applies, or publishes. operationId: postPortfolioReconciliation parameters: - $ref: "#/parameters/portfolioEnrichmentRunIdPath"