PR #3459
Sections
Review

feat(backend): enforce required store parity

main ← feature/prevent-database-dia-lhk 125 files +8421 −1873 PR #3459 ↗

This PR makes every built-in SQL store a required catalog entry with shared dialect rendering, AST SQL guard, and fixed conformance adapters, and it blocks startup and traffic when required persistence is unavailable.

Why this change

Built-in stores drifted between SQLite and PostgreSQL. Bootstrap treated provider errors as nonfatal, so a database failure looked like an unavailable provider and readiness stayed green. CI discovered Postgres coverage by grepping test markers, so a store without a marker could ship without Postgres tests.

What it does

Architecture, end to end

Catalog drives bootstrap, health, conformance, and CI. Dialect and sqlguard keep SQL portable. Middleware and readiness enforce the contract at runtime.

flowchart LR
  Catalog[requiredstores Catalog] --> Bootstrap[backendapp provideRepositories]
  Catalog --> Conformance[storeconformance Adapters]
  Catalog --> Health[requiredstores Health]
  Dialect[dialect RenderSchema + time helpers] --> Stores[All store SQL]
  Guard[sqlguard Analyzer] --> Stores
  Stores --> Bootstrap
  Bootstrap --> Tracker[Tracker Record]
  Tracker --> Health
  Health --> Ready[GET /ready]
  Health --> Middleware[requiredPersistenceMiddleware]
  Middleware --> API[API / MCP / WS]
  Conformance --> CI[backend-tests.yml]
  Guard --> CI

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func Catalog() []Descriptor
Click for details →

The catalog is the single source of truth for every built-in SQL owner, its tables, dependencies, and capabilities.

Descriptor
type Descriptor struct {
  ID             string
  OwnerPackage   string
  RequiredTables []string
  DependsOn      []string
  Capabilities   []Capability
}
Catalog entries
var catalog = []Descriptor{
  {ID: "schema-meta", OwnerPackage: "internal/persistence", RequiredTables: []string{"kandev_meta"}},
  {ID: "task", OwnerPackage: "internal/task/repository/sqlite", RequiredTables: []string{"workspaces", "tasks"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}},
  {ID: "workflow", OwnerPackage: "internal/workflow/repository", RequiredTables: []string{"workflow_templates", "workflow_steps"}, DependsOn: []string{"task"}},
  {ID: "office", OwnerPackage: "internal/office/repository/sqlite", RequiredTables: []string{"office_projects", "runs"}, DependsOn: []string{"task", "agent-settings"}},
  {ID: "github", OwnerPackage: "internal/github", RequiredTables: []string{"github_pr_watches"}, DependsOn: []string{"task", "user"}},
}
Validation
func ValidateCatalog(descriptors []Descriptor) error {
  if len(descriptors) == 0 {
    return fmt.Errorf("catalog is empty")
  }
  // checks duplicate IDs, unknown dependencies, out-of-order deps, cycles
  if err := validateCycles(descriptors); err != nil {
    return err
  }
  return nil
}
func RenderSchema(driver, schema string) (string, error)
Click for details →

Stores render one portable schema text for both engines instead of branching SQL in each repository.

RenderSchema
func RenderSchema(driver, schema string) (string, error) {
  if driver != SQLite3 && driver != PGX {
    return "", fmt.Errorf("unsupported database driver %q", driver)
  }
  timestamp := TimestampType(driver)
  boolean := "INTEGER"
  identity := "INTEGER"
  if IsPostgres(driver) {
    boolean = "BOOLEAN"
    identity = "BIGSERIAL"
  }
  rendered := strings.NewReplacer(
    "{{timestamp}}", timestamp,
    "{{boolean}}", boolean,
    "{{identity}}", identity,
    "{{current_time}}", "CURRENT_TIMESTAMP",
  ).Replace(schema)
  if strings.Contains(rendered, "{{") {
    return "", fmt.Errorf("schema contains unknown or unexpanded token")
  }
  return rendered, nil
}
Time helpers
func NullableTimestamp(driver, placeholder string) string {
  if IsPostgres(driver) {
    return fmt.Sprintf("(%s)::timestamptz", placeholder)
  }
  return placeholder
}

