test(provision): contract SSOT + producer-pin for the core→CP provision request #3058

Merged
devops-engineer merged 2 commits from feat/provision-request-contract into main 2026-06-19 04:20:37 +00:00
2 changed files with 178 additions and 0 deletions
@@ -0,0 +1,43 @@
{
"_doc": [
"SSOT for the core -> control-plane provision request wire shape.",
"Endpoint: POST {cp}/cp/workspaces/provision (core sender = cpProvisionRequest in cp_provisioner.go;",
"CP receiver = wsProvisionRequest in molecule-controlplane internal/handlers/workspace_provision.go).",
"",
"WHY THIS EXISTS: core and the CP define the request as two DUPLICATED structs in two repos with no",
"shared compile unit, so a field added on the sender silently does nothing if the receiver lacks it.",
"That is exactly how `template_assets` was dropped for weeks (config.yaml/prompts never materialized on",
"SaaS via that channel) -- see memory project_saas_restart_re_stub_config / RFC #2843.",
"",
"TWO GUARDS keep the channels honest:",
" 1. PRODUCER PIN (molecule-core provisioner/provision_request_contract_test.go): cpProvisionRequest's",
" JSON tags MUST equal `fields` keys exactly. Adding/removing a wire field forces a deliberate edit.",
" 2. CONSUMER COMPLETENESS (molecule-controlplane handlers/provision_request_contract_test.go):",
" wsProvisionRequest MUST have a json tag for every field marked cp_consumes:true. A field with",
" cp_consumes:false is a DEAD wire field (sent but ignored) -- it must carry a `note` justifying why,",
" so dead channels are explicit, not silent.",
"",
" This file is the SSOT; the copy in the other repo MUST stay byte-identical (a CI diff-check is a follow-up)."
],
"endpoint": "POST /cp/workspaces/provision",
"fields": {
"org_id": {"type": "string", "cp_consumes": true},
"workspace_id": {"type": "string", "cp_consumes": true},
"runtime": {"type": "string", "cp_consumes": true},
"tier": {"type": "int", "cp_consumes": true},
"instance_type": {"type": "string", "cp_consumes": true},
"disk_gb": {"type": "int", "cp_consumes": true},
"provider": {"type": "string", "cp_consumes": true},
"data_persistence": {"type": "string", "cp_consumes": true},
"kind": {"type": "string", "cp_consumes": true},
"display": {"type": "object", "cp_consumes": true},
"platform_url": {"type": "string", "cp_consumes": true},
"env": {"type": "map", "cp_consumes": true},
"config_files": {"type": "map", "cp_consumes": true},
"template_assets": {
"type": "map",
"cp_consumes": false,
"note": "DEAD WIRE FIELD as of 2026-06-19: core fetches+sends template assets here (RFC #2843 #24) but the deployed CP wsProvisionRequest has no `template_assets` field, so it is silently dropped. config.yaml/prompts reach SaaS boxes via config_files instead. Follow-up: either remove this send from core OR add the consumer to the CP. Tracked alongside project_saas_restart_re_stub_config."
}
}
}
@@ -0,0 +1,135 @@
package provisioner
// Producer-side guard for the core -> control-plane provision request contract.
//
// cpProvisionRequest is the wire shape core POSTs to {cp}/cp/workspaces/provision.
// The control-plane decodes it into its OWN duplicated struct (wsProvisionRequest)
// in a SEPARATE repo, so a field added here silently does nothing if the CP lacks
// it (this is exactly how `template_assets` was dropped — RFC #2843 /
// project_saas_restart_re_stub_config).
//
// This test pins cpProvisionRequest's JSON tags to provision_request.contract.json
// (the SSOT). Adding or removing a wire field FAILS here until the contract is
// updated deliberately — at which point the reviewer must decide cp_consumes
// true/false, and the CP-side companion test (molecule-controlplane) enforces that
// every cp_consumes:true field is actually present on wsProvisionRequest.
import (
"encoding/json"
"os"
"reflect"
"sort"
"strings"
"testing"
)
const provisionRequestContractPath = "provision_request.contract.json"
type provisionRequestContract struct {
Fields map[string]struct {
Type string `json:"type"`
CPConsumes bool `json:"cp_consumes"`
Note string `json:"note"`
} `json:"fields"`
}
// jsonWireTags returns the set of JSON wire field names for a struct type,
// stripping ",omitempty"/options and skipping "-" and untagged fields.
func jsonWireTags(t reflect.Type) map[string]bool {
tags := map[string]bool{}
for i := 0; i < t.NumField(); i++ {
raw := t.Field(i).Tag.Get("json")
if raw == "" {
continue
}
name := strings.Split(raw, ",")[0]
if name == "" || name == "-" {
continue
}
tags[name] = true
}
return tags
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func TestProvisionRequestContract_ProducerMatchesSSOT(t *testing.T) {
data, err := os.ReadFile(provisionRequestContractPath)
if err != nil {
t.Fatalf("read contract %s: %v", provisionRequestContractPath, err)
}
var contract provisionRequestContract
if err := json.Unmarshal(data, &contract); err != nil {
t.Fatalf("parse contract %s: %v", provisionRequestContractPath, err)
}
if len(contract.Fields) == 0 {
t.Fatalf("contract %s has no fields — refusing to pass a vacuous contract", provisionRequestContractPath)
}
structTags := jsonWireTags(reflect.TypeOf(cpProvisionRequest{}))
contractFields := map[string]bool{}
for name := range contract.Fields {
contractFields[name] = true
}
var addedInStruct, missingFromStruct []string
for name := range structTags {
if !contractFields[name] {
addedInStruct = append(addedInStruct, name)
}
}
for name := range contractFields {
if !structTags[name] {
missingFromStruct = append(missingFromStruct, name)
}
}
sort.Strings(addedInStruct)
sort.Strings(missingFromStruct)
if len(addedInStruct) > 0 {
t.Errorf("cpProvisionRequest sends wire field(s) NOT in the contract: %v\n"+
" -> Add each to %s with an explicit cp_consumes (true if the CP's wsProvisionRequest already\n"+
" consumes it; false ONLY with a note explaining the dead channel). A field core sends that\n"+
" the CP does not consume is silently dropped — this is the template_assets failure class.",
addedInStruct, provisionRequestContractPath)
}
if len(missingFromStruct) > 0 {
t.Errorf("contract declares wire field(s) NOT present on cpProvisionRequest: %v\n"+
" -> If core intentionally stopped sending these, remove them from %s (and confirm the CP no\n"+
" longer requires them). Otherwise restore the struct field.",
missingFromStruct, provisionRequestContractPath)
}
if t.Failed() {
t.Logf("cpProvisionRequest json tags: %v", sortedKeys(structTags))
t.Logf("contract fields: %v", sortedKeys(contractFields))
}
}
// TestProvisionRequestContract_DeadFieldsAreJustified ensures any cp_consumes:false
// field carries a note — so a dead wire field (sent-but-ignored) is an explicit,
// reviewed decision, never silent.
func TestProvisionRequestContract_DeadFieldsAreJustified(t *testing.T) {
data, err := os.ReadFile(provisionRequestContractPath)
if err != nil {
t.Fatalf("read contract: %v", err)
}
var contract provisionRequestContract
if err := json.Unmarshal(data, &contract); err != nil {
t.Fatalf("parse contract: %v", err)
}
for name, f := range contract.Fields {
if !f.CPConsumes && strings.TrimSpace(f.Note) == "" {
t.Errorf("field %q is cp_consumes:false but has no `note` — a dead wire field MUST justify why "+
"(remove the send, or document why it is intentionally unconsumed)", name)
}
}
}