Skip to content

SCRUM-218 — Config: publish a release to a channel

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

Behaviour — POST /api/admin/config/channels/{ch}/releases

live_ops for dev/staging; admin for live (literal route, enforced by both gateway_dev and Config's route table). Body:

{"base_release_id": 41, "versions": [{"namespace":"balance.weapons","version":11}],
 "packs": [{"sha256":"…"}], "min_client_version": "1.4.0", "message": "nerf the sword"}

One transaction: 1. SELECT … FROM channel_head … FOR UPDATE — serialises publishes per channel. 2. head ≠ base_release_id → 409 stale_release "channel dev has moved from release X to Y" (you publish exactly the diff you previewed). 3. Resolve versions and packs; unknown → 404 naming it; a server-audience namespace → 400 "namespace … is server-only and cannot be published to a client channel" (rejected, never silently dropped). 4. Sizes come from the blob files on disk (what clients download); a missing blob is a server fault → 500. 5. nextval for the release id (the manifest contains it), build the manifest (doc 02 §4: format, channel, release_id, created_at, min_client_version, config map, packs sorted), schema.Canonical, sha256. 6. Insert the release, move the head, audit release.publish, pg_notify('config_release', ch) — delivered only after commit.

201 {release_id, channel, manifest_sha256, min_client_version, message, created_by, created_at, manifest}.

Config ↔ Patch hash parity: the test asserts manifest_sha256 == sha256(schema.Canonical(manifest::text)), the exact check Patch performs after reading jsonb. Checked during review that Canonical also normalises string escapes (< → <), which jsonb's text output does not preserve.

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 'Publish|Release' ./internal/...
Test Proves
TestPublishHappyPath version + pack in the manifest with blob sizes; sha parity with the jsonb re-derivation; head moved; one audit row
TestPublishStaleReleaseWritesNothing 409; head and release table unchanged
TestPublishIsSerialised, TestPublishConcurrentThroughTheLiveServer two concurrent publishes with one base → exactly one 201, one 409
TestPublishRejectsServerNamespace 400 naming it
TestPublishUnknownTargets unknown namespace / version / pack → 404
TestPublishMissingBlobIsAServerFault 500
TestPublishNotifiesOnlyAfterCommit a LISTENer receives dev after commit, and nothing for a failed (stale) publish
TestReleasesRoutesThroughTheLiveServer, TestLivePublishIsAdminOnly live_ops → 403 on live; validation → 400

Tests publish to dev under a session advisory lock and restore the original head (checked: dev/staging/live back on 1/2/3). They also move dev, as Patch's watcher tests (SCRUM-225) do — don't run the two suites against one database at the same moment.

Results at time of writing

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

How it was built

DeepSeek run scoped (Landlock) to services/config (276 s, ~45k output tokens). Claude review: transaction order read; string-escape parity probed. No changes needed.