func DurationMs(driver, end, start string) string {
  if IsPostgres(driver) {
    return fmt.Sprintf("EXTRACT(EPOCH FROM (%s - %s)) * 1000", end, start)
  }
  return fmt.Sprintf("(julianday(%s) - julianday(%s)) * 86400000", end, start)
}
Rebind boundary
func Bind(rebinder Rebinder, query string, args ...any) (string, []any, error) {
  expanded, expandedArgs, err := sqlx.In(query, args...)
  if err != nil {
    return "", nil, err
  }
  return rebinder.Rebind(expanded), expandedArgs, nil
}
func AnalyzeSource(filename string, source []byte, exemptions []Exemption) ([]Finding, error)
Click for details →

The guard parses Go AST and rejects SQLite-only catalog, conflict, boolean, and date syntax before it ships.

Rules
const (
  RuleSQLiteCatalog      Rule = "sqlite-catalog"
  RuleConflictSyntax     Rule = "conflict-syntax"
  RuleRawPlaceholder     Rule = "raw-placeholder"
  RuleBooleanInteger     Rule = "boolean-integer"
  RuleSQLiteDateFunction Rule = "sqlite-date-function"
  RuleDateTimeType       Rule = "datetime-type"
)
Literal check
func analyzeSQLLiteral(filename, value, symbol string, position token.Position, exemptions []Exemption, seen map[string]struct{}, result *analysisResult, forceSQL ...bool) {
  checks := []struct{ rule Rule; match bool; message string }{
    {RuleSQLiteCatalog, sqlText && sqliteCatalogPattern.MatchString(value), "SQLite catalog or PRAGMA syntax must stay behind a dialect boundary"},
    {RuleConflictSyntax, sqlText && conflictPattern.MatchString(value), "use portable conflict syntax"},
    {RuleBooleanInteger, sqlText && booleanIntegerPattern.MatchString(value), "use a boolean value or a dialect-rendered boolean default"},
    {RuleSQLiteDateFunction, sqlText && sqliteDatePattern.MatchString(value), "use an internal/db/dialect date helper"},
  }
}
Exemptions
func ValidateExemptions(exemptions []Exemption) error {
  for _, exemption := range exemptions {
    if exemption.File == "" || filepath.IsAbs(exemption.File) || strings.ContainsAny(exemption.File, "*?") {
      return fmt.Errorf("SQL guard exemption file must be an exact relative path: %q", exemption.File)
    }
    if _, ok := knownRules[exemption.Rule]; !ok {
      return fmt.Errorf("SQL guard exemption has unknown rule %q", exemption.Rule)
    }
  }
  return nil
}
func Adapters() []testconformance.Adapter
Click for details →

One fixed adapter per catalog entry proves schema creation and real API behavior on both engines.

Adapters
func Adapters() []testconformance.Adapter {
  descriptors := requiredstores.Catalog()
  adapters := make([]testconformance.Adapter, 0, len(descriptors))
  for _, descriptor := range descriptors {
    adapters = append(adapters, adapterFor(descriptor))
  }
  return adapters
}

func adapterFor(descriptor requiredstores.Descriptor) testconformance.Adapter {
  return testconformance.Adapter{
    ID: descriptor.ID,
    Engines: map[testconformance.EngineName]testconformance.EngineAdapter{
      testconformance.EngineSQLite:   {Fresh: initializer, Replay: initializer},
      testconformance.EnginePostgres: {Fresh: initializer, Replay: initializer},
    },
    Scenarios: behaviorScenarios(descriptor),
  }
}
Capability probe
func exerciseCapability(s testconformance.ScenarioContext, action apiAction, capability requiredstores.Capability, suffix string) error {
  switch capability {
  case requiredstores.CapabilityBoolean:
    for _, want := range []bool{false, true} {
      record, err = action.setBoolean(s, key, record, want)
      got, ok := action.readBoolean(record)
      if got != want {
        return fmt.Errorf("boolean = %t, want %t", got, want)
      }
    }
  case requiredstores.CapabilityTimestamp:
    created, updatedAt, ok := recordTimestamps(before)
    if created.Location() != time.UTC || updatedAt.Location() != time.UTC {
      return fmt.Errorf("timestamps are not UTC")
    }
  }
  return nil
}
Bootstrap and health enforcementapps/backend/internal/backendapp/storage.go ↗
func provideRepositories(ctx context.Context, cfg *Config, log *Logger, version string) (*Pool, *Repositories, []func() error, error)
Click for details →

