SCRUM-215 — Config: create an immutable version from the draft¶
Plan ref: CFG-B5 (docs/11-admin-plane-plan.md). Stacked on SCRUM-214.
What changed¶
POST /api/admin/config/namespaces/{ns}/versions (live_ops), body
{"message": "…", "revision": N} → 201
{namespace, version, schema_version, sha256, size, message, created_by, created_at}.
One transaction (store.CreateVersion):
1. SELECT … FROM config_draft … FOR UPDATE — serialises version creation per namespace.
2. revision ≠ N → 409 stale_revision (you version exactly what you reviewed).
3. Validate the draft against the latest schema (cached validator) → invalid →
400 validation_failed naming up to three pointers.
4. Canonicalise → sha256 → write the canonical bytes to the blob store before the
INSERT (an orphan blob is harmless; a version pointing at a missing blob is not).
5. Same sha as the latest version → 409 no_changes "the draft is identical to version M".
6. Insert config_version (version = max+1), set config_draft.base_version, audit
version.create {version, schema_version, sha256}.
Canonical form (schema.Canonical): keys sorted, no whitespace, floats
canonicalised, integers kept exactly (RFC 8785's Canonicalize would turn
9007199254740993 into …992). Floats are canonicalised because Postgres jsonb can
re-print them (1e2 → 100) and Patch re-derives manifest bytes from jsonb; Patch
(SCRUM-224) implements the identical rule, and the two must stay identical.
How to verify¶
cd services/config
export CONFIG_TEST_DATABASE_URL='postgres://USER:PASS@127.0.0.1:5433/config_test?sslmode=disable'
go vet ./... && go test -race -count=1 ./...
go test -count=1 -v -run 'Version|Canonical' ./internal/...
| Test | Proves |
|---|---|
schema.TestCanonical* |
key order / whitespace independence, nested sort, 2^53+1 preserved, idempotent, bad JSON rejected, float spellings normalised |
store.TestCreateVersion |
v1: sha256 = sha256(canonical), blob file exists with those exact bytes, base_version = 1, one audit row; repeat → ErrNoChanges |
store.TestCreateVersionStaleRevisionWinsOverNoChanges |
stale revision reported first |
store.TestCreateVersionPrepareErrorAborts |
a prepare failure leaves no version and no audit row |
store.TestCreateVersionIsSerialised |
5 concurrent creates → exactly one version, others no_changes |
server.TestVersionsRoutesThroughTheLiveServer |
live_ops 201; viewer 403; empty message 400; invalid draft 400 naming the pointer; repeat 409 no_changes; stale 409; then GET namespaces shows latest_version: 1, has_unpublished_changes: false |
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 (165 s, ~31k output tokens).
Claude review: transaction order and blob-before-insert checked; changed
Canonical to also canonicalise floats (DeepSeek left them raw, which would make
Config's and Patch's hashes disagree for a float after a jsonb round-trip) and added
the test for it.