Files
summercms/.planning/phases/11-jobs-realtime-and-search-infrastructure/11-01-PLAN.md
2026-09-29 14:34:06 +02:00

52 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
11-jobs-realtime-and-search-infrastructure 01 execute 1
go.mod
go.sum
modules/lagoon/queue_migrations.go
modules/lagoon/migrations.go
modules/lagoon/ondatabase.go
modules/lagoon/transaction.go
modules/lagoon/connection.go
modules/lagoon/ondatabase_test.go
modules/lagoon/transaction_test.go
modules/lagoon/README.md
modules/conga/conga.go
modules/conga/record.go
modules/conga/job.go
modules/conga/client.go
modules/conga/worker.go
modules/conga/commands.go
modules/conga/postgres_test.go
modules/conga/listen_test.go
modules/conga/README.md
modules/surf/serve.go
modules/surf/README.md
internal/build/build.go
internal/build/build_test.go
internal/build/artifact.go
internal/build/stubs/artifacts.tmpl
cmd/summer/main.go
cmd/summer/runtime.go
cmd/summer/main_test.go
examples/hello/main.go
examples/hello/go.mod
examples/hello/go.sum
examples/hello/plugins/base/go.sum
examples/hello/plugins/greeter/go.sum
examples/hello/plugins/optional/go.sum
README.md
../fonoteka.go/go.mod
../fonoteka.go/go.sum
../fonoteka.go/main.go
../fonoteka.go/config/queue.yaml
../fonoteka.go/parity/schema_diff_test.go
../fonoteka.go/parity/migrate_test.go
../fonoteka.go/plugins/golem15/fonoteka/go.mod
../fonoteka.go/plugins/golem15/fonoteka/go.sum
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go
../fonoteka.go/plugins/golem15/user/go.mod
../fonoteka.go/plugins/golem15/user/go.sum
true
JOBS-01
CLI-06
tokens raw_tokens tasks confidence
160000 160000 3 low
truths artifacts key_links prohibitions
Per D-01, `lagoon.Migrate` always runs a framework migration set under history id summercms.conga (history table summer_migrations_summercms_conga) that creates River's schema pinned at version 7 and the `summer_jobs` table with exactly the PHP golem15_apparatus_jobs columns (SERIAL id, label, status default 0, progress default 0, progress_max default 0, nullable user_id, is_admin default false, is_canceled default false, metadata TEXT NOT NULL, nullable created_at/updated_at) plus the internal nullable `river_job_id BIGINT`.
Per D-02, `conga.From(app).Dispatch` inserts the summer_jobs row with status 1 (IN_PROGRESS, PHP JobManager.php:76, not IN_QUEUE), user_id/is_admin from the request principal and metadata `""` when none is given, and enqueues the River job with InsertTx on the same *sql.Tx; rolling that transaction back leaves neither a summer_jobs row nor a river_job row.
Per D-03, statuses are IN_QUEUE=0, IN_PROGRESS=1, COMPLETE=2, ERROR=3, STOPPED=4; a job that errors on a non-final attempt leaves its row IN_PROGRESS, the final failed attempt (Attempt >= MaxAttempts) or a recovered panic sets ERROR with the error text under metadata key `error`, and skip is expressed as CompleteJob with metadata {"skipped": true} (no extra status).
Per D-04, CancelJob sets is_canceled=true and status STOPPED and calls River JobCancel, so a queued job never starts and a running job's ctx is cancelled; StopJob (worker side) sets only STOPPED like PHP cancelJob; CheckIfCanceled reads is_canceled.
Per D-17 and ROADMAP SC-1, a worker runs one `*river.Client[*sql.Tx]` built with `riverdatabasesql.NewWithPgxListener(sharedSQLDB, listenerPool)` where the listener pgxpool has MaxConns 1 and MinConns 0; with FetchPollInterval 30s, a job inserted with InsertTx from a different insert-only client and committed is picked up in under 1s, while a poll-only `riverdatabasesql.New` worker with the same poll interval does not pick it up within 2s.
Per D-17, `summer serve` (surf.ServeCommand) starts the worker in-process after publishing the database unless `queue.work_in_serve` is false, and stops it on shutdown; `summer queue:work` runs a foreground worker and accepts repeatable `--queue` filters; an unknown queue name is an error listing the known queues.
Per D-05, `queue:clear [queue]` deletes available, scheduled and retryable jobs of one queue (default `default`) in loops of River JobDeleteMany until a pass deletes none, prints `Cleared N jobs`, and never touches running jobs.
Per RESEARCH Pattern 2, plugins register River-free jobs with `conga.Job[T pact.JobArgs](fn, opts...)`; a pact.Job not built by conga.Job fails worker start with an error naming the plugin; `summer make:job` scaffolds a typed args struct wrapped in conga.Job that compiles.
Per RESEARCH Pattern 4, `lagoon.OnDatabase(app, fn)` runs fn immediately when the database is already published and otherwise when `lagoon.Publish` runs, so the fonoteka artist-resolver and join-table GORM callbacks now register under `summer serve` too (the documented Boot-order gap).
Per RESEARCH Pattern 10 and D-20's seam, `lagoon.Transaction(ctx, gdb, fn)` runs `lagoon.AfterCommit` callbacks only after a successful commit (none on rollback, inner savepoint work dropped when the inner call fails); an implicit single-statement GORM transaction flushes its AfterCommit callbacks after `gorm:commit_or_rollback_transaction`; outside any lagoon-managed transaction the callback runs immediately with the caller's handle.
fonoteka.go stays green: parity/schema_diff_test.go allowedDiffs names each River table and summer_jobs with the D-01/JOBS-01 reason, and parity/migrate_test.go expects summer_migrations_summercms_conga in the history-table list; `go vet ./... && go test ./...` pass in both repositories after every task.
path provides contains
modules/lagoon/queue_migrations.go QueueMigrations(sqlDB), QueueHistoryID, JobsTable, RiverSchemaVersion summer_jobs
path provides contains
modules/lagoon/ondatabase.go OnDatabase seam drained by Publish func OnDatabase
path provides contains
modules/lagoon/transaction.go Transaction and AfterCommit func AfterCommit
path provides contains
modules/conga/conga.go Manager, From, Dispatch, Enqueue, Register and the PHP JobManager methods func (m *Manager) Dispatch
path provides contains
modules/conga/worker.go StartWorker, StartServeWorker, Worker.Stop on NewWithPgxListener NewWithPgxListener
path provides contains
modules/conga/commands.go RuntimeCommands: queue:work and queue:clear queue:clear
path provides contains
modules/conga/listen_test.go TestListenPickupLatency and TestDispatchTransactional TestListenPickupLatency
path provides
modules/conga/README.md Module documentation with the standard structure
path provides
../fonoteka.go/config/queue.yaml work_in_serve, max_attempts, job_timeout, queues
from to via pattern
modules/lagoon/migrations.go modules/lagoon/queue_migrations.go Migrate runs the summercms.conga set after attach and cabana QueueMigrations(
from to via pattern
modules/conga/conga.go github.com/riverqueue/river InsertTx on tx.Statement.ConnPool.(*sql.Tx) InsertTx(
from to via pattern
modules/surf/serve.go modules/conga/worker.go StartServeWorker after lagoon.Publish, Stop on shutdown conga.StartServeWorker
from to via pattern
internal/build/build.go modules/conga/commands.go generated main appends conga.RuntimeCommands conga.RuntimeCommands
from to via pattern
modules/lagoon/connection.go modules/lagoon/ondatabase.go Publish drains queued OnDatabase callbacks runDatabaseHooks|drain
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/plugin.go modules/lagoon/ondatabase.go classes.RegisterHooks registered through lagoon.OnDatabase lagoon.OnDatabase
requirement_id category statement status verification
JOBS-01 transparency MUST NOT report a summer_jobs row as COMPLETE (2) for work that failed, panicked or was discarded by River; a final failure ends as ERROR (3) with the error recorded in metadata resolved test
requirement_id category statement status verification
JOBS-01 safety MUST NOT leave a River job or a summer_jobs row behind when the business transaction that dispatched it rolls back resolved test
requirement_id category statement status verification
CLI-06 safety queue:clear MUST NOT delete, cancel or interrupt a running job; only available, scheduled and retryable jobs are removed resolved test

Phase Goal

ROADMAP Phase 11 goal (verbatim; it is not in user-story form, see the planner return note): River jobs run on the correct dual-driver split, Centrifugo publishing and channel authorization match the existing server, and Typesense sync stays a re-gated pre-filter — all brought up before the API phases that depend on them.

This plan's slice: a plugin can dispatch a job inside its write transaction, a worker running in summer serve or summer queue:work picks it up through LISTEN/NOTIFY, and the job's outcome is queryable from summer_jobs (JOBS-01, CLI-06).

Build the `conga` job framework package on River v0.47.0 and the two `lagoon` seams later plans need, and wire workers into `serve`, `queue:work` and `queue:clear`.

Purpose: CSV import (Phase 13/14), broadcasts (plan 11-03) and the scheduler (plan 11-02) all run on this. Decisions implemented: D-01, D-02, D-03, D-04, D-05, D-17; user decisions 2 (NewWithPgxListener, one client) and 4 (river_job_id column); RESEARCH Patterns 1-4 and 10, Pitfalls 1-5, 12, 13. Output: River dependency, framework migrations (River v7 + summer_jobs), modules/conga with README, lagoon OnDatabase/Transaction/AfterCommit, worker in serve, queue:work/queue:clear, generated-main and summer delegate changes, the make:job stub, the fonoteka.go allow-list and Boot-order fixes.

Repos: summercms.go (framework) and fonoteka.go (go.mod/go.sum tidy, config, allow-lists, Boot seam adoption, regenerated main.go). Commit each repository separately; fonoteka.go changes that keep its tests green land in the same task as the framework change that needs them. Planning docs and code in separate commits. Never add co-author tags.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/notes/apparatus-dissolved-into-framework.md @.planning/phases/11-jobs-realtime-and-search-infrastructure/11-CONTEXT.md @.planning/phases/11-jobs-realtime-and-search-infrastructure/11-RESEARCH.md @.planning/phases/11-jobs-realtime-and-search-infrastructure/11-PATTERNS.md @.planning/phases/11-jobs-realtime-and-search-infrastructure/11-VALIDATION.md @modules/lagoon/connection.go @modules/lagoon/migrations.go @modules/lagoon/backend_admin_migrations.go @modules/lagoon/commands.go @modules/lagoon/postgres_test.go @modules/surf/serve.go @modules/pact/capabilities.go @modules/postcard/mailer.go @internal/build/build.go @cmd/summer/runtime.go Existing seams (read, do not re-derive): - modules/lagoon/connection.go: `Open(ctx, dsn) (*sql.DB, *gorm.DB, error)`, `Use(ctx, sqlDB) (*gorm.DB, error)`, unexported `gormFromSQL(sqlDB)` (opens GORM with `&gorm.Config{}` and checks it wraps the same *sql.DB), `OpenFromApp(ctx, app)`, `DSN(cfg *compass.Config) string` (database.dsn), `Publish(app, sqlDB, gdb) error` (publishes *sql.DB then *gorm.DB; backpack refuses a second publish of the same type). The comment "Phase 11 owns a separate pgxpool.Pool for River LISTEN/NOTIFY. Do not create that listener pool here" stays true: conga creates it. - modules/lagoon/migrations.go: `Migrate(gdb, plugins []party.Plugin)` runs `migrator(gdb, "summercms.attach", attach.Migrations)` then `migrator(gdb, "summercms.cabana", BackendAdminMigrations)` then plugin sets; `migrator` uses gormigrate with `UseTransaction: true`; `HistoryTableName(id)` gives `summer_migrations_`. - modules/lagoon/commands.go: `RuntimeCommands(app, plugins)`; unexported `withDB(ctx, app, fn)` opens, publishes and closes the DB for CLI commands. - modules/pact/capabilities.go:84-99: `JobArgs{Kind() string}`, `Job{Work(ctx, args JobArgs) error}`, `HasJobs{Jobs() []Job}` — River-free by contract ("the interface itself must not import River"). - modules/bonfire/command.go: `Command{Name, Description, Flags, Args, Run}`, `Flag{Name, Description, Default, Bare, Repeatable}`, `Input.Flag(name)`, `Input.Flags(name) []string` (repeatable), `Input.Argument(name)`. - modules/bouncer/context.go: `User(ctx) (*Principal, bool)`; `Principal.ID uint`, `Principal.Backend bool`. - modules/surf/serve.go: `ServeCommand(app, plugins)`: OpenFromApp → lagoon.Publish → publishUploads → Assemble → signal.NotifyContext → ListenAndServe/Shutdown(10s). - cmd/summer/runtime.go: `delegateCommand(name, description)` forwards only positional args; `delegateRollbackCommand()` is the flag-forwarding precedent. - River v0.47.0 (module cache): `riverdatabasesql.New(dbPool *sql.DB) *Driver`, `riverdatabasesql.NewWithPgxListener(dbPool *sql.DB, listenerPool *pgxpool.Pool) *Driver`; `river.NewClient(driver, *river.Config)`; Config fields `Queues map[string]river.QueueConfig{MaxWorkers}`, `Workers *river.Workers`, `PeriodicJobs`, `MaxAttempts`, `JobTimeout`, `FetchPollInterval`, `FetchCooldown`, `Logger *slog.Logger`; `(*Client[TTx]).InsertTx(ctx, tx TTx, args JobArgs, opts *InsertOpts) (*rivertype.JobInsertResult, error)`, `Insert`, `JobCancel(ctx, id)`, `JobDeleteMany(ctx, *JobDeleteManyParams)`, `Start`, `Stop`, `StopAndCancel`; `river.NewJobDeleteManyParams().Queues(q).States(...).First(n)`; `river.AddWorkerSafely[T](workers, worker)`; `river.Worker[T]` = `Work(ctx, *Job[T]) error` plus `Timeout(*Job[T])`, `NextRetry`, `Middleware` (embed `river.WorkerDefaults[T]`); `river.Job[T]{*rivertype.JobRow; Args T}`; `rivertype.JobRow{ID, Attempt, MaxAttempts, Metadata []byte, Kind, Queue, State}`; `river.JobCancel(err) error`; InsertOpts `{MaxAttempts, Metadata []byte, Queue, ScheduledAt, UniqueOpts}`; `rivermigrate.New(driver, nil)`, `(*Migrator).Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{TargetVersion: 7})` (per-migration transactions; `MigrateTx` is marked Deprecated at river_migrate.go:344-346 because "Certain migrations cannot be batched together in a single transaction"); DirectionDown with TargetVersion -1 removes River entirely (river_migrate.go:239-241). Defaults: FetchPollInterval 1s, JobTimeout 1m, MaxAttempts 25. - PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/JobManager.php (dispatch status IN_PROGRESS, json_encode metadata, startJob sets progress 0/progress_max/updated_at, updateJobState sets only progress then replaces metadata when non-empty, completeJob sets COMPLETE and progress=progress_max (1 when the row is missing) and replaces metadata only when non-empty, failJob ERROR, cancelJob STOPPED, checkIfCanceled, getMetadata `json_decode ?: []`, raw query-builder updates that never touch updated_at except dispatch/startJob), contracts/JobStatus.php, updates/create_jobs_table.php, console/QueueClearCommand.php.

Artifacts this phase produces

(This plan's share of the phase artifacts.)

  • Dependencies: github.com/riverqueue/river v0.47.0, github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0, github.com/riverqueue/river/rivertype v0.47.0 (plus the transitive riverdriver, riverpgxv5, rivershared modules River pulls in).
  • Tables: summer_jobs (with internal river_job_id BIGINT NULL), River v7 tables (river_job, river_leader, river_queue, river_migration and whatever else version 7 leaves; the executor lists the exact set from pg_tables after migrating), history table summer_migrations_summercms_conga.
  • lagoon: QueueMigrations(sqlDB *sql.DB) []*gormigrate.Migration, const QueueHistoryID = "summercms.conga", const JobsTable = "summer_jobs", const RiverSchemaVersion = 7, OnDatabase(app, fn func(*sql.DB, *gorm.DB) error) error, Transaction(ctx, gdb, fn func(ctx context.Context, tx *gorm.DB) error) error, AfterCommit(ctx, db *gorm.DB, fn func(ctx context.Context, db *gorm.DB)), GORM callback name lagoon:after_commit.
  • conga: Manager, From(app) (*Manager, error), (*Manager).Register(jobs ...pact.Job) error, Dispatch(ctx, db, args, DispatchOpts) (uint, error), Enqueue(ctx, db, args, EnqueueOpts) error, StartJob, UpdateJobState, UpdateMetadata, CompleteJob, FailJob, CancelJob, StopJob, CheckIfCanceled, GetMetadata, Get; Record (summer_jobs model); Status with StatusInQueue, StatusInProgress, StatusComplete, StatusError, StatusStopped; DispatchOpts{Label, Count, Metadata, Queue, Delay, MaxAttempts}; EnqueueOpts{Queue, Delay, MaxAttempts}; Job[T pact.JobArgs](fn func(context.Context, T) error, opts ...JobOption) pact.Job; JobOption, OnQueue, MaxAttempts, Timeout; JobID(ctx) (uint, bool); WorkerOptions{Queues []string}; StartWorker(ctx, app, plugins, WorkerOptions) (*Worker, error); StartServeWorker(ctx, app, plugins) (*Worker, error); (*Worker).Stop(ctx) error; RuntimeCommands(app, plugins) []bonfire.Command; errors ErrNoDatabase, ErrRegistrationClosed, ErrNotCongaJob, ErrUnknownQueue.
  • CLI: app commands queue:work [--queue name ...], queue:clear [queue]; summer delegates queue:work (forwarding repeatable --queue) and queue:clear.
  • Config keys (app-level queue.*): queue.work_in_serve (default true), queue.max_attempts (3), queue.job_timeout (300 seconds, int or duration string), queue.queues.<name> (MaxWorkers; default queue default: 4).
  • Files: modules/conga/*, ../fonoteka.go/config/queue.yaml.

Flagged assumptions (edge probe: unclassified)

  • JOBS-01 and CLI-06 came back unclassified from the spec-less edge probe and stay unresolved; they are surfaced here, not auto-resolved. Planner reading for manual review: (a) concurrency — two workers never run the same River job concurrently (River row locking) and two Dispatch calls produce distinct summer_jobs ids; (b) idempotency — Dispatch is not idempotent (each call is a new job, like PHP); (c) empty — queue:work with no registered jobs still starts and idles; queue:clear on an empty queue prints Cleared 0 jobs. Confirm or correct at verify time.
Task 1: A job dispatched in a write transaction is picked up through LISTEN and completes its summer_jobs row D-01: summer_jobs ids become the public import_job_id/match_job_id contract and Phase 15 copies golem15_apparatus_jobs rows with ids preserved; the user chose this table and column list in CONTEXT.md (plus river_job_id at the plan-count checkpoint), so it is recorded without a checkpoint. Docker is reachable for testcontainers: `docker info` exits 0. go.mod, go.sum, modules/lagoon/queue_migrations.go, modules/lagoon/migrations.go, modules/lagoon/README.md, modules/conga/conga.go, modules/conga/record.go, modules/conga/job.go, modules/conga/client.go, modules/conga/worker.go, modules/conga/postgres_test.go, modules/conga/listen_test.go, modules/conga/README.md, README.md, examples/hello/go.mod, examples/hello/go.sum, examples/hello/plugins/base/go.sum, examples/hello/plugins/greeter/go.sum, examples/hello/plugins/optional/go.sum, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/plugins/golem15/fonoteka/go.mod, ../fonoteka.go/plugins/golem15/fonoteka/go.sum, ../fonoteka.go/plugins/golem15/user/go.mod, ../fonoteka.go/plugins/golem15/user/go.sum, ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/parity/migrate_test.go modules/lagoon/connection.go, modules/lagoon/migrations.go, modules/lagoon/backend_admin_migrations.go, modules/lagoon/postgres_test.go (TestMain, dedicatedDB, dsnWithDB), modules/lagoon/README.md, modules/pact/capabilities.go (JobArgs/Job/HasJobs), modules/postcard/mailer.go (Activate/driverFromApp/loggerFromApp pattern), modules/bouncer/context.go, README.md (modules table), /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/JobManager.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/updates/create_jobs_table.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/contracts/JobStatus.php, $(go env GOMODCACHE)/github.com/riverqueue/river/riverdriver/riverdatabasesql@v0.47.0/river_database_sql_driver.go (lines 40-140), $(go env GOMODCACHE)/github.com/riverqueue/river@v0.47.0/rivermigrate/river_migrate.go (lines 215-360), ../fonoteka.go/parity/schema_diff_test.go (allowedDiffs, publicBaseTables), ../fonoteka.go/parity/migrate_test.go (wantTables), .planning/phases/11-jobs-realtime-and-search-infrastructure/11-RESEARCH.md (Patterns 1-3, Pitfalls 1, 3, 5, 12, 13, Code Examples "Transactional dispatch") (1) Dependency (user decision 2, research-named): in summercms.go run `go get github.com/riverqueue/river@v0.47.0 github.com/riverqueue/river/riverdriver/riverdatabasesql@v0.47.0 github.com/riverqueue/river/rivertype@v0.47.0` and `go mod tidy`; MVS bumps testify to v1.12.1, which is expected. Then run `go mod tidy` in examples/hello and in ../fonoteka.go (root, plugins/golem15/fonoteka, plugins/golem15/user) so every module that links lagoon has River in go.sum; commit only files that changed. No other new module.

(2) Framework migrations (D-01, user decision 4, Pitfall 13): new modules/lagoon/queue_migrations.go with consts QueueHistoryID = "summercms.conga", JobsTable = "summer_jobs", RiverSchemaVersion = 7 and func QueueMigrations(sqlDB *sql.DB) []*gormigrate.Migration returning two migrations. 202609290001_river_schema: Migrate builds rivermigrate.New(riverdatabasesql.New(sqlDB), nil) and calls Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{TargetVersion: RiverSchemaVersion}) on the shared pool (per-migration transactions; the Tx variant is deprecated upstream because some River migrations cannot share one transaction); Rollback calls DirectionDown with TargetVersion -1. 202609290002_summer_jobs: CREATE TABLE IF NOT EXISTS summer_jobs (id SERIAL PRIMARY KEY, label VARCHAR(255) NOT NULL, status INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, progress_max INTEGER NOT NULL DEFAULT 0, user_id INTEGER NULL, is_admin BOOLEAN NOT NULL DEFAULT FALSE, is_canceled BOOLEAN NOT NULL DEFAULT FALSE, metadata TEXT NOT NULL, river_job_id BIGINT NULL, created_at TIMESTAMPTZ NULL, updated_at TIMESTAMPTZ NULL) (Laravel timestamps() are nullable; no extra indexes, matching PHP); Rollback drops the table. In migrations.go Migrate runs the set with migrator(gdb, QueueHistoryID, QueueMigrations(sqlDB)) right after the cabana set, where sqlDB comes from gdb.DB(); wrap errors as lagoon: migrate queue: %w. The set lives in lagoon for the same reason BackendAdminMigrations does (lagoon cannot import conga), and every app gets it on migrate.

(3) conga core, new package modules/conga (errors prefixed conga: , logger from app.Lookup[*slog.Logger]() else slog.Default() like postcard):

  • record.go: type Status int with the five PHP constants; type Record struct mapping every summer_jobs column (ID uint, Label string, Status Status, Progress int, ProgressMax int, UserID *uint, IsAdmin bool, IsCanceled bool, Metadata string, RiverJobID *int64, CreatedAt/UpdatedAt *time.Time), TableName() string { return lagoon.JobsTable }.
  • job.go: type JobOption func(*jobConfig), OnQueue(name string), MaxAttempts(n int), Timeout(d time.Duration); func Job[T pact.JobArgs](fn func(ctx context.Context, args T) error, opts ...JobOption) pact.Job returning an unexported *typedJob[T] that implements pact.Job (its Work type-asserts args to T and calls fn) plus unexported kind() string, register(*river.Workers) error (calls river.AddWorkerSafely[T] with an unexported riverWorker[T] embedding river.WorkerDefaults[T]) and config() jobConfig. JobID(ctx) (uint, bool) reads the summer_jobs id the wrapper stored in ctx.
  • conga.go: type Manager struct holding the app, a mutex, the registered jobs by kind, the lazily built insert-only client and the running worker client; func From(app *backpack.App) (*Manager, error) does lookup-or-publish of *Manager on the app (on a publish race, look up again); Register(jobs ...pact.Job) error returns ErrNotCongaJob for a job not built by Job, an error on a duplicate kind, and ErrRegistrationClosed once a client has been built; Dispatch(ctx, db *gorm.DB, args pact.JobArgs, o DispatchOpts) (uint, error): when db is not inside a transaction (db.Statement.ConnPool is not a *sql.Tx) wrap the work in db.WithContext(ctx).Transaction; insert the Record with Status StatusInProgress (Pitfall 1), Label (required, error when empty), ProgressMax = o.Count, Metadata = JSON of o.Metadata or the two characters "" when nil (PHP json_encode of ''), UserID/IsAdmin from bouncer.User(ctx) (IsAdmin = Principal.Backend), CreatedAt = UpdatedAt = now; then client.InsertTx(ctx, sqlTx, args, &river.InsertOpts{Queue, MaxAttempts, ScheduledAt: now+o.Delay when Delay > 0, Metadata: {"summer_job_id": id}}) using the registered job's queue/max attempts when o leaves them zero; then set river_job_id with an UpdateColumn on the same tx; return the id. Enqueue(ctx, db, args, EnqueueOpts) does only the River InsertTx (Insert when db is not in a transaction) and is what plan 11-03 uses for broadcasts. CompleteJob(ctx, id, metadata map[string]any) error and Get(ctx, id) (Record, error) land here now (the rest in Task 2); CompleteJob reads progress_max (1 when the row is missing) and sets status 2 and progress with UpdateColumns on Table(lagoon.JobsTable) so updated_at is never auto-touched, replacing metadata only when the map is non-empty. The Manager resolves the database per call with app.Lookup[*gorm.DB]() / *sql.DB and returns ErrNoDatabase when unpublished.
  • client.go: riverConfig(app, workers, queues) reads queue.max_attempts (default 3, PHP --tries=3), queue.job_timeout (default 300s; accept "300s" or an int of seconds, the postcard timeout idiom; Pitfall 4) and queue.queues.<name> MaxWorkers (default 4); the queue set is the configured queues plus every queue a registered job names plus default. The insert-only client is river.NewClient(riverdatabasesql.New(sqlDB), cfg without Queues) built once, on first Dispatch/Enqueue/cancel when no worker client runs; the worker client is used for inserts when present.
  • worker.go: type WorkerOptions struct { Queues []string } (nil = every known queue) plus unexported test knobs pollInterval time.Duration and pollOnly bool; StartWorker(ctx, app, plugins []party.Plugin, o WorkerOptions) (*Worker, error): registers every pact.HasJobs job of the plugins (a non-conga job fails with an error naming the plugin id), builds the listener pool from lagoon.DSN(app.Config) with pgxpool.ParseConfig, MaxConns 1, MinConns 0, builds riverdatabasesql.NewWithPgxListener(sqlDB, listener) (or riverdatabasesql.New when pollOnly), filters queues (ErrUnknownQueue listing known names), sets FetchPollInterval when the knob is set, starts the client and hands it to the Manager. (*Worker).Stop(ctx) calls Stop, falls back to StopAndCancel when ctx expires, and closes the listener pool. The riverWorker[T].Work wrapper: put the summer_jobs id from job.Metadata into ctx; call fn with panic recovery (a panic becomes an error); on error, when the row is STOPPED or is_canceled, return river.JobCancel(err); otherwise when job.Attempt >= job.MaxAttempts set status 3 with metadata = current metadata plus key error (D-03); return the error so River retries non-final attempts; Timeout returns the job option or 0.

(4) Smoke tests (coverage is plan 11-07): modules/conga/postgres_test.go copies the lagoon TestMain harness (testcontainers postgres:16-alpine, ICU pl-PL, -short skips, Docker failure fails TestMain) exposing a helper that returns a dedicated *sql.DB plus its DSN migrated with lagoon.Migrate(gdb, nil). modules/conga/listen_test.go: TestListenPickupLatency (Pitfall 5: worker with pollInterval 30s, wait until SELECT count(*) FROM pg_stat_activity WHERE query ILIKE 'LISTEN%' is positive, insert through a second insert-only client with InsertTx and commit, assert the job function runs within 1s; negative control with pollOnly and the same interval asserts no run within 2s) and TestDispatchTransactional (commit path: row status 1 then the job calls CompleteJob and Get shows status 2 and progress = progress_max; rollback path: no summer_jobs row and SELECT count(*) FROM river_job unchanged).

(5) fonoteka.go allow-lists (Pitfall 12), same task so both repos stay green: add allowedDiffs entries in parity/schema_diff_test.go for summer_jobs ("Framework job record (11-01, D-01); PHP golem15_apparatus_jobs is copied at cutover") and for each River table actually created (reason "River v7 schema (11-01, JOBS-01)"); determine the names by running the migration and reading pg_tables rather than from memory. Add summer_migrations_summercms_conga to wantTables in parity/migrate_test.go (keep the ORDER BY order).

(6) Docs in the same commits: new modules/conga/README.md in the standard structure (H1 conga, the one-sentence summary "Background jobs on River over the shared Postgres pool: transactional dispatch, a summer_jobs progress record, in-process or dedicated workers, and a wall-clock scheduler.", import line, Overview, Features, Usage, API reference, Configuration, CLI commands, Dependencies (River v0.47.0 and why), Testing), mentioning the PgBouncer session-pooling requirement for the listener pool; a root README.md modules-table row with the same sentence; modules/lagoon/README.md gains the QueueMigrations set in the migrations bullet. Framework text uses neutral names (acme); verify each identifier with go doc ./modules/conga <Identifier>. go vet ./... && go test ./modules/conga -run '^(TestListenPickupLatency|TestDispatchTransactional)$' -count=1 -v && go test ./modules/lagoon -count=1 && (cd ../fonoteka.go && go test ./parity -run '^(TestMigrateSeedsCanonicalGenres|TestSchemaMatchesPHPSnapshot)$' -count=1 -v) <fails_when>Any command exits non-zero; the verbose output lacks a "--- PASS" line for TestListenPickupLatency, TestDispatchTransactional, TestMigrateSeedsCanonicalGenres or TestSchemaMatchesPHPSnapshot, or prints "no tests to run" or "--- SKIP"; the schema test prints "Go extra table" for a River table or summer_jobs.</fails_when> <acceptance_criteria> - go list -m github.com/riverqueue/river prints github.com/riverqueue/river v0.47.0. - grep -c 'NewWithPgxListener' modules/conga/worker.go prints at least 1 and grep -c 'MaxConns' modules/conga/worker.go prints at least 1. - grep -c 'river_job_id BIGINT' modules/lagoon/queue_migrations.go prints 1 and grep -c 'TargetVersion: RiverSchemaVersion' modules/lagoon/queue_migrations.go prints at least 1. - grep -c 'StatusInProgress' modules/conga/conga.go prints at least 1 (Dispatch writes status 1). - grep -c 'summer_migrations_summercms_conga' ../fonoteka.go/parity/migrate_test.go prints 1 and grep -c '"summer_jobs"' ../fonoteka.go/parity/schema_diff_test.go prints 1. - go doc ./modules/conga Manager.Dispatch, go doc ./modules/conga Job, go doc ./modules/conga StartWorker and go doc ./modules/lagoon QueueMigrations exit 0. - grep -c '\[conga\](modules/conga/README.md)' README.md prints 1. - TestListenPickupLatency measures pickup under 1s with a 30s poll interval and the poll-only control shows no pickup in 2s. </acceptance_criteria> River is a pinned dependency, migrate creates River v7 and summer_jobs, a transactional Dispatch is picked up by a NewWithPgxListener worker well under the poll interval and completes its row, rollback leaves nothing, and fonoteka.go's schema and migrate tests pass with the new tables.

Task 2: Job outcomes and cancellation are queryable, and workers run in serve, queue:work and queue:clear modules/conga/conga.go, modules/conga/job.go, modules/conga/worker.go, modules/conga/commands.go, modules/conga/listen_test.go, modules/conga/README.md, modules/surf/serve.go, modules/surf/README.md, internal/build/build.go, internal/build/build_test.go, internal/build/artifact.go, internal/build/stubs/artifacts.tmpl, cmd/summer/main.go, cmd/summer/runtime.go, cmd/summer/main_test.go, examples/hello/main.go, ../fonoteka.go/main.go, ../fonoteka.go/config/queue.yaml modules/conga/conga.go and modules/conga/worker.go (Task 1), modules/lagoon/commands.go (RuntimeCommands, withDB), modules/surf/serve.go, modules/surf/README.md, modules/bonfire/command.go, internal/build/build.go (generateMain), internal/build/build_test.go (TestGenerateMainRegistersCabanaRuntimeCommands, TestScaffoldAllArtifacts), internal/build/artifact.go (MakeJob), internal/build/stubs/artifacts.tmpl (job.go block), cmd/summer/main.go, cmd/summer/runtime.go (delegateCommand, delegateRollbackCommand), cmd/summer/main_test.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/JobManager.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/console/QueueClearCommand.php, ../fonoteka.go/config/app.yaml (comment style), .planning/phases/11-jobs-realtime-and-search-infrastructure/11-RESEARCH.md (Pattern 3 table, Pitfalls 2-4, Don't Hand-Roll "Queue bulk delete") - StartJob(id, 5) sets progress 0, progress_max 5 and updated_at; UpdateJobState(id, 3, nil) sets only progress; UpdateJobState(id, 4, {"a":1}) also replaces metadata; UpdateMetadata replaces metadata without touching updated_at. - FailJob(id, nil) sets status 3 and keeps metadata; FailJob(id, {"x":1}) replaces it; GetMetadata returns an empty map for the stored `""` and the decoded object otherwise. - A job that returns an error on attempts 1 and 2 of 3 leaves the row at status 1; the third failure sets status 3 with metadata key `error`; a panicking job ends the same way. - CancelJob on a queued job sets is_canceled and status 4 and the job function never runs; CancelJob on a running job cancels its ctx and the row stays status 4 (not 3). - StopJob(id, nil) sets status 4 without is_canceled; CheckIfCanceled reflects is_canceled. - queue:clear on queue `default` with two available jobs and one running job deletes two, prints `Cleared 2 jobs`, and the running job completes. - queue:work --queue unknown exits non-zero naming the known queues. (1) Finish the PHP JobManager surface on Manager (D-02, D-03, D-04), every write through `UpdateColumns` on `Table(lagoon.JobsTable)` so GORM never auto-sets updated_at: `StartJob(ctx, id uint, total int) error` (progress 0, progress_max total, updated_at now); `UpdateJobState(ctx, id, current int, metadata map[string]any) error` (progress only, then UpdateMetadata when the map is non-empty); `UpdateMetadata(ctx, id, metadata map[string]any) error`; `FailJob(ctx, id, metadata map[string]any) error` (status 3, metadata only when non-empty); `CancelJob(ctx, id) error` (is_canceled true and status 4 in one update, then River JobCancel on river_job_id when set; a JobCancel "not found" for an already finished job is not an error); `StopJob(ctx, id, metadata map[string]any) error` (status 4 only, PHP cancelJob semantics, Pitfall 2); `CheckIfCanceled(ctx, id) (bool, error)`; `GetMetadata(ctx, id) (map[string]any, error)` (a non-object or empty decode yields an empty map). Doc comments state the Skip convention: `CompleteJob(ctx, id, map[string]any{"skipped": true})`.

(2) Worker wrapper refinement (D-03, D-04): after fn returns, if the row is status 4 treat any ctx-cancel error as river.JobCancel; a nil return with the row still at status 1 is left as is (jobs complete themselves, as in PHP). Recovered panics log the stack at Error and follow the final-attempt rule.

(3) Commands, new modules/conga/commands.go: func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command returning queue:work (description "Run background job workers in the foreground"; flag queue Repeatable, "Queue to work (repeatable; default all known queues)") and queue:clear (description "Clear all queued jobs, by deleting all pending jobs." as in PHP; optional arg queue, default default; the PHP connection argument has no Go counterpart because River uses the one database). Both open the DB the lagoon way (OpenFromApp + Publish, closing on return; mirror lagoon's unexported withDB inside conga). queue:work starts StartWorker with the filter, prints worker started on queues: <sorted list>, blocks on signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) (surf/serve.go idiom) and stops with a 10s timeout. queue:clear prints Clearing queue "<q>", loops JobDeleteMany(ctx, river.NewJobDeleteManyParams().Queues(q).States(rivertype.JobStateAvailable, rivertype.JobStateScheduled, rivertype.JobStateRetryable).First(10000)) until a pass deletes zero, and prints Cleared N jobs (D-05).

(4) serve (D-17): in modules/surf/serve.go create the signal ctx before starting anything long-lived, then after Assemble call worker, err := conga.StartServeWorker(ctx, app, plugins); StartServeWorker returns nil, nil when queue.work_in_serve is explicitly false and otherwise StartWorker with all queues. On every exit path after a successful start (shutdown and ListenAndServe error) stop the worker with the same 10s shutdown ctx after srv.Shutdown. surf importing conga is allowed because conga never imports surf. Update modules/surf/README.md (serve starts workers; queue.work_in_serve).

(5) Generated main and summer delegates: internal/build/build.go imports git.golem15.com/golem15/summercms/modules/conga and appends commands = append(commands, conga.RuntimeCommands(app, plugins)...) right after lagoon.RuntimeCommands; add TestGenerateMainRegistersCongaRuntimeCommands to build_test.go asserting that exact line appears once. Regenerate examples/hello/main.go and ../fonoteka.go/main.go with the framework CLI (go run ./cmd/summer build from each app directory, or go run <path to summercms.go>/cmd/summer build from ../fonoteka.go); bin/ output stays untracked. In cmd/summer add delegateQueueWorkCommand() (declares the repeatable queue flag and forwards every value as --queue <v>, modelled on delegateRollbackCommand) and delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"), register both in toolCommands, and extend the expected list in cmd/summer/main_test.go.

(6) make:job stub (RESEARCH Pattern 2): the job.go block in internal/build/stubs/artifacts.tmpl generates {{.Ident}}Args with Kind() and func {{.Func}}() pact.Job { return conga.Job(func(ctx context.Context, args {{.Ident}}Args) error { return nil }) }, importing conga and pact but never River (the command description stays "Generate a plugin job without importing River"); drop the now-unused Worker field from artifact.go's data if nothing else reads it. TestScaffoldAllArtifacts must still compile the scaffolded plugin.

(7) fonoteka.go config: new ../fonoteka.go/config/queue.yaml with commented keys work_in_serve: true, max_attempts: 3, job_timeout: 300, queues: {default: 4} (comment: set work_in_serve false when a separate fonoteka queue:work process runs; the listener needs session pooling, not PgBouncer transaction pooling).

(8) Tests in listen_test.go for the behavior list above (the Postgres ones), kept as smoke-level; plan 11-07 adds branch coverage. Update modules/conga/README.md (API reference rows for every new method, CLI commands section, Configuration section) in the same commit. go vet ./... && go test ./modules/conga ./modules/surf ./internal/build ./cmd/summer -count=1 && go test ./internal/build -run '^(TestGenerateMainRegistersCongaRuntimeCommands|TestScaffoldAllArtifacts)$' -count=1 -v && (cd ../fonoteka.go && go vet ./... && go build ./... && SUMMER_GOLEM15__USER__JWT__SECRET=test-only-cli-secret go run . queue:clear --help) <fails_when>Any command exits non-zero; the verbose run lacks "--- PASS" for TestGenerateMainRegistersCongaRuntimeCommands or TestScaffoldAllArtifacts or prints "no tests to run"; the help output does not contain "queue:clear".</fails_when> <acceptance_criteria> - grep -c 'conga.RuntimeCommands(app, plugins)' ../fonoteka.go/main.go examples/hello/main.go prints 1 for each file. - grep -c 'conga.StartServeWorker' modules/surf/serve.go prints 1. - grep -c 'JobStateRunning' modules/conga/commands.go prints 0 and grep -c 'JobStateAvailable' modules/conga/commands.go prints at least 1. - grep -c 'queue:work' cmd/summer/main_test.go prints at least 1. - grep -c 'conga.Job(' internal/build/stubs/artifacts.tmpl prints 1. - go doc ./modules/conga Manager.CancelJob, go doc ./modules/conga Manager.StopJob and go doc ./modules/conga RuntimeCommands exit 0. - test -f ../fonoteka.go/config/queue.yaml succeeds and grep -c 'work_in_serve' ../fonoteka.go/config/queue.yaml prints at least 1. </acceptance_criteria> Every PHP JobManager operation exists with PHP semantics, retries only become ERROR on the final attempt, cancellation both stops River and marks the row, and workers run in serve (unless disabled), in queue:work with queue filters, and queue:clear removes only pending jobs.

Task 3: GORM callbacks registered at Boot fire under summer serve, and work can run after commit modules/lagoon/ondatabase.go, modules/lagoon/transaction.go, modules/lagoon/connection.go, modules/lagoon/ondatabase_test.go, modules/lagoon/transaction_test.go, modules/lagoon/README.md, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go modules/lagoon/connection.go (Publish, gormFromSQL), modules/lagoon/postgres_test.go, modules/lagoon/README.md, modules/backpack/services.go (Publish refuses duplicates), $(go env GOMODCACHE)/gorm.io/gorm@v1.31.2/callbacks/transaction.go, $(go env GOMODCACHE)/gorm.io/gorm@v1.31.2/callbacks.go (Register/Replace/sortCallbacks: duplicate names keep the last handler), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Boot comment on the RegisterHooks gap), ../fonoteka.go/plugins/golem15/fonoteka/classes/registry.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go, .planning/phases/11-jobs-realtime-and-search-infrastructure/11-RESEARCH.md (Patterns 4 and 10, Pitfall 6) - OnDatabase registered before Publish runs once when Publish runs; registered after Publish runs immediately; an error from a callback makes Publish (or OnDatabase) return it. - Transaction commit runs AfterCommit callbacks in registration order after COMMIT; a rolled-back Transaction runs none; a nested Transaction whose fn fails drops only the callbacks it added. - A plain gdb.Create (implicit single-statement transaction) with an AfterCommit registered from a GORM after_create callback runs it after commit and not when the insert fails. - Inside gdb.Transaction (not lagoon.Transaction) AfterCommit runs immediately with the tx handle. - In fonoteka, activating plugins with no database published and then calling lagoon.Publish registers the `fonoteka:album_artist_resolver` callback on that *gorm.DB. (1) modules/lagoon/ondatabase.go (RESEARCH Pattern 4): `func OnDatabase(app *backpack.App, fn func(sqlDB *sql.DB, gdb *gorm.DB) error) error`. Per-app state is an unexported `*databaseHooks` service published on the app (lookup-or-publish, no package globals): when `*gorm.DB` and `*sql.DB` are already published, call fn now and return its error; otherwise append fn. In connection.go `Publish` drains the queue once after publishing both handles (`runDatabaseHooks`), returning the first error wrapped `lagoon: database hook: %w`. Update the listener-pool comment in connection.go to say conga owns it.

(2) modules/lagoon/transaction.go (RESEARCH Pattern 10, D-20 seam): an unexported ctx key carries an *afterCommitBuffer. func Transaction(ctx context.Context, gdb *gorm.DB, fn func(ctx context.Context, tx *gorm.DB) error) error: when ctx already carries a buffer, run a nested tx.Transaction (savepoint) with a child buffer merged into the parent only when fn succeeds; otherwise create a buffer, run gdb.WithContext(txCtx).Transaction(func(tx) error { return fn(txCtx, tx) }), and after a nil return run each buffered callback in order with gdb.Session(&gorm.Session{NewDB: true, Context: ctx}), recovering and logging (slog.Default Warn) any panic so a committed write is never reported as failed. func AfterCommit(ctx context.Context, db *gorm.DB, fn func(ctx context.Context, db *gorm.DB)): (a) buffer in ctx → append; (b) else when the statement opened its own transaction (db.InstanceGet("gorm:started_transaction")) → append to a statement-scoped buffer stored with db.InstanceSet("lagoon:after_commit", ...); (c) else run fn now with db (the PHP after_commit=false fallback; inside an explicit non-lagoon GORM transaction db is that tx). In gormFromSQL register a callback named lagoon:after_commit with .After("gorm:commit_or_rollback_transaction") on the Create, Update and Delete processors that flushes the statement buffer only when db.Error == nil, using a fresh session on the committed connection pool. Register with Register when Get(name) is nil so repeated opens of one *gorm.DB stay idempotent.

(3) fonoteka.go Boot fix (flagged in RESEARCH Pattern 4 as optional; included because it closes a documented production gap): replace the if gdb, ok := app.Lookup[*gorm.DB](); ok { classes.RegisterHooks(gdb) } block in plugins/golem15/fonoteka/plugin.go with lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error { return classes.RegisterHooks(gdb) }), rewrite the surrounding comment to say the hooks now register whenever the database is published, and add TestHooksRegisterWhenDatabasePublishedAfterBoot to plugin_boot_test.go (activate with no DB published, then lagoon.Publish, then assert gdb.Callback().Create().Get("fonoteka:album_artist_resolver") is non-nil; use a fresh lagoon.Use(ctx, bootSQL) handle so the shared bootGDB is not involved).

(4) Tests: modules/lagoon/ondatabase_test.go TestOnDatabaseAfterActivate and modules/lagoon/transaction_test.go TestTransactionAfterCommit covering the behavior list (use the existing lagoon Postgres harness and a throwaway table). Update modules/lagoon/README.md (Features and API reference for OnDatabase, Transaction, AfterCommit and the lagoon:after_commit callback) in the same commit; check identifiers with go doc ./modules/lagoon OnDatabase etc. go vet ./... && go test ./modules/lagoon -run '^(TestOnDatabaseAfterActivate|TestTransactionAfterCommit)$' -count=1 -v && go test ./... && (cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... && go test ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/...) <fails_when>Any command exits non-zero; the verbose run lacks "--- PASS" for TestOnDatabaseAfterActivate or TestTransactionAfterCommit, or prints "no tests to run" or "--- SKIP"; any package in either repository reports FAIL.</fails_when> <acceptance_criteria> - go doc ./modules/lagoon OnDatabase, go doc ./modules/lagoon Transaction and go doc ./modules/lagoon AfterCommit exit 0. - grep -l 'lagoon:after_commit' modules/lagoon/*.go lists at least one file. - grep -c 'lagoon.OnDatabase' ../fonoteka.go/plugins/golem15/fonoteka/plugin.go prints 1. - (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestHooksRegisterWhenDatabasePublishedAfterBoot$' -count=1 -v) shows "--- PASS". - grep -c 'OnDatabase' modules/lagoon/README.md prints at least 1. </acceptance_criteria> Anything registered through lagoon.OnDatabase at Boot runs when serve publishes the database, the fonoteka hooks now fire in production, and lagoon.Transaction/AfterCommit give later plans a commit-safe place to run side effects; both repositories' full suites pass.

<threat_model>

Trust Boundaries

Boundary Description
HTTP handler → business transaction → River Request-driven writes enqueue jobs whose rows and River entries must share the write's fate
Worker process → shared Postgres pool + listener pool Workers hold a dedicated LISTEN connection and run plugin job code
Operator CLI → queue:clear / queue:work Destructive and long-running commands on the job tables
Go module proxy → go.mod/go.sum New third-party code (River) enters the binary

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-11-08 Elevation of Privilege summer_jobs ids (cancel/progress IDOR) medium accept Phase 11 exposes no HTTP route over summer_jobs; the Phase 13 CSV endpoints must scope ids through the owning import (findVisible). Recorded for Phase 13.
T-11-12 Tampering Dispatch/Enqueue (orphan jobs for rolled-back writes) high mitigate Row insert and River InsertTx run on the caller's *sql.Tx (Dispatch opens one when the caller has none); TestDispatchTransactional asserts rollback leaves neither (Task 1).
T-11-13 Denial of Service River listener pool medium mitigate Dedicated pgxpool with MaxConns 1, MinConns 0 per worker; README documents session pooling for PgBouncer (Task 1).
T-11-14 Tampering queue:clear medium mitigate JobDeleteMany restricted to available, scheduled and retryable states of one queue; a running job is untouched (Task 2 behavior test).
T-11-15 Information Disclosure job failure metadata and logs medium mitigate Only the error text is written to metadata key error; the wrapper never logs job args; River's logger is the app logger (Task 1-2).
T-11-16 Denial of Service panicking plugin jobs medium mitigate The wrapper recovers panics into errors and applies the final-attempt ERROR rule, so a panic cannot crash the worker or strand the row (Task 2).
T-11-SC Tampering Go module installs (River v0.47.0 and sub-modules) high mitigate River is named by STACK.md and RESEARCH's legitimacy audit (Go module proxy, official docs); versions pinned in go.mod with go.sum checksums verified by GOSUMDB; no other module added.
</threat_model>
After Task 3: `go vet ./... && go test ./...` in summercms.go and `(cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... && go test ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/...)` pass; `go test ./modules/conga -run '^TestListenPickupLatency$' -count=1 -v` shows LISTEN pickup under 1s with a 30s poll. [BLOCKING, schema gate equivalent] The migration set is proven against live Postgres, not only by compilation: the conga TestMain migrates a real database and fonoteka.go's TestMigrateSeedsCanonicalGenres and TestSchemaMatchesPHPSnapshot run `lagoon.Migrate` on testcontainers Postgres.

<success_criteria>

  • River v0.47.0 runs on one client type with NewWithPgxListener; LISTEN pickup is proven not to be poll latency.
  • summer_jobs mirrors PHP columns plus river_job_id; Dispatch is transactional; every PHP JobManager method exists with PHP semantics; outcomes complete/fail/skip/cancel are queryable.
  • serve runs workers in-process unless queue.work_in_serve: false; queue:work --queue and queue:clear exist in the app binary and as summer delegates.
  • lagoon.OnDatabase closes the Boot-order gap; lagoon.Transaction/AfterCommit exist for plan 11-05.
  • READMEs of conga (new), lagoon and surf plus the root modules row are updated in the same commits; both repositories green. </success_criteria>
Create `.planning/phases/11-jobs-realtime-and-search-infrastructure/11-01-SUMMARY.md` when done.