Bootstrap records every store through the tracker and fails fast when a required store is missing or unhealthy.

Tracker wiring
func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Logger, version string) (*db.Pool, *Repositories, []func() error, error) {
  tracker, err := requiredstores.NewCatalogTracker()
  if err != nil {
    return nil, nil, nil, err
  }
  pool, cleanup, err := persistence.Provide(cfg, log, version)
  if err != nil {
    return nil, nil, nil, err
  }
  if err := recordRequiredStore(tracker, "schema-meta", nil); err != nil {
    return nil, nil, nil, err
  }
  taskRepoImpl, cleanup, err := repository.Provide(writer, reader, log)
  if err := recordRequiredStore(tracker, "task", err); err != nil {
    return nil, nil, nil, fmt.Errorf("task store: %w", err)
  }
}
Health probe
func (h *Health) Check(ctx context.Context) error {
  probeErr := h.ping(ctx)
  for index, descriptor := range h.tracker.catalog {
    results[index] = probeErr
    if probeErr == nil {
      results[index] = h.probeTables(ctx, descriptor)
    }
  }
  for index, result := range results {
    if err := h.tracker.RecordProbe(h.tracker.catalog[index].ID, result); err != nil {
      failures = append(failures, err)
    }
  }
  if len(failures) == 0 {
    return nil
  }
  return errors.Join(failures...)
}
Middleware
func requiredPersistenceMiddleware(health *requiredstores.Health) gin.HandlerFunc {
  return func(c *gin.Context) {
    if health == nil || health.Healthy() || persistencePathExcluded(c.Request.URL.Path) {
      c.Next()
      return
    }
    c.Abort()
    c.JSON(http.StatusServiceUnavailable, gin.H{
      "error": "required persistence is unavailable",
      "code": "persistence_unavailable",
      "store_ids": health.UnavailableStoreIDs(),
    })
  }
}
Run fixed PostgreSQL 16 persistence gates
Click for details →

CI now runs explicit, reviewable package lists and pinned Postgres versions instead of grep discovery.

Static checks
- name: Check required-store SQL portability
  run: go run ./cmd/sqlguard ./internal
- name: Run catalog and SQLite conformance gates
  run: |
    go test -v -race ./internal/persistence/storeconformance \
      -run '^(TestStoreCatalogCompleteness|TestStoreConformance|TestUpgradeFixtureManifest|TestPreviousStableUpgrade)$'
Postgres 16
- name: Run fixed PostgreSQL 16 persistence gates
  run: |
    POSTGRES_PACKAGES=(
      ./internal/agent/settings/store
      ./internal/analytics/repository/sqlite
      ./internal/backendapp
      ./internal/db
      ./internal/persistence/storeconformance
      ./internal/task/repository/sqlite
    )
    go test -v -race "${POSTGRES_PACKAGES[@]}"
Postgres 18
postgres-18:
  name: Backend Postgres 18
  services:
    postgres:
      image: ghcr.io/kdlbs/kandev-ci:postgres-18@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
  steps:
    - name: Run PostgreSQL 18 boot and upgrade gates
      run: |
        go test -v -race ./internal/persistence/storeconformance ./internal/backendapp \
          -run '^(TestPreviousStableUpgrade|TestPostgresBootInitializesRepositories)$'
