Milestone 1 — Config
Owner domain: Staff (admin-auth tokens only)
Prerequisites: 00-common-stack.md tasks COM-1 → COM-10 and WEB-1 → WEB-7
Consumed by: Patch (reads published releases), Dashboard (reads audit log)
1. Purpose and scope
Config is where staff author remote game configuration (balance values, feature flags, event schedules) and upload content packs, validate them, and publish them to a release channel without shipping a new client build.
In scope for M1
- Namespaced JSON configuration with JSON Schema validation
- Drafts with concurrent-edit protection
- Immutable versions, publish, rollback, diff
- Release channels:
dev, staging, live
- Content pack (Godot
.pck) upload with content-addressed storage
- Separation of client-visible and server-only configuration
- Audit log of every change
Out of scope for M1
- Scheduled publishes (publish at a future time)
- Per-player or percentage rollouts / A/B tests
- Approval workflows (two-person publish)
- Delta patching of content packs
Core design rules
- Published data is immutable. A version, once created, is never edited. A release is a pointer to a set of versions. Rollback moves the pointer.
- Everything published is content-addressed. Each config document and content pack is stored under the SHA-256 hash of its bytes. Identical content is stored once, and caches never serve stale data because a changed file gets a new name.
- Audience is explicit. Every namespace is marked
client or server. Server-only config (for example matchmaking tuning next milestone) is never included in a client manifest.
- Config writes; Patch reads. Config never serves game clients.
| Tool |
What it is |
Role here |
PostgreSQL jsonb column type |
Binary JSON stored inside a relational table |
Drafts, versions, schemas, releases, audit log |
| JSON Schema (draft 2020-12) |
Standard language for describing the allowed shape of a JSON document (types, ranges, required fields) |
Rejects bad config before publish. Example: "damage": {"type":"number","minimum":0,"maximum":500} |
santhosh-tekuri/jsonschema |
Go library that checks a document against a JSON Schema |
Server-side validation (authoritative) |
encoding/json/jsontext (Go std) |
Low-level JSON tokens and values |
Canonical serialization before hashing (see §3 note) |
| @cfworker/json-schema |
JavaScript JSON Schema validator (draft 2020-12, no eval) |
Instant client-side feedback in the editor (the server still re-validates). Replaced Ajv in SCRUM-260: Ajv compiles schemas with new Function, which the admin UI's CSP (script-src 'self') blocks |
| SHA-256 |
Cryptographic hash function (standard library in every language) |
Content addressing and integrity checks |
| Blob storage |
Where binary files live. M1: a Docker named volume shared with Patch's nginx. Later: an S3-compatible object store (Garage, SeaweedFS or RustFS; avoid MinIO, whose community edition is no longer maintained) |
Content packs and published JSON documents |
| JSON Forms |
Generates editable forms from a JSON Schema; supports Angular, React and Vue |
Friendly editor for simple namespaces |
| CodeMirror 6 |
Lightweight in-browser code editor |
Raw JSON editing with syntax errors highlighted |
| jsondiffpatch |
JSON-aware diff library with an HTML visual formatter |
Showing what changed between versions |
Godot export (--export-pack) |
Godot CLI flag that exports only a .pck resource pack |
Producing content packs to upload |
3. Data model
-- Schema definitions per namespace
config_namespace (
name text primary key, -- e.g. 'balance.weapons'
audience text not null check (audience in ('client','server')),
description text,
created_at timestamptz not null default now()
)
config_schema (
namespace text references config_namespace(name),
schema_version int not null,
body jsonb not null, -- JSON Schema document
created_by text not null, -- staff sub
created_at timestamptz not null default now(),
primary key (namespace, schema_version)
)
-- One working draft per namespace
config_draft (
namespace text primary key references config_namespace(name),
body jsonb not null,
base_version int, -- version the draft was started from
revision int not null default 1, -- bumps on every save (optimistic locking)
updated_by text not null,
updated_at timestamptz not null default now()
)
-- Immutable snapshots
config_version (
namespace text references config_namespace(name),
version int not null,
schema_version int not null,
body jsonb not null,
sha256 char(64) not null, -- hash of canonical serialized bytes
message text not null,
created_by text not null,
created_at timestamptz not null default now(),
primary key (namespace, version)
)
-- Uploaded binary content
content_pack (
pack_id uuid primary key,
name text not null, -- e.g. 'season1_maps'
sha256 char(64) not null unique,
size_bytes bigint not null,
uploaded_by text not null,
uploaded_at timestamptz not null default now()
)
-- A release = full manifest snapshot for a channel
release (
release_id bigserial primary key,
channel text not null check (channel in ('dev','staging','live')),
manifest jsonb not null, -- see §4
manifest_sha256 char(64) not null,
min_client_version text not null,
message text not null,
created_by text not null,
created_at timestamptz not null default now()
)
-- Current pointer per channel
channel_head (
channel text primary key,
release_id bigint not null references release(release_id),
updated_by text not null,
updated_at timestamptz not null default now()
)
audit_log (
id bigserial primary key,
at timestamptz not null default now(),
actor_id text not null,
actor_name text not null, -- denormalized; staff DB is separate
action text not null, -- 'draft.save','version.create','release.publish','release.rollback','pack.upload',...
target text not null,
details jsonb not null default '{}'
)
Canonical serialization: before hashing, serialize JSON with sorted keys and no insignificant whitespace. Otherwise the same logical document produces different hashes depending on key order. In Go, use jsontext.Value's canonicalization method (RFC 8785 JSON Canonicalization Scheme) rather than writing your own; confirm the exact method name against the go1.27.1 stdlib docs (same API surface as 1.27.0 — the .1 patch touched encoding/json internals, not jsontext's exported API, but verify before relying on it). Postgres jsonb does not preserve key order or formatting, so always canonicalize from the parsed value, never from what the database returns.
Built by Config at publish time, stored in release.manifest, served by Patch.
{
"format": 1,
"channel": "live",
"release_id": 42,
"created_at": "2026-10-01T09:00:00Z",
"min_client_version": "0.3.0",
"config": {
"balance.weapons": { "version": 7, "sha256": "ab12…", "size": 1834 },
"features.flags": { "version": 3, "sha256": "cd34…", "size": 211 }
},
"packs": [
{ "name": "season1_maps", "sha256": "ef56…", "size": 48211934 }
]
}
- Only
client-audience namespaces appear in this manifest. A separate server manifest (same format) is built for gameplay servers next milestone.
- Clients fetch each item from
/patch/v1/blob/{sha256}.
- Published config documents are written to blob storage under their hash at publish time, the same as packs.
5. API contract
All routes are under /api/admin/config and require a staff token.
| Method & path |
Role |
Purpose |
GET /namespaces |
viewer |
List namespaces with audience, latest version, draft status |
POST /namespaces |
admin |
Create namespace |
GET /namespaces/{ns}/schema · PUT |
viewer · admin |
Read / replace schema (creates new schema_version) |
GET /namespaces/{ns}/draft |
viewer |
Read draft with revision |
PUT /namespaces/{ns}/draft |
live_ops |
Save draft; body includes revision; 409 on mismatch |
POST /namespaces/{ns}/draft/validate |
live_ops |
Validate without saving; returns error list with JSON pointers |
POST /namespaces/{ns}/versions |
live_ops |
Snapshot draft into new immutable version (requires message) |
GET /namespaces/{ns}/versions · /{v} |
viewer |
Version history / one version |
GET /namespaces/{ns}/diff?from=&to= |
viewer |
Diff between versions (or to=draft) |
POST /packs |
live_ops |
Upload .pck (streamed); returns hash |
GET /packs |
viewer |
List packs |
POST /channels/{ch}/releases |
live_ops (dev,staging) / admin (live) |
Publish: choose versions + packs + min_client_version + message |
GET /channels/{ch}/releases |
viewer |
Release history |
POST /channels/{ch}/rollback |
admin |
Point channel head at an earlier release_id |
POST /channels/{ch}/promote?from=staging |
admin |
Copy staging's current manifest to live as a new release |
GET /audit |
viewer |
Paginated audit log (consumed by Dashboard) |
5a. Go implementation notes
- Publish transaction: use
pgx.BeginFunc (or pool.BeginTx + deferred rollback). Take the channel lock with SELECT … FROM channel_head WHERE channel = $1 FOR UPDATE. Send NOTIFY with SELECT pg_notify('config_release', $1) inside the transaction; Postgres only delivers it after commit, which is exactly the ordering you need.
- Blob writes before commit: write blobs to storage before inserting the release row. If the transaction then fails, an orphaned blob is harmless; a release pointing at a missing blob is not.
- Pack upload streaming:
io.Copy(io.MultiWriter(tmpFile, sha256Hasher), http.MaxBytesReader(w, r.Body, maxPackBytes)), then tmpFile.Sync(), then os.Rename into the fan-out path. Check the first 4 bytes for GDPC with a small bufio.Reader.Peek(4) before copying.
- Schema compilation cache: compile each JSON Schema once per
(namespace, schema_version) and keep the compiled validator in a map guarded by sync.RWMutex; compiling on every validation request is wasted work.
- Strict JSON input: the Go 1.27 JSON engine rejects duplicate keys and invalid UTF-8 by default. Keep those defaults for draft bodies; a config file with two
"damage" keys should be an error, not a silent last-one-wins.
- Optimistic locking: check
CommandTag.RowsAffected() == 0 from the UPDATE … WHERE revision = $2 to return 409.
6. Task breakdown
Phase A — Service foundation
| ID |
Task |
Acceptance criteria |
Depends on |
| CFG-A1 |
Create service from template; Gateway route /api/admin/config/* with staff issuer; in-service token and role re-check |
Player token and missing token both 401 |
COM-2, COM-4 |
| CFG-A2 |
Write migrations for all §3 tables; seed channel_head rows with an empty release per channel |
Migration job runs in Jenkins before deploy; re-running is a no-op |
COM-6 |
| CFG-A3 |
Blob storage abstraction: put(bytes or stream) → sha256, exists(sha256), open(sha256). M1 implementation writes to the shared volume as blobs/ab/cd/abcd… (two-level fan-out avoids huge directories); write to a temp file then atomic rename |
Uploading the same file twice stores it once; a crash mid-upload leaves no partial blob |
CFG-A1 |
| CFG-A4 |
Audit writer: every mutating handler writes its audit entry in the same database transaction as the change |
Forcing a failure after the change but before commit leaves neither change nor audit row |
CFG-A2 |
Phase B — Backend features
| ID |
Task |
Acceptance criteria |
Depends on |
| CFG-B1 |
Namespace CRUD with audience flag |
Audience cannot be changed after the first version exists |
CFG-A2 |
| CFG-B2 |
Schema upload: validate that the upload is itself a valid JSON Schema; store as new schema_version |
Invalid schema rejected with 400 |
CFG-B1 |
| CFG-B3 |
Draft save with optimistic locking: UPDATE … WHERE namespace = $1 AND revision = $2; zero rows updated → 409 with current draft |
Two concurrent saves: one succeeds, one gets 409 |
CFG-A2 |
| CFG-B4 |
Validation endpoint: returns errors as [{pointer: "/weapons/3/damage", message: "…"}] |
Errors map to exact fields in the UI |
CFG-B2 |
| CFG-B5 |
Create version: re-validate draft against current schema, canonicalize, hash, insert immutable row |
Invalid draft cannot become a version |
CFG-B4 |
| CFG-B6 |
Diff endpoint: structural JSON diff between two versions or version-vs-draft |
Array reorder and nested value changes shown correctly |
CFG-B5 |
| CFG-B7 |
Pack upload: stream request body to blob storage while hashing (never load whole file in memory); enforce max size (e.g. 512 MB); verify file starts with Godot PCK magic bytes GDPC |
400 MB upload keeps service memory flat; non-PCK file rejected |
CFG-A3 |
| CFG-B8 |
Publish: in one transaction — lock channel_head row (SELECT … FOR UPDATE), resolve selected versions, write each config body to blob storage, build client manifest, canonicalize + hash, insert release, move head, write audit, then NOTIFY config_release, '<channel>' |
Two simultaneous publishes to the same channel are serialized; Patch sees the notification |
CFG-B5, CFG-B7, CFG-A4 |
| CFG-B9 |
Release history, rollback, promote-staging-to-live, audit endpoint |
Rollback produces a notification and Patch serves the older manifest |
CFG-B8 |
| CFG-B10 |
Role enforcement per §5 table, including live publish requiring admin |
live_ops publishing to live returns 403 |
CFG-A1 |
| CFG-B11 |
Metrics: config_publish_total{channel}, config_validation_failures_total, config_pack_upload_bytes_total |
Visible on Dashboard |
DSH-B1 |
| CFG-B12 |
(Stretch) Manifest signing: sign manifest_sha256 with a private key mounted only into Config; store signature on the release. Godot's Crypto.verify works with RSA keys, so use RSA and prototype client verification before committing |
Tampered manifest fails client verification |
CFG-B8 |
Phase C — Seed content for the demo
| ID |
Task |
Acceptance criteria |
Depends on |
| CFG-C1 |
Define 2–3 real namespaces with schemas the Godot client will actually read (e.g. features.flags, balance.player, ui.motd for a message of the day) |
Schemas committed in a repo folder and loaded by a seed script |
CFG-B2 |
| CFG-C2 |
Godot content pack export: a small .pck containing a texture or scene, exported with --export-pack in a Jenkins job or by hand |
Pack loads in the client when mounted manually |
— |
Phase D — Frontend module (inside the admin WebUI)
| ID |
Task |
Acceptance criteria |
Depends on |
| CFG-D1 |
Namespace list: audience badge, latest version, "draft has unpublished changes" indicator |
Matches API state |
CFG-B1, WEB-5 |
| CFG-D2 |
Schema-driven form editor using JSON Forms for namespaces whose schemas are simple; time-box custom renderers to 2 days |
Editing a number respects min/max from schema |
CFG-B4 |
| CFG-D3 |
Raw JSON editor (CodeMirror 6) with local JSON Schema validation (@cfworker/json-schema) on each keystroke (debounced) and server validation on save; error markers at JSON pointers |
Invalid value highlighted before saving |
CFG-B4 |
| CFG-D4 |
Save handling: send revision; on 409, show a dialog with the other editor's changes and let the user reload or copy their own edits out |
No silent overwrite of another staff member's work |
CFG-B3 |
| CFG-D5 |
Diff view (jsondiffpatch visual formatter) used in version history and before publishing |
Shows added/removed/changed values clearly |
CFG-B6 |
| CFG-D6 |
Create-version dialog requiring a message |
Empty message blocked |
CFG-B5 |
| CFG-D7 |
Pack upload page with progress bar, displays resulting hash and size |
100 MB upload shows progress and completes |
CFG-B7 |
| CFG-D8 |
Release composer per channel: pick version per namespace (defaults to latest), pick packs, set min_client_version, preview manifest diff against current head, confirm with message; live requires typing the channel name to confirm |
Publish to live requires deliberate confirmation |
CFG-B8 |
| CFG-D9 |
Release history with rollback and promote buttons (admin only) |
Rollback updates the view immediately |
CFG-B9 |
Phase E — Testing and delivery
| ID |
Task |
Acceptance criteria |
Depends on |
| CFG-E1 |
Unit tests: canonical serialization is stable across key orders; manifest builder excludes server namespaces |
Pass in Jenkins |
CFG-B8 |
| CFG-E2 |
Integration tests (real Postgres via Testcontainers or compose): concurrent draft saves, concurrent publishes, rollback, audit row in same transaction |
Pass in Jenkins |
Phase B |
| CFG-E3 |
Hurl contract tests covering every role boundary in §5 |
Pass in Jenkins |
CFG-B10 |
| CFG-E4 |
Jenkins pipeline (COM-8) with the migration step before deploy |
Push to main deploys |
COM-8 |
| CFG-E5 |
Backup: nightly pg_dump of the config database plus a copy of the blob volume to a second location |
Restore rehearsed once on a scratch VM |
CFG-A2 |
7. Definition of done
- Staff can create a namespace, edit it with validation, version it, publish it to
dev, promote to live, and roll back.
- A server-only namespace never appears in any client manifest.
- A content pack uploaded through the UI appears in a published manifest with the correct hash.
- Every change is visible in the Dashboard audit trail with the staff member's name.
- Patch serves the new manifest within 5 seconds of publish.
8. Risks
| Risk |
Mitigation |
| A bad value reaches live and breaks clients |
Schema validation on server, staging channel, diff preview, one-click rollback |
| Two staff members overwrite each other's edits |
Optimistic locking (CFG-B3, CFG-D4) |
| Secrets or server-only tuning leak to clients |
Audience flag enforced in manifest builder with a unit test (CFG-E1) |
| Blob volume and database drift apart |
Blobs written before the release row commits; blobs are never deleted in M1 (garbage collection later) |
| Schema-form generator consumes the schedule |
Time-box CFG-D2; raw JSON editor covers everything |