Skip to content

SCRUM-213 — Config: draft read and save with optimistic locking

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

What changed

Route Role Behaviour
GET /namespaces/{ns}/draft viewer {namespace, document, revision, base_version, updated_by, updated_at}; unknown ns → 404
PUT /namespaces/{ns}/draft live_ops body {"document": {…}, "revision": N} → 200 {revision: N+1, updated_at}; stale N → 409 stale_revision "the draft has moved on since revision N"; unknown ns → 404

Save is one statement inside a transaction: UPDATE config_draft SET body=$3, revision=revision+1, … WHERE namespace=$1 AND revision=$2. Under Postgres's default READ COMMITTED isolation a concurrent loser blocks on the row lock, re-checks revision=$2 after the winner commits, and matches nothing → 409. The draft.save audit row is written in the same transaction, so a loser leaves no audit row.

Body rules (400): ≤1 MiB (413 body_too_large); duplicate keys / invalid UTF-8 / unknown field / trailing data → invalid_body; document must be an object and revision ≥ 1 → validation_failed. The draft is not schema-validated on save (it is work in progress); versioning (SCRUM-215) enforces validity.

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 -race -count=1 -v -run Draft ./internal/...
Test Proves
api draft body tests each body rule
store.TestDraftAndSaveDraft fresh draft is rev 1 {}; save at 1 → 2 + one audit row; save at 1 again → ErrStaleRevision, no audit row
store.TestSaveDraftIsOptimistic 10 concurrent saves at revision 1 → exactly 1 win, 9 stale, final revision 2, exactly 1 audit row
server.TestDraftRoutesThroughTheLiveServer viewer GET 200, viewer PUT 403, live_ops PUT 200, repeat → 409 stale_revision

By hand (live_ops token $T):

curl -s -X PUT -H "Authorization: Bearer $T" -d '{"document":{"motd":"hi"},"revision":1}' \
  localhost:8080/api/admin/config/namespaces/ui.motd/draft      # 200 {"revision":2,…}
# same command again → 409 stale_revision

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 (87 s, ~16k output tokens). Claude review: store SQL and isolation reasoning checked, concurrency assertions read, suite re-run with -race against Postgres. No changes needed.