Read the changes as a list

Required-store catalog

apps/backend/internal/persistence/requiredstores/catalog.go

The catalog is the single source of truth for every built-in SQL owner, its tables, dependencies, and capabilities.

Descriptor
type Descriptor struct {
  ID             string
  OwnerPackage   string
  RequiredTables []string
  DependsOn      []string
  Capabilities   []Capability
}
Catalog entries
var catalog = []Descriptor{
  {ID: "schema-meta", OwnerPackage: "internal/persistence", RequiredTables: []string{"kandev_meta"}},
  {ID: "task", OwnerPackage: "internal/task/repository/sqlite", RequiredTables: []string{"workspaces", "tasks"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}},
  {ID: "workflow", OwnerPackage: "internal/workflow/repository", RequiredTables: []string{"workflow_templates", "workflow_steps"}, DependsOn: []string{"task"}},
  {ID: "office", OwnerPackage: "internal/office/repository/sqlite", RequiredTables: []string{"office_projects", "runs"}, DependsOn: []string{"task", "agent-settings"}},
  {ID: "github", OwnerPackage: "internal/github", RequiredTables: []string{"github_pr_watches"}, DependsOn: []string{"task", "user"}},
}
Validation
func ValidateCatalog(descriptors []Descriptor) error {
  if len(descriptors) == 0 {
    return fmt.Errorf("catalog is empty")
  }
  // checks duplicate IDs, unknown dependencies, out-of-order deps, cycles
  if err := validateCycles(descriptors); err != nil {
    return err
  }
  return nil
}

Shared SQL rendering

apps/backend/internal/db/dialect/schema.go

Stores render one portable schema text for both engines instead of branching SQL in each repository.

RenderSchema
func RenderSchema(driver, schema string) (string, error) {
  if driver != SQLite3 && driver != PGX {
    return "", fmt.Errorf("unsupported database driver %q", driver)
  }
  timestamp := TimestampType(driver)
  boolean := "INTEGER"
  identity := "INTEGER"
  if IsPostgres(driver) {
    boolean = "BOOLEAN"
    identity = "BIGSERIAL"
  }
  rendered := strings.NewReplacer(
    "{{timestamp}}", timestamp,
    "{{boolean}}", boolean,
    "{{identity}}", identity,
    "{{current_time}}", "CURRENT_TIMESTAMP",
  ).Replace(schema)
  if strings.Contains(rendered, "{{") {
    return "", fmt.Errorf("schema contains unknown or unexpanded token")
  }
  return rendered, nil
}
Time helpers
func NullableTimestamp(driver, placeholder string) string {
  if IsPostgres(driver) {
    return fmt.Sprintf("(%s)::timestamptz", placeholder)
  }
  return placeholder
}

func DurationMs(driver, end, start string) string {
  if IsPostgres(driver) {
    return fmt.Sprintf("EXTRACT(EPOCH FROM (%s - %s)) * 1000", end, start)
  }
  return fmt.Sprintf("(julianday(%s) - julianday(%s)) * 86400000", end, start)
}
Rebind boundary
func Bind(rebinder Rebinder, query string, args ...any) (string, []any, error) {
  expanded, expandedArgs, err := sqlx.In(query, args...)
  if err != nil {
    return "", nil, err
  }
  return rebinder.Rebind(expanded), expandedArgs, nil
}

SQL guard analyzer

apps/backend/internal/db/sqlguard/analyzer.go

The guard parses Go AST and rejects SQLite-only catalog, conflict, boolean, and date syntax before it ships.

