Skip to content

SCRUM-212 — Config: schema get and replace

Plan ref: CFG-B2 (docs/11-admin-plane-plan.md). Stacked on SCRUM-211.

What changed

Change Where
New package internal/schema: Compile (JSON Schema draft 2020-12, format asserted), Cache of compiled validators per (namespace, schema_version) behind an RWMutex, and Issues (flattens a schema error to {pointer, message} leaves) internal/schema/
GET /namespaces/{ns}/schema (viewer) → latest {namespace, schema_version, schema, created_by, created_at}; unknown ns → 404 internal/api/schemas.go, internal/store/schemas.go
PUT /namespaces/{ns}/schema (admin): body is the schema itself; ≤1 MiB (413 body_too_large); invalid → 400 validation_failed naming the location; success → 200 new version + schema.replace audit row same
Dependency github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 (and golang.org/x/text made direct) go.mod

Security: the library's default loader is FileLoader, so a schema containing "$ref":"file:///etc/passwd" would make Config read its own filesystem. Compile installs a loader that refuses every URL; only the embedded meta-schemas resolve. Duplicate keys and invalid UTF-8 are rejected first (encoding/json/jsontext).

Concurrency: replace locks the namespace row FOR UPDATE before computing max(schema_version)+1, so concurrent replaces get consecutive versions instead of a PK collision.

Error messages: the library's own Error() starts with a generic headline that locates nothing. The handler reports up to three leaves, e.g. schema is not a valid JSON Schema: at /type: got number, want array. Issues is the same helper SCRUM-214's draft/validate will return as its errors array.

How to verify

cd services/config
go vet ./... && go test -race -count=1 ./...
export CONFIG_TEST_DATABASE_URL='postgres://USER:PASS@127.0.0.1:5433/config_test?sslmode=disable'
go test -race -count=1 -v -run 'Schema|Issues|Compile' ./internal/...
Test Proves
schema.TestCompile* valid compiles; non-object, duplicate keys, bad schemas rejected; file:// and http:// $ref rejected without file contents in the error; format: email enforced; Cache returns the same pointer
schema.TestIssuesLocateEachLeafFailure /type, /properties/damage/type, /minimum, and RFC 6901 escaping (a/b~c → a~1b~0c)
store.TestLatestSchemaAndReplace new namespace is v1 {}; replace → v2 + one audit row
store.TestReplaceSchemaIsSerialised 10 concurrent replaces → versions 2..11, no error
server.TestSchemaRoutesThroughTheLiveServer viewer GET 200, live_ops PUT 403, admin PUT 200 v2, {"type":12} → 400 naming at /type:, file:// ref → 400 without root:, unknown ns → 404

By hand (admin token in $T):

curl -s -X PUT -H "Authorization: Bearer $T" -d '{"type":"object","properties":{"damage":{"type":"integr"}}}' \
  localhost:8080/api/admin/config/namespaces/ui.motd/schema
# 400 … "at /properties/damage/type: value must be one of 'array', 'boolean', …"

Results at time of writing

  • go vet, go test -race ./... against Postgres 16: pass (7 packages).

How it was built

DeepSeek run scoped (Landlock) to services/config (192 s, ~29k output tokens). Claude: added the dependency beforehand (the sandbox cannot write the module cache), reverted an unrelated go.work.sum churn from that step, and added schema.Issues + its tests after finding the 400 message named no location.