1 Reference Module Registry
picklemustard edited this page 2026-09-07 15:49:27 -04:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

M2 — Module catalog and registry: structure and architecture

Context

M1 landed a working Linux build: source.Assembler mirror-clones the engine and creates a worktree, runner.DockerRunner compiles it in a container and collects the binary into an artifact store. The one thing that pipeline cannot do is the thing the project exists for — grafting custom engine modules into the tree before SCons runs.

Two stubs hold that gap open:

  • internal/registry/registry.go:73 — Resolve returns errors.New("registry: Resolve not implemented").
  • internal/source/workspace.go:60 — TODO(M2): resolve modules and copy each checkout to its DestPath in the tree. Workspace.Modules is declared and never populated.

Outcome: a user picks Godot 4.4, is offered exactly the modules that work with 4.4, and gets an engine binary with those modules compiled in — with the commit each one resolved to recorded.

The inversion

An earlier draft of this document had each module declare a constraint (godot_compat: ">=4.3 <5.0") which the registry evaluated against the engine version at build time. That asks does this module accept this engine? — a per-module predicate, recomputed on every build, scattered across as many manifest entries as there are modules.

The question users actually ask is the other one: which modules work with Godot 4.4? That is a list. It is computed once, when 4.4 is added, and touched again only when a module ships an update before the next Godot version arrives. The list — not a constraint — is what the picker shows and what the build gates on.

So compatibility is a property of a Godot version, stored, not a property of a module, evaluated. Membership in the list replaces the predicate; there is no constraint grammar anywhere in the system.

Three things follow, and they are what makes M2 larger than "implement Resolve":

  • The list needs a home. Postgres, which also replaces SQLite for everything else. internal/store is a stub and go.mod names no database driver, so there is nothing to migrate away from — only a decision not yet taken.
  • configs/modules.yaml and configs/godot-versions.yaml go away. Modules, module versions, and Godot versions are tables. internal/manifest is deleted whole.
  • Someone has to curate the list. Privileged users administer modules and compatibility over the API, which pulls M6's API tokens forward.

Decisions taken

Question Decision Why
Direction of the compatibility relation A stored list per Godot major/minor, not a constraint per module It is the question users ask, it is computed once instead of on every build, and it can be curated when the automatic answer is wrong
What keys the list (major, minor), not a godot_versions row A fork of 4.4 shares 4.4's list rather than needing its own. See the studioteal/4.4 hole below
Where the catalog lives Postgres It is edited at runtime by admins, so a file in the image is the wrong shape. One database for jobs and catalog both
Postgres driver github.com/jackc/pgx/v5 Pure Go, so the "no cgo" non-choice in docs/architecture.md survives the move off SQLite
Engine version identity major/minor are explicit columns an admin sets Parsing the ref cannot answer for master, a SHA, or studioteal/4.4, and the old fallback was to skip the check silently
Where modules are cloned Host-side, mirrored like the engine Reuses syncMirror's pattern; build containers stay on NetworkMode: "none", which they are today (runner/docker.go hostConfig)
How a module lands in the tree Copy the tree, .git excluded, via git archive Self-contained workspace. A worktree's .git is a file pointing into a mirror the build container cannot see under workspace_mount: volume — the trap that already empties the engine commit stamp there
Admin authentication M6's API tokens, built early and scoped to the admin surface Curation needs an identity now; building a throwaway mechanism means building it twice

The hole this closes

configs/godot-versions.yaml ships studioteal/4.4, a fork branch. spec.ParseEngineVersion cannot parse it, and the constraint design's rule for an unparseable ref was skip the check and log one line. Every build against the StudioTeal fork therefore bypassed compatibility entirely — silently, and by design, for the two versions that matter most to us.

With major and minor as columns the admin fills in, studioteal/4.4 is recorded as 4, 4 and shares 4.4's list. ParseEngineVersion survives as the admin form's suggestion for those two fields, never as a gate.


1. The compatibility rule

A module version records the minimum Godot it supports: min_godot_major and min_godot_minor. Nothing else. No upper bound, no constraint string, no grammar to parse.