Rules
const (
  RuleSQLiteCatalog      Rule = "sqlite-catalog"
  RuleConflictSyntax     Rule = "conflict-syntax"
  RuleRawPlaceholder     Rule = "raw-placeholder"
  RuleBooleanInteger     Rule = "boolean-integer"
  RuleSQLiteDateFunction Rule = "sqlite-date-function"
  RuleDateTimeType       Rule = "datetime-type"
)
Literal check
func analyzeSQLLiteral(filename, value, symbol string, position token.Position, exemptions []Exemption, seen map[string]struct{}, result *analysisResult, forceSQL ...bool) {
  checks := []struct{ rule Rule; match bool; message string }{
    {RuleSQLiteCatalog, sqlText && sqliteCatalogPattern.MatchString(value), "SQLite catalog or PRAGMA syntax must stay behind a dialect boundary"},
    {RuleConflictSyntax, sqlText && conflictPattern.MatchString(value), "use portable conflict syntax"},
    {RuleBooleanInteger, sqlText && booleanIntegerPattern.MatchString(value), "use a boolean value or a dialect-rendered boolean default"},
    {RuleSQLiteDateFunction, sqlText && sqliteDatePattern.MatchString(value), "use an internal/db/dialect date helper"},
  }
}
Exemptions
func ValidateExemptions(exemptions []Exemption) error {
  for _, exemption := range exemptions {
    if exemption.File == "" || filepath.IsAbs(exemption.File) || strings.ContainsAny(exemption.File, "*?") {
      return fmt.Errorf("SQL guard exemption file must be an exact relative path: %q", exemption.File)
    }
    if _, ok := knownRules[exemption.Rule]; !ok {
      return fmt.Errorf("SQL guard exemption has unknown rule %q", exemption.Rule)
    }
  }
  return nil
}

Conformance framework

apps/backend/internal/persistence/storeconformance/adapters.go

One fixed adapter per catalog entry proves schema creation and real API behavior on both engines.

Adapters
func Adapters() []testconformance.Adapter {
  descriptors := requiredstores.Catalog()
  adapters := make([]testconformance.Adapter, 0, len(descriptors))
  for _, descriptor := range descriptors {
    adapters = append(adapters, adapterFor(descriptor))
  }
  return adapters
}

func adapterFor(descriptor requiredstores.Descriptor) testconformance.Adapter {
  return testconformance.Adapter{
    ID: descriptor.ID,
    Engines: map[testconformance.EngineName]testconformance.EngineAdapter{
      testconformance.EngineSQLite:   {Fresh: initializer, Replay: initializer},
      testconformance.EnginePostgres: {Fresh: initializer, Replay: initializer},
    },
    Scenarios: behaviorScenarios(descriptor),
  }
}
Capability probe
func exerciseCapability(s testconformance.ScenarioContext, action apiAction, capability requiredstores.Capability, suffix string) error {
  switch capability {
  case requiredstores.CapabilityBoolean:
    for _, want := range []bool{false, true} {
      record, err = action.setBoolean(s, key, record, want)
      got, ok := action.readBoolean(record)
      if got != want {
        return fmt.Errorf("boolean = %t, want %t", got, want)
      }
    }
  case requiredstores.CapabilityTimestamp:
    created, updatedAt, ok := recordTimestamps(before)
    if created.Location() != time.UTC || updatedAt.Location() != time.UTC {
      return fmt.Errorf("timestamps are not UTC")
    }
  }
  return nil
}

Bootstrap and health enforcement

apps/backend/internal/backendapp/storage.go

Bootstrap records every store through the tracker and fails fast when a required store is missing or unhealthy.

