Contract-First Go API Delivery
Build a Go API with contract tests, keyless ECR publishing, and teardown.
Introduction
30 Second Summary
Software can look finished until another team discovers that its behavior no longer matches its promise. Without evidence, that gap becomes a handover problem.
In this project, you will build a contract-first delivery system for a Go service whose OpenAPI promise is checked against its routes in both directions. You will package it as a shell-free image that a keyless GitLab.com pipeline publishes to an immutable Amazon ECR repository.
What You'll Build
You will run one delivery rail that turns broken promises into named failures before it produces a verified image digest.
By the end of this project, you'll have:
- Two synthetic API routes over exactly three records. You can retrieve one record. You can also filter the sandbox by status.
- A bidirectional contract gate that finishes with two documented routes plus two registered routes. Its retained red runs name each contract failure.
- A keyless publishing trail that records a shell-free image. It retains the immutable digest plus the refused second push. It also proves the cloud rehearsal was torn down.
- Secret Mission: Harden route normalization for method case, trailing slashes, renamed parameters, and regex parameters.
Are there any prerequisites?
You need a private GitHub repository, a Linear project, an AWS Free Plan account, and a GitLab.com Free account.
Your workstation needs Go 1.27.1 with Docker Engine inside WSL 2 Ubuntu, unless the hosted job handles the image checks.
Before We Start
Before hands-on work begins, this checkpoint commits you to writing the OpenAPI promise first so implementation drift fails in both directions. The evidence boundary accepts contract maintenance, router comparison, normalization, and retained red runs as carrying cost while reporting adoption as 1/1, handing one immutable image digest to a later deploy without a cluster credential, and limiting the service to three synthetic in-memory records that touch no production, customer, regulated, or internal system.
Set Up the GitHub Delivery Rail and Linux Tooling
A contract-first sequence is credible when its earlier decisions are permanent. Your repository history must place the delivery plan before any contract or handler.
A private GitHub repository keeps that history. It becomes the permanent delivery record for this project.
One Linear project keeps the work order. A Linux build path makes the later image evidence reproducible.
In this step, get ready to:
- Create the private GitHub repository and Linear work plan.
- Verify Go, Git, and your selected Linux image path.
- Commit the pre-build artifacts before contract or handler work.
Create the permanent delivery records
The private repository preserves decisions, reviews, evidence, and the release. The GitLab fixture belongs to the later cloud lane.
- Open github.com/new in your browser.
- Select your account from the Owner menu.
- Enter recordsvc in the Repository name field.
- Select Private as the repository visibility.
- Leave the optional starter files unselected.
- Click Create repository.
- Record the HTTPS repository URL shown on the empty repository page here: https://github.com/YOUR_USERNAME/recordsvc.git.
Why Keep the Repository Empty?
The first commit must contain your pre-build artifacts. A starter file would create an earlier commit that weakens the sequence evidence.
- Open your Linear workspace in another browser tab.
- Go to your workspace or team project view.
- Click the + button.
- Enter Contract-first record service as the project name.
- Finish creating the project.
The ticket order is part of the control. Create each issue inside the new Linear project.
- Create an issue named Pre-artifacts.
- Create an issue named Contract.
- Create an issue named Service and configuration.
- Create an issue named Two-way gate and three red runs.
- Create an issue named Static image.
What Does the Order Prove?
The contract issue must finish before the service issue begins. That order turns contract-first delivery into an auditable control.
The toolchain-debt issue keeps the older platform constraint visible. It does not weaken the supported toolchain used for this rehearsal.
- Create an issue named AWS identity and registry.
- Create an issue named GitLab fixture.
- Create an issue named Evidence and readouts.
- Create an issue named Go 1.23 toolchain debt.
Verify Go, Git, and the Linux path
The service builds with Go 1.27.1. The exact release becomes part of the handover evidence.
- Check the installed Go release by running this command:
go version
✔️ I see version 1.27.1 or higher
Your Go toolchain meets the project requirement. The later evidence should still record the exact go1.27.1 release used by this project.
ⓧ I see an older version
The older executable cannot provide the pinned build evidence. Install Go 1.27.1 before continuing.
- Open the official Go installation guide.
- Install Go 1.27.1 for your Linux architecture.
- Close the current terminal.
- Start a new terminal session.
- Run the version check again.
ⓧ Command not found
The Go executable is missing from your command path. Install the pinned release before creating the module.
- Open the official Go download page.
- Download Go 1.27.1 for your Linux architecture.
- Follow the linked Linux installation instructions.
- Start a new terminal session.
- Run the version check again.
Go Version Still Looks Wrong?
A previously installed executable may appear earlier in your command path. Close every terminal before checking the version again.
Ask for help if the older release remains active. Help me find why my Linux terminal still runs an older Go version after I installed Go 1.27.1.
Git records the sequence that makes contract-first delivery provable. Confirm that its command is available before creating the initial commit.
- Check Git by running this command:
git --version
✔️ I see a Git version
Git is ready to record the pre-build sequence.
ⓧ Command not found
Git must be available before the repository can preserve the required commit order.
- Open the official Git downloads page.
- Select the Linux installation instructions for Ubuntu.
- Install the documented package for your Ubuntu release.
- Start a new terminal session.
- Run the Git version check again.
The later container proof needs a Linux Docker Engine. On Windows, the local path runs through Windows Subsystem for Linux 2 with Ubuntu.
The hosted path delegates all image checks to the later GitLab job. It leaves the GitLab fixture uncreated for now.
✔️ My local Linux path is ready
- Check the WSL installation from PowerShell by running:
wsl --version
wsl.exe --list --verbose
What Does the WSL Check Prove?
The first command prints the WSL version information. The distribution list should show Ubuntu using WSL version 2.
- Switch back to your Ubuntu terminal.
- Check the Docker service and Engine connection by running:
systemctl status docker
docker version
What Do the Docker Checks Prove?
The service output confirms that the Docker daemon is running. The version output confirms that the client can communicate with the Engine.
🙋♀️ Docker Service Is Inactive?
Use the official Docker Engine installation guide for Ubuntu to check the installation steps. Keep Docker Desktop outside this project.
Ask for environment-specific help if the service remains unavailable. Help me diagnose Docker Engine inside WSL 2 Ubuntu.
ⓧ WSL or Docker Engine is missing
Installing WSL may require a restart while Windows enables the required features. Ubuntu also asks you to create a Linux username during its first launch.
- Open PowerShell with administrator privileges.
- Install WSL with Ubuntu by running this command:
wsl --install
What Does This Command Do?
The command enables WSL components. It installs Ubuntu as the default Linux distribution.
- Restart Windows when the installer requests it.
- Complete the Ubuntu username prompt after the restart.
- Follow the official Docker Engine installation guide for Ubuntu.
- Check WSL from PowerShell by running:
wsl --version
wsl.exe --list --verbose
What Should the WSL Output Show?
The output should list Ubuntu with WSL version 2.
- Check Docker Engine from Ubuntu by running:
systemctl status docker
docker version
What Should the Docker Output Show?
You should see an active Docker service. You should also see client and server version details.
🙋♀️ Installation Did Not Complete?
Confirm that PowerShell was running with administrator privileges. Complete the Windows restart before retrying the checks.
Ask for help with the exact output if installation remains incomplete. Help me troubleshoot my WSL 2 Ubuntu installation.
ⓧ I will use hosted image checks
The later GitLab hosted job owns the image build, byte-size measurement, and shell-absence check. This path does not require a local Docker installation.
- Keep the GitLab fixture uncreated during this step.
- Continue with the permanent repository artifacts.
Commit the pre-build boundary
The first commit carries the decisions, topology, delivery plan, evidence contract, quickstart, fidelity boundary, module floor, and repository layout. It must exist before tests/contract/openapi.yaml or internal/handlers exists.
Visual Studio Code gives you one workspace for these files. Create the workspace on your Desktop so its location stays predictable.
- Press the Windows key to open system search.
- Type Visual Studio Code.
- Press Enter to start Visual Studio Code.
- Select File from the menu.
- Select Open Folder....
- Select New Folder inside your Desktop.
- Enter recordsvc as the folder name.
- Click Select Folder.
The Explorer sidebar should now show recordsvc as the workspace. Add each file below at its stated path.
- Create .gitignore at the top level of recordsvc with the Explorer new-file control.
- Add the ignore rules by pasting:
bin/
*.exe
What Does This File Protect?
The rules keep compiled executables outside the delivery record. Source files and retained evidence remain visible to Git.
- Save .gitignore.
- Confirm that .gitignore appears at the top level of Explorer.
File Name Looks Different?
Confirm that the name starts with a period. Remove any hidden .txt suffix.
Ask for help if Windows keeps changing the filename. Help me create the required .gitignore file in Visual Studio Code.
- Create go.mod at the top level of recordsvc with the Explorer new-file control.
- Define the module and dependency versions by pasting:
module recordsvc
go 1.25
require (
github.com/getkin/kin-openapi v0.149.0
github.com/go-chi/chi/v5 v5.3.2
)
Why Does the Module Declare Go 1.25?
The build uses Go 1.27.1. The module declares Go 1.25 because kin-openapi v0.149.0 requires that floor.
The second dependency is chi v5.3.2. Its registered routes become one side of the later comparison.
- Save go.mod.
- Confirm that Explorer lists go.mod beside .gitignore.
Module File Shows Warnings?
Check the blank lines and closing parenthesis against the reference. Keep both dependency versions unchanged.
Ask for help with any remaining mismatch. Help me compare my go.mod with the required recordsvc module file.
- Create docs/decisions.md with the Explorer new-file control.
- Record the initial decision index by pasting:
# Decision Index
Each decision was written before implementation and records the alternative, accepted tradeoff, evidence, and reversal trigger. The complete table is the Pre-Build Decision Records section of the project.
Toolchain debt: `go.mod` declares `go 1.25` because kin-openapi v0.149.0 requires it. Builds use supported Go 1.27.1. Go 1.23 lost support on 2025-08-12 under Go's two-newer-major policy. The upgrade ticket must link the real scanner finding; never invent one.
What Does the Decision Index Preserve?
The index records why each delivery control exists. It also records the condition that could justify reversing that decision.
- Save docs/decisions.md.
- Confirm that Explorer shows decisions.md inside the docs folder.
Decision File Landed Elsewhere?
Move the file into the docs folder. Its final path must be docs/decisions.md.
Ask for help if the Explorer path is unclear. Help me place decisions.md inside the docs folder.
- Create docs/architecture-at-a-glance.md with the Explorer new-file control.
- Record the delivery topology by pasting:
# Architecture at a Glance
GitHub is the permanent private delivery rail. Linear holds the work plan. The contract pull request merges before handler work. GitLab.com is a temporary fixture of the same commit.
The GitLab job receives an OIDC ID token from its public issuer. AWS IAM reads discovery and signing keys. STS exchanges the token for temporary credentials mapped to one role. The role can write one immutable ECR repository; only token retrieval uses wildcard resource scope. Registry-level basic scanning runs on push.
The pipeline outputs one digest line. A later deploy changes that line and nothing else. No cluster credential is present.
What Does the Topology Establish?
The topology separates the permanent GitHub record from the temporary GitLab runner. Temporary credentials can publish to one immutable repository without giving the build a deployment credential.
- Save docs/architecture-at-a-glance.md.
- Confirm that the final paragraph names the digest-only boundary.
Architecture File Is Incomplete?
Check that the topology covers the permanent delivery rail, temporary fixture, token exchange, immutable repository, and digest handoff.
Ask for a focused comparison if a paragraph is missing. Help me compare my architecture document with the required topology.
- Create docs/delivery-plan.md with the Explorer new-file control.
- Define the delivery order and adoption measure by pasting:
# Delivery Plan
One Linear project contains tickets for pre-artifacts, contract, service/configuration, two-way gate and three red runs, image, AWS identity/registry, GitLab fixture, evidence/readouts, and Go 1.23 toolchain debt.
Branch names carry Linear issue IDs. GitHub pull requests use `Fixes ISSUE-ID`. The contract merges before the handler. Evidence and readouts merge before tag `v0.1.0`.
Adoption is reported as a fraction: services whose bidirectional contract test runs in pipeline over services the platform runs. Landing day is `1/1`, not a broader claim.
What Does the Delivery Plan Measure?
The plan fixes the review order before implementation begins. It limits the adoption claim to one qualifying service out of one platform-run service.
- Save docs/delivery-plan.md.
- Confirm that v0.1.0 and 1/1 appear in the saved file.
Delivery Order Looks Ambiguous?
Check that the contract merges before the handler. Check that evidence merges before the release tag.
Ask for help reviewing the sequence. Help me verify the contract-first order in my delivery plan.
- Create evidence/index.md with the Explorer new-file control.
- Define the release evidence contract by pasting:
# Evidence Index
The release is blocked unless these files contain command output: contract-order, contract-clean, three red runs, config-exit, lint-and-test, image, aws-create, gitlab-identity, publish, scan, teardown, and gitlab-deletion.
Do not prefill runtime values. Record command, timestamp, commit SHA, exit status, and unedited output for each result.
Why Block the Release on Evidence?
Every runtime claim must trace to retained command output. Templates and estimated values cannot satisfy the release gate.
- Save evidence/index.md.
- Confirm that Explorer shows index.md inside the evidence folder.
Evidence File Contains Results Already?
Remove any estimated digest, image size, account ID, timestamp, or command result. Those values must come from later commands.
Ask for help separating requirements from evidence. Help me find runtime values that should not be prefilled.
- Create docs/quickstart.md with the Explorer new-file control.
- Add the lifecycle skeleton by pasting:
# Quickstart
1. Clone the private GitHub repository or use an approved source archive.
2. Verify pre-artifacts and contract commit precede handler commits with `git log --oneline --reverse`.
3. Run contract validation, the service, required-setting proof, three red runs, and `make test`.
4. Optional local image path: use Docker Engine on Linux. On Windows use WSL 2. On macOS use a Linux VM. Never use Docker Desktop for this rehearsal.
5. In AWS CloudShell, create and read back the budget, identity provider, role/policy, registry-level scan configuration, and immutable ECR repository.
6. Push the same commit to the temporary GitLab.com fixture and collect identity, image, digest, second-push, and scan artifacts.
7. Complete both readouts and the fidelity note from evidence only. Put the digest in `deploy/image-digest.txt` and tag `v0.1.0`.
8. Run AWS teardown and retain three not-found outputs. Schedule GitLab.com deletion and record its 30-day retention state.
What Does the Quickstart Promise?
Another engineer gets one path from source verification to teardown evidence. The local image path remains optional because the hosted job performs the same checks.
- Save docs/quickstart.md.
- Confirm that the file contains eight numbered stages.
Quickstart Numbering Is Broken?
Keep each stage on its own numbered line. Confirm that the sequence starts with source verification and ends with teardown.
Ask for help checking the lifecycle. Help me compare my quickstart with the required delivery lifecycle.
- Create docs/configuration-reference.md with the Explorer new-file control.
- Reserve the service and cloud interfaces by pasting:
# Configuration Reference
`LISTEN_ADDR` is required, has no default, and exits 78 when absent.
`BUDGET_EMAIL`, `GITLAB_GROUP`, `GITLAB_PROJECT`, `GITLAB_NAMESPACE_ID`, and `GITLAB_PROJECT_ID` are required by bootstrap. `AWS_REGION`, `ECR_REPOSITORY`, `ROLE_NAME`, `POLICY_NAME`, and `BUDGET_NAME` have rehearsal defaults.
GitLab stores only `AWS_OIDC_ROLE_ARN`, `AWS_REGION`, and `ECR_REPOSITORY`. It stores no AWS access key or cluster credential.
What Does the Configuration Boundary Reserve?
The reference separates required service configuration from later cloud bootstrap values. It prevents stored AWS keys or cluster credentials from entering GitLab.
- Save docs/configuration-reference.md.
- Confirm that the final sentence excludes AWS access keys and cluster credentials.
Configuration Names Do Not Match?
Compare every uppercase name character for character. These names become interfaces between the later scripts and hosted job.
Ask for help locating a mismatch. Help me compare the configuration names in this file.
- Create docs/limits.md with the Explorer new-file control.
- Fix the fidelity boundary by pasting:
# Limits and Fidelity Note
This is a hand-written GitLab job, not the platform's versioned template. The image is unsigned and its scan gates nothing. GitLab hosted runners and the OIDC issuer are on the public internet.
It uses one Region and one AWS account. There is no image pull path, cluster, deployment, production network, cross-account promotion, approval gate, long-term evidence store, vulnerability exception process, or incident process. The OpenAPI document has no security scheme. The service stores only three explicitly synthetic records in memory.
Shell absence proves image shape, not vulnerability absence. Two valid exchanges do not prove all business behavior. `1/1` is a landing-day fraction over one service, not a platform-wide claim. No customer, production, regulated environment, agency, employer, procurement contract, product in use, or internal system is named or touched.
Why Record Limits Before Building?
The fidelity note prevents a rehearsal result from becoming a production claim. It fixes the synthetic sandbox and non-gating scan boundary before evidence is collected.
- Save docs/limits.md.
- Confirm that the file names the three synthetic in-memory records.
Fidelity Note Makes a Broader Claim?
Remove any statement that implies production readiness, vulnerability absence, platform-wide adoption, or customer use.
Ask for help narrowing the claim. Help me check whether my fidelity note exceeds this rehearsal.
- Create docs/agent-architecture-and-layout.md with the Explorer new-file control.
- Add the ownership statement by pasting:
# Agent Architecture and Folder Layout
One Linear project owns all delivery tickets. The contract ticket and commit precede handler work. GitHub remains the delivery rail; GitLab.com is a temporary CI fixture.
What Does the Ownership Statement Fix?
Linear owns the work plan. GitHub owns the permanent delivery history while GitLab remains temporary.
- Save docs/agent-architecture-and-layout.md.
- Confirm that the heading and ownership paragraph appear in the editor.
Ownership Statement Is Missing?
Check that the file names Linear, GitHub, and GitLab.com. Confirm that handler work follows the contract ticket.
Ask for help comparing the opening section. Help me verify the ownership statement in my layout file.
The repository map distinguishes authored files from generated output. Add it in two parts so each path stays easy to check.
- Append the first part of the fenced repository layout:
```text
cmd/recordsvc/main.go
internal/config/config.go
internal/config/config_test.go
internal/handlers/records.go
internal/models/records.go
tests/contract/openapi.yaml
tests/contract/contract_test.go
infra/bootstrap.sh
infra/teardown.sh
deploy/image-digest.txt generated
evidence/index.md
evidence/*.txt generated
readouts/engineering.md
readouts/program.md
What Does the First Half Map?
This half maps the executable, internal packages, contract tests, infrastructure scripts, deployment digest, evidence, and readouts. The next chunk completes the same fenced layout.
- Save docs/agent-architecture-and-layout.md.
- Confirm that the first path is cmd/recordsvc/main.go.
First Layout Paths Do Not Align?
Keep each path on its own line. Preserve the spacing before each generated label.
Ask for help if a path is missing. Help me compare the first half of my repository layout.
- Complete the same fenced layout by appending:
docs/architecture-at-a-glance.md
docs/delivery-plan.md
docs/quickstart.md
docs/operating-it.md
docs/runbook.md
docs/decisions.md
docs/agent-architecture-and-layout.md
docs/configuration-reference.md
docs/limits.md
Dockerfile
Makefile
.gitlab-ci.yml
go.mod
go.sum generated
```
What Does the Completed Layout Prove?
The completed map covers the documentation and build files. It marks go.sum as generated so the first commit does not invent it.
- Save docs/agent-architecture-and-layout.md.
- Confirm that go.sum and deploy/image-digest.txt are marked as generated.
Layout Renders as Normal Prose?
Confirm that the layout begins with three backticks followed by text. Confirm that it ends with three backticks.
Ask for help checking the Markdown fence. Help me fix the fenced repository layout.
Use this comparison before creating the historical checkpoint. Every file in the second tab must match your saved copy.
✔️ Awesome, I've got everything!
Great. Your pre-build files are ready for the initial commit.
ⓧ I'd like to double check the full code
bin/
*.exe
module recordsvc
go 1.25
require (
github.com/getkin/kin-openapi v0.149.0
github.com/go-chi/chi/v5 v5.3.2
)
# Decision Index
Each decision was written before implementation and records the alternative, accepted tradeoff, evidence, and reversal trigger. The complete table is the Pre-Build Decision Records section of the project.
Toolchain debt: `go.mod` declares `go 1.25` because kin-openapi v0.149.0 requires it. Builds use supported Go 1.27.1. Go 1.23 lost support on 2025-08-12 under Go's two-newer-major policy. The upgrade ticket must link the real scanner finding; never invent one.
# Architecture at a Glance
GitHub is the permanent private delivery rail. Linear holds the work plan. The contract pull request merges before handler work. GitLab.com is a temporary fixture of the same commit.
The GitLab job receives an OIDC ID token from its public issuer. AWS IAM reads discovery and signing keys. STS exchanges the token for temporary credentials mapped to one role. The role can write one immutable ECR repository; only token retrieval uses wildcard resource scope. Registry-level basic scanning runs on push.
The pipeline outputs one digest line. A later deploy changes that line and nothing else. No cluster credential is present.
# Delivery Plan
One Linear project contains tickets for pre-artifacts, contract, service/configuration, two-way gate and three red runs, image, AWS identity/registry, GitLab fixture, evidence/readouts, and Go 1.23 toolchain debt.
Branch names carry Linear issue IDs. GitHub pull requests use `Fixes ISSUE-ID`. The contract merges before the handler. Evidence and readouts merge before tag `v0.1.0`.
Adoption is reported as a fraction: services whose bidirectional contract test runs in pipeline over services the platform runs. Landing day is `1/1`, not a broader claim.
# Evidence Index
The release is blocked unless these files contain command output: contract-order, contract-clean, three red runs, config-exit, lint-and-test, image, aws-create, gitlab-identity, publish, scan, teardown, and gitlab-deletion.
Do not prefill runtime values. Record command, timestamp, commit SHA, exit status, and unedited output for each result.
# Quickstart
1. Clone the private GitHub repository or use an approved source archive.
2. Verify pre-artifacts and contract commit precede handler commits with `git log --oneline --reverse`.
3. Run contract validation, the service, required-setting proof, three red runs, and `make test`.
4. Optional local image path: use Docker Engine on Linux. On Windows use WSL 2. On macOS use a Linux VM. Never use Docker Desktop for this rehearsal.
5. In AWS CloudShell, create and read back the budget, identity provider, role/policy, registry-level scan configuration, and immutable ECR repository.
6. Push the same commit to the temporary GitLab.com fixture and collect identity, image, digest, second-push, and scan artifacts.
7. Complete both readouts and the fidelity note from evidence only. Put the digest in `deploy/image-digest.txt` and tag `v0.1.0`.
8. Run AWS teardown and retain three not-found outputs. Schedule GitLab.com deletion and record its 30-day retention state.
# Configuration Reference
`LISTEN_ADDR` is required, has no default, and exits 78 when absent.
`BUDGET_EMAIL`, `GITLAB_GROUP`, `GITLAB_PROJECT`, `GITLAB_NAMESPACE_ID`, and `GITLAB_PROJECT_ID` are required by bootstrap. `AWS_REGION`, `ECR_REPOSITORY`, `ROLE_NAME`, `POLICY_NAME`, and `BUDGET_NAME` have rehearsal defaults.
GitLab stores only `AWS_OIDC_ROLE_ARN`, `AWS_REGION`, and `ECR_REPOSITORY`. It stores no AWS access key or cluster credential.
# Limits and Fidelity Note
This is a hand-written GitLab job, not the platform's versioned template. The image is unsigned and its scan gates nothing. GitLab hosted runners and the OIDC issuer are on the public internet.
It uses one Region and one AWS account. There is no image pull path, cluster, deployment, production network, cross-account promotion, approval gate, long-term evidence store, vulnerability exception process, or incident process. The OpenAPI document has no security scheme. The service stores only three explicitly synthetic records in memory.
Shell absence proves image shape, not vulnerability absence. Two valid exchanges do not prove all business behavior. `1/1` is a landing-day fraction over one service, not a platform-wide claim. No customer, production, regulated environment, agency, employer, procurement contract, product in use, or internal system is named or touched.
# Agent Architecture and Folder Layout
One Linear project owns all delivery tickets. The contract ticket and commit precede handler work. GitHub remains the delivery rail; GitLab.com is a temporary CI fixture.
```text
cmd/recordsvc/main.go
internal/config/config.go
internal/config/config_test.go
internal/handlers/records.go
internal/models/records.go
tests/contract/openapi.yaml
tests/contract/contract_test.go
infra/bootstrap.sh
infra/teardown.sh
deploy/image-digest.txt generated
evidence/index.md
evidence/*.txt generated
readouts/engineering.md
readouts/program.md
docs/architecture-at-a-glance.md
docs/delivery-plan.md
docs/quickstart.md
docs/operating-it.md
docs/runbook.md
docs/decisions.md
docs/agent-architecture-and-layout.md
docs/configuration-reference.md
docs/limits.md
Dockerfile
Makefile
.gitlab-ci.yml
go.mod
go.sum generated
```
The saved files now define the boundary that implementation must follow. The next action turns that boundary into the first historical checkpoint.
- Open the integrated terminal from View by selecting Terminal.
- Create the local Git history and initial snapshot by running:
git init -b main
git add .
git commit -m "Add pre-build delivery artifacts"
What Do These Commands Record?
- The first command creates the local repository on the main branch.
- The second command stages the pre-build artifacts as one snapshot.
- The third command records that snapshot before contract or handler work.
- Confirm the initial commit by running:
git log --oneline
You should see one commit with the message Add pre-build delivery artifacts.
Commit Asks for Your Identity?
Git needs an author name and email before it can create the commit. Follow the configuration guidance printed in your terminal.
Ask for help if the commit remains blocked. Help me configure the Git author identity required for this repository.
- Connect the local repository to your recorded GitHub URL by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/recordsvc.git"]]
git remote -v
git push -u origin main
What Does the Remote Connection Prove?
The remote read-back shows the GitHub destination for fetches and pushes. The final command publishes the pre-build commit to the private repository.
- Complete GitHub authentication if the terminal requests it.
- Refresh the private repository page in your browser.
- Confirm that the repository shows the initial pre-build commit.
Push or Remote Setup Failed?
Confirm that the saved URL belongs to your private recordsvc repository. Do not share authentication credentials while troubleshooting.
Ask for help with the exact command output. Help me diagnose why I cannot push to my GitHub origin.
Before the final check, which tool versions, commit, and remote destination do you expect the terminal to show?
- Verify Go, Git, the initial commit, and the GitHub origin by running:
go version
git --version
git log --oneline
git remote -v
What Should You See?
The Go output should name release 1.27.1. Git should print its installed version.
The log should show the pre-build delivery-artifact commit. The remote output should list your private GitHub repository for fetch and push.
✔️ I chose local image checks
Before this check, do you expect both the Docker client and server to answer?
- Verify WSL from PowerShell by running:
wsl --version
What Should the WSL Check Show?
PowerShell should print the installed WSL version information.
- Verify Docker Engine from Ubuntu by running:
systemctl status docker
docker version
What Should the Docker Check Show?
You should see an active service. You should also see Docker client and server details.
ⓧ I delegated image checks
Your local checkpoint ends with Go, Git, the initial commit, and the GitHub origin. The later GitLab hosted job owns every Docker image check.
Final Verification Does Not Match?
Confirm that the integrated terminal is inside the recordsvc workspace. Check that the initial commit exists before adding contract or handler files.
Ask for help with the unedited output. Help me diagnose my Step 1 verification output.
Your permanent delivery rail now begins with decisions instead of implementation. Next, you will write and validate the OpenAPI promise before creating any handler package.
Write and Validate the Contract First
Your delivery rail now holds the decisions that govern this build. The next proof must show that the service promise existed before implementation began.
The reviewed OpenAPI document becomes the promise that every handler must follow. Git history preserves that decision as evidence before implementation begins.
In this step, get ready to:
- Define the two API paths with their required parameters.
- Validate the contract while no handler package exists.
- Lock the contract-first order into the repository history.
Define the two API paths
The contract defines exactly two client interactions. One returns a record by ID. The other filters the synthetic records by status.
Why write the contract first?
A contract-first workflow gives reviewers the API shape before implementation choices can influence it. The handler must satisfy an approved promise.
The carrying cost is deliberate duplication between the contract and router. That duplication becomes useful when the later two-way gate detects drift.
- Switch back to the local repository from Step 1 in Visual Studio Code.
- Create the tests/contract folder path in the repository file tree.
- Create openapi.yaml inside tests/contract.
The YAML document starts with version 3.1.0. The first path requires callers to provide a status filter.
- Build the document header plus the first route foundation by pasting this into tests/contract/openapi.yaml:
openapi: 3.1.0
info:
title: Synthetic Records API
version: 1.0.0
description: A rehearsal API over three synthetic in-memory records.
paths:
/v1/records:
get:
operationId: listRecords
parameters:
- name: status
in: query
required: true
schema:
$ref: '#/components/schemas/Status'
responses:
'200':
description: Matching synthetic records
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Record'
What does this contract section define?
- The document metadata labels every record as synthetic rehearsal data.
- The /v1/records path accepts one required status query parameter.
- The success response returns an array shaped by the shared Record schema.
- Save tests/contract/openapi.yaml.
- Confirm the file begins with openapi: 3.1.0.
- Confirm the first route requires the status query parameter.
Does the first section look misaligned?
YAML uses indentation to express structure. Check that get sits beneath /v1/records.
Check that the response schema remains beneath application/json.
Help me compare my tests/contract/openapi.yaml metadata and first route against the provided snippet.
- Complete the list route by adding its response examples below the $ref: '#/components/schemas/Record' line:
example:
- id: rec-001
name: Alpha dataset
status: active
- id: rec-003
name: Gamma dataset
status: active
'400':
description: Invalid or missing status
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: 'status must be one of: active, inactive, archived'
What do these responses promise?
- The successful example shows the two synthetic records whose status is active.
- The invalid request uses the shared Error schema.
- The error example lists the three accepted status values.
- Save tests/contract/openapi.yaml.
- Confirm the list route now contains response entries for 200 and 400.
- Confirm each response contains an example.
Missing a list response example?
Check that both example entries sit beneath their matching application/json blocks.
Help me find the missing or misplaced response example in my list route.
- Define the record lookup path by adding this below the invalid-status example:
/v1/records/{id}:
get:
operationId: getRecord
parameters:
- name: id
in: path
required: true
schema:
type: string
pattern: '^rec-[0-9]{3}$'
responses:
'200':
description: The requested synthetic record
content:
application/json:
schema:
$ref: '#/components/schemas/Record'
example:
id: rec-001
name: Alpha dataset
status: active
'404':
description: Synthetic record not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: record not found
How is the lookup route constrained?
- The id parameter is required because it forms part of the path.
- The pattern accepts identifiers shaped like rec-001.
- The route returns either a matching Record response or an Error response.
- Save tests/contract/openapi.yaml.
- Confirm the file now contains GET /v1/records/{id} with a required path parameter.
- Confirm the lookup route contains response entries for 200 and 404.
Does the lookup path look incomplete?
Check that the path keeps the braces around id.
Check that the parameter location is path.
Help me compare my lookup path with the required OpenAPI contract.
- Complete the shared schemas by adding this below the record not found example:
components:
schemas:
Status:
type: string
enum: [active, inactive, archived]
Record:
type: object
additionalProperties: false
required: [id, name, status]
properties:
id:
type: string
pattern: '^rec-[0-9]{3}$'
name:
type: string
minLength: 1
status:
$ref: '#/components/schemas/Status'
Error:
type: object
additionalProperties: false
required: [error]
properties:
error:
type: string
minLength: 1
What do the shared schemas enforce?
- The Status schema limits values to active, inactive, or archived.
- The Record schema requires id, name, and status.
- The Error schema requires one non-empty error message.
- The additionalProperties: false rules make unexpected response fields fail schema validation.
- Save tests/contract/openapi.yaml.
- Confirm the file contains the Status, Record, and Error schemas.
- Confirm both object schemas set additionalProperties to false.
Seeing an unresolved schema reference?
Check the capitalization of Status, Record, and Error.
Check that components starts at the left edge of the file.
Help me trace an unresolved schema reference in my OpenAPI document.
Use the comparison below to catch an omitted response or indentation mismatch before validation.
✔️ Awesome, I've got everything!
Great. Double-check that you saved tests/contract/openapi.yaml.
ⓧ I'd like to double check the full code
openapi: 3.1.0
info:
title: Synthetic Records API
version: 1.0.0
description: A rehearsal API over three synthetic in-memory records.
paths:
/v1/records:
get:
operationId: listRecords
parameters:
- name: status
in: query
required: true
schema:
$ref: '#/components/schemas/Status'
responses:
'200':
description: Matching synthetic records
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Record'
example:
- id: rec-001
name: Alpha dataset
status: active
- id: rec-003
name: Gamma dataset
status: active
'400':
description: Invalid or missing status
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: 'status must be one of: active, inactive, archived'
/v1/records/{id}:
get:
operationId: getRecord
parameters:
- name: id
in: path
required: true
schema:
type: string
pattern: '^rec-[0-9]{3}$'
responses:
'200':
description: The requested synthetic record
content:
application/json:
schema:
$ref: '#/components/schemas/Record'
example:
id: rec-001
name: Alpha dataset
status: active
'404':
description: Synthetic record not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: record not found
components:
schemas:
Status:
type: string
enum: [active, inactive, archived]
Record:
type: object
additionalProperties: false
required: [id, name, status]
properties:
id:
type: string
pattern: '^rec-[0-9]{3}$'
name:
type: string
minLength: 1
status:
$ref: '#/components/schemas/Status'
Error:
type: object
additionalProperties: false
required: [error]
properties:
error:
type: string
minLength: 1
How to compare the full file
Compare the route names, indentation, required arrays, schema references, and response examples. Your saved file should match this reference exactly.
Validate and merge the contract
The validator checks the document itself while internal/handlers and cmd/recordsvc are still absent. That separation proves the promise can stand before its implementation.
The first run can pause while Go downloads kin-openapi version v0.149.0. The download activity is expected.
Before you run the validator, do you expect it to need any handler code?
- Validate tests/contract/openapi.yaml by running this command from the repository:
go run github.com/getkin/kin-openapi/cmd/validate@v0.149.0 -- tests/contract/openapi.yaml
What does this command prove?
The pinned validator loads the contract as an OpenAPI document. It checks the document structure and referenced schemas without starting the service.
A successful exit proves the promise is internally valid before handler code exists.
- Check that the validation command exits successfully.
- Confirm the repository still has no internal/handlers folder.
- Confirm the repository still has no cmd/recordsvc program.
You should see the validator finish without reporting a contract error. The successful result comes entirely from the reviewed document.
Contract validation failed?
Check the indentation around paths and components.
Check that every schema reference starts with #/components/schemas/.
Help me diagnose the validator output for my OpenAPI contract.
Review now turns the valid document into delivery evidence. The existing Linear contract ticket remains ahead of all handler work.
- Commit tests/contract/openapi.yaml through your existing Git workflow.
- Push the contract branch to the private GitHub repository.
- Link the existing Linear contract ticket in the contract pull request.
- Request review on the contract pull request.
- Merge the contract pull request after approval.
- Mark the Linear contract ticket complete after the merge.
- Keep the service and configuration ticket out of active work until the contract merge is complete.
That is the control point secured. The contract now has review evidence before any handler work begins.
Retain the contract order
A merged file shows the current repository state. The ordered commit log proves when the promise entered the delivery history.
Before you inspect the log, which commit do you expect to appear first?
- Print the repository history from oldest commit to newest by running:
git log --oneline --reverse
Why reverse the commit log?
The reverse order puts the earliest commit at the top. That makes the pre-artifact commit and reviewed contract commit easy to compare.
The later handler commit must extend this sequence below both entries. Moving files after implementation cannot recreate the original order.
You should see the pre-artifact commit first. The reviewed contract commit should appear after it.
Contract commit missing from the log?
Confirm that the contract pull request was merged into the branch you are viewing.
Check that your local branch includes the latest merged commits from the private repository.
Help me understand why my merged contract commit is missing from the ordered Git log.
- Copy the complete terminal output.
- Create evidence/contract-order.txt in Visual Studio Code.
- Paste the copied output into evidence/contract-order.txt.
- Save evidence/contract-order.txt.
- Confirm the pre-artifact commit appears before the reviewed contract commit.
You have a validated promise plus the first two entries in its evidence chain. Next, you will implement the synthetic service behind that contract and make missing configuration exit with status 78.
Build the Service and Make Configuration Fail Closed
Your reviewed OpenAPI contract now controls the service shape. The commit history proves that the promise existed before its implementation.
Implementation can now follow that promise. This step builds the Go service with explicit configuration that stops startup when LISTEN_ADDR is absent.
In this step, get ready to:
- Define exactly three synthetic records.
- Register the two contracted record handlers.
- Prove that missing configuration exits with status 78.
Define the synthetic record model
The service needs a small deterministic data set before its handlers can return useful responses. Three fictional records keep every result reproducible without touching customer or production data.
- Return to the repository in Visual Studio Code from your setup work.
- Create internal/models/records.go through the Explorer sidebar by copying the first model section below:
package models
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
StatusArchived Status = "archived"
)
type Record struct {
ID string `json:"id"`
Name string `json:"name"`
Status Status `json:"status"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
// SyntheticRecords returns exactly three fictional, in-memory rehearsal records.
func SyntheticRecords() []Record {
return []Record{
{ID: "rec-001", Name: "Alpha dataset", Status: StatusActive},
{ID: "rec-002", Name: "Beta dataset", Status: StatusInactive},
{ID: "rec-003", Name: "Gamma dataset", Status: StatusActive},
}
}
What Does This Model Define?
- The Status type limits the service to the three states promised by the contract.
- The Record fields use JSON names that match the response schema.
- The SyntheticRecords() function returns the same three fictional records on every run.
- Save internal/models/records.go.
- Confirm that the new package compiles by running this command:
go test ./...
What Does This Check Prove?
This command compiles every current Go package. A successful result proves that the model types and synthetic data are valid Go code.
You should see the command finish without a package failure. The new recordsvc/internal/models package is now part of the project.
Model Package Failing to Compile?
Check each struct tag for matching backticks. Confirm that every record literal stays inside the slice returned by SyntheticRecords().
Compare the three status constants with their lowercase string values. A missing quote or brace prevents the package from compiling.
Help me diagnose my model compilation error.
A query value arrives as text. ParseStatus() turns an allowed value into the typed status used by the records.
- Add ParseStatus() below SyntheticRecords() in internal/models/records.go by copying this code:
func ParseStatus(value string) (Status, bool) {
status := Status(value)
switch status {
case StatusActive, StatusInactive, StatusArchived:
return status, true
default:
return "", false
}
}
How Does Status Validation Work?
The switch accepts only the three contract values. Every other value returns an empty status with false so the handler can send a controlled error response.
- Save internal/models/records.go.
- Verify that the completed model still compiles by running:
go test ./...
What Does This Second Check Prove?
This run compiles the new parser with the model package. A clean result proves that handlers can now validate a status without inventing another set of allowed values.
You should see the command finish without a test or compilation failure.
Status Parser Failing?
Confirm that each case uses the existing status constants. Check that the default branch returns an empty string with false.
Help me fix ParseStatus().
Require explicit service configuration
A listening address belongs to the deployment environment. Requiring LISTEN_ADDR prevents the service from silently choosing an address that nobody approved.
- Create internal/config/config.go through the Explorer sidebar by copying this configuration loader:
package config
import (
"fmt"
"os"
"strings"
)
const (
ListenAddrEnv = "LISTEN_ADDR"
ExitConfig = 78
)
type Config struct {
ListenAddr string
}
func Load() (Config, error) {
listenAddr := strings.TrimSpace(os.Getenv(ListenAddrEnv))
if listenAddr == "" {
return Config{}, fmt.Errorf("required environment variable %s is not set", ListenAddrEnv)
}
return Config{ListenAddr: listenAddr}, nil
}
Why Does Configuration Fail Closed?
- The ListenAddrEnv constant keeps the setting name consistent across loading and error reporting.
- The Load() function trims whitespace before checking the value.
- An empty value returns an error that names LISTEN_ADDR.
- The ExitConfig constant reserves status 78 for this configuration failure.
- Save internal/config/config.go.
- Confirm that the configuration package compiles by running:
go test ./...
What Does This Configuration Check Prove?
The command compiles Load() with its environment lookup and error path. A clean result proves the loader can join the project before its behavior tests are added.
You should see the command complete without a compilation failure.
Configuration Package Failing?
Check that fmt, os, and strings appear inside the import block. Confirm that Load() returns a Config value on both paths.
Help me diagnose my config package error.
The loader needs proof for both sides of its contract. One test checks the missing setting while the other checks the accepted address.
- Create internal/config/config_test.go through the Explorer sidebar by copying these tests:
package config
import (
"strings"
"testing"
)
func TestLoadRequiresListenAddr(t *testing.T) {
t.Setenv(ListenAddrEnv, "")
_, err := Load()
if err == nil {
t.Fatal("expected missing LISTEN_ADDR to fail")
}
if !strings.Contains(err.Error(), ListenAddrEnv) {
t.Fatalf("error %q does not name %s", err, ListenAddrEnv)
}
}
func TestLoadReturnsListenAddr(t *testing.T) {
t.Setenv(ListenAddrEnv, ":8080")
cfg, err := Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.ListenAddr != ":8080" {
t.Fatalf("ListenAddr = %q, want %q", cfg.ListenAddr, ":8080")
}
}
What Do These Tests Protect?
- The first test proves that an empty setting produces an error.
- The first test also proves that the error identifies LISTEN_ADDR.
- The second test proves that :8080 reaches the returned configuration unchanged.
- Save internal/config/config_test.go.
- Run the configuration tests with every current package by running:
go test ./...
What Does the Passing Test Show?
A successful run proves that the loader rejects an empty address. It also proves that the accepted address survives loading.
You should see the command finish without a failed test.
Configuration Test Failing?
Compare the environment value in each test with the expected branch in Load().
Confirm that the missing-setting error includes the value of ListenAddrEnv.
Help me fix the configuration tests.
Wire the routes and prove both outcomes
The handlers connect the reviewed paths to the synthetic records. Each response keeps the HTTP status and body shape aligned with the contract.
- Create internal/handlers/records.go through the Explorer sidebar by copying this package foundation:
package handlers
import (
"encoding/json"
"log"
"net/http"
"recordsvc/internal/models"
)
type service struct {
records []models.Record
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("encode response: %v", err)
}
}
What Does the Handler Foundation Provide?
The private service type holds the records available to each request. The writeJSON() helper applies the response content type before encoding a value.
- Save internal/handlers/records.go.
- Confirm that the handler foundation compiles by running:
go test ./...
What Does This Handler Check Prove?
This run compiles the shared response writer before any route depends on it. You should see the command complete without a package failure.
🙋♀️ Handler Foundation Failing?
Check that the service type uses []models.Record.
Confirm that writeJSON() receives an http.ResponseWriter.
Help me fix the handler foundation.
The list endpoint requires a valid status. It returns only records whose typed status matches the query value.
- Insert listRecords() directly above writeJSON() in internal/handlers/records.go by copying this code:
func (svc service) listRecords(w http.ResponseWriter, r *http.Request) {
status, ok := models.ParseStatus(r.URL.Query().Get("status"))
if !ok {
writeJSON(w, http.StatusBadRequest, models.ErrorResponse{Error: "status must be one of: active, inactive, archived"})
return
}
matches := make([]models.Record, 0, len(svc.records))
for _, record := range svc.records {
if record.Status == status {
matches = append(matches, record)
}
}
writeJSON(w, http.StatusOK, matches)
}
How Does the List Handler Work?
- The handler reads the required status query parameter.
- An unsupported value returns the contract's 400 error body.
- A valid value filters the synthetic slice before returning status 200.
- Save internal/handlers/records.go.
- Confirm that the list handler compiles by running:
go test ./...
What Does the List Check Prove?
A clean run proves that listRecords() can call the model parser and shared response writer. You should see no package failure.
🙋♀️ List Handler Failing?
Confirm that the query key is status.
Check that the successful response passes matches to writeJSON().
Help me fix listRecords().
The read endpoint needs the path value captured by chi. The matching record returns immediately while an unknown identifier reaches the contract's missing-record response.
- Find this part of the import block in internal/handlers/records.go:
"net/http"
"recordsvc/internal/models"
Why Locate This Import Section?
The path-parameter helper belongs between the standard-library imports and the project import. This reference keeps the grouped import layout consistent.
- Replace that import section with this version:
"net/http"
"github.com/go-chi/chi/v5"
"recordsvc/internal/models"
What Does the New Import Enable?
The added package exposes the URL parameter helper used by getRecord().
- Insert getRecord() directly above writeJSON() by copying this code:
func (svc service) getRecord(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
for _, record := range svc.records {
if record.ID == id {
writeJSON(w, http.StatusOK, record)
return
}
}
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "record not found"})
}
How Does the Read Handler Work?
The handler reads the id path parameter and searches the three records. A match returns status 200 while the completed search returns status 404.
- Save internal/handlers/records.go.
- Confirm that the read handler compiles by running:
go test ./...
What Does the Read Check Prove?
This run proves that the handler can read the contracted path parameter. It also proves that both response branches compile.
You should see the command finish without a package failure.
Read Handler Failing?
Confirm that the import path ends with /v5.
Check that chi.URLParam() reads the key id.
Help me fix getRecord().
The router is the implementation's route inventory. Its two registrations must correspond to the two paths already approved in the contract.
- Insert NewRouter() directly below the service type by copying this code:
func NewRouter(records []models.Record) chi.Router {
svc := service{records: append([]models.Record(nil), records...)}
router := chi.NewRouter()
router.Get("/v1/records", svc.listRecords)
router.Get("/v1/records/{id}", svc.getRecord)
return router
}
What Does the Router Register?
The router copies the supplied records into its private service state. It registers only /v1/records and /v1/records/{id}.
- Save internal/handlers/records.go.
- Confirm that the completed router compiles by running:
go test ./...
What Does the Router Check Prove?
A successful run proves that both handlers can be registered on one router. You should see the command complete without a package failure.
🙋♀️ Router Failing to Compile?
Confirm that both route strings match the reviewed contract. Check that each route points to its corresponding service method.
Help me fix NewRouter().
The executable joins configuration and routing at startup. It loads the address before constructing the server so missing configuration stops the process at the boundary.
- Create cmd/recordsvc/main.go through the Explorer sidebar by copying this program:
package main
import (
"log"
"net/http"
"os"
"time"
"recordsvc/internal/config"
"recordsvc/internal/handlers"
"recordsvc/internal/models"
)
func main() {
log.SetFlags(0)
cfg, err := config.Load()
if err != nil {
log.Print(err)
os.Exit(config.ExitConfig)
}
server := &http.Server{
Addr: cfg.ListenAddr,
Handler: handlers.NewRouter(models.SyntheticRecords()),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("recordsvc listening on %s", cfg.ListenAddr)
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
How Does Startup Enforce the Rule?
- The program loads configuration before creating the server.
- A loading error is written to stderr before the process exits with ExitConfig.
- A valid configuration connects the synthetic records to the router.
- The server uses the loaded address with a five-second header timeout.
- Save cmd/recordsvc/main.go.
- Confirm that the complete service compiles by running:
go test ./...
What Does the Service Check Prove?
This run compiles the model, configuration, handlers, tests, and executable together. You should see every package finish without a failure.
🙋♀️ Executable Failing to Compile?
Check that the three project imports begin with the module name recordsvc.
Confirm that the server handler calls handlers.NewRouter(models.SyntheticRecords()).
Help me diagnose the executable build.
Use this reference to compare every new file before running the service.
✔️ Awesome, I've got everything!
Great. Save every new Go file before moving to the runtime checks.
ⓧ I'd like to double check the full code
package models
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
StatusArchived Status = "archived"
)
type Record struct {
ID string `json:"id"`
Name string `json:"name"`
Status Status `json:"status"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
// SyntheticRecords returns exactly three fictional, in-memory rehearsal records.
func SyntheticRecords() []Record {
return []Record{
{ID: "rec-001", Name: "Alpha dataset", Status: StatusActive},
{ID: "rec-002", Name: "Beta dataset", Status: StatusInactive},
{ID: "rec-003", Name: "Gamma dataset", Status: StatusActive},
}
}
func ParseStatus(value string) (Status, bool) {
status := Status(value)
switch status {
case StatusActive, StatusInactive, StatusArchived:
return status, true
default:
return "", false
}
}
package config
import (
"fmt"
"os"
"strings"
)
const (
ListenAddrEnv = "LISTEN_ADDR"
ExitConfig = 78
)
type Config struct {
ListenAddr string
}
func Load() (Config, error) {
listenAddr := strings.TrimSpace(os.Getenv(ListenAddrEnv))
if listenAddr == "" {
return Config{}, fmt.Errorf("required environment variable %s is not set", ListenAddrEnv)
}
return Config{ListenAddr: listenAddr}, nil
}
package config
import (
"strings"
"testing"
)
func TestLoadRequiresListenAddr(t *testing.T) {
t.Setenv(ListenAddrEnv, "")
_, err := Load()
if err == nil {
t.Fatal("expected missing LISTEN_ADDR to fail")
}
if !strings.Contains(err.Error(), ListenAddrEnv) {
t.Fatalf("error %q does not name %s", err, ListenAddrEnv)
}
}
func TestLoadReturnsListenAddr(t *testing.T) {
t.Setenv(ListenAddrEnv, ":8080")
cfg, err := Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.ListenAddr != ":8080" {
t.Fatalf("ListenAddr = %q, want %q", cfg.ListenAddr, ":8080")
}
}
package handlers
import (
"encoding/json"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"recordsvc/internal/models"
)
type service struct {
records []models.Record
}
func NewRouter(records []models.Record) chi.Router {
svc := service{records: append([]models.Record(nil), records...)}
router := chi.NewRouter()
router.Get("/v1/records", svc.listRecords)
router.Get("/v1/records/{id}", svc.getRecord)
return router
}
func (svc service) listRecords(w http.ResponseWriter, r *http.Request) {
status, ok := models.ParseStatus(r.URL.Query().Get("status"))
if !ok {
writeJSON(w, http.StatusBadRequest, models.ErrorResponse{Error: "status must be one of: active, inactive, archived"})
return
}
matches := make([]models.Record, 0, len(svc.records))
for _, record := range svc.records {
if record.Status == status {
matches = append(matches, record)
}
}
writeJSON(w, http.StatusOK, matches)
}
func (svc service) getRecord(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
for _, record := range svc.records {
if record.ID == id {
writeJSON(w, http.StatusOK, record)
return
}
}
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "record not found"})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("encode response: %v", err)
}
}
package main
import (
"log"
"net/http"
"os"
"time"
"recordsvc/internal/config"
"recordsvc/internal/handlers"
"recordsvc/internal/models"
)
func main() {
log.SetFlags(0)
cfg, err := config.Load()
if err != nil {
log.Print(err)
os.Exit(config.ExitConfig)
}
server := &http.Server{
Addr: cfg.ListenAddr,
Handler: handlers.NewRouter(models.SyntheticRecords()),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("recordsvc listening on %s", cfg.ListenAddr)
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
The code now compiles as one service. The final checks prove the successful route behavior before capturing the required startup refusal.
- Build the executable inside bin by running these commands:
mkdir -p bin evidence
go build -o bin/recordsvc ./cmd/recordsvc
What Does This Build Create?
The first command ensures the output folders exist. The second command compiles cmd/recordsvc into bin/recordsvc.
You should see the command return to the prompt without a build error. The bin folder now contains the service executable.
Build Command Failing?
Run the command from the repository folder that contains go.mod.
Confirm that cmd/recordsvc/main.go exists before rebuilding.
Help me diagnose the recordsvc build failure.
Before you start the service, which two synthetic records do you expect the active-status route to return?
- Start the service with the required address by running this command:
LISTEN_ADDR=:8080 ./bin/recordsvc
What Does This Startup Command Do?
The command supplies LISTEN_ADDR only to this service process. The terminal remains occupied while the server listens on port 8080.
You should see recordsvc listening on :8080 in the terminal.
- Open a browser tab at http://localhost:8080/v1/records?status=active.
- Open another browser tab at http://localhost:8080/v1/records/rec-002.
The active-status response contains rec-001 and rec-003 with status active.
The record response contains rec-002 with name Beta dataset and status inactive.
Routes Not Responding?
Confirm that the service terminal still shows the listening message. Check that each browser address uses port 8080.
If the list route returns an error body, confirm that its address includes ?status=active.
Help me diagnose the local route response.
- Stop the running service by pressing Ctrl+C in its terminal.
Before you run the configuration proof, what exit status should a missing required setting produce?
- Capture the missing-setting evidence by running this exact command sequence:
mkdir -p bin evidence
go build -o bin/recordsvc ./cmd/recordsvc
set +e
env -u LISTEN_ADDR ./bin/recordsvc >evidence/config-exit.txt 2>&1
status=$?
set -e
printf 'exit_status=%s\n' "$status" | tee -a evidence/config-exit.txt
test "$status" -eq 78
grep -q LISTEN_ADDR evidence/config-exit.txt
What Does the Evidence Command Prove?
- The service runs with LISTEN_ADDR removed from its environment.
- Both stderr and the process status are retained in evidence/config-exit.txt.
- The final checks require status 78 and an error that names LISTEN_ADDR.
- Select evidence/config-exit.txt in the Explorer sidebar.
You should see an error naming LISTEN_ADDR plus exit_status=78. That retained output proves the service refuses to invent deployment configuration.
Configuration Evidence Missing?
Confirm that the command ran from the repository folder. Check that bin/recordsvc was rebuilt before the missing-setting run.
If the status differs from 78, compare the error branch in cmd/recordsvc/main.go with config.ExitConfig.
Help me diagnose the fail-closed evidence.
Your service now follows the reviewed promise and rejects missing configuration with retained evidence. Next up, you will make route drift and schema drift fail in both directions.
Prove the Two-Way Gate and Static Image
Your service now follows the reviewed contract. The next risk is drift after that first implementation, when a route disappears, an undocumented route appears, or a response changes shape.
This step turns the OpenAPI promise into a bidirectional gate against the chi router. You will retain three deliberate failures before restoring the service and packaging it as a static Docker image.
In this step, get ready to:
- Build route-set comparison and response-schema validation.
- Capture three red runs before restoring a clean gate.
- Define a static image with a pinned shell-free runtime.
Build the bidirectional contract gate
A one-way test confirms that documented routes exist. A bidirectional test also catches registered routes that never entered the reviewed contract.
How does the gate work?
The gate converts both the document and router into normalized method-path pairs. It compares the two sets in both directions.
A separate exchange test sends two successful requests through the service. It validates each request and response against the same OpenAPI document.
💡 Normalization keeps comparisons stable
The helper uppercases methods and removes trailing slashes. It also replaces each named path parameter with {} so different parameter names do not create false route differences.
- In the VS Code Explorer sidebar, create contract_test.go inside tests/contract.
- Choose whether to write the gate from its responsibilities or compare your work with the complete file below.
✔️ I'll try it first
Build the gate as a set of small helpers. Each helper has one source of truth or one comparison responsibility.
- Define routeSet and normalize method-path pairs with normalizeRoutePair.
- Load and validate openapi.yaml with loadDocument.
- Collect contract operations with documentedRoutes.
- Walk the chi router with registeredRoutes.
- Compare both sets with difference and TestRouteSetsMatch.
- Validate the two successful exchanges with TestDocumentedExchanges.
ⓧ Let me check my work
package contract_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"regexp"
"sort"
"strings"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers/gorillamux"
"github.com/go-chi/chi/v5"
"recordsvc/internal/handlers"
"recordsvc/internal/models"
)
type routeSet map[string]struct{}
var pathParameter = regexp.MustCompile(`\{[^{}]+\}`)
func normalizeRoutePair(method, path string) string {
normalizedPath := strings.TrimRight(path, "/")
if normalizedPath == "" {
normalizedPath = "/"
}
return strings.ToUpper(method) + " " + pathParameter.ReplaceAllString(normalizedPath, "{}")
}
func loadDocument(t *testing.T) *openapi3.T {
t.Helper()
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile("openapi.yaml")
if err != nil {
t.Fatalf("load OpenAPI document: %v", err)
}
if err := doc.Validate(context.Background()); err != nil {
t.Fatalf("validate OpenAPI document: %v", err)
}
return doc
}
func documentedRoutes(doc *openapi3.T) routeSet {
routes := routeSet{}
for path, item := range doc.Paths.Map() {
if item == nil {
continue
}
for method := range item.Operations() {
routes[normalizeRoutePair(method, path)] = struct{}{}
}
}
return routes
}
func registeredRoutes(t *testing.T, router chi.Routes) routeSet {
t.Helper()
routes := routeSet{}
err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
routes[normalizeRoutePair(method, route)] = struct{}{}
return nil
})
if err != nil {
t.Fatalf("walk chi router: %v", err)
}
return routes
}
func difference(left, right routeSet) []string {
missing := make([]string, 0)
for pair := range left {
if _, ok := right[pair]; !ok {
missing = append(missing, pair)
}
}
sort.Strings(missing)
return missing
}
func TestRouteSetsMatch(t *testing.T) {
doc := loadDocument(t)
router := handlers.NewRouter(models.SyntheticRecords())
documented := documentedRoutes(doc)
registered := registeredRoutes(t, router)
documentedOnly := difference(documented, registered)
registeredOnly := difference(registered, documented)
t.Logf("route sets: documented=%d registered=%d documented-only=%d registered-only=%d", len(documented), len(registered), len(documentedOnly), len(registeredOnly))
for _, pair := range documentedOnly {
t.Errorf("documented but unregistered: %s", pair)
}
for _, pair := range registeredOnly {
t.Errorf("registered but undocumented: %s", pair)
}
}
func TestDocumentedExchanges(t *testing.T) {
doc := loadDocument(t)
schemaRouter, err := gorillamux.NewRouter(doc)
if err != nil {
t.Fatalf("create OpenAPI router: %v", err)
}
serviceRouter := handlers.NewRouter(models.SyntheticRecords())
tests := []struct {
name string
url string
}{
{name: "read one synthetic record", url: "http://recordsvc.test/v1/records/rec-001"},
{name: "list active synthetic records", url: "http://recordsvc.test/v1/records?status=active"},
}
validated := 0
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, testCase.url, nil)
route, pathParams, err := schemaRouter.FindRoute(request)
if err != nil {
t.Fatalf("find documented route: %v", err)
}
requestInput := &openapi3filter.RequestValidationInput{Request: request, PathParams: pathParams, Route: route}
if err := openapi3filter.ValidateRequest(context.Background(), requestInput); err != nil {
t.Fatalf("request does not match schema: %v", err)
}
recorder := httptest.NewRecorder()
serviceRouter.ServeHTTP(recorder, request)
response := recorder.Result()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
if err := response.Body.Close(); err != nil {
t.Fatalf("close response body: %v", err)
}
responseInput := &openapi3filter.ResponseValidationInput{
RequestValidationInput: requestInput,
Status: response.StatusCode,
Header: response.Header,
Options: &openapi3filter.Options{IncludeResponseStatus: true},
}
responseInput.SetBodyBytes(body)
if err := openapi3filter.ValidateResponse(context.Background(), responseInput); err != nil {
t.Fatalf("response does not match schema: %v", err)
}
validated++
})
}
t.Logf("responses validated: %d/%d", validated, len(tests))
}
- Save tests/contract/contract_test.go.
- Confirm the VS Code Explorer shows the new file inside tests/contract.
Capture three deliberate red runs
A gate earns trust by failing for the right reasons. You will now break one contract relationship at a time and retain the resulting evidence before restoring the intended implementation.
Why retain red evidence?
A later green run proves the current files agree. The red runs prove the gate can identify three different classes of drift.
- In internal/handlers/records.go, locate NewRouter.
- Find this current function:
func NewRouter(records []models.Record) chi.Router {
svc := service{records: append([]models.Record(nil), records...)}
router := chi.NewRouter()
router.Get("/v1/records", svc.listRecords)
router.Get("/v1/records/{id}", svc.getRecord)
return router
}
What does this registration prove?
Both contracted paths are present in the router. Removing the item path leaves the document with one route the implementation cannot serve.
- Temporarily remove only the item-route registration so the function looks like this:
func NewRouter(records []models.Record) chi.Router {
svc := service{records: append([]models.Record(nil), records...)}
router := chi.NewRouter()
router.Get("/v1/records", svc.listRecords)
return router
}
What changed?
The contract still documents the item path. The router now registers only the list path, which creates a document-only difference.
Before you run the gate, which normalized route do you expect it to report as missing?
- Run the contract tests and retain the first red result by running:
go test -v ./tests/contract 2>&1 | tee evidence/red-document-only.txt
The first failure is intentional
You should see documented but unregistered: GET /v1/records/{} in the failing test output. This proves the document-to-router direction detects missing implementation.
- Restore the item-route registration so NewRouter returns to this implementation:
func NewRouter(records []models.Record) chi.Router {
svc := service{records: append([]models.Record(nil), records...)}
router := chi.NewRouter()
router.Get("/v1/records", svc.listRecords)
router.Get("/v1/records/{id}", svc.getRecord)
return router
}
Why restore before the next run?
Each retained failure must isolate one kind of drift. Restoring the item route prevents the second evidence file from mixing two failures.
The reverse direction protects the reviewed promise from behavior that bypasses it. A temporary debug route gives the router an endpoint the contract never approved.
- Register a temporary GET /v1/debug route inside NewRouter with svc.getRecord as its handler.
- Save internal/handlers/records.go.
Before you run the gate, do you expect the extra route to appear on the documented side or the registered side?
- Run the contract tests and retain the second red result by running:
go test -v ./tests/contract 2>&1 | tee evidence/red-router-only.txt
The reverse check caught the route
You should see registered but undocumented: GET /v1/debug in the failing output. This proves the router-to-document direction detects unreviewed behavior.
- Remove the temporary GET /v1/debug registration from NewRouter.
- Save internal/handlers/records.go.
Matching route names still leave room for response drift. The final red run keeps both routes intact and changes the encoded type of one successful response.
- In listRecords, locate the final successful-response line:
writeJSON(w, http.StatusOK, matches)
What does this line preserve?
The line encodes the matching records with their declared string-based Status values. The OpenAPI schema expects that same string type.
- Temporarily change one successful response so its encoded JSON uses the number 1 for status.
- Keep the response status as 200.
- Save internal/handlers/records.go.
Before you run the test, which response field should the schema validator identify?
- Run the contract tests and retain the schema-drift result by running:
go test -v ./tests/contract 2>&1 | tee evidence/red-schema-status.txt
The schema failure is expected
The validator should name field /status. Its output should identify the actual numeric type and the expected string type.
- Restore the successful-response line to the contracted implementation:
writeJSON(w, http.StatusOK, matches)
What is restored?
The handler once again serializes the typed record slice. Its status values now match the contract's string schema.
- Save internal/handlers/records.go.
- Create Makefile inside the repository folder with this content:
.PHONY: test
test:
go vet ./...
golangci-lint run
go test ./...
What does the test target enforce?
- The first command checks Go code for suspicious constructs.
- The second command applies the configured lint rules.
- The third command runs every Go test package, including the contract gate.
- Save Makefile.
Before you run the restored gate, do you expect either route-set direction or either documented exchange to fail?
- Run the complete quality target and retain its output by running:
make test 2>&1 | tee evidence/lint-and-test.txt
What should the quality run prove?
The vet, lint, and test commands should complete without a failure. This shows the temporary red changes are no longer present.
- Run the verbose contract package and retain its clean counts by running:
go test -v ./tests/contract 2>&1 | tee evidence/contract-clean.txt
Read the clean evidence
You should see documented=2 registered=2 documented-only=0 registered-only=0 and responses validated: 2/2 in the retained output.
That is the hard part complete. Your gate now proves both clean agreement and three distinct failure modes.
🙋♀️ Contract tests still failing?
Check that NewRouter contains only the two contracted registrations. Confirm the successful list response passes matches to writeJSON.
Compare tests/contract/contract_test.go with the complete file above. A changed route normalizer can make equivalent paths compare differently.
Help me diagnose which retained red change is still present in my Go service.
Define the static image
The same test gate now becomes part of the image build. A multi-stage build runs the checks before producing a statically linked binary for a pinned Distroless runtime.
- Create .dockerignore inside the repository folder with this content:
.git
bin
evidence
readouts
docs
infra
What stays out of the build context?
The ignore file excludes repository history, generated binaries, evidence, readouts, documentation, and infrastructure scripts. The builder receives only the files needed to test and compile the service.
- Save .dockerignore.
- Confirm the VS Code Explorer shows .dockerignore beside go.mod.
- Create Dockerfile inside the repository folder with this content:
# syntax=docker/dockerfile:1
FROM golangci/golangci-lint:v2.13.2 AS linttool
FROM golang:1.27.1-bookworm AS test
WORKDIR /src
COPY --from=linttool /usr/bin/golangci-lint /usr/local/bin/golangci-lint
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
FROM test AS build
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/recordsvc ./cmd/recordsvc
FROM gcr.io/distroless/static-debian13@sha256:2293b36c7c9082bf4115aab724b4d2cddec82c8eba39bf27ac0517e159acf150
COPY --from=build /out/recordsvc /recordsvc
EXPOSE 8080
ENTRYPOINT ["/recordsvc"]
What does this image definition do?
- The test stage copies the pinned linter into the pinned Go builder and runs make test.
- The build stage disables CGO and compiles a Linux AMD64 executable.
- The final stage copies only recordsvc into the pinned static runtime.
- The entrypoint starts the service directly without a shell wrapper.
- Save Dockerfile.
- Confirm the VS Code Explorer shows Dockerfile beside Makefile.
Use the local path when Docker Engine is running in Linux. Use the hosted path when image checks were delegated to GitLab.com.
Docker Engine is running locally
The first build downloads the pinned images before running the full test target. A quiet wait during those downloads is normal.
- Build the final image with a local tag by running:
docker build --tag recordsvc:local .
What does this build prove?
Docker executes every stage in the Dockerfile. A successful build proves the quality gate passed before the static binary entered the final image.
🙋♀️ Local build failed?
Confirm Docker Engine is running inside Linux. Check that go.sum exists beside go.mod before rebuilding.
Help me diagnose why my recordsvc Docker build failed.
- Measure the image and assert the byte limit by running:
IMAGE_SIZE=$(docker image inspect --format '{{.Size}}' recordsvc:local)
printf 'image_size_bytes=%s\nlimit_bytes=31457280\n' "$IMAGE_SIZE"
test "$IMAGE_SIZE" -lt 31457280
What does the size check prove?
The first line reads the exact image size from Docker. The final command succeeds only when that value is below 31457280 bytes.
Before you test the runtime shape, do you expect a shell to start inside this image?
- Attempt to start /bin/sh inside the final image by running:
docker run --rm --entrypoint /bin/sh recordsvc:local
This failed start is the expected result
Docker should report that /bin/sh cannot start. The failure proves the final runtime does not contain a shell.
Hosted build path
Keep the completed Dockerfile and .dockerignore unchanged. The GitLab.com job in the next step runs the same test stage, size assertion, and shell-start check.
- Confirm both image-definition files are saved in the repository folder.
Before the final check, do you expect the image files to change any Go test result?
- Re-run the complete quality target and refresh the retained output by running:
make test 2>&1 | tee evidence/lint-and-test.txt
What should you see?
All vet, lint, and Go test commands should complete without a failure. The service is restored, the two-way gate is clean, and the static image definition is ready for the cloud lane.
🙋♀️ Final quality run failed?
Read the first failing command in evidence/lint-and-test.txt. A route mismatch points to an unrestored red edit, while a lint failure points to the temporary schema change or an unused import.
Help me trace the first failure in my final make test output.
Your delivery rail now rejects missing routes, undocumented routes, and response-schema drift. Next up, you will carry that evidence into a keyless immutable publishing rehearsal.
Rehearse Keyless Publishing, Release the Evidence, and Tear Down
Your permanent GitHub record now proves that the contract preceded the implementation. This final step moves the same commit through a temporary publishing lane.
The cloud lane must prove identity before publishing. Its retained output must also prove image shape, tag immutability, digest equality, release lineage, and negative teardown.
In this step, get ready to:
- Bootstrap the temporary AWS publishing resources.
- Run the federated GitLab publishing fixture.
- Release the retained evidence before proving teardown.
Bootstrap AWS around the fixture identity
A temporary GitLab.com project supplies the claim values that AWS IAM checks before trusting a job. The namespace plus project identifiers restrict the publishing role to this fixture.
- Create a temporary private project through the GitLab.com project creation flow.
- Record its namespace path as your GitLab namespace path.
- Record its project name as your GitLab fixture project name.
- Record its namespace ID as your GitLab namespace ID.
- Record its project ID as your GitLab project ID.
Why bind trust to one project?
The OIDC issuer identifies GitLab.com. The audience plus project claims identify the permitted job context.
A job with different claims cannot satisfy the same trust policy. This keeps the federation boundary tied to one temporary fixture.
The bootstrap script creates an AWS Budgets notification before the publishing resources. It then creates the identity provider, repository-scoped role, registry scan configuration, and immutable Amazon ECR repository.
This action touches account billing controls. The $5.00 monthly budget sends a delayed notification while the rehearsal runs.
- Create `infra/bootstrap.sh` inside the repository from earlier.
- Compare the new file with the complete reference below.
✔️ I've saved bootstrap.sh
Keep the resource names aligned with the delivery plan. The script must remain unchanged when it moves into CloudShell.
ⓧ I'd like to double check the full code
#!/usr/bin/env bash
set -euo pipefail
: "${AWS_REGION:=us-east-1}"
: "${ECR_REPOSITORY:=contract-first-records}"
: "${ROLE_NAME:=recordsvc-gitlab-publisher}"
: "${POLICY_NAME:=recordsvc-ecr-write}"
: "${BUDGET_NAME:=recordsvc-rehearsal}"
: "${BUDGET_EMAIL:?set BUDGET_EMAIL}"
: "${GITLAB_GROUP:?set GITLAB_GROUP}"
: "${GITLAB_PROJECT:?set GITLAB_PROJECT}"
: "${GITLAB_NAMESPACE_ID:?set GITLAB_NAMESPACE_ID}"
: "${GITLAB_PROJECT_ID:?set GITLAB_PROJECT_ID}"
AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
OIDC_PROVIDER_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/gitlab.com"
REPOSITORY_ARN="arn:aws:ecr:${AWS_REGION}:${AWS_ACCOUNT_ID}:repository/${ECR_REPOSITORY}"
cat > /tmp/recordsvc-budget.json <<JSON
{"BudgetLimit":{"Amount":"5.00","Unit":"USD"},"BudgetName":"${BUDGET_NAME}","BudgetType":"COST","TimeUnit":"MONTHLY"}
JSON
cat > /tmp/recordsvc-budget-notification.json <<JSON
[{"Notification":{"ComparisonOperator":"GREATER_THAN","NotificationType":"ACTUAL","Threshold":80,"ThresholdType":"PERCENTAGE"},"Subscribers":[{"Address":"${BUDGET_EMAIL}","SubscriptionType":"EMAIL"}]}]
JSON
aws budgets create-budget --account-id "$AWS_ACCOUNT_ID" --budget file:///tmp/recordsvc-budget.json --notifications-with-subscribers file:///tmp/recordsvc-budget-notification.json
aws budgets describe-budget --account-id "$AWS_ACCOUNT_ID" --budget-name "$BUDGET_NAME"
aws budgets describe-subscribers-for-notification --account-id "$AWS_ACCOUNT_ID" --budget-name "$BUDGET_NAME" --notification NotificationType=ACTUAL,ComparisonOperator=GREATER_THAN,Threshold=80,ThresholdType=PERCENTAGE --max-results 5
cat > /tmp/gitlab-oidc-provider.json <<'JSON'
{"Url":"https://gitlab.com","ClientIDList":["https://gitlab.com"]}
JSON
aws iam create-open-id-connect-provider --cli-input-json file:///tmp/gitlab-oidc-provider.json
cat > /tmp/recordsvc-trust-policy.json <<JSON
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"${OIDC_PROVIDER_ARN}"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"gitlab.com:aud":"https://gitlab.com","gitlab.com:sub":"project_path:${GITLAB_GROUP}/${GITLAB_PROJECT}:ref_type:branch:ref:main","gitlab.com:namespace_id":"${GITLAB_NAMESPACE_ID}","gitlab.com:project_id":"${GITLAB_PROJECT_ID}"}}}]}
JSON
aws iam create-role --role-name "$ROLE_NAME" --assume-role-policy-document file:///tmp/recordsvc-trust-policy.json
cat > /tmp/recordsvc-ecr-policy.json <<JSON
{"Version":"2012-10-17","Statement":[{"Sid":"GetAuthorizationToken","Effect":"Allow","Action":"ecr:GetAuthorizationToken","Resource":"*"},{"Sid":"OneRepository","Effect":"Allow","Action":["ecr:BatchCheckLayerAvailability","ecr:BatchGetImage","ecr:CompleteLayerUpload","ecr:DescribeImages","ecr:DescribeImageScanFindings","ecr:GetDownloadUrlForLayer","ecr:InitiateLayerUpload","ecr:PutImage","ecr:UploadLayerPart"],"Resource":"${REPOSITORY_ARN}"}]}
JSON
aws iam put-role-policy --role-name "$ROLE_NAME" --policy-name "$POLICY_NAME" --policy-document file:///tmp/recordsvc-ecr-policy.json
cat > /tmp/recordsvc-registry-scanning.json <<JSON
{"scanType":"BASIC","rules":[{"scanFrequency":"SCAN_ON_PUSH","repositoryFilters":[{"filter":"${ECR_REPOSITORY}","filterType":"WILDCARD"}]}]}
JSON
aws ecr put-registry-scanning-configuration --cli-input-json file:///tmp/recordsvc-registry-scanning.json --region "$AWS_REGION"
aws ecr create-repository --repository-name "$ECR_REPOSITORY" --image-tag-mutability IMMUTABLE --region "$AWS_REGION"
aws ecr describe-repositories --repository-names "$ECR_REPOSITORY" --region "$AWS_REGION"
aws ecr get-registry-scanning-configuration --region "$AWS_REGION"
aws iam get-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_PROVIDER_ARN"
aws iam get-role --role-name "$ROLE_NAME"
aws iam get-role-policy --role-name "$ROLE_NAME" --policy-name "$POLICY_NAME"
What does this script prove?
- The budget commands retain the monthly threshold plus its subscriber.
- The trust policy binds the role to the intended GitLab project claims.
- The inline policy limits repository operations to `contract-first-records`.
- The final reads retain `IMMUTABLE`, `BASIC`, and `SCAN_ON_PUSH` in command output.
Trace the trust boundary
The script uses wildcard resource scope only for token retrieval. Repository operations use the ARN derived from `contract-first-records`.
The role therefore receives the smallest publishing boundary used by this rehearsal. The retained policy makes that boundary reviewable.
- Save `infra/bootstrap.sh`.
- Sign in to the AWS account used for this rehearsal.
- Start AWS CloudShell from the signed-in account.
- Place `infra/bootstrap.sh` in the CloudShell copy of the repository.
- Set `BUDGET_EMAIL` to your budget alert email.
- Set the four GitLab claim variables from the values you recorded above.
Keep account values out of source
Set the bootstrap inputs only in the active CloudShell session. The repository keeps the variable names plus their rehearsal defaults.
- Create the AWS bootstrap evidence by running this command:
bash infra/bootstrap.sh | tee evidence/aws-create.txt
What does this command retain?
The script creates each rehearsal resource. `tee` writes the read-backs to `evidence/aws-create.txt` while CloudShell displays the same output.
Both readouts use this file as their AWS creation source. Estimated account values cannot replace it.
- Confirm the budget read-back names `recordsvc-rehearsal` with an amount of `$5.00`.
- Confirm the subscriber read-back names the alert email you supplied.
- Confirm the repository read-back reports `IMMUTABLE`.
- Confirm the registry read-back reports `BASIC` plus `SCAN_ON_PUSH`.
- Record the created role ARN as your recordsvc GitLab publisher role ARN.
The account boundary is now visible in retained output. The publishing job can request a role without receiving a stored AWS key.
Bootstrap stopped before the read-backs?
Check that the five required bootstrap values exist in the same CloudShell session. A missing value stops the script before resource creation continues.
If AWS denies a request, confirm that the signed-in account can manage Budgets, IAM, and ECR for this rehearsal.
Share the exact failed command plus its unedited output when you ask for bootstrap help.
Run the federated publishing fixture
The hosted job receives a short-lived ID token from GitLab.com. AWS STS exchanges it for temporary credentials after the role conditions match.
The same job builds the static Docker image under the existing Dockerfile. Its artifacts connect caller identity, image shape, remote digest, tag behavior, and scan output to one pipeline run.
- Create `.gitlab-ci.yml` inside the repository from earlier.
- Compare the pipeline file with the complete reference below.
✔️ I've saved the pipeline file
Keep the pipeline aligned with the existing Dockerfile. The fixture must receive only the three permitted GitLab variables.
ⓧ I'd like to double check the full code
stages: [test, publish]
.docker_job:
image: docker:29.8.1-cli-alpine3.24
services:
- name: docker:29.8.1-dind-alpine3.24
alias: docker
tags: [saas-linux-small-amd64]
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: /certs
DOCKER_TLS_VERIFY: '1'
DOCKER_CERT_PATH: /certs/client
before_script:
- docker info
test:
extends: .docker_job
stage: test
script:
- docker build --target test --tag recordsvc:test .
publish:
extends: .docker_job
stage: publish
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
before_script:
- docker info
- apk add --no-cache aws-cli=2.34.63-r0
script:
- |
if env | grep -Eq '^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY)='; then
echo 'stored AWS access-key variable detected'
exit 1
fi
echo 'no stored AWS access-key variable present before federation' | tee gitlab-identity.txt
- >
aws_sts_output=$(aws sts assume-role-with-web-identity
--role-arn "$AWS_OIDC_ROLE_ARN"
--role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
--web-identity-token "$GITLAB_OIDC_TOKEN"
--duration-seconds 3600
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text)
- set -- $aws_sts_output
- export AWS_ACCESS_KEY_ID="$1"
- export AWS_SECRET_ACCESS_KEY="$2"
- export AWS_SESSION_TOKEN="$3"
- aws sts get-caller-identity | tee -a gitlab-identity.txt
- AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
- IMAGE_TAG="$CI_COMMIT_SHA"
- IMAGE_URI="$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$ECR_REPOSITORY:$IMAGE_TAG"
- aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com"
- docker build --tag "$IMAGE_URI" .
- IMAGE_SIZE=$(docker image inspect --format '{{.Size}}' "$IMAGE_URI")
- printf 'image_size_bytes=%s\nlimit_bytes=31457280\n' "$IMAGE_SIZE" | tee image.txt
- test "$IMAGE_SIZE" -lt 31457280
- |
set +e
docker run --rm --entrypoint /bin/sh "$IMAGE_URI" >>image.txt 2>&1
shell_status=$?
set -e
printf 'shell_start_status=%s\n' "$shell_status" | tee -a image.txt
test "$shell_status" -ne 0
- docker push "$IMAGE_URI" | tee publish.txt
- REMOTE_DIGEST=$(aws ecr describe-images --repository-name "$ECR_REPOSITORY" --image-ids imageTag="$IMAGE_TAG" --query 'imageDetails[0].imageDigest' --output text --region "$AWS_REGION")
- printf '%s\n' "$REMOTE_DIGEST" > image-digest.txt
- printf 'remote_digest=%s\n' "$REMOTE_DIGEST" | tee -a publish.txt
- test "$(cat image-digest.txt)" = "$REMOTE_DIGEST"
- |
set +e
docker push "$IMAGE_URI" >second-push.txt 2>&1
second_push_status=$?
set -e
cat second-push.txt | tee -a publish.txt
printf 'second_push_status=%s\n' "$second_push_status" | tee -a publish.txt
test "$second_push_status" -ne 0
grep -q ImageTagAlreadyExistsException second-push.txt
- aws ecr wait image-scan-complete --repository-name "$ECR_REPOSITORY" --image-id imageTag="$IMAGE_TAG" --region "$AWS_REGION"
- aws ecr describe-image-scan-findings --repository-name "$ECR_REPOSITORY" --image-id imageTag="$IMAGE_TAG" --region "$AWS_REGION" | tee scan.txt
artifacts:
paths:
- image-digest.txt
- image.txt
- publish.txt
- second-push.txt
- scan.txt
- gitlab-identity.txt
expire_in: 1 week
How does the publishing proof fit together?
- The opening guard rejects stored `AWS_ACCESS_KEY_ID` or `AWS_SECRET_ACCESS_KEY` values before federation.
- The token exchange creates temporary credentials for the current job.
- The image checks retain its exact byte size plus the failed `/bin/sh` start.
- The publishing checks retain the remote digest plus the result of reusing one tag.
- The scan output is retained as non-gating evidence.
- Save `.gitlab-ci.yml`.
- Add `AWS_OIDC_ROLE_ARN` to the fixture variables with the value your recordsvc GitLab publisher role ARN.
- Add `AWS_REGION` to the fixture variables with the value `us-east-1`.
- Add `ECR_REPOSITORY` to the fixture variables with the value `contract-first-records`.
- Confirm the fixture variable list contains no `AWS_ACCESS_KEY_ID` entry.
- Confirm the fixture variable list contains no `AWS_SECRET_ACCESS_KEY` entry.
Why temporary credentials?
AWS STS returns credentials with a limited lifetime plus the permissions of `recordsvc-gitlab-publisher`. The job uses them only after federation succeeds.
The repository stores no long-lived AWS access key. The publishing role also carries no cluster credential.
Before you push the commit, do you expect both attempts to publish the same commit-SHA tag to succeed?
- Push the exact GitHub `main` commit to the temporary GitLab.com fixture.
The hosted build can pause while it downloads image layers. Quiet logs during that stage do not mean the job is stuck.
- Wait for the test job to finish.
- Wait for the publish job to finish.
- Open the completed publish job to inspect its artifact list.
The first image push succeeds. The second same-tag push fails with `ImageTagAlreadyExistsException`, proving that the repository refuses tag replacement.
- Copy `gitlab-identity.txt` from the job artifacts to `evidence/gitlab-identity.txt`.
- Copy `image.txt` from the job artifacts to `evidence/image.txt`.
- Copy `publish.txt` from the job artifacts to `evidence/publish.txt`.
- Copy `scan.txt` from the job artifacts to `evidence/scan.txt`.
- Keep `image-digest.txt` ready for the release handoff.
Publishing stopped before the second push?
Check that the role ARN matches the role created by `infra/bootstrap.sh`. Check that the Region plus repository name match the three permitted fixture variables.
For a federation failure, compare the issuer, audience, subject, namespace ID, project ID, and branch with the trust policy.
Keep stored keys out of the recovery path when you ask for OIDC help.
Release the evidence and prove teardown
The digest is the boundary between this build and a later deployment. The release keeps one command-derived digest line without giving the publishing job access to a cluster.
- Create `deploy/image-digest.txt` inside the repository from earlier.
- Copy the single line from the downloaded `image-digest.txt` artifact into `deploy/image-digest.txt`.
- Compare that line with `remote_digest` in `evidence/publish.txt`.
The values should match exactly. Their equality ties the handoff file to the image that ECR reports for the immutable commit tag.
The operating guide plus runbook turn the retained evidence into repeatable delivery rules. The fidelity note keeps every claim inside the boundaries of this rehearsal.
- Create `docs/operating-it.md` from its reference below.
- Create `docs/runbook.md` from its reference below.
- Return to `docs/limits.md` from earlier.
docs/operating-it.md
# Operating It
The release is incomplete until every file in `evidence/index.md` exists and both readouts cite only those files. Runtime values are copied from command output, never estimated.
A route change begins with the contract. If implementation changes first, the gate must fail. A reviewer asks why the test did not fail, not whether documentation was manually updated.
What does this document establish?
The operating rule blocks release while evidence is incomplete. It also keeps intentional route changes contract-first.
docs/runbook.md
# Runbook
Route failure: run `go test -v ./tests/contract -run TestRouteSetsMatch` and change the contract first when the change is intentional.
OIDC failure: compare issuer, audience, subject, namespace ID, project ID, and branch. Never add an AWS access key.
Publish failure: run `aws sts get-caller-identity`, inspect ECR mutability, compare the commit tag, and use a new commit SHA for new content.
Teardown failure: rerun only the failed delete, then rerun the corresponding read-back. A successful delete command without a negative read-back is not completion.
What does this runbook protect?
Each recovery path preserves the delivery controls. A failure cannot become a reason to add a stored key or overwrite an immutable tag.
docs/limits.md
# Limits and Fidelity Note
This is a hand-written GitLab job, not the platform's versioned template. The image is unsigned and its scan gates nothing. GitLab hosted runners and the OIDC issuer are on the public internet.
It uses one Region and one AWS account. There is no image pull path, cluster, deployment, production network, cross-account promotion, approval gate, long-term evidence store, vulnerability exception process, or incident process. The OpenAPI document has no security scheme. The service stores only three explicitly synthetic records in memory.
Shell absence proves image shape, not vulnerability absence. Two valid exchanges do not prove all business behavior. `1/1` is a landing-day fraction over one service, not a platform-wide claim. No customer, production, regulated environment, agency, employer, procurement contract, product in use, or internal system is named or touched.
What does the fidelity note limit?
The note separates command-backed results from claims this rehearsal cannot support. It keeps the scan, adoption fraction, synthetic data, and deployment boundary precise.
- Save `docs/operating-it.md`.
- Save `docs/runbook.md`.
- Save the completed `docs/limits.md`.
- Confirm all three files appear in the repository file list.
The engineering and program readouts serve different audiences. Both must cite the same retained run without introducing estimated values.
- Use the engineering reference below to complete `readouts/engineering.md` from retained evidence.
- Use the program reference below to complete `readouts/program.md` from retained evidence.
readouts/engineering.md
# Engineering Readout
Populate only from `evidence/` and downloaded GitLab artifacts.
- Commit order:
- Contract route counts and differences:
- Validated exchanges:
- Three red runs:
- Required-setting stderr and exit status:
- Vet, lint, and test result:
- Go module line, builder release, and Go 1.23 support date:
- Image byte size, threshold, and shell result:
- ECR mutability and registry scan configuration:
- Pre-federation AWS key-variable check:
- STS caller identity:
- Pipeline and ECR digest equality:
- Second same-tag push status and service error:
- Scan result and non-gating statement:
- AWS teardown exceptions:
- GitLab scheduled-deletion state:
How should this readout be completed?
Replace each blank with the corresponding retained command result. Cite the evidence filename beside every result.
readouts/program.md
# Program Readout
Populate only from retained evidence.
- Rule: write the promise first, then make breaking it fail.
- Carrying cost: contract and router repeat API shape; normalization and red evidence require maintenance.
- Benefit: route and schema drift become pipeline failures before release.
- Adoption numerator:
- Adoption denominator:
- Landing-day fraction: `1/1`.
- Credential blast radius: one federated role can write one repository; no stored key and no cluster credential.
- Delivery interface: one digest line.
- Fidelity limits: cite `docs/limits.md`.
- Teardown: three AWS workload resources gone; GitLab fixture scheduled for deletion; budget gone.
How should this readout be completed?
Fill the numerator plus denominator from the retained pipeline run. Keep `1/1` scoped to this landing-day service.
- Complete every blank in `readouts/engineering.md` with retained evidence.
- Complete every blank in `readouts/program.md` with retained evidence.
- Cite `docs/limits.md` from both readouts.
- Commit the digest file plus the completed evidence documents to the private GitHub repository.
- Create the private GitHub release tag `v0.1.0` from that evidence commit.
The release record is now complete. The next action intentionally removes every temporary AWS workload resource while preserving the permanent GitHub and Linear evidence.
- Create `infra/teardown.sh` inside the repository from earlier.
- Compare the teardown script with the complete reference below.
✔️ I've saved teardown.sh
Keep every default aligned with the resource names created by `infra/bootstrap.sh`.
ⓧ I'd like to double check the full code
#!/usr/bin/env bash
set -euo pipefail
: "${AWS_REGION:=us-east-1}"
: "${ECR_REPOSITORY:=contract-first-records}"
: "${ROLE_NAME:=recordsvc-gitlab-publisher}"
: "${POLICY_NAME:=recordsvc-ecr-write}"
: "${BUDGET_NAME:=recordsvc-rehearsal}"
AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
OIDC_PROVIDER_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/gitlab.com"
aws ecr delete-repository --repository-name "$ECR_REPOSITORY" --force --region "$AWS_REGION"
aws iam delete-role-policy --role-name "$ROLE_NAME" --policy-name "$POLICY_NAME"
aws iam delete-role --role-name "$ROLE_NAME"
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_PROVIDER_ARN"
aws budgets delete-budget --account-id "$AWS_ACCOUNT_ID" --budget-name "$BUDGET_NAME"
aws ecr put-registry-scanning-configuration --scan-type BASIC --rules '[]' --region "$AWS_REGION"
assert_missing() {
label="$1"
expected="$2"
shift 2
set +e
output="$("$@" 2>&1)"
status=$?
set -e
printf '%s\n' "$output"
if [ "$status" -eq 0 ]; then
echo "$label still exists"
return 1
fi
printf '%s\n' "$output" | grep -q "$expected"
echo "$label read-back: not found"
}
assert_missing repository RepositoryNotFoundException aws ecr describe-repositories --repository-names "$ECR_REPOSITORY" --region "$AWS_REGION"
assert_missing role NoSuchEntity aws iam get-role --role-name "$ROLE_NAME"
assert_missing oidc-provider NoSuchEntity aws iam get-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_PROVIDER_ARN"
if aws budgets describe-budget --account-id "$AWS_ACCOUNT_ID" --budget-name "$BUDGET_NAME"; then
echo "budget still exists"
exit 1
else
echo "budget read-back: not found"
fi
How does teardown prove completion?
The script deletes the repository, inline role policy, role, identity provider, and budget. It also clears the temporary registry scan rule.
Each workload resource receives a negative read-back. The budget receives its own absence check.
- Save `infra/teardown.sh`.
- Place `infra/teardown.sh` in the CloudShell repository copy from earlier.
Before you run teardown, which three AWS resource reads do you expect to fail after deletion?
- Delete the rehearsal resources with retained output by running this command:
bash infra/teardown.sh 2>&1 | tee evidence/teardown.txt
What should the retained output prove?
The ECR read-back includes `RepositoryNotFoundException`. The role plus identity-provider read-backs include `NoSuchEntity`.
The final budget check reports that the budget is absent. These negative reads complete the AWS teardown evidence.
🙋♀️ A teardown read-back still succeeds?
Rerun only the delete associated with the remaining resource. Follow it with the corresponding read-back from `infra/teardown.sh`.
Check the AWS account plus `us-east-1` before retrying an ECR deletion. A successful delete request without a negative read-back leaves the evidence incomplete.
Use the retained output when you ask for teardown help.
- Copy the CloudShell teardown output to `evidence/teardown.txt` in the private GitHub repository.
- Schedule the temporary GitLab.com fixture project for deletion.
- Create `evidence/gitlab-deletion.txt` in the private GitHub repository.
- Record the fixture URL in `evidence/gitlab-deletion.txt`.
- Record the deletion action in `evidence/gitlab-deletion.txt`.
- Record this scheduled-deletion state in `evidence/gitlab-deletion.txt`:
scheduled for deletion; retention 30 days
Why retain the scheduled-deletion state?
GitLab.com keeps the project pending deletion for 30 days. Recording that state distinguishes scheduled teardown from immediate removal.
Before the final check, do you expect every evidence-index entry to point to retained output from this exact release?
- Check `evidence/index.md` against every retained evidence file.
- Confirm `evidence/teardown.txt` contains the three AWS not-found read-backs.
- Confirm `evidence/gitlab-deletion.txt` contains the fixture URL plus its scheduled-deletion state.
- Confirm `deploy/image-digest.txt` contains one remote immutable digest line.
- Confirm the private GitHub repository contains tag `v0.1.0`.
That closes the delivery loop. The release preserves its evidence plus immutable digest while every temporary publishing resource has a retained teardown result.
Secret mission
Harden Route Normalization
Small route spelling changes can create noisy drift unless normalization stays stable. Add a table-driven test that locks method case, trailing slashes, renamed parameters, and regex parameters to the same route keys.
Clean Up Your Resources
Clean Up Your Resources
Your AWS rehearsal is fully torn down, including its temporary budget. The expected project cost is $0 on the AWS Free Plan because no rehearsal workload remains active.
Cost warning
The $5.00 monthly AWS Budget provided a delayed notification threshold without enforcing a cap. Budget data can lag.
Your budget is deleted. Your Amazon ECR repository is also deleted.
Resources you used:
- Amazon ECR repository contract-first-records with its contained image. The completed teardown is recorded in evidence/teardown.txt.
- AWS IAM role recordsvc-gitlab-publisher with inline policy recordsvc-ecr-write. The completed teardown is recorded in evidence/teardown.txt.
- OIDC provider for gitlab.com. The completed teardown is recorded in evidence/teardown.txt.
- Temporary budget named recordsvc-rehearsal. Its absent read-back is recorded in evidence/teardown.txt.
- GitLab.com fixture project. Its scheduled deletion state is recorded in evidence/gitlab-deletion.txt.
Keep everything running
No action is needed. Choose this if you want to preserve the permanent delivery record for future reviews.
- Keep the private GitHub repository as the permanent delivery rail.
- Keep the Linear project as the work-planning record.
- Retain release tag v0.1.0.
- Retain the command output under evidence/.
- Retain readouts/engineering.md.
- Retain readouts/program.md.
- Retain the fidelity note in docs/limits.md.
- Retain the immutable digest in deploy/image-digest.txt.
Pause - I'll come back to this later
No cloud workload remains to pause. This option keeps the permanent delivery records while you step away.
- Leave the deleted AWS workload resources deleted.
- Leave the GitLab.com fixture scheduled for deletion.
- Keep the private GitHub repository.
- Keep the Linear project.
- Preserve evidence/teardown.txt as proof of the negative AWS read-backs.
- Preserve evidence/gitlab-deletion.txt as proof of the GitLab.com retention state.
Delete - I don't want to use this again
The disposable workload is already removed. This option confirms the negative evidence before you remove any optional local image.
- Review evidence/teardown.txt for the completed infra/teardown.sh run.
- Confirm RepositoryNotFoundException names the removed contract-first-records repository.
- Confirm one NoSuchEntity read-back names the removed recordsvc-gitlab-publisher role.
- Confirm the other NoSuchEntity read-back names the removed OIDC provider.
- Confirm budget read-back: not found proves the temporary budget is gone.
- Review evidence/gitlab-deletion.txt for the GitLab.com project status.
- Confirm the fixture is scheduled for deletion on its 30-day clock.
- Skip local-image cleanup unless you used the optional local image path in Step 4.
- Remove any optional local image through your Docker Engine image-removal workflow using the tag from your local build.
- Retain the private GitHub repository as the delivery record.
- Retain the Linear project as the work-history record.
Keep the Negative Read-Backs
A deletion request records intent. The canonical not-found responses prove the AWS resources are gone.
Keep both teardown evidence files with the tagged release. They close the rehearsal with command-derived proof.
Nice Work!
Nice Work!
You did it! You built an evidence-backed delivery rail from the API promise through an immutable digest.
You've learned how to:
- Built a contract-first service in Go over exactly three synthetic records. OpenAPI defines two routes. Git history proves the contract preceded every handler.
- Created a bidirectional contract gate for route-set equivalence. One retained red run exposes missing implementation. A second exposes undocumented behavior. A third exposes schema drift. The configuration proof names LISTEN_ADDR. It also records exit_status=78.
- Published a shell-free static image to immutable Amazon ECR. GitLab.com uses OIDC with AWS STS to obtain temporary credentials. The pipeline leaves one command-derived digest for a later deploy. Negative read-backs prove the AWS rehearsal resources are gone.
- Secret Mission: Hardened normalizeRoutePair with table-driven tests. Method casing normalizes consistently. Trailing slashes normalize consistently. Renamed parameters become {}. Regex parameters become {}.
Ready to quiz yourself?