For Godot X.Y and module M, the module version offered is:

  1. The row in godot_module_compat for (X, Y, M), if one exists. This is the stored list — generated once, then frozen.
  2. Otherwise the newest version of M whose (min_major, min_minor) <= (X, Y).
  3. Otherwise M is not offered for X.Y.

Rule 2 is "use the current version for every Godot at or above the declared minimum". It is the generator. Rule 1 is what the generator wrote down.

When the list changes

Event Effect
Admin adds Godot X.Y, a major/minor not seen before Generate its full list once, by rule 2 over every module
Admin adds a version of module M Refresh M's row only for the newest (major, minor) in godot_versions
Admin pins a row by hand Row is written with origin = 'admin'. Generation never overwrites it
Admin forces a regenerate Every origin = 'generated' row for that minor is rebuilt

The second line is the whole point of storing the list rather than recomputing it. A module shipping a 4.5-targeting release must not silently change what 4.3 offers — 4.3's list records what was true when 4.3 was current, and stays that way. Only the newest Godot minor is still accumulating module updates, because it is the only one where a module can still "provide an update before Godot".

Regeneration is the escape hatch for when the automatic answer is wrong, and it respects admin pins, so a hand-curated exception survives it.

Two errors, not one

  • ErrUnknownModule — no such module in modules at all. The user typed a name that does not exist.
  • ErrModuleNotOffered — the module exists but has no row for this Godot minor. Different problem, different fix, so it gets its own message naming the minor and the module's minimum:
Godot 4.2 does not offer every selected module:
  telemetry_bridge      needs 4.4 or newer
  deep_engine_tool      needs 4.3 or newer

Collect every violation and return them in one error, not the first. A user fixing a three-module selection should need one round trip.


2. Schema — internal/store/migrations

Embedded in the binary and applied at Open.

-- 0001_catalog.sql

CREATE TABLE godot_versions (
  id         BIGSERIAL PRIMARY KEY,
  name       TEXT NOT NULL UNIQUE,            -- "4.4 stable"
  ref        TEXT NOT NULL,                   -- "4.4-stable", "studioteal/4.4"
  repo       TEXT NOT NULL DEFAULT '',        -- '' -> config.godot_repo
  major      INT  NOT NULL,                   -- explicit, not parsed from ref
  minor      INT  NOT NULL,
  is_default BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (ref, repo)
);

