Skip to content

Commit 48d67e2

Browse files
riddim-developer-bot[bot]riddim-developer-bot
andauthored
[EPAC-2304]: Lock bills SQLite artifact schema with a producer-to-consumer seam test (#823)
## Scope The bills SQLite artifact is an implicit contract between two separate Go binaries: the **bills-indexer writer** (producer, `backend/bills-indexer/internal/adapter/sqlite/writer.go`) and the **bills serving repository** (consumer, `backend/bills/internal/adapter/sqlite/repository.go`). Nothing fast verified the assembled contract — only the staging smoke did, which cannot catch drift before deploy. The serving repo also masked drift with dynamic column-name fallbacks (`columnExpr`/`firstColumn`/…), so as shipped the served `BillVersion`'s `title`/`chamber`/`published_on` were always empty (the indexer never writes those columns) and the version `label` silently fell back to `stage`. This PR locks that contract: - **Seam test** (`backend/bills/artifact_seam_test.go`, `TestBillsArtifactSeam`). It drives the *real* producer binary in a new offline fixture mode to write a real `bills.db`, then opens that file with the *real* serving repository and asserts `ListBills`, `GetBillDepth`, and `GetBillVersionDiff` return the promised columns populated — no NULL fallbacks. The two adapters are `internal` to separate Go modules, so (per the issue's guidance) neither imports the other's internals; the only thing shared across the seam is the on-disk SQLite file, exactly as in production. - **Single-sourced `bill_versions` contract.** The served `BillVersion` is now exactly `{id, label, stage, source_url}`, where `label` and `stage` both carry the LEGISinfo publication-type name (the indexer has no separate label column) and `source_url` is the indexer's `html_url`. The always-empty `title`, `chamber`, and `published_on` were dropped from the domain model and `backend/openapi/openapi.json` (schema + examples). The decision is recorded in `docs/architecture/bills-artifact-contract-epac2304.md`. - **Dead fallbacks removed.** The version and amendment reads now use fixed SQL against the producer's real columns; the `columnExpr`/`firstColumn`/`tableColumns`/`orderExpr` helpers are gone. - **Offline producer mode** (`BILLS_FIXTURE_BATCH`) added to the bills-indexer so the seam test can drive the real writer without LEGISinfo or AWS. The deployed pipeline never sets it. - **CI gate.** The `bills` module is now run in `pr-build`'s `backend-tests` job, so schema drift fails the required gate instead of in staging smoke or production. **No runtime response change / no `Release-Note`:** the dropped version fields were already omitted from responses (always empty, `omitempty`), and iOS decodes every version field as optional. The served JSON is byte-identical; this is an API-spec + test/refactor change with no user-visible effect. **Out of scope (documented follow-up):** the served `BillAmendment` over-promises fields the indexer never writes; the consumer now reads only the producer-backed `id`/`source_url` (identical to today's production output) with fixed SQL, and the remaining contract trim/enrich is captured in the "Known limitation" section of the architecture note. ## Bugfix SPEC * **Spec:** N/A — architecture/test hardening, not a bug fix. * **Trace ID:** N/A ## Testing notes * **Automated tests run:** `go test ./backend/bills/... ./backend/bills-indexer/...` (pass), `go test -tags=integration ./...` for bills (pass), `go test ./...` for the openapi module (pass), `gofmt`/`go vet` clean, `actionlint` on the workflow (pass). * **Manual verification:** N/A — backend-only change, no UI surface. The seam test is the verification; it builds a real artifact and reads it back through the serving repository. ## Screenshots N/A — no UI change. ## Related issue - Closes: https://linear.app/riddimsoftware/issue/EPAC-2304/lock-bills-sqlite-artifact-schema-with-a-producer-to-consumer-seam Co-authored-by: riddim-developer-bot <developer-bot@riddimsoftware.com>
1 parent 4eafcd1 commit 48d67e2

9 files changed

Lines changed: 417 additions & 204 deletions

File tree

.github/workflows/pr-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ jobs:
102102
- name: Run bills-indexer tests
103103
working-directory: backend/bills-indexer
104104
run: go test ./...
105+
- name: Run bills tests
106+
working-directory: backend/bills
107+
run: go test ./...
105108
- name: Run members-indexer tests
106109
working-directory: backend/members-indexer
107110
run: go test ./...

backend/bills-indexer/main.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ func main() {
3939
}
4040

4141
func run(ctx context.Context) error {
42+
if fixturePath := strings.TrimSpace(os.Getenv("BILLS_FIXTURE_BATCH")); fixturePath != "" {
43+
return runFromFixture(ctx, fixturePath)
44+
}
4245
session, err := sessionFromEnv()
4346
if err != nil {
4447
return err
@@ -66,7 +69,7 @@ func run(ctx context.Context) error {
6669
)
6770
writer := sqliteadapter.NewWriter(sqliteadapter.WithLogger(logger))
6871
store := s3adapter.NewStore(awss3.NewFromConfig(awsCfg), bucket, prefix, s3adapter.WithLogger(logger))
69-
72+
7073
dbPath := firstEnvDefault(defaultDBPath, "DB_PATH", "BILLS_DB_PATH")
7174
return runPipeline(ctx, session, prefix, bucket, source, writer, store, store, dbPath)
7275
}
@@ -109,6 +112,37 @@ func runPipeline(
109112
return nil
110113
}
111114

115+
// runFromFixture builds the SQLite artifact from a local JSON batch instead of
116+
// fetching from LEGISinfo and uploading to S3. The bills serving module's
117+
// producer-to-consumer seam test (EPAC-2304) sets BILLS_FIXTURE_BATCH and
118+
// DB_PATH to drive the real writer, then reads the on-disk artifact back with
119+
// the serving repository so SQLite schema drift fails at build time. The
120+
// deployed pipeline never sets BILLS_FIXTURE_BATCH.
121+
func runFromFixture(ctx context.Context, fixturePath string) error {
122+
data, err := os.ReadFile(fixturePath)
123+
if err != nil {
124+
return fmt.Errorf("read fixture batch: %w", err)
125+
}
126+
var batch domain.Batch
127+
if err := json.Unmarshal(data, &batch); err != nil {
128+
return fmt.Errorf("decode fixture batch: %w", err)
129+
}
130+
dbPath := firstEnvDefault(defaultDBPath, "DB_PATH", "BILLS_DB_PATH")
131+
writer := sqliteadapter.NewWriter(sqliteadapter.WithLogger(func(payload map[string]any) { logJSON(payload) }))
132+
stats, err := writer.Write(ctx, dbPath, batch)
133+
if err != nil {
134+
return fmt.Errorf("write fixture artifact: %w", err)
135+
}
136+
logJSON(map[string]any{
137+
"pipeline": "bills-indexer",
138+
"event": "fixture_artifact_written",
139+
"db_path": dbPath,
140+
"bill_count": stats.BillCount,
141+
"table_counts": stats.TableCounts,
142+
})
143+
return nil
144+
}
145+
112146
func sessionFromEnv() (domain.Session, error) {
113147
parliament, err := positiveIntFromEnv("PARLIAMENT_NUMBER", defaultParliamentNumber)
114148
if err != nil {
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package main
2+
3+
// This is the producer-to-consumer seam test for the bills SQLite artifact
4+
// (EPAC-2304). The artifact schema is an implicit contract between two separate
5+
// binaries: the bills-indexer writer (producer) and the bills serving
6+
// repository (consumer). Per-unit fixtures on either side can drift apart
7+
// without anyone noticing, because the serving repository used to mask missing
8+
// columns with NULL fallbacks. This test crosses the *real* seam: it drives the
9+
// real producer binary to write a real bills.db from a fixture batch, then
10+
// opens that file with the real serving repository and asserts the served shape
11+
// is populated from columns the producer actually writes — no NULL fallbacks.
12+
//
13+
// The two adapters live in separate Go modules and are internal to each, so
14+
// (matching the issue's guidance) we do not import one's internals into the
15+
// other. The only thing shared across the seam is the on-disk SQLite file, just
16+
// like in production: the producer runs as a binary and the consumer reads its
17+
// output.
18+
//
19+
// Contract decision recorded here and in
20+
// docs/architecture/bills-artifact-contract-epac2304.md: the bills-indexer's
21+
// bill_versions table records only a publication stage name and a canonical
22+
// viewer URL (html_url) per version. It has no label, title, or chamber column
23+
// and never populates a published date. The served BillVersion therefore
24+
// exposes exactly {id, label, stage, source_url}, where label and stage both
25+
// carry the stage name and source_url is html_url. The previously-served title,
26+
// chamber, and published_on fields were always empty and have been dropped from
27+
// the domain model and OpenAPI.
28+
29+
import (
30+
"bytes"
31+
"context"
32+
"database/sql"
33+
"encoding/json"
34+
"os"
35+
"os/exec"
36+
"path/filepath"
37+
"testing"
38+
"time"
39+
40+
sqliteadapter "epac/bills/internal/adapter/sqlite"
41+
42+
_ "modernc.org/sqlite"
43+
)
44+
45+
func TestBillsArtifactSeam(t *testing.T) {
46+
batchJSON := contractFixtureBatchJSON(t)
47+
dbPath := buildContractArtifact(t, batchJSON)
48+
49+
db, err := sql.Open("sqlite", dbPath)
50+
if err != nil {
51+
t.Fatalf("open produced artifact: %v", err)
52+
}
53+
defer db.Close()
54+
db.SetMaxOpenConns(1)
55+
repo := sqliteadapter.New(db, sqliteadapter.WithNow(func() time.Time {
56+
return time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
57+
}))
58+
ctx := context.Background()
59+
60+
// ListBills must read the bills + bill_stages tables the producer wrote.
61+
bills, err := repo.ListBills(ctx)
62+
if err != nil {
63+
t.Fatalf("ListBills: %v", err)
64+
}
65+
if len(bills) != 1 || bills[0].ID != "13543613" || bills[0].Number != "C-2" {
66+
t.Fatalf("ListBills = %+v", bills)
67+
}
68+
if len(bills[0].Stages) != 2 || bills[0].Stages[0].Name != "First reading" {
69+
t.Fatalf("ListBills stages = %+v", bills[0].Stages)
70+
}
71+
72+
// GetBillDepth assembles bill + stages + versions + amendments from the
73+
// produced artifact.
74+
bill, err := repo.GetBillDepth(ctx, "13543613")
75+
if err != nil {
76+
t.Fatalf("GetBillDepth: %v", err)
77+
}
78+
if bill.ID != "13543613" || bill.Title == "" {
79+
t.Fatalf("GetBillDepth bill = %+v", bill)
80+
}
81+
if len(bill.Stages) != 2 {
82+
t.Fatalf("GetBillDepth stages = %+v", bill.Stages)
83+
}
84+
if len(bill.Versions) != 2 {
85+
t.Fatalf("GetBillDepth versions = %+v", bill.Versions)
86+
}
87+
// Promised version columns are populated from real producer columns.
88+
v1 := bill.Versions[0]
89+
if v1.ID != "C-2-v1" || v1.Label != "First Reading" || v1.Stage != "First Reading" {
90+
t.Fatalf("version[0] = %+v", v1)
91+
}
92+
if v1.SourceURL == "" {
93+
t.Fatalf("version[0] source_url empty; expected the producer html_url")
94+
}
95+
// Amendments expose the producer-backed fields (id, source_url).
96+
if len(bill.Amendments) != 1 || bill.Amendments[0].ID != "C-2-a1" || bill.Amendments[0].SourceURL == "" {
97+
t.Fatalf("GetBillDepth amendments = %+v", bill.Amendments)
98+
}
99+
100+
// GetBillVersionDiff is the bill-diff route's read. Both endpoints of the
101+
// diff and every clause must be populated from the produced artifact.
102+
diff, err := repo.GetBillVersionDiff(ctx, "13543613", "C-2-v1", "C-2-v2")
103+
if err != nil {
104+
t.Fatalf("GetBillVersionDiff: %v", err)
105+
}
106+
if diff == nil {
107+
t.Fatal("GetBillVersionDiff = nil; expected a populated diff")
108+
}
109+
if diff.From.ID != "C-2-v1" || diff.From.Label != "First Reading" || diff.From.Stage != "First Reading" || diff.From.SourceURL == "" {
110+
t.Fatalf("diff.From = %+v", diff.From)
111+
}
112+
if diff.To.ID != "C-2-v2" || diff.To.Label != "Third Reading" || diff.To.Stage != "Third Reading" || diff.To.SourceURL == "" {
113+
t.Fatalf("diff.To = %+v", diff.To)
114+
}
115+
if len(diff.Clauses) != 2 {
116+
t.Fatalf("diff.Clauses = %+v", diff.Clauses)
117+
}
118+
if diff.Clauses[0].ID != "C-2-clause-1" || diff.Clauses[0].ChangeType != "added" || diff.Clauses[0].FromText != "" || diff.Clauses[0].ToText == "" {
119+
t.Fatalf("clause[0] = %+v", diff.Clauses[0])
120+
}
121+
if diff.Clauses[1].ID != "C-2-clause-2" || diff.Clauses[1].ChangeType != "modified" ||
122+
diff.Clauses[1].HansardAnchorURL == nil || *diff.Clauses[1].HansardAnchorURL == "" {
123+
t.Fatalf("clause[1] = %+v", diff.Clauses[1])
124+
}
125+
}
126+
127+
// buildContractArtifact builds the bills-indexer binary and runs it in offline
128+
// fixture mode to write a real SQLite artifact from batchJSON, returning the
129+
// path to the produced bills.db. Only the on-disk file crosses the seam.
130+
func buildContractArtifact(t *testing.T, batchJSON []byte) string {
131+
t.Helper()
132+
133+
indexerDir, err := filepath.Abs("../bills-indexer")
134+
if err != nil {
135+
t.Fatalf("resolve bills-indexer dir: %v", err)
136+
}
137+
if _, err := os.Stat(filepath.Join(indexerDir, "main.go")); err != nil {
138+
t.Fatalf("bills-indexer producer not found at %s: %v", indexerDir, err)
139+
}
140+
141+
tmp := t.TempDir()
142+
fixturePath := filepath.Join(tmp, "batch.json")
143+
if err := os.WriteFile(fixturePath, batchJSON, 0o644); err != nil {
144+
t.Fatalf("write fixture batch: %v", err)
145+
}
146+
dbPath := filepath.Join(tmp, "bills.db")
147+
binPath := filepath.Join(tmp, "bills-indexer-bin")
148+
149+
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
150+
defer cancel()
151+
152+
build := exec.CommandContext(ctx, "go", "build", "-o", binPath, ".")
153+
build.Dir = indexerDir
154+
var buildOut bytes.Buffer
155+
build.Stdout = &buildOut
156+
build.Stderr = &buildOut
157+
if err := build.Run(); err != nil {
158+
t.Fatalf("build bills-indexer producer: %v\n%s", err, buildOut.String())
159+
}
160+
161+
run := exec.CommandContext(ctx, binPath)
162+
run.Env = append(os.Environ(),
163+
"BILLS_FIXTURE_BATCH="+fixturePath,
164+
"DB_PATH="+dbPath,
165+
)
166+
var runOut bytes.Buffer
167+
run.Stdout = &runOut
168+
run.Stderr = &runOut
169+
if err := run.Run(); err != nil {
170+
t.Fatalf("run bills-indexer producer offline: %v\n%s", err, runOut.String())
171+
}
172+
if _, err := os.Stat(dbPath); err != nil {
173+
t.Fatalf("producer did not write artifact at %s: %v\n%s", dbPath, err, runOut.String())
174+
}
175+
return dbPath
176+
}
177+
178+
// contractFixtureBatchJSON is a representative bills-indexer batch serialized
179+
// with the producer domain's field names. Keeping it here (rather than
180+
// importing the producer's internal domain types) preserves the module
181+
// boundary: the producer decodes it into its own domain.Batch.
182+
func contractFixtureBatchJSON(t *testing.T) []byte {
183+
t.Helper()
184+
185+
batch := map[string]any{
186+
"Bills": []map[string]any{{
187+
"ID": "13543613",
188+
"Number": "C-2",
189+
"Title": "An Act respecting certain measures relating to border security",
190+
"ShortTitle": "Strong Borders Act",
191+
"SponsorName": "Hon. Example Minister",
192+
"Status": "At third reading",
193+
"CurrentStage": "Third reading",
194+
"IntroducedOn": "2026-05-01",
195+
"SourceURL": "https://www.parl.ca/legisinfo/en/bill/45-1/c-2",
196+
"BillType": "House Government Bill",
197+
"Parliament": 45,
198+
"Session": 1,
199+
"LegisInfoURL": "https://www.parl.ca/legisinfo/en/bill/45-1/c-2",
200+
"Stages": []map[string]any{
201+
{"ID": "60029", "Name": "First reading", "Chamber": "House of Commons", "State": "Completed", "CompletedDate": "2026-05-01", "SortOrder": 1, "IsCompleted": true},
202+
{"ID": "60030", "Name": "Third reading", "Chamber": "House of Commons", "State": "In progress", "SortOrder": 2, "IsCompleted": false},
203+
},
204+
"Versions": []map[string]any{
205+
{"ID": "C-2-v1", "PublicationID": "1001", "Stage": "First Reading", "StageSlug": "first-reading", "HTMLURL": "https://www.parl.ca/DocumentViewer/en/45-1/bill/C-2/first-reading", "XMLURL": "https://www.parl.ca/Content/Bills/451/Government/C-2/C-2_1/C-2_E.xml", "Source": "LEGISinfo publication", "SortOrder": 1},
206+
{"ID": "C-2-v2", "PublicationID": "1002", "Stage": "Third Reading", "StageSlug": "third-reading", "HTMLURL": "https://www.parl.ca/DocumentViewer/en/45-1/bill/C-2/third-reading", "XMLURL": "https://www.parl.ca/Content/Bills/451/Government/C-2/C-2_3/C-2_E.xml", "Source": "LEGISinfo publication", "SortOrder": 2},
207+
},
208+
"Diffs": []map[string]any{{
209+
"ID": "C-2-diff-v1-v2",
210+
"FromVersionID": "C-2-v1",
211+
"ToVersionID": "C-2-v2",
212+
"SourceURL": "https://www.parl.ca/DocumentViewer/en/45-1/bill/C-2/diff",
213+
"Clauses": []map[string]any{
214+
{"ID": "C-2-clause-1", "Label": "1", "ChangeType": "added", "FromText": "", "ToText": "Inserted clause 1 text.", "HansardAnchorURL": nil},
215+
{"ID": "C-2-clause-2", "Label": "2", "ChangeType": "modified", "FromText": "Old clause 2 text.", "ToText": "New clause 2 text.", "HansardAnchorURL": "https://www.ourcommons.ca/hansard#clause-2"},
216+
},
217+
}},
218+
"Amendments": []map[string]any{
219+
{"ID": "C-2-a1", "EventID": "event-1", "StageName": "Consideration in committee", "AmendmentNoteID": "note-1", "AmendmentCount": 3, "SourceURL": "https://www.parl.ca/DocumentViewer/en/45-1/bill/C-2/amendments"},
220+
},
221+
}},
222+
}
223+
224+
data, err := json.Marshal(batch)
225+
if err != nil {
226+
t.Fatalf("marshal fixture batch: %v", err)
227+
}
228+
return data
229+
}

0 commit comments

Comments
 (0)