Compare commits

...

1 Commits

Author SHA1 Message Date
core-lead 826a9dc9c3 chore(workspace-server): drop dead runtime_image_pins mig 047 + reader (task #335, RFC internal#617)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
cascade-list-drift-gate / check (pull_request) Failing after 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
Check migration collisions / Migration version collision check (pull_request) Successful in 13s
CI / Detect changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 26s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
CI / Platform (Go) (pull_request) Successful in 4m45s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 44s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 4s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 10s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m15s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m16s
CI / Canvas (Next.js) (pull_request) Successful in 5m54s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 4s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m20s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 6m59s
gate-check-v3 / gate-check (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
qa-review / approved (pull_request) Failing after 5s
CI / all-required (pull_request) Successful in 6m39s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 3s
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m39s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 16s
Harness Replays / Harness Replays (pull_request) Successful in 10s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m50s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m21s
E2E Chat / E2E Chat (pull_request) Failing after 6m20s
audit-force-merge / audit (pull_request) Waiting to run
Empirical finding (a6e3ff018, 2026-05-20): molecule-core's runtime_image_pins
table (mig 047) has never had a writer in any repo. The reader at
handlers/runtime_image_pin.go has been hitting sql.ErrNoRows on every
workspace provision since mig 047 landed, silently falling through to the
:latest path. CP's parallel table (CP mig 027) is the de-facto and only SSOT
— it has the writer (POST /cp/admin/runtime-image/promote), the reader, the
hard-gate (RFC internal#541 Step 2), seeded post-suspension digests (CP
mig 028), and the admin endpoints.

This PR ratifies that reality.

What

- Add 20260520120000_drop_runtime_image_pins.up.sql / .down.sql to drop the
  unused table. Care zone PRESERVED: workspaces.runtime_image_digest column
  + its partial index untouched (earmarked for a future stale-workspace
  panel per RFC internal#617 §3).
- Delete handlers/runtime_image_pin.go (the dead reader) +
  handlers/runtime_image_pin_test.go.
- handlers/workspace_provision.go line 296: replace resolveRuntimeImage(ctx,
  payload.Runtime) with Image: "" (the dead reader was already returning
  "" on every call). Rewire the surviving db.DB.QueryRow on this call site
  to QueryRowContext so the provision-timeout ctx stays load-bearing.
- Doc comments in provisioner/provisioner.go + provisioner/registry.go
  updated to point at CP as the SSOT instead of the dead local table.
- Add db/migration_20260520_drop_runtime_image_pins_test.go — static-file
  pin that the up.sql DROPs runtime_image_pins, does NOT touch the
  care-zone column / index, and that the dead reader files cannot be
  re-added without failing the test.

Why

Two parallel-named tables with structurally incompatible schemas, only one
ever written — that is exactly the kind of internal drift
feedback_no_single_source_of_truth was written about for non-vendor
surfaces. The deletion is reversible (down.sql recreates the table) and
the only behavior change is "ctx is now propagated into the workspace_dir
DB lookup", which is a small correctness nudge.

Verification

- [x] go vet ./internal/handlers/... ./internal/db/... ./internal/provisioner/...  — clean
- [x] go build ./...  — clean
- [x] go test ./internal/handlers/ ./internal/db/ ./internal/provisioner/  — all pass (17s + 0.3s + 0.5s)
- [x] New regression tests assert the care-zone column is not touched + the
      dead reader cannot return
- [x] Empirical grep cross-check: no writer for runtime_image_pins in
      molecule-core; no reader for workspaces.runtime_image_digest anywhere
      (both confirmed in RFC internal#617 §1 + §3)

Tier

tier:medium + area:schema — schema/migration change. Reversible by re-running
the down-migration. Two-eye review reviewers: core-be (read path / Go) +
core-qa (migration correctness). Cascade plan to ~6 live tenant DBs per
RFC internal#617 §7 + feedback_image_promote_is_not_user_live (verify on
at least 2 tenants post-deploy).

Memory consulted: feedback_no_single_source_of_truth,
feedback_image_promote_is_not_user_live,
feedback_verify_actual_endstate_not_ack_follow_sop.

RFC: molecule-ai/internal#617

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:24:54 -07:00
8 changed files with 222 additions and 221 deletions
@@ -0,0 +1,142 @@
package db
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Regression pin for RFC internal#617 / task #335.
//
// The drop-runtime_image_pins migration MUST honor the care zone documented
// in the RFC: drop the `runtime_image_pins` table but PRESERVE the column
// `workspaces.runtime_image_digest` and its partial index
// `idx_workspaces_runtime_image_digest`.
//
// This is a static-file lint, not a DB-execution test. Running the actual
// migration is out of scope for unit tests (the migration test infra in
// postgres_schema_migrations_test.go already proves the apply mechanism
// works for any forward file). What we pin here is the *content shape* of
// the new migration:
//
// - up.sql DROPs runtime_image_pins (the dead table)
// - up.sql does NOT touch runtime_image_digest (the care-zone column)
// - up.sql does NOT touch idx_workspaces_runtime_image_digest (care-zone index)
// - down.sql recreates runtime_image_pins (idempotent rollback)
//
// If a future cleanup PR wants to also drop the column, it should be a
// separate migration with its own RFC — this test catches accidental
// scope creep at PR time, before it ships to tenant DBs.
func TestMigration20260520_DropsRuntimeImagePins_PreservesDigestColumn(t *testing.T) {
// Locate the migrations dir relative to this test file's package dir.
// /workspace-server/internal/db/ → ../../migrations/
const migDir = "../../migrations"
const upFile = "20260520120000_drop_runtime_image_pins.up.sql"
const downFile = "20260520120000_drop_runtime_image_pins.down.sql"
upPath := filepath.Join(migDir, upFile)
downPath := filepath.Join(migDir, downFile)
upBytes, err := os.ReadFile(upPath)
if err != nil {
t.Fatalf("read %s: %v", upPath, err)
}
downBytes, err := os.ReadFile(downPath)
if err != nil {
t.Fatalf("read %s: %v", downPath, err)
}
// Strip single-line SQL comments (`-- ...`) before assertion so the
// rationale prose in the migration headers can mention the care-zone
// column by name without tripping the DDL-touch guard. The guard is
// specifically about DDL statements that act on the column.
upDDL := stripSQLLineComments(strings.ToLower(string(upBytes)))
downDDL := stripSQLLineComments(strings.ToLower(string(downBytes)))
// up.sql MUST drop the dead table.
if !strings.Contains(upDDL, "drop table") || !strings.Contains(upDDL, "runtime_image_pins") {
t.Errorf("up.sql must DROP TABLE runtime_image_pins; got DDL:\n%s\n(full file:\n%s)", upDDL, upBytes)
}
// CARE ZONE: up.sql DDL MUST NOT touch the workspaces.runtime_image_digest
// column or its index. A DDL statement that references either name is a
// scope-creep defect — file a separate RFC.
if strings.Contains(upDDL, "runtime_image_digest") {
t.Errorf("up.sql DDL references runtime_image_digest — care-zone column must NOT be touched by this migration. See RFC internal#617 §3. DDL:\n%s\n(full file:\n%s)", upDDL, upBytes)
}
if strings.Contains(upDDL, "idx_workspaces_runtime_image_digest") {
t.Errorf("up.sql DDL references idx_workspaces_runtime_image_digest — care-zone index must NOT be touched by this migration. See RFC internal#617 §3. DDL:\n%s\n(full file:\n%s)", upDDL, upBytes)
}
// down.sql MUST recreate the table (rollback path).
if !strings.Contains(downDDL, "create table") || !strings.Contains(downDDL, "runtime_image_pins") {
t.Errorf("down.sql must CREATE TABLE runtime_image_pins (rollback path); got DDL:\n%s\n(full file:\n%s)", downDDL, downBytes)
}
// down.sql DDL also must not touch the care-zone column (symmetry —
// we never added the column in the up so we cannot drop or recreate it
// in the down either).
if strings.Contains(downDDL, "runtime_image_digest") {
t.Errorf("down.sql DDL references runtime_image_digest — should be a no-op for the care-zone column. DDL:\n%s\n(full file:\n%s)", downDDL, downBytes)
}
}
// stripSQLLineComments removes `-- ...` line comments from a SQL string,
// leaving only DDL statements + whitespace. Used by the migration-content
// guards so descriptive prose in the migration header doesn't false-flag.
//
// This is intentionally minimal — does NOT handle `/* */` block comments
// (the migration files don't use them) or string-literal embedded `--`
// (DDL doesn't use that shape). Good enough for static-content lint.
func stripSQLLineComments(s string) string {
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
for _, ln := range lines {
// Trim everything after the first `--`. Conservative — if a future
// migration genuinely needs `--` inside a string literal, that
// would require parsing.
if idx := strings.Index(ln, "--"); idx >= 0 {
ln = ln[:idx]
}
out = append(out, ln)
}
return strings.Join(out, "\n")
}
// TestMigration20260520_PairExists is a belt-and-braces guard that the
// up + down files both exist and aren't empty. RunMigrations only consumes
// the up but a missing down breaks the dev-side rollback workflow silently.
func TestMigration20260520_PairExists(t *testing.T) {
const migDir = "../../migrations"
for _, f := range []string{
"20260520120000_drop_runtime_image_pins.up.sql",
"20260520120000_drop_runtime_image_pins.down.sql",
} {
p := filepath.Join(migDir, f)
info, err := os.Stat(p)
if err != nil {
t.Errorf("expected migration file %s to exist: %v", p, err)
continue
}
if info.Size() < 50 {
t.Errorf("migration file %s is suspiciously small (%d bytes) — header comment missing?", p, info.Size())
}
}
}
// TestMigration20260520_DeadReaderIsGone pins the deletion of the dead
// runtime_image_pin.go reader. If anyone reintroduces it (e.g., a cherry-
// pick from a stale branch), this catches it in unit tests before it hits
// review. The reader is provably dead under CP-as-SSOT — re-adding it
// reopens the divergence the RFC closed.
func TestMigration20260520_DeadReaderIsGone(t *testing.T) {
const readerPath = "../handlers/runtime_image_pin.go"
if _, err := os.Stat(readerPath); err == nil {
t.Errorf("dead reader %s reappeared — RFC internal#617 retired it. If you really need a per-tenant pin path, file a follow-up RFC; do not just re-add the reader.", readerPath)
}
const testPath = "../handlers/runtime_image_pin_test.go"
if _, err := os.Stat(testPath); err == nil {
t.Errorf("dead reader test %s reappeared — should have been removed alongside the implementation.", testPath)
}
}
@@ -1,67 +0,0 @@
package handlers
import (
"context"
"database/sql"
"errors"
"log"
"os"
"strings"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
)
// resolveRuntimeImage returns the digest-pinned image ref for a runtime when
// an operator has promoted one via the runtime_image_pins table (#2272 layer 1),
// otherwise "" so the caller falls back to the legacy `:latest` lookup in
// provisioner.RuntimeImages.
//
// Policy: availability over pinning. Any DB hiccup (sql.ErrNoRows is the
// steady-state when nothing is pinned, but transient network blips, table
// missing post-rollback, etc.) returns "" and the provision continues on
// the moving tag — better one workspace on a slightly-newer image than a
// provision-blocked tenant.
//
// WORKSPACE_IMAGE_LOCAL_OVERRIDE=1 short-circuits the lookup entirely so a
// developer rebuilding template images locally gets their fresh build via
// `:latest` even when a remote digest is pinned for the same runtime.
func resolveRuntimeImage(ctx context.Context, runtime string) string {
if runtime == "" {
return ""
}
base, ok := provisioner.RuntimeImages[runtime]
if !ok {
// Unknown runtime — let provisioner.Start fall through to its own
// DefaultImage. Querying the pin table for a runtime that doesn't
// exist would only produce noise and a guaranteed ErrNoRows.
return ""
}
if os.Getenv("WORKSPACE_IMAGE_LOCAL_OVERRIDE") != "" {
return ""
}
if db.DB == nil {
return ""
}
var digest string
err := db.DB.QueryRowContext(ctx,
`SELECT digest FROM runtime_image_pins WHERE template_name = $1`, runtime,
).Scan(&digest)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("resolveRuntimeImage: pin lookup for %q failed (%v) — falling back to :latest", runtime, err)
}
return ""
}
// Strip the moving tag suffix (`:latest`, `:staging`) before appending
// the immutable digest. Docker treats `name:tag@sha256:...` as valid
// but the tag is ignored; dropping it keeps logs and admin diffs honest
// about what's actually being pulled.
pinned := base
if idx := strings.LastIndex(pinned, ":"); idx > strings.LastIndex(pinned, "/") {
pinned = pinned[:idx]
}
return pinned + "@" + digest
}
@@ -1,138 +0,0 @@
package handlers
import (
"context"
"database/sql"
"strings"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
sqlmock "github.com/DATA-DOG/go-sqlmock"
)
// TestResolveRuntimeImage_NoPin: lookup returns sql.ErrNoRows (the steady-
// state when an operator hasn't pinned this runtime). resolveRuntimeImage
// returns "" so the caller falls back to RuntimeImages[runtime] (legacy
// :latest behavior). This is the expected hot path until digest pinning
// is opted into per runtime.
func TestResolveRuntimeImage_NoPin(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer mockDB.Close()
prev := db.DB
db.DB = mockDB
defer func() { db.DB = prev }()
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins WHERE template_name = \$1`).
WithArgs("claude-code").
WillReturnError(sql.ErrNoRows)
got := resolveRuntimeImage(context.Background(), "claude-code")
if got != "" {
t.Errorf("expected empty (no pin = fallback), got %q", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestResolveRuntimeImage_DBError: an unexpected DB failure (transient
// network blip, table missing post-rollback, etc.) must NOT block the
// provision — log + fall through to the legacy :latest path. This is
// the availability-over-pinning policy spelled out in resolveRuntimeImage's
// doc comment.
func TestResolveRuntimeImage_DBError(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer mockDB.Close()
prev := db.DB
db.DB = mockDB
defer func() { db.DB = prev }()
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins WHERE template_name = \$1`).
WithArgs("claude-code").
WillReturnError(sqlmock.ErrCancelled)
got := resolveRuntimeImage(context.Background(), "claude-code")
if got != "" {
t.Errorf("expected empty on DB error (fallback), got %q", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestResolveRuntimeImage_WithPin returns image@sha256:<digest> when row exists.
func TestResolveRuntimeImage_WithPin(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer mockDB.Close()
prev := db.DB
db.DB = mockDB
defer func() { db.DB = prev }()
digest := "sha256:3d6761a97ed07d7d33cfc19a8fbab81175d9d9179618d493dbc00c5f7ef076a3"
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins WHERE template_name = \$1`).
WithArgs("claude-code").
WillReturnRows(sqlmock.NewRows([]string{"digest"}).AddRow(digest))
got := resolveRuntimeImage(context.Background(), "claude-code")
if !strings.HasSuffix(got, "@"+digest) {
t.Errorf("expected suffix @%s, got %q", digest, got)
}
if !strings.HasPrefix(got, "ghcr.io/molecule-ai/workspace-template-claude-code") {
t.Errorf("expected GHCR prefix preserved, got %q", got)
}
if strings.Contains(got, ":latest") {
t.Errorf("expected :latest stripped, got %q", got)
}
}
// TestResolveRuntimeImage_EmptyRuntime short-circuits to "" without DB.
func TestResolveRuntimeImage_EmptyRuntime(t *testing.T) {
got := resolveRuntimeImage(context.Background(), "")
if got != "" {
t.Errorf("expected empty for empty runtime, got %q", got)
}
}
// TestResolveRuntimeImage_UnknownRuntime returns "" without DB lookup.
func TestResolveRuntimeImage_UnknownRuntime(t *testing.T) {
got := resolveRuntimeImage(context.Background(), "no-such-runtime")
if got != "" {
t.Errorf("expected empty for unknown runtime, got %q", got)
}
}
// TestResolveRuntimeImage_LocalOverride: when WORKSPACE_IMAGE_LOCAL_OVERRIDE
// is set, the pin lookup is short-circuited even with a row present —
// devs rebuild images locally and want the floating tag to resolve to
// their fresh build, not a remote-pinned digest.
func TestResolveRuntimeImage_LocalOverride(t *testing.T) {
t.Setenv("WORKSPACE_IMAGE_LOCAL_OVERRIDE", "1")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer mockDB.Close()
prev := db.DB
db.DB = mockDB
defer func() { db.DB = prev }()
// No expectation set — if resolveRuntimeImage queries the DB despite
// the override, sqlmock fails the test via ExpectationsWereMet.
got := resolveRuntimeImage(context.Background(), "claude-code")
if got != "" {
t.Errorf("expected empty under WORKSPACE_IMAGE_LOCAL_OVERRIDE=1, got %q", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB queried despite override: %v", err)
}
}
@@ -261,7 +261,14 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
workspaceAccess := payload.WorkspaceAccess
if (workspacePath == "" || workspaceAccess == "") && db.DB != nil {
var dbDir, dbAccess string
if err := db.DB.QueryRow(
// QueryRowContext (not QueryRow) so the provision-timeout ctx
// propagates here too. Previously ctx flowed in only to be passed
// to resolveRuntimeImage; that dead reader was removed by
// RFC internal#617 / task #335. Wiring ctx into the surviving DB
// query keeps the parameter load-bearing and is a small correctness
// nudge (a 10s ProvisionTimeout now actually bounds this lookup).
if err := db.DB.QueryRowContext(
ctx,
`SELECT COALESCE(workspace_dir, ''), COALESCE(workspace_access, 'none') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&dbDir, &dbAccess); err == nil {
@@ -293,7 +300,15 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
PlatformURL: h.platformURL,
AwarenessURL: os.Getenv("AWARENESS_URL"),
AwarenessNamespace: awarenessNamespace,
Image: resolveRuntimeImage(ctx, payload.Runtime),
// Image left empty — molecule-core's runtime_image_pins table (mig
// 047, dead reader removed by RFC internal#617 / task #335) was an
// aspirational SSOT that never received a writer. CP's
// runtime_image_pins (CP migration 027) is the single SSOT; the
// pin is applied at CP's provisioner layer before this code path
// runs. Empty here means selectImage() falls back to the legacy
// RuntimeImages[Runtime] :latest lookup, which is what the dead
// reader's sql.ErrNoRows path was producing already.
Image: "",
}
}
@@ -105,19 +105,27 @@ type WorkspaceConfig struct {
WorkspaceAccess string // #65: "none" (default), "read_only", or "read_write"
ResetClaudeSession bool // #12: if true, discard the claude-sessions volume before start (fresh session dir)
// Image, when non-empty, overrides the runtime→image lookup. The handler
// layer sets this to the digest-pinned form (`<base>@sha256:<digest>`)
// when an operator has promoted a specific runtime build via the
// runtime_image_pins table (#2272 layer 1). Empty = legacy behavior,
// fall back to RuntimeImages[Runtime] which resolves to the moving
// `:latest` tag.
// Image, when non-empty, overrides the runtime→image lookup. CP
// (molecule-controlplane) is the single SSOT for runtime image digest
// pins via its migrations/027_runtime_image_pins table — the pin is
// applied at CP's provisioner layer before the workspace-server even
// runs, so under the current architecture this field is always empty
// on the workspace-server side. Empty = fall back to RuntimeImages
// [Runtime] which resolves to the moving `:latest` tag.
//
// Historical note: molecule-core's own runtime_image_pins table
// (workspace-server/migrations 047) was the original aspirational
// design (#2272 layer 1) but never received a writer; RFC internal#617 /
// task #335 retired the dead reader + table in favor of CP-as-SSOT.
Image string
}
// selectImage resolves the final Docker image ref for a workspace. The handler
// layer is the source of truth — if it set cfg.Image (the digest-pinned form
// from runtime_image_pins, #2272), honor that. Otherwise fall back to the
// runtime→tag lookup in RuntimeImages (legacy `:latest` behavior).
// supplied by CP, the SSOT for runtime image pins; molecule-core's own
// runtime_image_pins reader retired by RFC internal#617 / task #335), honor
// that. Otherwise fall back to the runtime→tag lookup in RuntimeImages
// (legacy `:latest` behavior).
//
// Fail-closed contract (RFC internal#483 / security review 4269 /
// feedback_platform_must_hardgate_base_contract): if the workspace NAMES a
@@ -378,7 +386,7 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
// + `docker build`s it locally. Replace the placeholder image ref with
// the SHA-pinned tag of the freshly-built image before ContainerCreate.
//
// Pinned overrides (cfg.Image set, e.g. via runtime_image_pins for
// Pinned overrides (cfg.Image set, e.g. via CP's runtime_image_pins for
// production thin-AMI launches) bypass this path — they pin a digest
// the operator chose explicitly.
if cfg.Image == "" && cfg.Runtime != "" {
@@ -89,11 +89,13 @@ func RegistryHost() string {
// RuntimeImage returns the canonical image reference for the given runtime,
// using the current RegistryPrefix() and the moving `:latest` tag.
//
// For SHA-pinned references (production thin-AMI launches), the
// runtime_image_pins lookup in handlers/runtime_image_pin.go strips the
// `:latest` suffix and appends an immutable `@sha256:<digest>` from the DB.
// That code path naturally inherits any RegistryPrefix() change because it
// reads from RuntimeImages[runtime] and only re-formats the tag suffix.
// SHA-pinned references for production thin-AMI launches are applied by CP
// (molecule-controlplane) at its provisioner layer using CP's
// migrations/027_runtime_image_pins table, which is the single SSOT for
// runtime image pins. The local digest-pin reader that previously lived at
// handlers/runtime_image_pin.go was retired by RFC internal#617 / task #335
// (it never had a writer; the table was always empty so the reader hit
// sql.ErrNoRows and fell through to :latest on every provision).
//
// Returns the empty string for unknown runtimes; callers should fall through
// to DefaultImage in that case (matching legacy behavior).
@@ -0,0 +1,14 @@
-- Reverse of 20260520120000_drop_runtime_image_pins.up.sql.
--
-- Recreates the runtime_image_pins table verbatim from migration 047 so a
-- down-cycle leaves the schema bit-identical to the state before the drop.
-- The `workspaces.runtime_image_digest` column is unaffected by both the
-- up and the down (we never touched it on the up side).
CREATE TABLE IF NOT EXISTS runtime_image_pins (
template_name TEXT PRIMARY KEY,
digest TEXT NOT NULL CHECK (digest ~ '^sha256:[a-f0-9]{64}$'),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT,
notes TEXT
);
@@ -0,0 +1,25 @@
-- Task #335 / RFC internal#617 — drop molecule-core's dead runtime_image_pins
-- table. CP (molecule-controlplane migrations/027_runtime_image_pins.up.sql)
-- is the single SSOT for runtime image digest pins.
--
-- Empirical state at the time of this migration (a6e3ff018 finding,
-- 2026-05-20): no code in any molecule-ai repo INSERTs or UPDATEs this
-- table. The reader in workspace-server/internal/handlers/runtime_image_pin.go
-- has been hitting sql.ErrNoRows on every single workspace provision since
-- mig 047 landed (PR #2276) — silently falling through to the legacy
-- :latest path. Functionally indistinguishable from removing the call entirely.
--
-- CP's parallel-named table (CP mig 027) has the writer, reader, hard-gate
-- (RFC internal#541 Step 2), seeded post-suspension digests (CP mig 028),
-- and admin endpoints. CP is now the de-facto SSOT and this migration just
-- ratifies that reality by removing the unused copy.
--
-- CARE ZONE: migration 047 ALSO added `workspaces.runtime_image_digest TEXT`
-- and `idx_workspaces_runtime_image_digest`. Per RFC internal#617 §3, that
-- column is earmarked for the canvas admin's stale-workspaces panel
-- (workspaces still on an old digest after a CP-side promotion). It has no
-- current consumer but the cost of keeping it is one nullable column + a
-- partial index, and dropping it is a separate decision out of scope here.
-- DO NOT touch the column or its index in this migration.
DROP TABLE IF EXISTS runtime_image_pins;