CREATE TABLE modules (
  id          BIGSERIAL PRIMARY KEY,
  name        TEXT NOT NULL UNIQUE,           -- ^[a-z][a-z0-9_]{0,63}$
  endpoint    TEXT NOT NULL,                  -- git URL the build host can clone
  path        TEXT NOT NULL DEFAULT '',       -- '' -> modules/<name>
  description TEXT NOT NULL DEFAULT '',
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE module_versions (
  id              BIGSERIAL PRIMARY KEY,
  module_id       BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
  version         TEXT NOT NULL,              -- "v0.3.1", the admin-facing label
  ref             TEXT NOT NULL,              -- tag, branch, or SHA to clone
  min_godot_major INT NOT NULL,
  min_godot_minor INT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (module_id, version)
);

CREATE TABLE module_version_requires (
  module_version_id  BIGINT NOT NULL REFERENCES module_versions(id) ON DELETE CASCADE,
  requires_module_id BIGINT NOT NULL REFERENCES modules(id),
  PRIMARY KEY (module_version_id, requires_module_id)
);

-- The stored compatibility list. Keyed by major/minor rather than by a
-- godot_versions row, so a fork of 4.4 shares 4.4's list.
CREATE TABLE godot_module_compat (
  godot_major       INT NOT NULL,
  godot_minor       INT NOT NULL,
  module_id         BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
  module_version_id BIGINT NOT NULL REFERENCES module_versions(id) ON DELETE CASCADE,
  origin            TEXT NOT NULL CHECK (origin IN ('generated','admin')),
  generated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (godot_major, godot_minor, module_id)
);

CREATE TABLE api_tokens (
  id           BIGSERIAL PRIMARY KEY,
  name         TEXT NOT NULL,
  token_hash   BYTEA NOT NULL UNIQUE,         -- sha256 of the bearer token
  is_admin     BOOLEAN NOT NULL DEFAULT FALSE,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at TIMESTAMPTZ,
  revoked_at   TIMESTAMPTZ
);

0002_jobs.sql carries the job table M3 needs; its shape is unchanged by any of this, only its dialect.

Two shapes worth defending:

  • requires hangs off a module version, not a module. A dependency set is a property of a release: v0.3 of a module may need deep_engine_tool and v0.4 may not. Attaching it to the module would make that unrepresentable and would silently rewrite history for every Godot minor whose frozen list points at the old version.
  • endpoint rather than repo. It is a git URL like the old manifest.Module.Repo, renamed because godot_versions.repo is also a git URL and the two are read side by side in the resolution path.

3. A shared git invoker — internal/gitcmd

source.Assembler.git (workspace.go:197-215) is exactly what the registry needs, and copying it would put the deploy-key handling in one of two places. Extract it.

package gitcmd

type Runner struct {
    SSHKeyPath     string // deploy key; empty uses the ambient identity
    KnownHostsPath string
    InsecureSkipHostKeyCheck bool
}

func (r Runner) Run(ctx context.Context, args ...string) (string, error)

// MirrorPath is where a bare mirror of repo lives under cacheDir.
//   kind "engine" -> cacheDir/engine/<sha256(repo)[:8]>.git
//   kind "module" -> cacheDir/modules/<sha256(repo)[:8]>.git
func MirrorPath(cacheDir, kind, repo string) string

Run keeps M1's behavior verbatim: GIT_TERMINAL_PROMPT=0, trimmed stdout, stderr folded into the error. When SSHKeyPath is set it also exports

GIT_SSH_COMMAND=ssh -i <key> -o IdentitiesOnly=yes -o BatchMode=yes \
  -o StrictHostKeyChecking=yes -o UserKnownHostsFile=<known_hosts>

BatchMode=yes matters for the same reason GIT_TERMINAL_PROMPT=0 does: a passphrase prompt on a worker with no terminal is indistinguishable from a hang.

MirrorPath("…/cache", "engine", repo) must produce the same path Assembler.mirrorPath produces today (workspace.go:184) — sha256[:8] hex — or the existing data/cache/engine/210929d6b009a21f.git is orphaned and the next build re-clones 2 GB. Assembler gains a Git gitcmd.Runner field and its private git becomes a one-line delegate; existing source tests must pass unchanged.


4. Storage — internal/store

store.Open(ctx, dsn) returns a *Postgres over a pgxpool.Pool, applying the embedded migrations. One type over one pool serves both the job store and the catalog.

func Open(ctx context.Context, dsn string) (*Postgres, error)
func (p *Postgres) Close() error

*Postgres implements job.Store as M3 planned, plus the catalog queries. Those are consumed through interfaces declared at the call sites, so registry and httpapi do not import each other's concerns:

// registry.Catalog, satisfied by *store.Postgres.
type Catalog interface {
    // ModulesFor returns the compatibility list for one Godot major/minor.
    ModulesFor(ctx context.Context, major, minor int) ([]CatalogEntry, error)
    // ModuleFor returns one entry from that list.
    ModuleFor(ctx context.Context, major, minor int, name string) (CatalogEntry, error)
}

Admin writes live on *Postgres directly, since only httpapi's admin handlers call them:

func (p *Postgres) UpsertModule(ctx, m Module) (Module, error)
func (p *Postgres) AddModuleVersion(ctx, moduleName string, v ModuleVersion) (ModuleVersion, error)
func (p *Postgres) UpsertGodotVersion(ctx, v GodotVersion) (GodotVersion, error)

// RefreshCompat rebuilds every origin='generated' row for one minor by rule 2,
// leaving origin='admin' rows alone. Returns how many rows changed.
func (p *Postgres) RefreshCompat(ctx context.Context, major, minor int) (int, error)

// PinCompat writes an origin='admin' row.
func (p *Postgres) PinCompat(ctx context.Context, major, minor int, module, version string) error

AddModuleVersion and UpsertGodotVersion run their generation step in the same transaction as the insert, so the catalog is never briefly inconsistent with its list. RefreshCompat on a minor with no godot_versions row is a no-op rather than an error: it means nobody offers that engine.


5. The registry — internal/registry/registry.go

The registry stops owning a manifest and starts reading a catalog. New(mods, cacheDir) becomes:

type Registry struct {
    Catalog  Catalog
    Git      gitcmd.Runner
    Log      io.Writer     // a cold module clone moves nothing anyone can watch
    CacheDir string
    // per-mirror locks, so M3's build.workers > 1 cannot have two builds
    // fetching one mirror concurrently
}

CatalogEntry replaces manifest.Module as the value the rest of the pipeline passes around:

type CatalogEntry struct {
    Name, Endpoint, Path, Description string
    Version  string   // module_versions.version, for provenance and messages
    Ref      string   // what to clone
    Requires []string
}

func (e CatalogEntry) DestPath() string // Path, else "modules/" + Name

Resolved changes shape: drop Dir, add Mirror. There is no persistent per-module checkout any more — the tree is exported straight into the workspace, so nothing lives at a Dir.

API

// Selection is one module chosen for a build, before anything is fetched.
type Selection struct {
    Entry    CatalogEntry
    Ref      string // spec override, else the catalog's ref for this minor
    Explicit bool   // named in the spec vs. pulled in by requires
}

// Select draws from one Godot minor's compatibility list and expands requires
// transitively. One catalog read, then pure: no network, no clone.
func (r *Registry) Select(ctx context.Context, major, minor int, refs []spec.ModuleRef) ([]Selection, error)

// Resolve selects, then fetches each mirror and records its commit.
func (r *Registry) Resolve(ctx context.Context, major, minor int, refs []spec.ModuleRef) ([]Resolved, error)

// Export writes the module's tree at res.Commit into dest, without .git.
func (r *Registry) Export(ctx context.Context, res Resolved, dest string) error

There is no CheckCompat. The list is the domain a selection is drawn from, so membership is the gate — a module that is not in the list is not selectable, and the error says which of the two reasons applies.

Select

Load the minor's list once, then DFS from the explicit refs with a visiting set for cycles. Rules:

  • A name absent from modules entirely → ErrUnknownModule. A name present but absent from this minor's list → ErrModuleNotOffered, with the module's minimum in the message. Both before any I/O beyond the one catalog read.
  • Both failures accumulate; one error names every offender.
  • An explicit ref wins over the catalog's ref, and over a default reached through requires. If b requires a and the spec says a@feature-branch, a resolves to feature-branch. The override changes which commit is cloned, never whether the module is offered — an unlisted module cannot be smuggled in by naming a ref.
  • requires is read from the selected version of each module, so a diamond resolves against the versions this Godot minor actually offers.
  • Duplicates collapse. spec.Validate already rejects a module listed twice at the top level (validate.go:71); this handles the diamond case.
  • Output order is topological, dependencies first, ties broken by name — so it is deterministic and so grafting order is stable.

Resolve

Select → per selection, under that repo's lock:

  1. mirror := gitcmd.MirrorPath(r.CacheDir, "module", e.Endpoint).
  2. If Ref is a 40-hex SHA and cat-file -e <sha>^{commit} succeeds against an existing mirror, skip the fetch. A pinned commit that is already local needs no network; module versions are expected to pin, so this is the common path, not an optimization for an edge case.
  3. Otherwise clone --mirror on first use, remote update --prune after — same shape as syncMirror (workspace.go:130), including removing a half-written mirror on a failed clone so it is not mistaken for a good one.
  4. rev-parse --verify --end-of-options <ref>^{commit} → Resolved.Commit.

Export

git -C <mirror> archive --format=tar <commit>, stdout piped into stdlib archive/tar, written into dest. Not worktree add (unreachable .git pointer under volume mode), not a recursive copy of a checkout (needs a second cache tree with its own GC), not a symlink (breaks under every container mount).

git archive also honors export-ignore gitattributes for free, and excludes .git by construction rather than by filter.

Guards, because a tar stream is untrusted input even from a trusted repo:

  • MkdirAll(dest, 0o755); every entry's cleaned path must stay under dest (tar-slip).
  • Reject symlink entries whose target escapes dest.
  • Skip pax/global headers; preserve mode &0o777.
  • Refuse a dest that already exists and is non-empty. modules/<name> can collide with a stock Godot module directory, and silently overwriting one is a very confusing way to change what the engine does. Fail with both paths named.

Where the strictness lands

The manifest's Validate was the old gate; the database is the new one. Enforce at write time, in UpsertModule / AddModuleVersion:

  • name matches ^[a-z][a-z0-9_]{0,63}$, the same rule Godot applies. A CHECK constraint as well as a Go check, since the API is not the only writer forever.
  • path relative and cleaned: reject absolute, reject any .. component. It becomes a filesystem destination inside the workspace.
  • No two modules may resolve to the same DestPath().
  • Cycle detection in requires, evaluated over the proposed graph before the transaction commits. a → b → a would hang or blow the stack in Select; keep a defensive visiting set there anyway.

6. Assembly — internal/source/workspace.go

Prepare is reordered so the cheap checks come before the expensive clone:

1. validate CacheDir / WorkDir / jobID / GodotRepo          (unchanged)
2. if len(s.Modules) > 0 && Registry != nil:
       sel := Registry.Select(ctx, major, minor, s.Modules)  -- unknown or unoffered dies here
       res := Registry.Resolve(ctx, major, minor, s.Modules) -- small repos, fail before the 2 GB engine clone
   if len(s.Modules) > 0 && Registry == nil: error
3. syncMirror(engine); rev-parse the engine ref             (unchanged)
4. worktree add --detach                                    (unchanged)
5. for each res: dest := filepath.Join(dir, res.Entry.DestPath())
                 re-check dest is under dir; Registry.Export(ctx, res, dest)
6. ws.Modules = res

Assembler gains GodotMajor/GodotMinor alongside the spec, since the spec carries a ref and the list is keyed by version. The caller resolves the godot_versions row and passes both — the assembler does not query the catalog itself, and never parses the ref.

Step 2 before step 3 is what makes two of M2's acceptance criteria true rather than approximately true.

One bug M2 makes reachable: Prepare returns Workspace{} on error, so a caller has nothing to Cleanup — and after step 4 a failure now leaks a registered worktree. Any error in steps 5–6 must call a.removeWorktree(context.Background(), mirror, dir) on the way out, for the same reason Cleanup does not use the caller's context.

The DestPath re-check in step 5 is defensive; the write-time validation in §5 is the real gate. Both are cheap.


7. Configuration and deployment

internal/config/config.go

ModulesManifest and VersionsManifest (config.go:27-28, :184-185, :247-250) are deleted along with DBPath() (config.go:383). In their place:

type DatabaseConfig struct {
    DSN            string        `yaml:"dsn"`             // GBB_DATABASE_DSN
    MaxConns       int32         `yaml:"max_conns"`
    ConnectTimeout time.Duration `yaml:"connect_timeout"`
}

type ModulesConfig struct {
    SSHKeyPath     string `yaml:"ssh_key_path"`
    KnownHostsPath string `yaml:"known_hosts_path"`
    // InsecureSkipHostKeyCheck is inverted deliberately: the zero value is the
    // safe one, and the rest of this config avoids pointer fields.
    InsecureSkipHostKeyCheck bool `yaml:"insecure_skip_host_key_check"`
}

Env overrides follow the existing pattern in applyEnv: GBB_DATABASE_DSN, GBB_MODULES_SSH_KEY_PATH, GBB_MODULES_KNOWN_HOSTS_PATH, GBB_MODULES_INSECURE_SKIP_HOST_KEY_CHECK. Credentials belong in the DSN from the environment, following the GBB_S3_* precedent — never in the file.

Validate: a DSN is required. If SSHKeyPath is set and the check is not skipped, KnownHostsPath is required — otherwise every private clone fails with an opaque host-key error at the worst possible moment.

deploy/k8s

  • A gbb-postgres Secret holding the DSN, projected as GBB_DATABASE_DSN.
  • A gbb-modules-ssh Secret holding id_ed25519 and known_hosts, mounted read-only at /etc/gbb/ssh in the server container, defaultMode: 0400 — ssh refuses a group- or world-readable key, and the default 0644 will fail.
  • GBB_ADMIN_BOOTSTRAP_TOKEN in the same Secret as the DSN.

Add all three to kustomization.yaml and the ConfigMap keys, with placeholders in secret.yaml matching the gbb-s3 precedent.

Blocker: git and ssh are not in the server image

build/docker/server.Dockerfile:43 runs on gcr.io/distroless/static-debian12:nonroot, which has no git and no ssh binary. Host-side cloning cannot work there as it stands — and this is not only an M2 problem: source.Assembler has always shelled out to git, so the deployed server could never have assembled a workspace either. M1 only ever ran --local on a host that had git, which is why it has not surfaced.

docs/architecture.md lists "Shell out to git" as a deliberate choice; the distroless runtime silently contradicts it. Options:

  1. Recommended — move the runtime stage to debian:bookworm-slim with apt-get install -y --no-install-recommends git openssh-client ca-certificates, keeping the non-root user and the static Go binary. Costs the distroless attack surface, buys a deployment that can actually clone.
  2. distroless/base-debian12 plus copied git/ssh binaries and their shared libraries. Fragile, and breaks on the next base bump.
  3. Run git in a container over the Docker API. Consistent with the runner's model, but a lot of machinery for git fetch.

Take (1) in M2 and amend the architecture note. Leaving it to M3 means M3's first in-cluster build fails for a reason that has nothing to do with M3.

Note that pgx is pure Go, so nothing here is forced by the database — the "no cgo" property still holds, and only git and ssh cost us distroless.


8. API — internal/httpapi

Public

Route Change
GET /api/v1/godot-versions Reads godot_versions instead of a manifest. Each entry carries major and minor
GET /api/v1/modules?godot_ref=<ref> The parameter is required. Returns that version's compatibility list, each entry carrying the module version chosen. A bare GET /api/v1/modules is 400

There is no version-independent module list any more. That is the inversion made visible at the API boundary: asking "what modules are there" without saying which engine is a question the system no longer has an answer to.

POST /api/v1/builds resolves the spec's godot_ref to a version row and rejects a selection that its minor does not offer, so a hand-written spec fails at submission rather than in the queue.

Admin

All behind requireAdmin:

POST   /api/v1/admin/modules
PATCH  /api/v1/admin/modules/{name}
DELETE /api/v1/admin/modules/{name}
POST   /api/v1/admin/modules/{name}/versions
DELETE /api/v1/admin/modules/{name}/versions/{version}

POST   /api/v1/admin/godot-versions
PATCH  /api/v1/admin/godot-versions/{id}
DELETE /api/v1/admin/godot-versions/{id}

GET    /api/v1/admin/compat/{major}.{minor}
PUT    /api/v1/admin/compat/{major}.{minor}/modules/{name}   -- pin, origin='admin'
DELETE /api/v1/admin/compat/{major}.{minor}/modules/{name}
POST   /api/v1/admin/compat/{major}.{minor}/regenerate

POST   /api/v1/admin/tokens
DELETE /api/v1/admin/tokens/{id}

DELETE on a module or module version that a frozen compat row points at is refused with 409 naming the minors that depend on it. Removing it would rewrite what an old Godot version offers, which is exactly what freezing the list exists to prevent. Deleting the compat row first is the deliberate path.

Auth

Authorization: Bearer <token>. Look the token up by sha256 hash, require is_admin, reject revoked_at IS NOT NULL. Constant-time comparison on the hash, and a 401 that does not distinguish "no token" from "bad token".

This is M6's model built early and scoped to the admin surface. M6 extended it rather than replacing it, exactly as this predicted: the same table, the same middleware and the same rejection now guard the build routes too, with per-token quotas on top. This section is history and needs no change.

The first token is seeded from GBB_ADMIN_BOOTSTRAP_TOKEN at migrate time when api_tokens is empty; otherwise a fresh deployment has no way in. Log loudly when it is used, and document rotating it away in docs/admin.md.


9. Wiring and provenance

gbb-server

cmd/gbb-server/main.go:117-128 stops loading two manifests and opens the database instead. The database is fatal at startup, unlike the manifests it replaces: the service cannot answer /api/v1/modules or /api/v1/godot-versions without it, so coming up degraded only moves the failure somewhere less visible.

registry.New takes the store as its Catalog. The same instance is what M3's queue must hand to its Assembler; add the TODO(M3) next to the runner construction at main.go:163-167 so the handoff is not missed.

--local

buildLocal (cmd/gbb/main.go:136) opens the database only when len(s.Modules) > 0, and resolves --godot to a version row on the same condition. A stock-engine local build must keep working on a machine with no Postgres, which is how M1 is exercised today.

Print the resolution table after a successful build, next to the artifact digests (main.go:191-202): name, module version, ref, commit, dest path.

Recording the commits

M2's provenance criterion is "the resolved commit SHA of every module is recorded on the job", and job.Job has no field for it. Add:

type ModuleResolution struct{ Name, Version, Endpoint, Ref, Commit, DestPath string }
// on Job:
ResolvedModules []ModuleResolution `json:"resolved_modules,omitempty"`

A flat type rather than []registry.Resolved, which carries a CatalogEntry plus Mirror — a host path with no business in a persisted job record. Version is here because "which release of the module" is the question a bug report starts with, and a commit SHA answers it only after a lookup.

M2 delivers the type, Workspace.Modules, and the --local printout. The persistence is M3's, since there is no queue yet.

web/

types.ts: Module loses godot_compat and gains version; GodotVersion gains major and minor. api.ts:45 listModules() takes a ref argument.

The picker in routes/+page.svelte must refetch the module list when the version changes, and clear any selection the new version does not offer. The module list is a function of the chosen version now; a UI that fetches it once at load is showing the wrong answer as soon as the user changes anything.


10. Tests

Following the existing fixture pattern in internal/source/workspace_test.go:14 — throwaway local repos, never the network.

File Covers
internal/spec/version_test.go Already written and passing. Ref parsing table: 4.4-stable, 4.4.1-stable, 4.5-beta3, v4.4, 4.4, and the ok == false cases master, 3.x, a 40-hex SHA. It informs the admin form now, so keep it
internal/store/compat_test.go Rule 2 selection: newest version at or above the minimum wins; a module with no qualifying version is absent. Rule-table behaviour: adding a Godot minor generates once; adding a module version touches only the newest minor; an origin='admin' row survives RefreshCompat; regenerate rebuilds generated rows only
internal/store/catalog_test.go Write-time validation: bad name, absolute and .. paths, duplicate DestPath, a → b → a cycle. DELETE refused while a frozen compat row references the row
internal/store/jobs_test.go job.Store round-trip against Postgres
internal/registry/select_test.go Transitive expansion, diamond dedupe, cycle, unknown module vs. unoffered module, explicit-ref precedence, that an override cannot admit an unlisted module, deterministic order. Against a fake Catalog — no database, no repos
internal/registry/resolve_test.go Two local fixture repos, one requiring the other: mirror creation, commit recorded, second resolve reuses the mirror, pinned-SHA path skips the fetch
internal/registry/export_test.go Exported tree matches the commit; no .git present; mode bits preserved; non-empty dest refused; tar-slip entry rejected
internal/httpapi/admin_test.go Admin routes reject a missing, non-admin, and revoked token identically; a valid one succeeds. GET /api/v1/modules without godot_ref is 400
internal/source/workspace_test.go Prepare with a fake catalog grafts into modules/<name>; a failing export leaves no worktree registered

The store tests need a real Postgres. Gate them on GBB_TEST_DSN and skip without it, so go test ./... stays runnable on a laptop, and add a Postgres service container to .github/workflows/ci.yml with that variable set. Git is already present on the runner, so the registry tests need no new CI wiring.

11. Verification

  1. make check — gofmt, go vet, go test ./..., go build.
  2. Migrations from empty. store.Open against a fresh database creates every table and seeds the bootstrap token; a second Open is a no-op.
  3. Catalog round-trip. Through the admin API: add Godot 4.3 and 4.4, add deep_engine_tool with v0.3.1 at minimum 4.3, add telemetry_bridge with v0.2.0 at minimum 4.4 requiring deep_engine_tool. Then GET /api/v1/modules?godot_ref=4.3-stable offers one module and ?godot_ref=4.4-stable offers two.
  4. The freeze holds. Add deep_engine_tool v0.4.0 at minimum 4.4. 4.4's list moves to v0.4.0; 4.3's list still says v0.3.1. This is the behaviour the whole design exists for, so it is the one to check by hand before trusting the test.
  5. The fork shares its minor's list. Add studioteal/4.4 with major: 4, minor: 4. It offers exactly what 4.4-stable offers — the case the constraint design skipped silently.
  6. Fixture end-to-end. Two local git repos containing a minimal real Godot module (config.py, SCsub, register_types.{h,cpp} — commit them under testdata/, which already has a .gitkeep), b requiring a. Register them with file:// endpoints, then:
    ./bin/gbb build --godot 4.4-stable --platform linuxbsd --modules b --local
    
    Both compile in — grep the SCons output for both module names. Warm ccache makes this ~21 s per M1's measurement, so it is a fast loop.
  7. An unoffered module fails immediately. Build 4.2 with telemetry_bridge: it must fail in under a second, naming the module and its minimum, before any clone.
  8. Unknown module. --modules nope fails before the engine mirror is touched — confirm by timing and by an untouched mirror mtime.
  9. Admin surface is closed. Every admin route returns 401 without a token and with a non-admin token.
  10. --only-selected shrinks the binary. Build with and without --only-selected and compare sizes; this needs no new code, only proof.
  11. Private repo. Point one module endpoint at a real private repo on git.picklemustard.dev, set modules.ssh_key_path and known_hosts_path, and confirm the clone succeeds and that removing known_hosts fails loudly rather than hanging.
  12. Server image. After the Dockerfile change, git --version and ssh -V inside the built image.

12. Docs to update on the way out

  • docs/modules.md — rewritten already for this design: no configs/modules.yaml, the admin flow, min_godot_major/min_godot_minor, and what name@ref can and cannot do.
  • docs/admin.md — the operator's guide to the catalog.
  • docs/architecture.md — Registry and Track rows are now real and Postgres-backed; amend the "Shell out to git" non-choice with the runtime-image consequence.
  • docs/deployment.md — Postgres, the two Secrets, the bootstrap token, and the new runtime base image.
  • ROADMAP.md — tick M2, add a "Deviations" section in M1's style.
  • configs/config.example.yaml, deploy/k8s/{secret,configmap,kustomization,statefulset}.yaml, README.md. Delete configs/modules{,.example}.yaml and configs/godot-versions{,.example}.yaml.

Files touched

New: internal/gitcmd/gitcmd.go, internal/store/postgres.go, internal/store/catalog.go, internal/store/compat.go, internal/store/migrations/*.sql, internal/httpapi/admin.go, internal/httpapi/auth.go, docs/admin.md, plus the test files in §10.

Modified: internal/registry/registry.go, internal/source/workspace.go, internal/store/store.go, internal/config/config.go, internal/job/job.go, internal/httpapi/{server,modules,builds}.go, cmd/gbb/main.go, cmd/gbb-server/main.go, build/docker/server.Dockerfile, web/src/lib/*, web/src/routes/+page.svelte, and the docs and manifests in §12.

Deleted: internal/manifest/ (whole package), internal/spec/compat.go (already removed — it never compiled), configs/modules{,.example}.yaml, configs/godot-versions{,.example}.yaml.