Tracker wiring
func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Logger, version string) (*db.Pool, *Repositories, []func() error, error) {
  tracker, err := requiredstores.NewCatalogTracker()
  if err != nil {
    return nil, nil, nil, err
  }
  pool, cleanup, err := persistence.Provide(cfg, log, version)
  if err != nil {
    return nil, nil, nil, err
  }
  if err := recordRequiredStore(tracker, "schema-meta", nil); err != nil {
    return nil, nil, nil, err
  }
  taskRepoImpl, cleanup, err := repository.Provide(writer, reader, log)
  if err := recordRequiredStore(tracker, "task", err); err != nil {
    return nil, nil, nil, fmt.Errorf("task store: %w", err)
  }
}
Health probe
func (h *Health) Check(ctx context.Context) error {
  probeErr := h.ping(ctx)
  for index, descriptor := range h.tracker.catalog {
    results[index] = probeErr
    if probeErr == nil {
      results[index] = h.probeTables(ctx, descriptor)
    }
  }
  for index, result := range results {
    if err := h.tracker.RecordProbe(h.tracker.catalog[index].ID, result); err != nil {
      failures = append(failures, err)
    }
  }
  if len(failures) == 0 {
    return nil
  }
  return errors.Join(failures...)
}
Middleware
func requiredPersistenceMiddleware(health *requiredstores.Health) gin.HandlerFunc {
  return func(c *gin.Context) {
    if health == nil || health.Healthy() || persistencePathExcluded(c.Request.URL.Path) {
      c.Next()
      return
    }
    c.Abort()
    c.JSON(http.StatusServiceUnavailable, gin.H{
      "error": "required persistence is unavailable",
      "code": "persistence_unavailable",
      "store_ids": health.UnavailableStoreIDs(),
    })
  }
}

CI contributor gates

.github/workflows/backend-tests.yml

CI now runs explicit, reviewable package lists and pinned Postgres versions instead of grep discovery.

Static checks
- name: Check required-store SQL portability
  run: go run ./cmd/sqlguard ./internal
- name: Run catalog and SQLite conformance gates
  run: |
    go test -v -race ./internal/persistence/storeconformance \
      -run '^(TestStoreCatalogCompleteness|TestStoreConformance|TestUpgradeFixtureManifest|TestPreviousStableUpgrade)$'
Postgres 16
- name: Run fixed PostgreSQL 16 persistence gates
  run: |
    POSTGRES_PACKAGES=(
      ./internal/agent/settings/store
      ./internal/analytics/repository/sqlite
      ./internal/backendapp
      ./internal/db
      ./internal/persistence/storeconformance
      ./internal/task/repository/sqlite
    )
    go test -v -race "${POSTGRES_PACKAGES[@]}"
Postgres 18
postgres-18:
  name: Backend Postgres 18
  services:
    postgres:
      image: ghcr.io/kdlbs/kandev-ci:postgres-18@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
  steps:
    - name: Run PostgreSQL 18 boot and upgrade gates
      run: |
        go test -v -race ./internal/persistence/storeconformance ./internal/backendapp \
          -run '^(TestPreviousStableUpgrade|TestPostgresBootInitializesRepositories)$'

Data and storage

Catalog descriptor and tracker status are the data contract that bootstrap, health, and diagnostics share.

FieldTypeNotes
Descriptor.IDstringstable store identity, e.g. task, office, github
Descriptor.RequiredTables[]stringtables probed by health and validated by conformance
Descriptor.Capabilities[]Capabilityboolean, timestamp, conflict, transaction probes
Status.Stateenuminitializing, healthy, or unhealthy
Status.Errorstringsanitized probe error, exposed via diagnostics
kandev_meta.kandev_versiontextbinary version marker for backup and upgrade detection

Risk

7 / 10 High
1 low5 medium10 high

Why this score

  • Bootstrap now fails closed on any required-store error, so a bad migration blocks startup and readiness.
  • 42 stores change SQL rendering and add conformance adapters, which widens blast radius across SQLite and Postgres.
  • New health middleware returns 503 for stateful traffic when probes fail, which changes failure mode for operators.

Trade-offs and review notes

Where to look first

  1. Verify catalog completeness and dependency order in requiredstores/catalog.go and tracker.go.
  2. Check dialect rendering covers all timestamp and boolean cases in db/dialect/schema.go and time.go.
  3. Confirm sqlguard rules and exemptions in db/sqlguard/analyzer.go and exemptions.json are exact and justified.
  4. Review conformance adapters in persistence/storeconformance/adapters.go and scenarios.go for real API coverage.
  5. Check bootstrap and health wiring in backendapp/storage.go, health.go, and persistence_middleware.go.
  6. Confirm CI gates in backend-tests.yml and ci-base-image.yml pin digests and run the fixed package lists.