Skip to content

Backend skeleton: health, one catalog endpoint, OpenAPI and image #2

Closed

Description

@ABilenduke

Serve a health endpoint and one catalog endpoint from Postgres, publish the OpenAPI spec and its SDK, and ship the image the platform runs. Step 2 of the walking skeleton.

Status

Stage Track Next action Branch PR
done standard Done feat/2-backend-skeleton #3, #4

Appetite: 6h active · Estimate: 6h50m judgement; forecast P50 52m, P80 59m · Active: 1h 20m (from the time comment)

v0.1.0 is released and verified: ghcr.io/reference-systems-lab/commerce-backend:0.1.0@sha256:3e17e6003767524951a0626cdfd9452fcfa51f746cac9eff88cbb0ea42660505 and @reference-systems-lab/commerce-api@0.1.0. See the retro comment.

Brief

Idea: "Create the epics for the remaining repos." This is step 2 of the walking skeleton (Reference-Systems-Lab/commerce#4): the backend serves its first data from Postgres, publishes its API contract, and ships the image the platform runs.

Problem and audience: The platform runs (Reference-Systems-Lab/commerce-platform#2), but no application does. Two consumers wait on the backend:

  • the storefront (step 4), which needs an endpoint and the generated SDK;
  • the platform, which needs an image and a compose fragment to run.

The audience is contributors and the frontends that consume the API.

Outcome: After make up on the platform:

  • https://api.rsl-commerce.test/health reports healthy;
  • GET https://api.rsl-commerce.test/v1/products returns seeded products from Postgres, with prices in minor units plus a currency.

Also:

  • the OpenAPI 3.1 spec is committed, and CI fails when it drifts from the code;
  • the openapi-typescript and openapi-fetch SDK package is published;
  • the image is on GHCR, and the backend's compose fragment declares how it runs (the platform's wiring file pins the digest).

Scope.

  • In:
    • a Go 1.27 module with Huma v2;
    • /health (liveness and readiness against Postgres);
    • a goose migration for a minimal products table, with seed rows;
    • a sqlc query and a paginated GET /v1/products;
    • the committed spec and its drift check;
    • the SDK package;
    • a multi-stage build onto distroless/static (DE-5);
    • the compose fragment that declares what the backend needs;
    • CI: golangci-lint v2, govulncheck, tests with testcontainers-go, CodeQL go, Dependabot gomod, and actions pinned by SHA;
    • the backend ADR, as the first PR.
  • Out:
    • auth and sessions (D7);
    • the outbox and RabbitMQ;
    • Valkey and Meilisearch use;
    • payments and the fake gateway (D9);
    • WebSocket (D7's protocol is still open);
    • the billing clock beyond its port;
    • every other domain and endpoint;
    • the platform change that includes the fragment and routes api., which is a separate commerce-platform sub-issue of commerce#4.
  • Deferred: GET /v1/products/{id} and the other catalog endpoints.

Constraints:

  • The backend owns the API, the OpenAPI spec and the generated SDK (README.md:11), plus the schema, migrations and seed data (README.md:21).
  • It declares its infrastructure, and the platform provides it (README.md:29-30; root AGENTS.md: repositories meet only through contracts).
  • Business logic never reads the system clock (AGENTS.md:9-10).
  • The server decides prices (AGENTS.md:19).
  • Decisions in commerce-backend#1:
    • D1: Go 1.27;
    • D2: code-first OpenAPI 3.1 with Huma v2, drift and breaking-change checks, and the SDK package;
    • D3: pgx, sqlc and goose;
    • D4: int64 minor units plus currency, and the Clock port;
    • D6: the CI tools and distroless/static-debian13:nonroot;
    • D8: CACHE_URL for Valkey.
  • Also Choose the tech stacks commerce#3 DE-4 (CI security), DE-5 (containers) and DE-7 (the SDK), and commerce-platform#1 P-D1 (per-app compose.platform.yaml, digest-pinned GHCR images, opt-in local builds).

Known in code:

  • The repo is docs-only: README.md:46-48 says "Planning. No code yet."
  • .githooks/setup holds the git-secrets hooks.
  • The platform answers 503 for api. today (platform/proxy/conf.d/90-not-running.conf:6), and its compose.yaml includes no app fragment.

Track: standard: the spike settled every outside question; what's left is answerable from the code and the decisions. · Appetite: 6h active

ID Research question Track Why it matters to the plan
RQ-1 What does the fragment contract look like? The service name, the network (data plus edge, D-13), the healthcheck with no shell in a distroless image, how secrets are passed (file-sourced, P-D5), and how the image digest gets bumped. code It fixes the interface the platform sub-issue implements
RQ-2 Where does the SDK package live and publish to (GitHub Packages under @reference-systems-lab, like DE-10), and who generates it: backend CI on release, or the consumer? code It decides the release job and the storefront's install path

Assumptions:

  • The SDK is published privately to GitHub Packages under @reference-systems-lab, as the design-system packages are (DE-10). To be confirmed in the plan.
  • The seed data loads through a migration or a seed command. The platform's make seed arrives with this step or the platform sub-issue.
  • The catalog is one GET /v1/products reading seeded rows (@ABilenduke, 2026-10-08).

Revisions

  • 2026-10-08 (plan): the image digest is pinned in the platform's wiring file, not in the backend's fragment, per commerce-platform#1 P-D2 (D-6). The Outcome line was corrected in place. RQ-1 is answered by D-6 and D-7, and RQ-2 by D-1.

Spec

ID Requirement (source) Priority Acceptance
REQ-001 One binary with subcommands (#1 research, Recommendation; commerce-platform#1 P-D2 follow-ups) Must The backend shall provide backend serve, migrate, seed, healthcheck and openapi.
If the subcommand is unknown or missing, then it shall print usage and exit 2.
If a required environment variable is missing, then serve, migrate and seed shall exit non-zero with a message naming the variable.
REQ-002 Configuration from the environment and files (commerce-platform#1 P-D5; commerce-platform#2 D-5) Must The backend shall read DATABASE_URL, which carries no password, and the password from the file named by DATABASE_PASSWORD_FILE.
PORT defaults to 8080.
If the password file is missing or empty, then it shall exit non-zero, naming the variable but not the value.
The password shall never appear in logs, errors or the spec.
REQ-003 Health (brief) Must While Postgres answers a ping within 2 s, GET /health shall return 200 {"status":"ok"}.
If it doesn't, then GET /health shall return 503 {"status":"unavailable"}.
When backend healthcheck runs, it shall call http://127.0.0.1:$PORT/health and exit 0 on 200, or 1 otherwise or after a 3 s timeout, with no shell.
REQ-004 Schema and migrations (#1 D3; README.md:21) Must When backend migrate runs, it shall apply the goose migrations embedded in the binary, and a second run shall apply nothing and exit 0.
The products table has:
• id bigint identity, the primary key;
• slug text, unique;
• name text, not null;
• price_amount bigint, >= 0;
• price_currency char(3), matching ^[A-Z]{3}$;
• created_at timestamptz.
REQ-005 Seed (brief; commerce-platform#1 FA60) Must When backend seed runs, it shall upsert 6 fixed products by slug. A second run shall leave the row count and every value unchanged.
REQ-006 Catalog list (brief; #1 D4) Must When a client sends GET /v1/products?limit=&cursor=, the backend shall return 200 with {"items":[{"id","slug","name","price":{"amount","currency"}}],"next_cursor"}.
• Items are ordered by id.
• amount is in minor units.
• limit is 1–100 and defaults to 20.
• next_cursor is null on the last page.
Given the 6 seeded products and limit=4, following next_cursor shall return each product exactly once, over 2 pages.
If limit is outside 1–100 or cursor is malformed, then it shall return 400 as application/problem+json, with no SQL or internals in the body.
REQ-007 HTTP hygiene (commerce#3 DE-5; commerce-platform#1 §3 headers) Must Every response shall carry X-Content-Type-Options: nosniff and Cache-Control: no-store, and never Strict-Transport-Security (the proxy owns it).
The server shall set non-zero read-header, read, write and idle timeouts.
When it receives SIGTERM, it shall stop accepting connections and exit 0 once in-flight requests finish, within 10 s.
REQ-008 OpenAPI contract (#1 D2) Must When backend openapi runs, it shall print the OpenAPI 3.1 document without a database.
The document is committed as api/openapi.json.
If the committed file differs from the generated one, then CI shall fail.
If oasdiff breaking against the base branch's api/openapi.json reports an error-level change, then CI shall fail. The check is skipped when the base branch has no spec.
REQ-009 Boundaries and clock (AGENTS.md:8-10; #1 D4, D6) Must If code in internal/catalog imports a domain package other than internal/platform/..., then go-arch-lint shall fail.
If code outside internal/platform/clock calls time.Now, then golangci-lint (forbidigo) shall fail.
REQ-010 Image (#1 D6; commerce#3 DE-5) Must The Dockerfile shall build onto gcr.io/distroless/static-debian13:nonroot, with every FROM pinned by digest. The image:
• runs as 65532:65532;
• uses an exec-form ENTRYPOINT ["/backend"];
• sets HEALTHCHECK CMD ["/backend","healthcheck"] and the org.opencontainers.image.source label;
• builds for linux/amd64 and linux/arm64.
REQ-011 Compose fragment (commerce-platform#1 P-D2; DE-5) Must compose.platform.yaml shall define only backend-migrate (runs migrate and exits) and backend-api (runs serve). Each service:
• sets no image:, ports, networks or depends_on, and names no infrastructure host;
• takes every value the platform provides as ${VAR:?} (the secret's path, /run/secrets/postgres_password, is fixed by Compose);
• is read_only with cap_drop: [ALL], no-new-privileges, init and a sized /tmp tmpfs;
• uses the secret postgres_password.
backend-api also has the healthcheck.
When CI includes the fragment from a stub platform file (Postgres plus a stub wiring file with a locally built image), backend-migrate shall exit 0, backend-api shall become healthy, and /v1/products shall return the seeded rows after seed.
REQ-012 SDK package (#1 D2; commerce#3 DE-7, DE-10) Must sdk/ shall build @reference-systems-lab/commerce-api from api/openapi.json:
• openapi-typescript paths types;
• a typed openapi-fetch createClient;
• ESM only;
• the DE-2 hardened .npmrc;
• publishConfig.registry set to https://npm.pkg.github.com.
On every PR, CI shall type-check a usage sample against the freshly generated types. A wrong field name in the sample shall fail it.
REQ-013 CI and updates (commerce#3 DE-4; #1 D6) Must On every PR, CI shall pass with contents: read, persist-credentials: false, a timeout per job and a concurrency group. It runs:
• golangci-lint v2;
• go-arch-lint;
• govulncheck;
• go test -race, with testcontainers-go on the platform's postgres:18.6-trixie digest;
• sqlc diff;
• the two spec checks;
• hadolint;
• the fragment job;
• the SDK type-check.
Every uses: is a 40-hex SHA with a # vX.Y.Z comment. CodeQL's matrix adds go. Dependabot covers gomod, docker, github-actions and npm (/sdk), with groups and a 7-day cooldown.
REQ-014 Release (@ABilenduke, 2026-10-08; DE-4) Must When a GitHub release vX.Y.Z is published, the release workflow shall:
• push ghcr.io/reference-systems-lab/commerce-backend:X.Y.Z for amd64 and arm64, with an SBOM, provenance and an actions/attest build-provenance attestation;
• run Grype on the image and fail on a critical finding;
• attach openapi.json to the release;
• publish the SDK at X.Y.Z to GitHub Packages, with an attested tarball.
Only the publishing jobs hold write scopes, and the SDK is built without them.
If the tag isn't on a commit on main, then nothing shall publish.
If a step fails, then nothing after it shall publish.
If a version is already published, then a rerun shall not push it again.
REQ-015 v0.1.0 (@ABilenduke, 2026-10-08) Must When the code PR has merged and @ABilenduke approves, v0.1.0 shall be released. Then:
• an anonymous docker pull ghcr.io/reference-systems-lab/commerce-backend@<digest> succeeds;
• gh attestation verify --owner Reference-Systems-Lab passes for the image and the SDK tarball;
• npm install @reference-systems-lab/commerce-api@0.1.0 with a read:packages token type-checks the sample.
REQ-016 Docs (root AGENTS.md ADRs; commerce#4) Must docs/adr/0001-backend-stack.md shall record #1 D1–D9 and this slice's decisions D-1 to D-9, landing first in its own PR (Part of #2).
README.md shall cover running it under the platform, the subcommands, the environment, releasing and the SDK.

Quality requirements:

  • Security: no secret in images, logs, errors or api/openapi.json; distroless with no shell.
  • Reliability: migrate, seed and the release workflow are idempotent.
  • Hypotheses, measured and not gates: the compressed image is under 30 MB (the spike measured 4.9 MB, F100), and GET /v1/products answers in under 50 ms locally.

Non-goals:

Plan

Approach

The repo is docs-only at 38a8548:

  • README.md and AGENTS.md;
  • .github/workflows/codeql.yml:24 analyzes only actions, and pins actions by tag (:27,30,36);
  • .github/dependabot.yml:3-8 covers only github-actions;
  • .githooks/setup:14 adds the git-secrets patterns;
  • .gitignore ignores only .agent/.

Everything below is proposed.

Binary. A single static backend binary built from cmd/backend. Its subcommands are a stdlib switch (D-4): serve, migrate, seed, healthcheck and openapi.

Layers (enforced by .go-arch-lint.yml):

  • internal/platform/{config,db,clock,httpserver}: shared infrastructure that domains may use;
  • internal/catalog/{store,service,http}: the first domain, which may import only platform;
  • internal/health.

clock defines the Clock port with a system implementation. forbidigo bans time.Now elsewhere (#1 D4).

HTTP.

  • Huma v2 on the Go 1.22+ net/http mux (humago adapter). Huma validates parameters and writes RFC 9457 problem details.
  • A middleware adds nosniff and no-store.
  • httpserver sets the timeouts and drains on SIGTERM.
  • Logs are slog JSON, with the database password never in them, since it is read from a file into pgxpool.Config and never into a URL (D-7).

Data.

  • pgx v5 pool.
  • goose migrations embedded with embed (db/migrations/00001_products.sql).
  • sqlc generates internal/catalog/store from db/queries/catalog.sql, and sqlc diff in CI catches stale code.
  • The keyset cursor is base64url of the last id, validated on decode (D-8).
  • seed uses INSERT … ON CONFLICT (slug) DO UPDATE for 6 fixed products.

Contract.

  • backend openapi marshals Huma's OpenAPI() (3.1), so api/openapi.json is generated, never hand-edited.
  • CI regenerates it and runs git diff --exit-code.
  • oasdiff breaking (a pinned image) compares it with the base branch's copy.

SDK (sdk/, D-1):

  • npm run generate runs openapi-typescript against ../api/openapi.json and produces schema.d.ts;
  • index.js and index.d.ts export createClient from openapi-fetch, typed with paths;
  • the version is set from the release tag;
  • the DE-2 .npmrc; Node 24 (26 from 2026-10-28).

Container and fragment.

  • Dockerfile:
    • the first stage is golang:1.27.x-trixie@sha256 on $BUILDPLATFORM, cross-compiling with GOOS/GOARCH from TARGETARCH, CGO_ENABLED=0, -trimpath and -ldflags=-s -w (D-9);
    • the final stage is distroless/static-debian13:nonroot@sha256.
  • compose.platform.yaml:
    • backend-migrate (command migrate, restart: "no") and backend-api (command serve, plus the healthcheck);
    • DATABASE_URL: ${BACKEND_DATABASE_URL:?}, DATABASE_PASSWORD_FILE: /run/secrets/postgres_password, secrets: [postgres_password], and the hardening;
    • no image:, networks or depends_on (D-6). The platform's wiring file (Run applications from their compose fragments commerce-platform#7) adds them, with backend-api depending on backend-migrate: service_completed_successfully (D-5).
  • ci/compose.stub.yaml: the platform's Postgres digest, the secret from a temp file, and an include: of [../compose.platform.yaml, ci/compose.wiring.yaml]. The wiring file uses a local build, which proves the merge the platform relies on (commerce-platform#1 FA8, FA20).

CI (.github/workflows/ci.yml):

  • lint: golangci-lint, go-arch-lint, sqlc diff, the spec drift and oasdiff checks, hadolint, and the SHA-pin check.
  • test: go test -race ./... with testcontainers-go.
  • vuln: govulncheck.
  • fragment: the stub stack, with up --wait, curls, a docker inspect hardening check and down -v always.
  • sdk: npm ci, generate, type-check.

Every action is SHA-pinned. CodeQL is re-pinned and adds go.

Release (.github/workflows/release.yml, on release: published, D-2):

  • image: buildx for amd64 and arm64 with --sbom and --provenance, a push to GHCR, Grype failing on critical, and actions/attest-build-provenance on the digest. It needs packages: write, id-token: write and attestations: write.
  • assets: uploads openapi.json with contents: write.
  • sdk: depends on image. It sets the version from the tag, runs npm pack, attests the tarball, then npm publish with packages: write.

Alternatives not chosen:

  • humacli or cobra (more dependencies);
  • running migrations at serve start (it races between replicas, and P-D2 asks for separate commands);
  • offset pagination (it drifts under inserts);
  • publishing the SDK from the storefront (DE-7 says the backend publishes it);
  • release-please (the user chose manual releases).

Steps

  1. S1: Backend ADR (REQ-016).
  2. S2: Binary, config and health (REQ-001, REQ-002, REQ-003, REQ-007).
    • go.mod, cmd/backend, internal/platform/*, internal/health, and the Huma server.
    • Check:
      • unit tests for usage and exit 2, missing variables, the password redacted from errors and logs, and the headers;
      • testcontainers tests for /health 200, then 503 after the container stops;
      • healthcheck exit codes;
      • a SIGTERM drain test with a slow handler.
    • Estimate 45m · Basis: P50 5m, P80 8m (build, 10)
  3. S3: Schema, seed and catalog (REQ-004, REQ-005, REQ-006). Depends on S2.
    • The migration, sqlc and the store; the catalog service and handler; seed.
    • Check, as testcontainers integration tests:
      • migrate twice, then goose status;
      • seed twice gives the same count and a hash of the rows;
      • with limit=4, 2 pages return 6 unique ids;
      • limit=0, limit=101 and cursor=%%% return 400 problem+json, with no pq/SQL text;
      • inserting a negative price or the currency usd violates a constraint.
    • Estimate 1h · Basis: P50 6m, P80 11m (build, 10)
  4. S4: OpenAPI contract (REQ-008). Depends on S3.
    • backend openapi, the committed api/openapi.json, make spec and make check-spec (proposed).
    • Check: editing a summary without regenerating fails drift; renaming next_cursor fails oasdiff against a saved copy.
    • Estimate 30m · Basis: P50 3m, P80 5m (build, 10)
  5. S5: Image and fragment (REQ-010, REQ-011). Depends on S3.
    • Dockerfile, compose.platform.yaml, ci/compose.stub.yaml and ci/compose.wiring.yaml.
    • Check:
      • docker buildx build --platform linux/amd64,linux/arm64 succeeds;
      • the stub stack comes up with --wait, then run --rm backend-api seed, and curl gets /health 200 and the 6 products;
      • docker inspect shows the user, read-only root, capabilities and no ports;
      • the image size is recorded.
    • Estimate 45m · Basis: P50 5m, P80 8m (build, 10)
  6. S6: Lint, boundaries and CI (REQ-009, REQ-013). Depends on S2–S5.
    • .golangci.yml (forbidigo, gosec, depguard), .go-arch-lint.yml, ci.yml, the codeql.yml re-pin plus go, and dependabot.yml.
    • Check:
      • a planted time.Now in catalog fails;
      • a planted catalog → health import fails;
      • every uses: matches @[0-9a-f]{40};
      • the first CI run on the PR is green.
    • Estimate 45m · Basis: P50 5m, P80 8m (build, 10)
  7. S7: SDK package (REQ-012). Depends on S4. Independent of S5 and S6.
    • sdk/package.json, .npmrc, the generate script, index.js and index.d.ts, sample.ts and tsconfig.json.
    • Check: the sample type-checks, and changing items to item in it fails tsc.
    • Estimate 30m · Basis: P50 3m, P80 5m (build, 10)
  8. S8: Release workflow (REQ-014). Depends on S5 and S7.
    • release.yml.
    • Check: actionlint and the pin check pass; the permissions are reviewed per job. The real run is S10.
    • Estimate 45m · Basis: P50 5m, P80 8m (build, 10)
  9. S9: README (REQ-016).
    • Replace "Planning. No code yet." with how to run it, the commands, the environment, releasing and the SDK.
    • Check: inspection.
    • Estimate 15m · Basis: P50 2m, P80 3m (build, 10)
  10. qa: Adversarial review and rehearsal (all REQs).
    • Independent read-only reviewers for security, containers and compose, Go correctness, and acceptance coverage. Then a rehearsal from a clean clone: make targets, the stub stack, and the SDK (the rule proposed in commerce-platform#6).
    • Check: each finding is fixed with its own commit or recorded.
    • Estimate 45m · Basis: P50 5m, P80 10m (all, 19)
  11. S10: v0.1.0 (REQ-015). After the code PR merges, and only with @ABilenduke's go-ahead.
    • gh release create v0.1.0, watch release.yml, then:
      • make the GHCR package public if an anonymous pull fails;
      • gh attestation verify for the image and the SDK;
      • install the SDK with a read:packages token and type-check the sample;
      • grant commerce-storefront Read on the SDK package.
    • Estimate 20m · Basis: P50 2m, P80 4m (build, 10)

Total: judgement 6h50m. The forecast gives P50 52m and P80 59m (worklog forecast --repo Reference-Systems-Lab/commerce-platform, per commerce#4 D-1; 10 past build steps, all from one issue). With QA and fixes the total is unchanged (+0% over 3 issues). Appetite: 6h. If time passes the appetite, the cuts are, in order: arm64, the oasdiff check, then the Grype gate.

Tests

Requirement Test or check (planned)
REQ-001 Unit: the subcommand table, usage and exit 2, missing-variable messages
REQ-002 Unit: password from the file, missing or empty file, redaction in errors and logs; grep the spec for the value
REQ-003 testcontainers: 200, then 503 after Stop; the healthcheck exit codes against an httptest server and a closed port
REQ-004 testcontainers: migrate twice, goose status, constraint violations
REQ-005 testcontainers: seed twice, the same count and row hash
REQ-006 testcontainers plus httptest: the pages, the cursor walk, 400s, problem+json content type, no internals
REQ-007 Unit: headers on 200, 400 and 503; server timeouts set; a SIGTERM drain with a 1 s handler
REQ-008 CI: drift check and oasdiff; locally, a planted drift and a planted breaking change
REQ-009 CI: go-arch-lint and golangci-lint; locally, planted violations
REQ-010 buildx for two platforms; docker inspect (user, entrypoint, healthcheck, label); hadolint
REQ-011 CI fragment job: up --wait, seed, curls, the hardening inspect, down -v
REQ-012 CI sdk job: generate and type-check the sample; locally, a planted wrong field
REQ-013 The PR's checks all pass; the pin grep
REQ-014 actionlint; the v0.1.0 run in S10
REQ-015 S10: anonymous pull, gh attestation verify for both artifacts, SDK install and type-check
REQ-016 Inspection

Risks

Risk (pre-mortem: "it failed because…") Likelihood Impact Mitigation
The fragment's secrets: [postgres_password] doesn't resolve under include, because the secret is defined in the platform's file Medium Medium The stub CI job proves it in S5. If it fails, the fragment declares the secret and the wiring file sets its file: (recorded for commerce-platform#7)
GHCR creates the package as private, so the platform can't pull anonymously High Medium S10 tests an anonymous pull; the user flips visibility once (P-D2 wants public images)
The first PR has no base spec, so oasdiff errors High Low Skip when the base has no api/openapi.json
testcontainers is slow or flaky in CI Low Low One Postgres container per package, pinned by digest, with job timeouts
The arm64 build breaks Low Low Pure-Go cross-compile with no QEMU; the first cut if it costs time
Information disclosure: errors leak SQL or the password Low Medium Problem details only, password redaction tests, and a grep of the spec
Tampering or denial of service: cursor or limit abuse Low Low Opaque, validated cursors, limit ≤ 100, server timeouts
Elevation through the release workflow's write tokens Low High Write scopes only on the jobs that publish, SHA-pinned actions, no pull_request_target, triggered only by a published release

Rollout and rollback

Nothing consumes the API until commerce-platform#7 wires it in. In the repo, rollback means reverting the PR. A bad release is fixed by releasing a new version, never by moving a tag. The platform keeps its previous digest pin until it is bumped.

Definition of Ready

  • Every Must has acceptance and a step that names it
  • No blocking decision is Open
  • Every step has an estimate and a basis, and the total fits the appetite (P80 59m against 6h)
  • Approved by @ABilenduke on 2026-10-08

Definition of Done

Revisions

  • 2026-10-08 (qa): two acceptance criteria were reworded, with the same IDs and no scope change.
    • REQ-011: "every environment value as ${VAR:?}" now reads "every value the platform provides". The plan itself prescribes the literal DATABASE_PASSWORD_FILE: /run/secrets/postgres_password, which Compose fixes.
    • REQ-014: "a job that alone holds packages: write" was impossible, because pushing to GHCR also needs it. It now reads that only the publishing jobs hold write scopes and the SDK is built without them. The criterion also gains three checks from the review: the tag must point at a commit on main, each stage stops everything after it, and a rerun never republishes a version.

Decisions

ID Decision Kind Source
D-1 The SDK is @reference-systems-lab/commerce-api, published privately to GitHub Packages, with consumer repos granted Read through "Manage Actions access" (as commerce#3 DE-10). Answers RQ-2. Confirmed @ABilenduke, 2026-10-08
D-2 Releases are manual GitHub releases vX.Y.Z. One workflow publishes the GHCR image, openapi.json and the SDK at that version. Confirmed @ABilenduke, 2026-10-08
D-3 v0.1.0 is cut in this issue (S10), after the code PR merges and with @ABilenduke's go-ahead. Confirmed @ABilenduke, 2026-10-08
D-4 Subcommands are a stdlib switch, not humacli or cobra, for a smaller supply chain. Default Plan; #1 research F96
D-5 migrate runs as a one-shot backend-migrate service. The platform makes backend-api wait for it (service_completed_successfully). seed runs through make seed (run --rm backend-api seed). Default Plan; commerce-platform#1 §2
D-6 The fragment sets no image:, networks or depends_on. The platform's wiring file compose/compose.backend.yaml holds the digest pin and the wiring. Answers RQ-1, and corrects the brief, which put the pin in the fragment. Confirmed commerce-platform#1 P-D2
D-7 The Postgres password reaches the backend only as the postgres_password secret, via DATABASE_PASSWORD_FILE. It never appears in a URL or the environment. DATABASE_URL comes from the platform as ${BACKEND_DATABASE_URL:?}. Default Plan; commerce-platform#2 D-5
D-8 Money in JSON is {amount: int64 minor units, currency: ISO 4217}. The list uses keyset pagination with an opaque base64url cursor. Default Plan; #1 D4
D-9 The image is built for linux/amd64 and linux/arm64 by Go cross-compilation on $BUILDPLATFORM, with no QEMU, so macOS contributors on Apple silicon run it natively. Default Plan; commerce-platform#1 P-D0b
D-10 The plan is approved. Confirmed @ABilenduke, 2026-10-08 ("I approve the plan")

ADR: docs/adr/0001-backend-stack.md, written in S1.

Links

Activity

  1. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    Time: issue-2

    Generated by worklog render from time.jsonl. Do not edit by hand.

    Step Estimate Started Finished Wall Active Sessions
    brief 20m 2026-10-08 15:15 2026-10-08 15:17 2m 2m 96f0480c (claude-code)
    plan 30m 2026-10-08 15:42 2026-10-08 15:44 2m 2m 96f0480c (claude-code)
    S1 30m 2026-10-08 16:12 2026-10-08 16:13 1m 1m 96f0480c (claude-code)
    S2 45m 2026-10-08 16:14 2026-10-08 16:18 4m 4m 96f0480c (claude-code)
    S3 1h 2026-10-08 16:18 2026-10-08 16:22 4m 4m 96f0480c (claude-code)
    S4 30m 2026-10-08 16:22 2026-10-08 16:23 1m 1m 96f0480c (claude-code)
    S5 45m 2026-10-08 16:23 2026-10-08 16:26 3m 3m 96f0480c (claude-code)
    S6 45m 2026-10-08 16:26 2026-10-08 16:39 13m 13m 96f0480c (claude-code)
    S7 30m 2026-10-08 16:39 2026-10-08 16:44 5m 5m 96f0480c (claude-code)
    S8 45m 2026-10-08 16:44 2026-10-08 16:47 3m 3m 96f0480c (claude-code)
    S9 15m 2026-10-08 16:47 2026-10-08 16:48 0m 0m 96f0480c (claude-code)
    qa 45m 2026-10-08 16:48 2026-10-08 17:01 13m 13m 96f0480c (claude-code)
    S10 20m 2026-10-08 21:40 2026-10-08 22:48 1h 8m 28m 96f0480c (claude-code)
    review 2026-10-08 22:53 2026-10-08 22:53 0m 0m 96f0480c (claude-code)
    Total 7h 40m 2h 1h 20m

    Active time counts agent turns plus up to 5 minutes of each wait for a person, and no single gap
    counts more than 30 minutes. — means nothing was recorded.

    workflow · claude-code · 2026-10-08 22:53

  2. linked a pull request that will close this issueRecord the backend stack #3on Oct 8, 2026
  3. moved this from Todo to In Progress in commerce workon Oct 8, 2026
  4. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S1: Backend ADR (REQ-016, the ADR part)

    docs/adr/0001-backend-stack.md follows the format of the platform's ADR 0001: status, date, context, decision, alternatives and consequences. It records #1 D1–D9 and this issue's D-1 to D-9, and notes that the WebSocket protocol is still open (#1 D7).

    Findings: None.

    Verification

    Iteration Checks run Result Fix
    1 Read each statement against #1's Decisions and #2's Decisions; the git-secrets hook ran on commit Consistent; commit accepted None

    Decisions and deviations: None.

    Commit: de219ca · PR: #3 ("Part of #2"; @ABilenduke merges) · Status: Complete

    workflow · claude-code · 2026-10-08 16:13

  5. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S2: Binary, config and health (REQ-001, REQ-002, REQ-003, REQ-007)

    New files:

    • cmd/backend/main.go: a stdlib command table (D-4) with serve and healthcheck. migrate, seed and openapi join in S3 and S4. A wrong or missing command prints usage and exits 2; a command error exits 1 with backend <cmd>: <error>. SIGTERM and SIGINT cancel the context.
    • internal/platform/config:
      • DATABASE_URL must be postgres:// with a host and no password, in the userinfo or the query; the password comes from DATABASE_PASSWORD_FILE (D-7);
      • PORT defaults to 8080;
      • Secret prints [redacted] through String, GoString, LogValue and MarshalText, and errors never include the value.
    • internal/platform/db: a pgx pool whose password is set on the config, never in a URL. It doesn't connect at start, so the API starts and reports 503 while Postgres is down.
    • internal/platform/httpserver:
      • Huma v2.39.1 on the stdlib mux, serving the spec at /openapi.json, with no docs page (it would load CDN scripts) and no $schema links;
      • the Headers middleware sets nosniff and no-store, and never HSTS;
      • read-header 5 s, read 10 s, write 15 s, idle 60 s;
      • Run drains for up to 10 s.
    • internal/health: GET /health pings with a 2 s timeout and returns 200 {"status":"ok"} or 503 {"status":"unavailable"}. Both responses are in the spec.
    • internal/platform/clock: the Clock port (System, and Fixed for tests).
    • internal/platform/dbtest: testcontainers-go v0.44.0 on the platform's exact postgres:18.6-trixie@sha256:74935e72… pin.
    • internal/app: wires the domains into the API.

    Findings: Go 1.27.2 is installed in ~/.local/go (checksum verified). @ABilenduke's ~/.profile stops being read at an existing run of NUL bytes after line 27, so the PATH line added there doesn't take effect yet; commands set PATH themselves.

    Verification

    Iteration Checks run Result Fix
    1 go vet ./... && go test -race ./... All pass. config: 8 rejection cases, none leaking the password; formatting and slog JSON redact it. main: exit 2 on no, unknown or extra args; serve with no env exits 1 naming DATABASE_URL; healthcheck exits 0 on 200, and 1 on 503 or a closed port. health (real Postgres 18.6): 200 ok, then 503 unavailable after Stop. httpserver: headers on 200, 400 and 503 with no HSTS; all four timeouts set; an in-flight 1 s request completes after cancel, Run returns nil, and new connections are refused None

    Decisions and deviations: None.

    Commit: 4441b97 · Status: Complete

    workflow · claude-code · 2026-10-08 16:17

  6. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S3: Schema, seed and catalog (REQ-004, REQ-005, REQ-006)

    • migrations/00001_products.sql: the products table as REQ-004 specifies, plus a slug-format check and a name-length check. migrations/migrations.go embeds it.
    • internal/platform/migrate: a goose v3.28.0 provider over the pgx pool (stdlib.OpenDBFromPool, which leaves the pool open), with a Postgres session advisory lock so concurrent migrate runs wait instead of racing.
    • internal/catalog/query.sql: two queries, generated into internal/catalog/store by sqlc 1.31.1 (make generate). ListProducts fetches one row more than the limit to detect a next page. UpsertProduct updates only when a value changed (IS DISTINCT FROM), so reseeding is a no-op.
    • internal/catalog:
      • GET /v1/products with limit 1–100 (default 20) and an opaque cursor: base64url of p:<id>, canonical, positive;
      • next_cursor is always present, and null on the last page;
      • Seed holds 6 fixed USD products.
    • internal/platform/httpserver: maps Huma's 422 for failed parameter validation to 400, which REQ-006 requires.
    • cmd/backend: adds migrate and seed.
    • compose.tools.yaml (sqlc, pinned by digest, no network, hardened) and a Makefile with generate and test.

    Findings: None.

    Verification

    Iteration Checks run Result Fix
    1 make generate; go build ./... && go vet ./... Generated; clean None
    1 go test -race ./... (catalog against Postgres 18.6) All pass:
    • a second migrate applies 0;
    • reseeding keeps the count at 6 and the md5 of every row and column unchanged;
    • a negative price, usd, a duplicate slug and an unsafe slug are all rejected;
    • limit=4 gives 4 items plus a cursor, then 2 items and null, with 6 unique ascending ids;
    • the default page has 6 items, next_cursor is JSON null, and there are no $schema or links fields;
    • limit=0, 101 and x, cursor=%%%, x:1, p:01 and p:-1 are each 400 application/problem+json with no sql, pgx, select or products in the body;
    • the valid cursor p:1 returns 5 items
    None
    2 Mutation: removed the 422→400 mapping and reran the bad-request test It failed (the test catches it); restored None

    Decisions and deviations: The catalog is one package (internal/catalog) plus the generated internal/catalog/store, not the plan's separate store/service/http packages. There is no service layer worth separating for one read. REQ-009's boundary rule applies to internal/catalog/... all the same.

    Commit: e3c8ea4 · Status: Complete

    workflow · claude-code · 2026-10-08 16:22

  7. 1 remaining item

  8. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S5: Image and fragment (REQ-010, REQ-011)

    • Dockerfile:
      • golang:1.27.2-trixie@sha256:e58d6f83… runs on $BUILDPLATFORM and cross-compiles with CGO_ENABLED=0, -trimpath and -s -w -buildid= (D-9);
      • the final stage is distroless/static-debian13:nonroot@sha256:e2e927ec…;
      • USER 65532:65532, exec-form ENTRYPOINT ["/backend"], CMD ["serve"], HEALTHCHECK CMD ["/backend","healthcheck"], and the OCI source label.
    • .dockerignore: only go.mod, go.sum, cmd, internal and migrations reach the context, without tests.
    • compose.platform.yaml:
      • backend-migrate (one-shot, no health check) and backend-api (health check, 15 s stop grace);
      • both read-only as 65532, with cap_drop ALL, no-new-privileges, init and a 16 MB /tmp;
      • DATABASE_URL: ${BACKEND_DATABASE_URL:?…}, and the password only through the postgres_password secret (D-7);
      • no image, ports, networks or depends_on (D-6).
    • ci/compose.stub.yaml and ci/compose.wiring.yaml: a stand-in platform. It runs the platform's Postgres pin with a file secret, and include: path: [fragment, wiring], the merge P-D2 uses.
    • scripts/fragment-smoke.sh (make fragment): up --wait, then:
      • checks backend-migrate exited 0;
      • run --rm backend-api seed;
      • /health returns {"status":"ok"};
      • /v1/products returns 6 products;
      • docker inspect shows the hardening on both services, and the fragment sets none of the four forbidden keys;
      • logs are printed on failure and down -v always runs.

    Findings: The plan's main risk didn't happen. The fragment's secrets: [postgres_password] resolves against the secret the including file defines, so D-7 holds as planned and commerce-platform#7 can define the secret once, as the platform already does.

    Verification

    Iteration Checks run Result Fix
    1 docker build -t commerce-backend:ci . Built; 24.7 MB on disk, 5.5 MB as a gzipped docker save (the hypothesis was under 30 MB; the spike measured 4.9 MB) None
    1 IMAGE=commerce-backend:ci sh scripts/fragment-smoke.sh postgres healthy, then migrate exited 0, then api healthy; seed; health ok; 6 products; hardening exact on both; "fragment: ok" None
    1 docker buildx build --platform linux/amd64,linux/arm64 --output type=cacheonly . Both platforms built None
    1 docker inspect commerce-backend:ci; docker run … nope user=65532:65532 entry=[/backend] cmd=[serve] hc=[CMD /backend healthcheck], with the source label; an unknown command exits 2 with usage None

    Decisions and deviations: None.

    Commit: 7b13f09 · Status: Complete

    workflow · claude-code · 2026-10-08 16:26

  9. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S6: Lint, boundaries and CI (REQ-009, REQ-013)

    • .golangci.yml (golangci-lint v2.14.0):

      • gosec, depguard (no lib/pq, pkg/errors or log), forbidigo, bodyclose, errorlint, noctx, nilerr and misspell, plus the gofmt and goimports formatters;
      • forbidigo bans time.Now everywhere except internal/platform/clock and tests.
    • .go-arch-lint.yml (go-arch-lint v1.19.0): catalog and health may depend only on themselves and platform, platform only on migrations, and only app and cmd know the domains.

    • scripts/lint.sh (make lint) runs, in order:

      1. go mod download and go mod tidy -diff;
      2. golangci-lint;
      3. go-arch-lint;
      4. sqlc diff;
      5. hadolint;
      6. actionlint;
      7. a SHA-pin check on every uses:;
      8. check-spec.

      The tools run from compose.tools.yaml, pinned by digest, with no network. The Go analysers read the module cache read-only (GOPROXY=off, GOTOOLCHAIN=local) and get a 2 GB /tmp.

    • .github/workflows/ci.yml:

      • jobs Lint, Test (make test with testcontainers), Vulnerabilities (go tool govulncheck, pinned through go.mod's tool directive at v1.8.0) and Fragment (the amd64 and arm64 build plus make fragment);
      • contents: read, persist-credentials: false, a timeout per job and a concurrency group.
    • codeql.yml: re-pinned by SHA, adding go (autobuild, Go from go.mod) next to actions.

    • dependabot.yml: github-actions, gomod, docker (the Dockerfile) and docker-compose (/ and /ci), with groups and a 7-day cooldown. Postgres majors are ignored, to follow the platform.

    • go.mod: go 1.27.0 with toolchain go1.27.2, so the analyser images (Go 1.27.1) work offline while CI and builds use 1.27.2.

    Findings:

    • govulncheck found GO-2026-6354, GO-2026-6355 (x/crypto) and GO-2026-6253 (moby/go-archive). All were reachable only through testcontainers in dbtest, never the binary. I upgraded them to x/crypto v0.56.0 and go-archive v0.3.0, both published more than 7 days ago; it now reports 0 affecting vulnerabilities.
    • actionlint 1.7.12 doesn't know the ubuntu-26.04 label, so .github/actionlint.yaml declares it.

    Verification

    Iteration Checks run Result Fix
    1 make lint golangci-lint: "no space left on device" in the 256 MB tool /tmp 2 GB /tmp for the Go analysers
    2 make lint 3 findings: bodyclose (a test's http.Get), gosec G115 (int to int32 on limit), noctx (net.Listen) Closed the body; limit is an int32 parameter (still 1–100); ListenConfig.Listen(ctx, …). The spec regenerated, with limit now int32
    3 make lint go-arch-lint flagged each component's imports of its own subpackages Each component lists itself in mayDependOn
    4 make lint actionlint: unknown label ubuntu-26.04 .github/actionlint.yaml
    5 make lint The pin check flagged codeql.yml's three tag pins (as planned) Re-pinned codeql.yml and added Go
    6 make lint; go tool govulncheck ./... lint ok; 3 vulnerabilities through testcontainers Upgraded x/crypto and go-archive
    7 make lint; govulncheck; go test -race ./... All pass; 0 affecting vulnerabilities None
    7 Planted var _ = time.Now and a catalog → health import forbidigo: "use of time.Now forbidden…"; go-arch-lint: "Component catalog shouldn't depend on …/internal/health"; both non-zero Removed
    8 The first CI run on draft PR #4 Lint, Test, Vulnerabilities, Fragment, Analyze (actions), Analyze (go), CodeQL and the Dependabot config all pass; setup-go used Go 1.27.2 None

    Decisions and deviations: go.mod's go line is 1.27.0, with a toolchain go1.27.2 line, so the pinned analyser images can run offline. The minimum language version is still 1.27 (#1 D1).

    Commit: 403182a · PR: #4 (draft) · Status: Complete

    workflow · claude-code · 2026-10-08 16:39

  10. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S7: SDK package (REQ-012)

    sdk/ is @reference-systems-lab/commerce-api (D-1):

    • Generation: npm run generate runs openapi-typescript 7.13.0 against ../api/openapi.json and writes schema.d.ts. The file is gitignored and generated in CI and at release.
    • Client: index.js wraps openapi-fetch's client. index.d.ts types createClient() as Client<paths> and exports Product, Money and ProductPage.
    • Package: ESM only, with an exports map. publishConfig points at https://npm.pkg.github.com with restricted access, and repository.directory: sdk links it to this repo.
    • Hardening: .npmrc carries the DE-2 settings (strict-allow-scripts, min-release-age=7, allow-git=none, allow-remote=none, engine-strict), with an empty allowScripts and engines.node >=24.
    • Sample: sample.ts pages through /v1/products and reads /health. Two @ts-expect-error lines (a wrong field and a wrong path) fail the check if the types ever go loose.

    Wiring: CI job SDK (Node 24: npm ci, generate, check), make sdk, and Dependabot npm for /sdk.

    Findings:

    • The spec said items could be null. Huma marks a Go slice nullable by default, so the sample failed to type-check against items: Product[] | null, although the handler always sends an array (S3). nullable:"false" on ProductPage.Items fixes it, and the spec is regenerated. The SDK check caught a contract inaccuracy that the Go tests couldn't.
    • openapi-typescript 7.13.0 declares a peer of typescript@^5.x, which conflicts with DE-3's TypeScript ~6.0. An overrides entry gives it our TypeScript 6.0.3, and generation and the check work with it.

    Verification

    Iteration Checks run Result Fix
    1 npm install ERESOLVE: peer typescript@^5.x from openapi-typescript overrides to $typescript
    2 npm run generate && npm run check TS2488: items is … | null nullable:"false" on Items; make spec
    3 npm run generate && npm run check; make lint; go test ./... All pass None
    3 Mutation: data.items changed to data.item in the sample TS2551 "Property 'item' does not exist … Did you mean 'items'?", exit 2 Reverted
    3 node -e "import('./index.js')…"; npm pack --dry-run createClient and client.GET are functions; the tarball has index.js, index.d.ts, schema.d.ts and package.json (2.4 kB) None
    4 CI on PR #4 (2777950) SDK, Lint, Test, Vulnerabilities, Fragment, both Analyze jobs and CodeQL all pass None

    Decisions and deviations: The npm overrides for openapi-typescript's TypeScript peer is new. I'll revisit it when openapi-typescript declares TypeScript 6 support.

    Commit: 2777950 · Status: Complete

    workflow · claude-code · 2026-10-08 16:44

  11. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S8: Release workflow (REQ-014)

    .github/workflows/release.yml runs on release: published (D-2). The workflow default is contents: read, and each job widens only what it needs.

    1. Version: the tag must match ^v[0-9]+\.[0-9]+\.[0-9]+$. It is read through env, never interpolated into a script.
    2. Image (packages: write, id-token: write, attestations: write):
      • builds amd64 locally and runs Grype with fail-build at critical before anything is pushed;
      • then builds and pushes ghcr.io/reference-systems-lab/commerce-backend:X.Y.Z for amd64 and arm64 with sbom: true, provenance: mode=max and a version label;
      • then attest-build-provenance on the digest, pushed to the registry. The digest goes to the step summary.
    3. OpenAPI asset (contents: write, after Image): gh release upload … api/openapi.json --clobber.
    4. SDK (packages: write and the attestation permissions, after Image):
      • npm ci, generate, then check;
      • npm version X.Y.Z and npm pack;
      • attests the tarball;
      • npm publish to GitHub Packages with GITHUB_TOKEN, skipped if that version is already published, so a rerun is safe.

    A failure in Version or Image stops everything after it. The concurrency group per tag doesn't cancel a run in progress.

    Findings: None. The real run is S10.

    Verification

    Iteration Checks run Result Fix
    1 make lint (actionlint, the pin check) ok None
    1 Local Grype v0.120.1 on commerce-backend:ci with --fail-on critical "No vulnerabilities found", exit 0, so the release's gate should pass None
    1 Read the permissions per job Write scopes only on Image, OpenAPI asset and SDK; nothing triggers on pull_request_target None

    Decisions and deviations: The image is built twice: amd64 to scan, then both platforms to push, from the same builder cache. Scanning first keeps REQ-014's rule that a failure publishes nothing.

    Commit: 8f67840 · Status: Complete (the end-to-end proof is S10)

    workflow · claude-code · 2026-10-08 16:47

  12. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    S9: README (REQ-016, the docs part)

    README.md replaces "Planning. No code yet." with these sections:

    • Getting started: Go 1.27 and Docker; make test, lint, fragment and help; running under the platform.
    • Commands: the five subcommands.
    • Configuration: DATABASE_URL without a password, DATABASE_PASSWORD_FILE and PORT.
    • The API and its SDK: the generated spec and its checks; installing @reference-systems-lab/commerce-api from GitHub Packages with the scope line and a read token; a usage example.
    • Releasing: gh release create vX.Y.Z and the four things the workflow does; verifying with gh attestation verify.
    • Decisions: a link to ADR 0001.

    The Responsibilities list also says "Valkey (Redis-compatible)" instead of Redis (commerce#3 DE-12).

    Findings: The ADR link resolves once #3 merges. That branch holds the ADR.

    Verification

    Iteration Checks run Result Fix
    1 Read against the code: the subcommands (cmd/backend/main.go usage), the variables (internal/platform/config), the Make targets (make help) and the release steps (release.yml) Consistent None

    Decisions and deviations: None.

    Commit: c566f05 · Status: Complete

    workflow · claude-code · 2026-10-08 16:48

  13. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    qa: Adversarial review and rehearsal (all REQs)

    Four independent read-only reviewers went over main...HEAD (at c566f05): security (STRIDE), containers and Compose, Go correctness, and acceptance coverage against REQ-001 to REQ-016. I checked each finding against the code or CI before acting on it.

    Fixed (three commits: fbd6125, ccea45e, 0b8de51; and 500a4e3 on the ADR branch):

    Finding (reviewer) Fix Verified by
    Medium: release tags weren't required to point at main (security) The Version job fails unless git merge-base --is-ancestor $GITHUB_SHA origin/main actionlint; S10
    Medium: a rerun re-pushed :X.Y.Z with a new digest (security, containers, acceptance) Image skips the build and push when imagetools inspect finds the version; the SDK already skipped actionlint; S10
    Medium: spec and sdk ran in parallel, so one could publish after the other failed (acceptance) Sequential stages: version, sdk-build, image, sdk-publish, spec actionlint
    Low: npm ci ran in a job that held packages: write and id-token: write (security) A new sdk-build job (read only) packs and uploads the tarball; sdk-publish holds the write scopes and runs no install actionlint
    Medium: make generate and make check-spec failed when run on their own, because ${GOMODCACHE:?} broke interpolation (containers, acceptance) A default path. Reproduced: make generate had silently generated nothing make generate regenerated the store
    Medium: Dependabot can't bump the Postgres pin in dbtest.go (containers) make lint fails unless it matches ci/compose.stub.yaml, which Dependabot does bump Planted a different digest: exit 1 with both values
    Low: reseeding used up 6 ids each run, because ON CONFLICT takes an id first (Go) UpdateProduct (only when changed), then InsertProductIfMissing (INSERT … SELECT … WHERE NOT EXISTS) New test: products_id_seq.last_value unchanged after reseeding; a changed price is restored to 1800
    Low: non-strict base64 let several strings mean one cursor (Go) RawURLEncoding.Strict() New case cDoxMh → 400. Mutation: without Strict(), it got 200
    Low: a limit that divides the total, and a cursor past the end, were untested (Go) Tests for limit=3 (3+3, then null), limit=6, and a cursor past the end ("items":[], null) Pass
    Low: one test mixed valid and invalid cursors (Go) A separate "valid cursor resumes" subtest Pass
    Low: a failed drain didn't close connections (Go) srv.Close() when Shutdown times out Inspection
    Low: no bound on the list query when the database is slow (security) 5 s QueryTimeout on the handler's query Inspection; the existing tests pass
    Low: the stand-in platform didn't model data (internal) and edge (containers) The stub declares both; migrate is on data, the api on data and edge make fragment ok
    Low: \s in grep -E is GNU-only; IMAGE had no effect; there was no infrastructure-hostname check (containers) [[:space:]]; SKIP_BUILD=1 instead of IMAGE; a grep for postgres, valkey, rabbitmq, meilisearch and mailpit in the fragment make fragment ok
    Low: the pin check covered actions only (containers) make lint also requires every image: and FROM to have a digest (except the local commerce-backend:ci) make lint ok
    Low: Dependabot would try to resolve commerce-backend:ci (containers) Ignored in the docker-compose entry Inspection
    Low: make spec could truncate the file on failure (containers) It writes a temporary file, then mv Inspection
    REQ-003: the 2 s and 3 s timeouts were untested (acceptance) A blocking Pinger gives 503 after about 2 s; a server that accepts but never answers makes healthcheck exit 1 after about 3 s Pass
    REQ-007: SIGTERM was only simulated by cancelling a context (acceptance) Builds the real binary, starts serve (503 with no database), sends SIGTERM and expects exit 0 Pass
    REQ-001: the missing-variable test covered only serve (acceptance) The test loops over serve, migrate and seed Pass
    REQ-006: the default of 20 was weakly tested (acceptance) A last subtest adds 15 rows and expects exactly 20 plus a cursor Pass
    REQ-016: the ADR lacked D-3 and coder/websocket (acceptance) Added both, plus the release stages (500a4e3 on PR #3) Re-read
    REQ-011 and REQ-014: the wording contradicted the plan (acceptance) Reworded in place; see the plan's Revisions (2026-10-08, qa) —

    Not changed, with the reason:

    • High, "the multi-platform build fails without setup-buildx" (containers): not reproduced. The CI log shows the default docker driver building linux/arm64, and the Fragment job passed.
    • Require sslmode and allowlist the URL's query keys (security, Low). Local Postgres has no TLS, so the platform would only ever write disable. No bypass was found, and the password check stands. To revisit when there's a non-local deployment.
    • Container resource limits (security, Low). The fragment's contract leaves runtime wiring to the platform; noted for commerce-platform#7.
    • arm64 isn't scanned separately (containers). The workflow says so: it is the same source and distroless base. Grype ran on the amd64 image.
    • The 10 s shutdown equals Docker's default grace period (Go). The fragment sets stop_grace_period: 15s.
    • Compose's minimum version for up --wait with a one-shot (containers). The platform already requires Compose ≥ 5 (P-D1).

    Rehearsal (the rule proposed in commerce-platform#6): a fresh clone of feat/2-backend-skeleton at 0b8de51. make lint, make test, make fragment and make sdk all pass, and the tree stays clean.

    Verification

    Iteration Checks run Result Fix
    1 The four reviews As above The fixes above
    2 go vet, go test -race ./..., make lint, make fragment, plus the mutations (Strict cursor, digest mismatch) All pass; both mutations caught None
    3 Rehearsal from a fresh clone: lint, test, fragment, sdk All ok; tree clean None
    3 CI on PR #4 at 0b8de51 All 9 checks pass None

    Commits: fbd6125, ccea45e, 0b8de51 (this branch), 500a4e3 (ADR, PR #3) · Status: Complete

    workflow · claude-code · 2026-10-08 17:01

  14. ABilenduke commented on Oct 8, 2026

    @ABilenduke
    ContributorAuthor

    Summary

    Status: Complete for S1–S9 and qa, with every Must in scope satisfied. REQ-014's real run and REQ-015 wait for S10, which comes after #3 and #4 merge and only with @ABilenduke's go-ahead.

    REQ Evidence
    REQ-001 TestUsage (exit 2 with usage); TestCommandsNameMissingVariable (serve, migrate and seed exit 1 naming DATABASE_URL)
    REQ-002 TestLoadDatabase (8 rejections, none leaking the password); TestSecretNeverPrints (fmt and slog JSON); TestLoadServer (default 8080)
    REQ-003 TestHealthFollowsPostgres (real Postgres: 200 ok, then 503 after Stop); TestHealthTimesOutOnAHungDatabase (about 2 s); TestHealthcheck (200→0, 503→1, closed port→1, hung API→1 after about 3 s)
    REQ-004 TestCatalog: a second migrate applies 0; constraints reject a negative price, usd, a duplicate slug and an unsafe slug
    REQ-005 TestCatalog: reseeding keeps the count and an md5 of every row; the id sequence doesn't move; a changed price is restored
    REQ-006 TestCatalog: pages of 4+2 and 3+3 with null on the last; past-the-end gives []; default 20 with more than 20 rows; 400 problem+json with no internals for bad limit and cursors (including non-canonical and padding-bit variants)
    REQ-007 TestHeaders (200, 400, 503; no HSTS); TestNewSetsTimeouts; TestRunDrainsOnCancel; TestServeExitsZeroOnSIGTERM (the real binary, exit 0)
    REQ-008 TestOpenAPI, TestCommittedSpecIsCurrent; make check-spec: planted drift fails, and a planted next_cursor rename fails oasdiff with ERR
    REQ-009 make lint: a planted time.Now fails forbidigo; a planted catalog→health import fails go-arch-lint
    REQ-010 CI Fragment: amd64 and arm64 buildx; docker inspect (user 65532, exec entrypoint, healthcheck, source label); hadolint; 5.5 MB compressed
    REQ-011 CI Fragment (make fragment): included through a stand-in platform with internal data and edge networks; migrate exits 0; api healthy; seed; 6 products; exact hardening; no forbidden keys or infrastructure hosts
    REQ-012 CI SDK: generate and type-check the sample; @ts-expect-error guards; a planted wrong field fails
    REQ-013 All CI checks pass; the pin checks for actions and images; CodeQL go; Dependabot for actions, gomod, docker, compose and npm
    REQ-014 release.yml (actionlint). The real run is S10, after merge
    REQ-015 S10, after merge and with @ABilenduke's go-ahead
    REQ-016 ADR 0001 (#3, to merge first); README

    Deviations:

    • The catalog is one package plus the generated store (S3).
    • go 1.27.0 with toolchain go1.27.2 (S6).
    • An npm overrides entry for openapi-typescript's TypeScript peer (S7).
    • REQ-011 and REQ-014 were reworded (the plan's Revisions, qa).

    Found on the way:

    • The SDK check caught items documented as nullable (S7).
    • govulncheck flagged x/crypto and go-archive through testcontainers; both were upgraded (S6).
    • The fragment's secret resolves under include, which retires the plan's top risk (S5).

    Open:

    1. Merge Record the backend stack #3 (the ADR) before Backend skeleton: health, one catalog endpoint, OpenAPI, SDK and image #4.
    2. S10: gh release create v0.1.0. Make the GHCR package public if an anonymous pull fails, and grant commerce-storefront Read on the SDK package.
    3. Possible follow-ups, to open only if you want them:
      • make the new CI jobs required checks on main (out of scope by the spec);
      • a tag ruleset limiting v* releases;
      • sslmode and query-key strictness once a non-local deployment exists.

    Time: active 51m against the 6h appetite and the forecast P80 of 59m (from the time comment). Judgement estimate 7h 20m.

    workflow · claude-code · 2026-10-08 17:01

  15. ABilenduke commented on Oct 9, 2026

    @ABilenduke
    ContributorAuthor

    S10: v0.1.0 (REQ-014, REQ-015)

    @ABilenduke merged #3 (8a0332f) and #4 (929b6f1), then published release v0.1.0 on 929b6f1, as a pre-release named "Initial Release". release.yml run 37870841905 passed all five stages:

    1. Version: the tag is vX.Y.Z and its commit is on main.
    2. SDK build: passed.
    3. Image: Grype found nothing critical; pushed ghcr.io/reference-systems-lab/commerce-backend:0.1.0@sha256:3e17e6003767524951a0626cdfd9452fcfa51f746cac9eff88cbb0ea42660505 for amd64 and arm64, then attested it.
    4. SDK publish: published @reference-systems-lab/commerce-api@0.1.0.
    5. OpenAPI asset: attached openapi.json to the release.

    Findings:

    • GHCR created the image package as private, as the plan expected. An anonymous pull got 401 until @ABilenduke made it public.
    • Checking the SDK needed read:packages. @ABilenduke added the scope to the gh token with gh auth refresh -s read:packages, a browser device login.
    • DE-10's way of verifying a tarball conflicts with DE-2's hardened .npmrc. npm pack @reference-systems-lab/commerce-api@0.1.0 fails with EALLOWREMOTE under allow-remote=none, while npm install works. Downloading dist.tarball with the token works, and its sha512 matches the registry's integrity. Consumers (the storefront) need that method, or a one-off --allow-remote=all for the pack, in their CI.

    Verification

    Iteration Checks run Result Fix
    1 gh run view 37870841905 All 5 jobs succeeded None
    1 Anonymous docker buildx imagetools inspect …:0.1.0 (empty DOCKER_CONFIG) 401 Unauthorized @ABilenduke made the package public
    2 The same OCI index sha256:3e17e600…; container visibility public None
    2 gh attestation verify oci://ghcr.io/reference-systems-lab/commerce-backend:0.1.0 --owner Reference-Systems-Lab 1 verified: SLSA v1 provenance for sha256:3e17e600…, repo commerce-backend, ref refs/tags/v0.1.0, signer release.yml None
    2 In a scratch consumer with the scope line and the hardened .npmrc: npm install @reference-systems-lab/commerce-api@0.1.0 typescript@6.0.3, tsc on sample.ts, runtime import Installed; type-check ok; createClient is a function None
    2 npm pack @…@0.1.0 EALLOWREMOTE (see Findings) Downloaded dist.tarball with the token instead
    3 sha512 of the tarball against dist.integrity; gh attestation verify sdk-0.1.0.tgz --owner Reference-Systems-Lab Matches; 1 verified, ref refs/tags/v0.1.0 None

    Decisions and deviations: None. @ABilenduke granted commerce-storefront Read access on the SDK package (confirmed 2026-10-08).

    Status: Complete

    workflow · claude-code · 2026-10-08 22:53

  16. ABilenduke commented on Oct 9, 2026

    @ABilenduke
    ContributorAuthor

    Retro

    Delivered: REQ-001 to REQ-016.

    • One Go binary (serve, migrate, seed, healthcheck, openapi) serving /health and a cursor-paged GET /v1/products from Postgres.
    • The database password only ever comes from a file.
    • A committed OpenAPI 3.1 spec, with CI failing on drift or a breaking change.
    • The typed SDK @reference-systems-lab/commerce-api on GitHub Packages.
    • A 5.5 MB distroless image for amd64 and arm64, and the compose fragment, tested against a stand-in platform with the real networks.
    • Lint and boundary rules, govulncheck, CodeQL for Go and Dependabot.
    • A staged release workflow, from main only, that never republishes a version.
    • ADR 0001 and the README.

    v0.1.0 is released and verified:

    • the image is public and pinned at sha256:3e17e600…;
    • both attestations verify;
    • the SDK installs and type-checks;
    • the storefront has Read access to the SDK.

    Not delivered: nothing in scope. Reworded without changing scope: REQ-011 and REQ-014 (the plan's Revisions). Deliberately left out after review: sslmode and query-key strictness, container resource limits (the platform's wiring, commerce-platform#7), and a separate arm64 scan.

    Time: estimate 7h 40m · active 1h 20m · ratio 0.17 · appetite 6h (from the time comment). The forecast P80 was 59m. S1–S9 and qa together took 51m, inside it. S10 took 28m of active time, most of it waiting on two package settings and a browser sign-in.

    Went well:

    • Independent reviewers found real problems that 100% green tests hid. Among them: the release job order and rerun behaviour, make generate silently broken, seeding using up ids, and non-strict cursors. Each fix got a test or a mutation check.
    • The SDK's type-check caught a contract error the Go tests couldn't: items was documented as nullable.
    • The plan's top risk, the fragment's secret under include, was retired early by the stand-in platform in S5. The real release then ran all five stages first time.
    • The CI-cross-repo forecast (commerce#4 D-1) was close for the build work: 51m against a P80 of 59m.

    Went badly:

    • The first release depended on manual steps the plan named but didn't sequence: a public GHCR package, consumer access to the SDK, and a read:packages token for verification. Each cost a round trip with @ABilenduke.
    • gh auth refresh run through ! is an interactive browser sign-in. It looked like a failure when it moved to the background, until the code was entered.
    • DE-10's way of verifying the SDK (npm pack <spec>) is blocked by DE-2's allow-remote=none. Nobody tried the two together until now.
    • The tool images and the code disagreed on Go versions (1.27.1 against 1.27.2), which forced the go/toolchain split.
    Lesson Where it goes
    A first release needs its manual steps (package visibility, consumer access, verification scopes) listed as a checklist in the plan, ahead of time Proposed: feature-plan, under rollout, for anything that publishes a package
    When a step needs gh auth refresh, say up front that it is a browser device login and give the exact code step Proposed: AGENTS.md, or nowhere
    Consumers verifying a private package can't use npm pack <spec> under allow-remote=none; download dist.tarball with the token instead Reference-Systems-Lab/commerce-storefront#2 (its plan), and a note against commerce#3 DE-10
    Optional follow-ups: make the new CI jobs required checks on main; a tag ruleset limiting v* releases Follow-up issues in commerce-backend, only if @ABilenduke wants them

    workflow · claude-code · 2026-10-08 22:53

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions