Build a Private Expiring-Link Lambda

Build a private Go Lambda for expiring links with DynamoDB and IAM.

Introduction

30 Second Summary

Written requirements often look complete until code reaches an exact boundary. A vague word such as "after" can decide whether a link still works at the moment it expires.

In this project, you will build spec-links, a private expiring-link service on AWS Lambda using Go, Amazon DynamoDB, and OpenTofu. You will trace each written rule through tests, Git history, deployed behavior, close-out evidence, and teardown results.

What You'll Build

Picture invoking your private link service from the terminal to watch a link move from ok to gone exactly at expiry.

By the end of this project, you'll have:

  • A test suite that prints five named PASS lines for the written rules. A one-line mutation proves the exact expiry boundary is protected.
  • A private cloud demo that returns created, ok, bad_request, not_found, and gone from real terminal invocations.
  • A released evidence trail in a private GitHub repository. It includes commit order, a pull request, a v1.0 tag, a traceable readout.html file, and four teardown counts of 0.
  • Secret Mission: Add S-7 for generated codes on a separate branch with a red-then-green test and no cloud redeployment.

Are there any prerequisites?

You need a Windows device with Cursor, an existing AI client, a GitHub account, and an AWS account. The setup checks each required command plus both authentication paths before the build begins.

Before We Start...

Before you begin the hands-on work, lock in what you are building and why it matters.

Before We Start

Set Up the Private Workspace

Application code can make an unclear requirement look settled. This step creates one trusted workspace before code can hide ambiguity.

You will verify Cursor, Git, GitHub CLI, Go, OpenTofu, and AWS CLI. You will create a private workspace with written decisions that guide every later check.

In this step, get ready to:
  • Verify every required command-line tool.
  • Create the private local workspace and GitHub repository.
  • Add the specification bundle and Delta tracker.
Verify every tool in Cursor

The built-in terminal keeps every project command beside the files it affects. A fresh terminal also loads any tool changes made by an installer.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type `Cursor` into system search.
  • Press Enter to open Cursor.
  • Select Terminal from the top menu.
  • Select New Terminal to open the built-in terminal.
  • Check Git by running this command:
git --version

What does this command check?

This asks Git to print its installed version. A version line proves the terminal can find the Git executable.

✔️ I see a Git version

Git is available in Cursor. Continue with the identity check below.

ⓧ Command not found

Git needs to be installed before this repository can track its history.

  • Open the official Git installer in your browser.
  • Install Git using the recommended options.
  • Close the current Cursor terminal after installation.
  • Select Terminal from the top menu.
  • Select New Terminal to load the updated tool path.
  • Verify Git by running the check again:
git --version
What should this show?

You should now see a Git version line. That result confirms the new terminal can find Git.

  • Check the email Git will attach to future commits by running:
git config user.email

Why check the Git email?

Git records an author email in every commit. The output lets you catch an empty or unexpected identity before creating project history.

You should see the email configured for Git. If the output is blank, use the official Git setup documentation before the first commit in the next step.

  • Check your GitHub authentication by running:
gh auth status

What does this check prove?

GitHub CLI reports the active account plus its authentication state. A successful result proves it can create a private remote for this workspace.

✔️ I see an authenticated account

GitHub CLI is authenticated. Keep the account details private.

ⓧ Authentication failed

The login flow opens a browser or shows a device code. Follow the prompt using the GitHub account that will own the private repository.

  • Start the GitHub login flow by running:
gh auth login
What happens during login?

GitHub CLI asks how you connect to GitHub. It then guides you through browser authentication for your account.

  • Complete the authentication prompts shown by GitHub CLI.
  • Confirm the new authentication state by running:
gh auth status
What should this confirm?

The result should identify an authenticated GitHub account. That account becomes the owner of the private repository.

ⓧ Command not found

GitHub CLI must be installed before its authentication state can be checked.

  • Open the official GitHub CLI installer in your browser.
  • Install GitHub CLI for your operating system.
  • Close the current Cursor terminal after installation.
  • Open a new built-in terminal from Cursor's Terminal menu.
  • Start authentication by running:
gh auth login
What does this command start?

This starts GitHub CLI's guided login. Complete its browser or device-code flow using your GitHub account.

  • Check the installed Go version by running:
go version

Why does the Go version matter?

This project targets Go 1.27.x. Matching that release line keeps the compiler aligned with the project's module declaration.

✔️ I see Go 1.27.x

Go is ready for the project. The version line should begin with the Go 1.27 release family.

ⓧ I see an older Go version

The installed Go release is below the project's required release line. Install Go 1.27.3 before continuing.

  • Open the official Go downloads page in your browser.
  • Download go1.27.3.windows-amd64.msi on Windows.
  • Run the downloaded installer.
  • Close the current Cursor terminal after installation.
  • Open a new built-in terminal from Cursor's Terminal menu.
  • Verify the replacement version by running:
go version
What should change?

The version line should now show Go 1.27.x. The fresh terminal loads the updated executable path.

ⓧ Go is not installed

Go needs to be installed before the project can compile or test its Lambda code.

  • Open the official Go downloads page in your browser.
  • Download go1.27.3.windows-amd64.msi on Windows.
  • Run the downloaded installer.
  • Close the current Cursor terminal after installation.
  • Open a new built-in terminal from Cursor's Terminal menu.
  • Verify the installation by running:
go version
What proves Go is installed?

A version line containing Go 1.27.x proves the compiler is available to this terminal.

  • Check the installed OpenTofu version by running:
tofu version

Why verify OpenTofu now?

OpenTofu later creates the Lambda infrastructure from code. This project requires OpenTofu 1.13 or later.

✔️ I see OpenTofu 1.13 or higher

OpenTofu is ready. Keep the terminal open for the remaining tool check.

ⓧ I see an older OpenTofu version

The installed release is below the project's minimum. Upgrade to OpenTofu 1.13.5 using the official installation guide.

  • Open the official OpenTofu installation guide in your browser.
  • Install OpenTofu 1.13.5 using the documented Windows method.
  • Close the current Cursor terminal after installation.
  • Open a new built-in terminal from Cursor's Terminal menu.
  • Verify the upgraded version by running:
tofu version
What should this confirm?

The output should show OpenTofu 1.13 or later. That version can initialize the infrastructure configuration used later.

ⓧ OpenTofu is not installed

OpenTofu needs to be installed before the infrastructure can be formatted or deployed.

  • Open the official OpenTofu installation guide in your browser.
  • Install OpenTofu 1.13.5 using the documented Windows method.
  • Close the current Cursor terminal after installation.
  • Open a new built-in terminal from Cursor's Terminal menu.
  • Verify the installation by running:
tofu version
What proves OpenTofu is ready?

A version line showing OpenTofu 1.13 or later proves the terminal can run the infrastructure tool.

  • Check the installed AWS CLI version by running:
aws --version

What does this check establish?

AWS CLI later verifies your identity and invokes the private Lambda. The current verified release for this project is 2.39.3.

✔️ I see AWS CLI 2.34 or higher

AWS CLI 2.34 or later is supported for this project. Version 2.39.3 is the verified release, so it is a reference rather than a hard stop. Continue to the profile and identity checks below.

ⓧ I see a version below 2.34

Update AWS CLI using Amazon's official installer before continuing. Version 2.39.3 is the verified release for this project.

  • Open the official AWS CLI installation guide in your browser.
  • Install the current AWS CLI release.
  • Close the current Cursor terminal after installation.
  • Open a new built-in terminal from Cursor's Terminal menu.
  • Verify the updated release by running:
aws --version
What should this show?

The output should identify AWS CLI 2.39.3. That result confirms the updated executable is available.

ⓧ AWS CLI is not installed

AWS CLI must be installed before the terminal can check your AWS identity.

  • Open the official AWS CLI installation guide in your browser.
  • Install AWS CLI using the documented Windows installer.
  • Close the current Cursor terminal after installation.
  • Open a new built-in terminal from Cursor's Terminal menu.
  • Verify the installation by running:
aws --version
What proves AWS CLI is installed?

A version line proves Cursor's terminal can find AWS CLI. You can use that terminal for the identity check next.

The AWS profile decides which account receives every later command. Use a named non-production profile so an accidental default cannot point this project at production.

  • List the AWS CLI profiles already configured on your computer by running this command:
aws configure list-profiles

✔️ I see a non-production profile

Choose a profile that belongs to a sandbox, development, or training account. Do not choose a production profile.

ⓧ I need to create one

Creating a profile touches credentials, so use only the access method approved for your training or development account.

  • Create a named non-production profile through the AWS CLI prompts by running:
aws configure --profile [[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]

The command asks for the credentials and defaults associated with that profile. Keep every credential private.

Set the chosen profile for the current terminal. The next AWS CLI and OpenTofu commands inherit this selection.

macOS

  • Select your non-production profile for this terminal and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"

Windows

  • Select your non-production profile for this terminal and display its name by running:
$env:AWS_PROFILE = '[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
$env:AWS_PROFILE

Linux

  • Select your non-production profile for this terminal and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"
  • Confirm that the selected profile can authenticate before setting the AWS region by running:
aws sts get-caller-identity

You should see the account, user ID, and ARN for your non-production identity. Stop before the region command if the result belongs to a production account.

Still unable to run a tool?

Close every Cursor terminal after an installation. Open a new terminal so it loads the updated executable path.

Use the installer linked in the matching outcome tab if the command still cannot be found. Help me diagnose why a verified tool is unavailable in Cursor.

Create the private workspace and remote

The local folder gives every specification file one known home. The private remote preserves commit order without exposing the project publicly.

  • Move to your home directory by running:
cd ~

Why start from your home directory?

This fixes the workspace path at ~/spec-links. Later commands can return to the same location without guessing where the folder was created.

  • Create the spec-links workspace by running:
mkdir spec-links

What does this create?

PowerShell creates one local directory named spec-links inside your home directory. You should see a directory record in the command output.

  • Move into the new workspace by running:
cd ~/spec-links

What changes in the terminal?

The terminal now runs commands inside ~/spec-links. This navigation command does not change any files.

  • Select File from Cursor's top menu.
  • Select Open Folder.
  • Choose the spec-links folder from your home directory.
  • Select the option to trust the folder if Cursor asks.
  • Select Terminal from the top menu.
  • Select New Terminal.
  • Return the new terminal to the workspace by running:
cd ~/spec-links

Why run the path again?

A new terminal can start from a different directory. This command confirms every following resource is created from ~/spec-links.

The next commands update your AWS CLI configuration and inspect the active identity. They do not create AWS resources.

The new Cursor terminal does not inherit the temporary profile from the earlier terminal. Set and display the non-production profile again before changing its region.

macOS

  • Select your non-production profile in the new terminal and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"

Windows

  • Select your non-production profile in the new terminal and display its name by running:
$env:AWS_PROFILE = '[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
$env:AWS_PROFILE

Linux

  • Select your non-production profile in the new terminal and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"
  • Confirm the new terminal uses the intended non-production identity by running:
aws sts get-caller-identity

Continue to the region command only when the account, user ID, and ARN belong to your non-production environment.

  • Set us-east-1 for the selected non-production profile by running:
aws configure set region us-east-1

Why use this region?

This stores us-east-1 as the default AWS CLI region. The later OpenTofu configuration uses the same region.

  • Check the active AWS identity by running:
aws sts get-caller-identity

Keep the identity output private

The command returns the IAM user or role used for AWS requests. Confirm that an identity appears without copying the result into project files or an AI client.

✔️ I see my AWS identity

AWS CLI can authenticate successfully. Leave the identity output in the terminal only.

ⓧ The identity check fails

This action handles AWS credentials. Enter them only in the AWS CLI prompts.

  • Start AWS CLI configuration by running:
aws configure --profile [[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]
What information does AWS CLI request?

AWS CLI prompts for credentials plus default settings. Use an AWS principal that may create the Lambda, DynamoDB, CloudWatch Logs, and IAM resources listed in this project.

  • Enter the access key from your AWS console when prompted.
  • Keep the credential values out of project files.
  • Repeat the identity check by running:
aws sts get-caller-identity
What confirms recovery?

A returned IAM identity proves AWS CLI can authenticate. Do not record or share the identity output.

  • Initialize the local repository on main by running:
git init -b main

What does Git initialize?

Git creates repository metadata inside ~/spec-links. The first branch is named main.

  • Create the private GitHub repository from this local source by running:
gh repo create spec-links --private --source=.

What does this create?

GitHub CLI creates a private repository named spec-links. It also connects the local repository to the new remote.

That is the remote boundary in place. Your future specification and implementation history now has a private destination.

Add the specification bundle and Delta tracker

An executable specification turns written rules into checks that can pass or fail. These files define the rules before implementation begins.

  • Create the document and build directories by running:
New-Item -ItemType Directory -Force docs, events, infra/build | Out-Null

What does this command create?

PowerShell creates docs, events, and infra/build. The force option makes the command safe if a directory already exists.

  • Confirm Cursor's Explorer lists docs, events, and infra.
  • Create .gitignore inside spec-links using Cursor's Explorer.
  • Paste this complete file into .gitignore:
infra/.terraform/
infra/.terraform.lock.hcl
infra/terraform.tfstate
infra/terraform.tfstate.backup
infra/build/
out.json
check-out.txt

What does this file protect?

The ignore rules keep generated OpenTofu state, packaged binaries, and captured command output out of commits. Source files remain visible to Git.

  • Save .gitignore.
  • Confirm Cursor's Explorer lists .gitignore at the top level of spec-links.
  • Create go.mod inside spec-links using Cursor's Explorer.
  • Paste this module declaration into go.mod:
module example.com/spec-links

go 1.27

What does the module file define?

The module path identifies this Go project. The Go 1.27 directive records the language release used by the source.

  • Save go.mod.
  • Confirm Cursor's Explorer lists go.mod at the top level of spec-links.
  • Create spec.md inside spec-links using Cursor's Explorer.
  • Paste the six service rules into spec.md:
# Spec: expiring links

S-1: A URL starts with https://; else bad_request.

S-2: A code is 4 to 12 lowercase letters or digits; else bad_request.

S-3: The time to live is 60 to 2592000 seconds; else bad_request.

S-4: Before expiry, get returns ok and the URL.

S-5: An unknown code returns not_found.

S-6: Links expire after their time to live.

What does the specification control?

Each numbered rule defines observable service behavior. Later tests and deployed calls must trace back to these exact lines.

  • Save spec.md.
  • Confirm the editor shows rules S-1 through S-6.
  • Create AGENTS.md inside spec-links using Cursor's Explorer.
  • Paste the repository guidance into AGENTS.md:
Read spec.md first.
Every line has a test named TestSn_ or, for S-5, an end-to-end call.
Run go test ./... before each commit.
Change the spec, then the test, then the code.

Why add agent instructions?

AGENTS.md gives coding agents repository-specific guidance. It also records the required order of specification, test, and code changes.

  • Save AGENTS.md.
  • Confirm the final line says Change the spec, then the test, then the code..
  • Create docs/build-spec.md using Cursor's Explorer.
  • Paste the first part of the build specification into docs/build-spec.md:
# Build specification

## Goal

Prove that a small internal service can carry written requirements through tests, implementation, private cloud behavior, and evidence-backed handoff.

## Non-goals

- No public function URL or browser interface.
- No generated codes in v1.0.
- No farmer advice, agricultural recommendation, or claim that software traceability fixes advisory quality.
- No production availability, collision, abuse, or multi-region design.

## User

An authenticated internal caller with IAM permission to invoke the Lambda function.

## Requirements

- S-1 through S-6 are defined in `spec.md`.
- S-1, S-2, S-3, S-4, and S-6 have named unit tests.
- S-5 has a deployed unknown-code invocation.
- Every claim in the close-out traces to this design bundle or captured output.

What does this first part establish?

The opening sections define the goal, limits, caller, and traceability requirements. These boundaries keep later implementation choices tied to the intended proof.

  • Append the remaining build specification below the requirements:
## Acceptance criteria

- AC-1: `git log --oneline` shows `spec.md` committed before `link.go`.
- AC-2: `README.md` maps each spec line to its check.
- AC-3: `go test -v ./...` prints five named PASS lines.
- AC-4: Deployed invocations print `created`, `ok`, `bad_request`, and `not_found`.
- AC-5: After 61 seconds, get prints `gone`; direct DynamoDB inspection records whether the expired item is still pending background deletion.
- AC-6: Replacing the exact-boundary comparison makes only `TestS6_ExpiresAtBoundary` fail; restoring it returns five PASS lines.
- AC-7: The inline role policy contains only `dynamodb:GetItem`, `dynamodb:PutItem`, `logs:CreateLogStream`, and `logs:PutLogEvents`.
- AC-8: `readout.html` puts the answer first and includes the spec, traceability table, five PASS lines, deliberate must-fail, Digital Green's 51.8% F1, and what this does not fix.

## Risks

- DynamoDB TTL is asynchronous, so physical deletion timing cannot be an acceptance guarantee.
- IAM propagation or provider download speed can consume the two-minute buffer.
- A caller with valid IAM access can overwrite an existing code because v1.0 has no conditional write.
- The project does not implement authentication design beyond AWS IAM, generated-code collision handling, rate limits, monitoring alarms, backups, or a public user experience.

## Sources

- Digital Green, `Fine-Tuning and Evaluating Conversational AI for Agricultural Advisory`, arXiv:2603.03294.
- Linux Foundation, Agentic AI Foundation formation announcement, 9 December 2025.
- AGENTS.md open format documentation.
- Trunk Based Development introduction.
- AWS, Go Lambda, DynamoDB TTL, and AWS CLI documentation.
- OpenTofu and HashiCorp provider documentation.

What does the second part add?

The acceptance criteria name the evidence required for completion. The risks prevent asynchronous cleanup or missing production controls from becoming unsupported claims.

  • Save docs/build-spec.md.
  • Confirm the file ends with the OpenTofu and HashiCorp provider source line.
  • Create docs/decisions.md using Cursor's Explorer.
  • Paste the three decision records into docs/decisions.md:
# Decision records

## Record 1: Enforce expiry in the application

Decision: `link.go` decides expiry on every read; DynamoDB TTL deletes within days.
Alternative: Trust DynamoDB TTL to make expired records disappear.
Cost: One time comparison per read.
Reverse when: Waiting expired items cost enough to justify a scheduled delete.

## Record 2: Test every spec line

Decision: Every spec line has a test or named end-to-end check.
Alternative: Use a one-line ticket and add tests after code.
Cost: Minutes spent writing tests first.
Reverse when: The work is an explicit spike whose output will be discarded.

## Record 3: Keep calls private

Decision: Calls go through IAM with `aws lambda invoke`.
Alternative: Publish a Lambda function URL.
Cost: No browser test.
Reverse when: Browser links are required behind sign-in.

Why record alternatives and reversal points?

Each record states the choice, its cost, and the condition that could reverse it. That structure makes the design defensible without pretending it is permanent.

  • Save docs/decisions.md.
  • Confirm the file contains three numbered decision records.
  • Create docs/topology.md using Cursor's Explorer.
  • Paste the system topology into docs/topology.md:
# Topology

```mermaid
flowchart LR
    C[Authenticated CLI caller] -->|aws lambda invoke| L[AWS Lambda: spec-links]
    L -->|PutItem and GetItem| D[(DynamoDB: spec-links)]
    L -->|CreateLogStream and PutLogEvents| G[CloudWatch Logs]
    R[IAM role: spec-links] -->|assumed by| L
    O[OpenTofu] -. creates and destroys .-> L
    O -. creates and destroys .-> D
    O -. creates and destroys .-> G
    O -. creates and destroys .-> R
```

There is no public endpoint. Application reads enforce expiry. DynamoDB TTL performs later physical cleanup.

What does the topology show?

The diagram shows a private CLI invocation path into AWS Lambda. It also separates application expiry checks from DynamoDB's later physical cleanup.

  • Save docs/topology.md.
  • Confirm the Mermaid diagram names Lambda, DynamoDB, CloudWatch Logs, IAM, and OpenTofu.
  • Create docs/implementation-plan.md using Cursor's Explorer.
  • Paste the ordered implementation plan into docs/implementation-plan.md:
# Implementation plan

## Step 0: Prepare

Verify the editor, terminal tools, GitHub authentication, AWS identity, private remote, folder, and Delta tracker.

## Step 1: Specify

Commit `spec.md`, `AGENTS.md`, and the design bundle on `main` before any Go implementation file exists.

## Step 2: Test

Write five named unit tests on `build`. Run them before `link.go` exists and preserve the expected failure.

## Step 3: Implement

Write the minimum Go logic that makes the five tests pass. Map every spec line in `README.md`.

## Step 4: Deploy and prove

Package a Linux `bootstrap`, create five AWS resources through OpenTofu, invoke the private Lambda, force one required test failure, restore it, destroy the resources, and hand off evidence.

## FinOps

- Region: us-east-1.
- Lambda: 128 MB, 5-second timeout, fewer than 10 planned invocations.
- DynamoDB: PAY_PER_REQUEST, fewer than 20 planned operations.
- CloudWatch Logs: one group with 1-day retention and tiny test output.
- Budget posture: estimated under $0.01 for the stated run; verify actual charges in AWS Billing.
- Cleanup: required in the same session with four zero-count checks.

How does this plan control the build?

The plan fixes the order from specification to proof. Its FinOps section also sets the region, expected usage, and same-session cleanup posture.

  • Save docs/implementation-plan.md.
  • Confirm the final line requires four zero-count cleanup checks.
  • Create docs/test-plan.md using Cursor's Explorer.
  • Paste the acceptance and mutation checks into docs/test-plan.md:
# Test plan

| Acceptance criterion | Check | Required observation |
|---|---|---|
| AC-1 | `git log --oneline` | Spec/design commit appears before the commit containing `link.go` |
| AC-2 | Inspect `README.md` | S-1 through S-6 each map to a named check |
| AC-3 | `go test -v ./...` | Five named PASS lines |
| AC-4 | Invoke create, get, bad, and missing events | `created`, `ok`, `bad_request`, `not_found` |
| AC-5 | Wait 61 seconds, invoke get, then inspect DynamoDB | Lambda returns `gone`; row presence or absence is recorded as an observation |
| AC-6 | Change one expiry comparison and rerun tests | Only `TestS6_ExpiresAtBoundary` fails; restore returns five PASS lines |
| AC-7 | Get the inline IAM policy actions | Only four listed actions appear |
| AC-8 | Open the self-contained readout | Answer first, required evidence, 51.8% F1 source, and limitation statement are present |

## Unit checks

- `TestS1_RequiresHTTPS`: `http://example.org` fails; an HTTPS URL passes.
- `TestS2_ValidatesCode`: a code outside `^[a-z0-9]{4,12}$` fails; `farm1` passes.
- `TestS3_ValidatesTTL`: 59 and 2592001 fail; 60 passes.
- `TestS4_ReturnsOKBeforeExpiry`: one second before expiry returns `ok` and preserves the URL.
- `TestS6_ExpiresAtBoundary`: exact expiry returns `gone`.

## Must-fail

Temporarily change `!now.Before(link.Expires)` to `now.After(link.Expires)`. Exactly `TestS6_ExpiresAtBoundary` must fail. Restore the original line and rerun all five tests.

Why include a must-fail check?

The mutation proves the exact expiry boundary has a test that can detect the wrong comparison. A green test suite becomes stronger evidence when one deliberate defect makes only the intended test fail.

  • Save docs/test-plan.md.
  • Confirm the file ends with the instruction to restore all five tests.

The Delta tracker records the difference between what the project requires and what your evidence proves. You will write this file yourself without AI.

  • Create TASKS.md at the top level of spec-links using Cursor's Explorer.
  • Add one unchecked checkbox for each numbered project step.
  • Add unchecked checkboxes for AC-1 through AC-8.
  • Add phase-start fields for setup, design, build, validation, and close-out.
  • Add one evidence field for the deliberate must-fail result.
  • Add one evidence field for each of the three spoken decision defenses.
  • Add a checkbox confirming no AI prompt ran before close-out.
  • Add a field for the first-draft claim score.

Where are you in the tracker?

The checklist now covers project progress, acceptance evidence, and the three decision defenses. The remaining fields capture timing plus honest gaps.

  • Add an AS BUILT time field.
  • Add an undefined-terms list.
  • Add an undone-work list with one trade beside each item.
  • Leave the design, build, validation, and close-out timestamp fields blank.
  • Leave every evidence checkbox unchecked until its check prints.
  • Save TASKS.md.
  • Record the setup start time by running the command for your terminal:

macOS

date -Iseconds
What does this timestamp capture?

The command prints the current time in an ISO-style format. Copy that result into the setup start field in TASKS.md.

Windows

Get-Date -Format o
What does this timestamp capture?

PowerShell prints the current time in an ISO-style format. Copy that result into the setup start field in TASKS.md.

  • Paste the timestamp into the setup start field in TASKS.md.
  • Save TASKS.md.
  • Select docs/topology.md in Cursor's Explorer.
  • Use the Markdown preview control in Cursor's editor toolbar.
  • Confirm the preview shows the authenticated caller connected to Lambda.
  • Confirm the preview shows Lambda connected to DynamoDB and CloudWatch Logs.
  • Confirm the preview shows OpenTofu creating and destroying the four resource types.

Topology preview not rendering?

Check that the opening Mermaid fence is followed by flowchart LR. Confirm the closing fence appears before the final paragraph.

Mermaid Live is a fallback for this diagram. Do not send AWS identity output to that site. Help me find a Markdown or Mermaid syntax problem in docs/topology.md.

✔️ Awesome, I have every file

Great. Save every open file before running the final repository check.

ⓧ I would like to double check the full code

Compare each page-written file with the complete reference below. Keep TASKS.md learner-authored using the required fields listed after the references.

infra/.terraform/
infra/.terraform.lock.hcl
infra/terraform.tfstate
infra/terraform.tfstate.backup
infra/build/
out.json
check-out.txt
What should match in .gitignore?

All seven generated paths should appear in this order. No source document belongs in this ignore list.

module example.com/spec-links

go 1.27
What should match in go.mod?

The module path should be example.com/spec-links. The language directive should be Go 1.27.

# Spec: expiring links

S-1: A URL starts with https://; else bad_request.

S-2: A code is 4 to 12 lowercase letters or digits; else bad_request.

S-3: The time to live is 60 to 2592000 seconds; else bad_request.

S-4: Before expiry, get returns ok and the URL.

S-5: An unknown code returns not_found.

S-6: Links expire after their time to live.
What should match in spec.md?

The file should contain exactly six numbered rules. Preserve every boundary value plus every response status.

Read spec.md first.
Every line has a test named TestSn_ or, for S-5, an end-to-end call.
Run go test ./... before each commit.
Change the spec, then the test, then the code.
What should match in AGENTS.md?

The four lines should preserve the required reading order, evidence rule, test command, and change sequence.

# Build specification

## Goal

Prove that a small internal service can carry written requirements through tests, implementation, private cloud behavior, and evidence-backed handoff.

## Non-goals

- No public function URL or browser interface.
- No generated codes in v1.0.
- No farmer advice, agricultural recommendation, or claim that software traceability fixes advisory quality.
- No production availability, collision, abuse, or multi-region design.

## User

An authenticated internal caller with IAM permission to invoke the Lambda function.

## Requirements

- S-1 through S-6 are defined in `spec.md`.
- S-1, S-2, S-3, S-4, and S-6 have named unit tests.
- S-5 has a deployed unknown-code invocation.
- Every claim in the close-out traces to this design bundle or captured output.

## Acceptance criteria

- AC-1: `git log --oneline` shows `spec.md` committed before `link.go`.
- AC-2: `README.md` maps each spec line to its check.
- AC-3: `go test -v ./...` prints five named PASS lines.
- AC-4: Deployed invocations print `created`, `ok`, `bad_request`, and `not_found`.
- AC-5: After 61 seconds, get prints `gone`; direct DynamoDB inspection records whether the expired item is still pending background deletion.
- AC-6: Replacing the exact-boundary comparison makes only `TestS6_ExpiresAtBoundary` fail; restoring it returns five PASS lines.
- AC-7: The inline role policy contains only `dynamodb:GetItem`, `dynamodb:PutItem`, `logs:CreateLogStream`, and `logs:PutLogEvents`.
- AC-8: `readout.html` puts the answer first and includes the spec, traceability table, five PASS lines, deliberate must-fail, Digital Green's 51.8% F1, and what this does not fix.

## Risks

- DynamoDB TTL is asynchronous, so physical deletion timing cannot be an acceptance guarantee.
- IAM propagation or provider download speed can consume the two-minute buffer.
- A caller with valid IAM access can overwrite an existing code because v1.0 has no conditional write.
- The project does not implement authentication design beyond AWS IAM, generated-code collision handling, rate limits, monitoring alarms, backups, or a public user experience.

## Sources

- Digital Green, `Fine-Tuning and Evaluating Conversational AI for Agricultural Advisory`, arXiv:2603.03294.
- Linux Foundation, Agentic AI Foundation formation announcement, 9 December 2025.
- AGENTS.md open format documentation.
- Trunk Based Development introduction.
- AWS, Go Lambda, DynamoDB TTL, and AWS CLI documentation.
- OpenTofu and HashiCorp provider documentation.
What should match in the build specification?

The full file should define the goal, non-goals, caller, requirements, eight acceptance criteria, risks, and sources. Every close-out claim must trace to this bundle or captured output.

# Decision records

## Record 1: Enforce expiry in the application

Decision: `link.go` decides expiry on every read; DynamoDB TTL deletes within days.
Alternative: Trust DynamoDB TTL to make expired records disappear.
Cost: One time comparison per read.
Reverse when: Waiting expired items cost enough to justify a scheduled delete.

## Record 2: Test every spec line

Decision: Every spec line has a test or named end-to-end check.
Alternative: Use a one-line ticket and add tests after code.
Cost: Minutes spent writing tests first.
Reverse when: The work is an explicit spike whose output will be discarded.

## Record 3: Keep calls private

Decision: Calls go through IAM with `aws lambda invoke`.
Alternative: Publish a Lambda function URL.
Cost: No browser test.
Reverse when: Browser links are required behind sign-in.
What should match in the decision records?

Each record should contain a decision, alternative, cost, and reversal condition. The three topics are expiry enforcement, test coverage, and private invocation.

# Topology

```mermaid
flowchart LR
    C[Authenticated CLI caller] -->|aws lambda invoke| L[AWS Lambda: spec-links]
    L -->|PutItem and GetItem| D[(DynamoDB: spec-links)]
    L -->|CreateLogStream and PutLogEvents| G[CloudWatch Logs]
    R[IAM role: spec-links] -->|assumed by| L
    O[OpenTofu] -. creates and destroys .-> L
    O -. creates and destroys .-> D
    O -. creates and destroys .-> G
    O -. creates and destroys .-> R
```

There is no public endpoint. Application reads enforce expiry. DynamoDB TTL performs later physical cleanup.
What should match in the topology?

The Mermaid graph should show the private caller path plus all four OpenTofu-managed resource types. The final paragraph should distinguish read-time expiry from later cleanup.

# Implementation plan

## Step 0: Prepare

Verify the editor, terminal tools, GitHub authentication, AWS identity, private remote, folder, and Delta tracker.

## Step 1: Specify

Commit `spec.md`, `AGENTS.md`, and the design bundle on `main` before any Go implementation file exists.

## Step 2: Test

Write five named unit tests on `build`. Run them before `link.go` exists and preserve the expected failure.

## Step 3: Implement

Write the minimum Go logic that makes the five tests pass. Map every spec line in `README.md`.

## Step 4: Deploy and prove

Package a Linux `bootstrap`, create five AWS resources through OpenTofu, invoke the private Lambda, force one required test failure, restore it, destroy the resources, and hand off evidence.

## FinOps

- Region: us-east-1.
- Lambda: 128 MB, 5-second timeout, fewer than 10 planned invocations.
- DynamoDB: PAY_PER_REQUEST, fewer than 20 planned operations.
- CloudWatch Logs: one group with 1-day retention and tiny test output.
- Budget posture: estimated under $0.01 for the stated run; verify actual charges in AWS Billing.
- Cleanup: required in the same session with four zero-count checks.
What should match in the implementation plan?

The plan should preserve specification-first ordering. Its FinOps section should end with same-session cleanup plus four zero-count checks.

# Test plan

| Acceptance criterion | Check | Required observation |
|---|---|---|
| AC-1 | `git log --oneline` | Spec/design commit appears before the commit containing `link.go` |
| AC-2 | Inspect `README.md` | S-1 through S-6 each map to a named check |
| AC-3 | `go test -v ./...` | Five named PASS lines |
| AC-4 | Invoke create, get, bad, and missing events | `created`, `ok`, `bad_request`, `not_found` |
| AC-5 | Wait 61 seconds, invoke get, then inspect DynamoDB | Lambda returns `gone`; row presence or absence is recorded as an observation |
| AC-6 | Change one expiry comparison and rerun tests | Only `TestS6_ExpiresAtBoundary` fails; restore returns five PASS lines |
| AC-7 | Get the inline IAM policy actions | Only four listed actions appear |
| AC-8 | Open the self-contained readout | Answer first, required evidence, 51.8% F1 source, and limitation statement are present |

## Unit checks

- `TestS1_RequiresHTTPS`: `http://example.org` fails; an HTTPS URL passes.
- `TestS2_ValidatesCode`: a code outside `^[a-z0-9]{4,12}$` fails; `farm1` passes.
- `TestS3_ValidatesTTL`: 59 and 2592001 fail; 60 passes.
- `TestS4_ReturnsOKBeforeExpiry`: one second before expiry returns `ok` and preserves the URL.
- `TestS6_ExpiresAtBoundary`: exact expiry returns `gone`.

## Must-fail

Temporarily change `!now.Before(link.Expires)` to `now.After(link.Expires)`. Exactly `TestS6_ExpiresAtBoundary` must fail. Restore the original line and rerun all five tests.
What should match in the test plan?

The plan should map all eight acceptance criteria. It should also name five unit checks plus the exact-boundary mutation.

Your learner-authored TASKS.md should contain unchecked project-step entries plus AC-1 through AC-8. It should also contain the recorded setup timestamp plus blank fields for later phases.

The tracker should include must-fail evidence, three spoken decision defenses, the no-AI-before-close-out check, a claim score, an AS BUILT field, undefined terms, and undone work with trades.

Before you run the final check, which design files do you expect Git to list as untracked?

  • Check the workspace state by running:
git status

What should the repository show?

Git should report the new specification and design files as untracked. No implementation file or AWS resource should exist yet.

You should see .gitignore, go.mod, spec.md, AGENTS.md, TASKS.md, and the docs directory represented in the untracked output.

The project uses Digital Green's Golden Facts only as an analogy for testable written truth. It provides no farm advice.

What will this project prove?

The finished service proves that written requirements can trace through tests, implementation, private cloud behavior, and handoff evidence.

Its limit remains exact: “A spec line nobody wrote is still untested.”

Your private workspace now has verified tools, a connected remote, and a written design bundle. Next, you will commit the specification before creating the red tests.

Commit the Spec and Write Red Tests

Your private workspace now holds the rules for the expiring-link service. This step turns those rules into evidence that can survive later implementation choices.

Implementation can hide ambiguity by making one interpretation look inevitable. A failing test exposes the missing implementation while the Git history proves the written rules came first.

In this step, get ready to:
  • Commit the written specification to main.
  • Write five named Go tests on build.
  • Capture the intended compile failure before preserving the red-test commit.
Freeze the spec on main

A specification commit creates a historical checkpoint before application code exists. The private remote preserves that checkpoint outside your local workspace.

  • In Cursor's file sidebar, select spec.md.
  • Read every requirement in spec.md aloud.
  • Switch to AGENTS.md.
  • Read every instruction in AGENTS.md aloud.
  • Capture the build start time in Cursor's built-in terminal by running this command:
Get-Date -Format o

Why record the time now?

This timestamp marks the start of the build before the first Go test file exists. Your close-out uses the phase timestamps to calculate the actual build time.

  • Switch back to TASKS.md from earlier.
  • Enter the timestamp in its build start field.
  • Save TASKS.md.
  • Create the specification checkpoint on main by running these commands:
git add -A
git commit -m "Add the spec and design docs"
git push -u origin main

What do these commands preserve?

  • The first command stages the specification plus its supporting design artifacts.
  • The second command records that exact state in the repository history.
  • The third command publishes the checkpoint to the private remote on main.

🙋‍♀️ Did the push fail?

Confirm that the terminal is using the GitHub account connected to the private repository. Authentication or remote-access problems can block the push even when the local commit succeeds.

Use this guided prompt if the private remote rejects your push: Help me diagnose why my authenticated Git push to the private spec-links repository failed.

  • Create the short-lived build branch by running this command:
git switch -c build

Why use a short-lived branch?

The build branch keeps the red tests plus their later implementation together. The separate main checkpoint keeps the original written rules visible in history.

Write the five tests

An executable specification converts each written rule into a repeatable check. Fixed times make the expiry checks deterministic on every computer.

  • Keep your AI client closed while you author the tests.
  • Create link_test.go beside go.mod using Cursor's new-file control in the file sidebar.
  • Set the file's package to main.
  • Add one import group containing Go's testing package plus its time package.

Why use a fixed time?

Wall-clock tests can change behavior according to the instant they run. Each test uses time.Unix(1000, 0) so the boundary stays identical across repeated runs.

  • Add TestS1_RequiresHTTPS with an HTTP rejection plus an HTTPS success.
  • Add TestS2_ValidatesCode with ABC rejected plus farm1 accepted.
  • Add TestS3_ValidatesTTL with 59 rejected plus 2592001 rejected plus 60 accepted.
  • Add TestS4_ReturnsOKBeforeExpiry with resolution one second before expiry returning ok.
  • Add TestS6_ExpiresAtBoundary with resolution at exact expiry returning gone.

✔️ Awesome, I've got everything!

You have translated five specification rules into named tests. Save link_test.go before continuing.

ⓧ I'd like to double check the full code

  • Compare your link_test.go file with this reference line by line.
  • Correct every difference before continuing.
package main

import (
	"testing"
	"time"
)

func TestS1_RequiresHTTPS(t *testing.T) {
	now := time.Unix(1000, 0)
	if _, err := NewLink("farm1", "http://example.org", 60, now); err == nil {
		t.Fatal("expected an HTTP URL to fail")
	}
	if _, err := NewLink("farm1", "https://example.org/a", 60, now); err != nil {
		t.Fatalf("expected an HTTPS URL to pass: %v", err)
	}
}

func TestS2_ValidatesCode(t *testing.T) {
	now := time.Unix(1000, 0)
	if _, err := NewLink("ABC", "https://example.org/a", 60, now); err == nil {
		t.Fatal("expected an invalid code to fail")
	}
	if _, err := NewLink("farm1", "https://example.org/a", 60, now); err != nil {
		t.Fatalf("expected farm1 to pass: %v", err)
	}
}

func TestS3_ValidatesTTL(t *testing.T) {
	now := time.Unix(1000, 0)
	if _, err := NewLink("farm1", "https://example.org/a", 59, now); err == nil {
		t.Fatal("expected 59 seconds to fail")
	}
	if _, err := NewLink("farm1", "https://example.org/a", 2592001, now); err == nil {
		t.Fatal("expected 2592001 seconds to fail")
	}
	if _, err := NewLink("farm1", "https://example.org/a", 60, now); err != nil {
		t.Fatalf("expected 60 seconds to pass: %v", err)
	}
}

func TestS4_ReturnsOKBeforeExpiry(t *testing.T) {
	now := time.Unix(1000, 0)
	link, err := NewLink("farm1", "https://example.org/a", 60, now)
	if err != nil {
		t.Fatal(err)
	}
	if got := Resolve(link, link.Expires.Add(-time.Second)); got != "ok" {
		t.Fatalf("expected ok, got %q", got)
	}
	if link.URL != "https://example.org/a" {
		t.Fatalf("expected URL to be preserved, got %q", link.URL)
	}
}

func TestS6_ExpiresAtBoundary(t *testing.T) {
	now := time.Unix(1000, 0)
	link, err := NewLink("farm1", "https://example.org/a", 60, now)
	if err != nil {
		t.Fatal(err)
	}
	if got := Resolve(link, link.Expires); got != "gone" {
		t.Fatalf("expected gone at exact expiry, got %q", got)
	}
}
  • Save link_test.go in Cursor.
  • Confirm that Cursor's file sidebar lists link_test.go while link.go remains absent.
Run the designed failure and preserve it

Before you run the tests, which missing project symbols do you think the compiler will identify?

  • Run the five tests from the spec-links workspace by running this command:
go test -v ./...

What does the red result prove?

You should see a compile failure naming one or more missing symbols from Link, NewLink, or Resolve. This expected failure proves the tests reached code that has not been implemented.

🙋‍♀️ Did you get a different failure?

A syntax problem inside link_test.go means the test file did not reach the intended missing-symbol check. Compare the file with the full reference before running the command again.

Use this guided prompt if the failure still does not name a missing link symbol: Help me distinguish a link_test.go syntax problem from the intended missing-symbol compile failure.

  • Switch back to TASKS.md from earlier.
  • Record the compile failure beside the red-test evidence field.
  • Save TASKS.md.
  • Preserve the tests plus their failure evidence on the remote build branch by running these commands:
git add -A
git commit -m "Add a build step"
git push -u origin build

What does this commit prove?

The red-test commit records link_test.go plus the evidence in TASKS.md. Its position after the specification commit makes the development order auditable.

Before you inspect the history, which commit should be older when you read the sequence chronologically?

  • Verify the commit order by running this command:
git log --oneline

What should the history show?

You should see Add a build step as the newest entry. The older Add the spec and design docs entry proves the written rules existed before the red tests.

That is the core proof secured: your private history now shows written rules before tests. Next, you will implement only enough Go code to make those five checks pass.

Implement and Trace the Expiry Rules

Your red tests have already proved the implementation is missing. That expected compile failure protects the order of test-first development.

Now you will write the smallest Go implementation that satisfies those tests. The exact expiry boundary is the key risk because one comparison decides whether a link is valid or gone.

In this step, get ready to:
  • Implement the link data model with validation plus exact-boundary expiry behavior.
  • Prove five named tests pass.
  • Trace S-1 through S-6 to evidence in README.md.
Implement link.go from the red tests

The tests define the public behavior of Link, NewLink(), and Resolve(). Your implementation should satisfy those contracts without changing the tests.

  • Select the New File icon beside spec-links in the Cursor Explorer sidebar from earlier.
  • Enter link.go as the file name.
  • Set the file package to main.
  • Import fmt, regexp, strings, and time.
  • Compile ^[a-z0-9]{4,12}$ into codePattern with regexp.MustCompile().

Why Validation Order Matters

The validation order mirrors the specification. The HTTPS rule runs before the code rule.

The code rule runs before the time to live rule. This order makes the implementation traceable to S-1, S-2, and S-3.

  • Define Link with Code string, URL string, and Expires time.Time fields.
  • Define NewLink(code, rawURL string, ttlSeconds int64, now time.Time) (Link, error).
  • Place the strings.HasPrefix(rawURL, "https://") check first with the error url must start with https://.
  • Place the codePattern.MatchString(code) check second with the error code must be 4 to 12 lowercase letters or digits.
  • Reject values below 60 or above 2592000 with the error ttl_seconds must be 60 to 2592000.
  • Return a Link whose Expires value uses now.Add(time.Duration(ttlSeconds) * time.Second).
  • Define Resolve() with !now.Before(link.Expires) returning gone.
  • Save link.go.
  • Format both Go files in the Cursor terminal from earlier by running this command:
gofmt -w link.go link_test.go

What Does This Command Do?

The formatter rewrites both files into Go's standard layout. Consistent formatting removes visual noise before you inspect test failures.

A successful formatting run returns you to the terminal prompt. Your saved files now use the same spacing that the Go toolchain expects.

Unable to Format the Files?

Confirm that the terminal is still inside spec-links. Check that both file names match link.go and link_test.go exactly.

Use this if the formatter still cannot process your files: Help me find the syntax or file-path problem preventing my Go files from formatting.

✔️ My file matches the requirements

Your link.go file now contains the data model, ordered validation, expiry calculation, and exact-boundary resolution logic.

ⓧ I'd like to double check the full code

package main

import (
	"fmt"
	"regexp"
	"strings"
	"time"
)

var codePattern = regexp.MustCompile(`^[a-z0-9]{4,12}$`)

type Link struct {
	Code    string
	URL     string
	Expires time.Time
}

func NewLink(code, rawURL string, ttlSeconds int64, now time.Time) (Link, error) {
	if !strings.HasPrefix(rawURL, "https://") {
		return Link{}, fmt.Errorf("url must start with https://")
	}
	if !codePattern.MatchString(code) {
		return Link{}, fmt.Errorf("code must be 4 to 12 lowercase letters or digits")
	}
	if ttlSeconds < 60 || ttlSeconds > 2592000 {
		return Link{}, fmt.Errorf("ttl_seconds must be 60 to 2592000")
	}

	return Link{
		Code:    code,
		URL:     rawURL,
		Expires: now.Add(time.Duration(ttlSeconds) * time.Second),
	}, nil
}

func Resolve(link Link, now time.Time) string {
	if !now.Before(link.Expires) {
		return "gone"
	}
	return "ok"
}

Why This File Passes the Tests

The three checks in NewLink() follow S-1 through S-3 in order. The returned value preserves the code plus URL while calculating an absolute expiry time.

The comparison in Resolve() includes the exact expiry instant. That boundary gives S-6 a deterministic result.

Prove all five tests pass

An executable specification turns each written rule into a repeatable check. The fixed times in your tests make the expiry boundary produce the same result on every run.

Before you run the tests, choose your expected exact-expiry result in your head: ok or gone.

  • Run the complete Go test suite in the Cursor terminal:
go test -v ./...

What Should You See?

Verbose mode prints the name and result of every test. Your implementation should produce these five named PASS lines.

  • The TestS1_RequiresHTTPS line passes.
  • The TestS2_ValidatesCode line passes.
  • The TestS3_ValidatesTTL line passes.
  • The TestS4_ReturnsOKBeforeExpiry line passes.
  • The TestS6_ExpiresAtBoundary line passes.

You have completed the red-to-green turn. Five tests now prove the validation rules plus the exact expiry boundary.

Still Seeing a Test Failure?

A compile failure usually means an identifier or type differs from link_test.go. Compare Link, NewLink(), and Resolve() character by character.

A single failing test points to its matching spec line. Use this for focused help: Help me trace my failing Go test to its matching spec rule without weakening the test.

  • Locate the unchecked S-1 through S-6 unit-test evidence lines in TASKS.md.
  • Record PASS evidence for TestS1_RequiresHTTPS, TestS2_ValidatesCode, TestS3_ValidatesTTL, TestS4_ReturnsOKBeforeExpiry, and TestS6_ExpiresAtBoundary.
  • Save TASKS.md.
Map the spec to evidence

Passing tests prove behavior. A traceability table shows which check supports each written requirement.

  • Select the New File icon beside spec-links in the Cursor Explorer sidebar.
  • Enter README.md as the file name.
  • Add The spec is the test as the level-one title.

Why S-5 Uses Deployed Evidence

S-5 covers a missing item in the deployed data store. The agent instructions assign that rule to an end-to-end Lambda invocation.

The table can name that future check now. Its evidence remains pending until the deployed invocation runs.

  • Map S-1 to TestS1_RequiresHTTPS.
  • Map S-2 to TestS2_ValidatesCode.
  • Map S-3 to TestS3_ValidatesTTL.
  • Map S-4 to TestS4_ReturnsOKBeforeExpiry.
  • Map S-5 to Unknown-code Lambda invocation.
  • Map S-6 to TestS6_ExpiresAtBoundary.
  • Add the required limitation statement exactly as shown in the reference below.
  • Save README.md.

✔️ My traceability table is complete

Your table maps every spec line to named evidence. The final statement records the limit that remains outside the implementation.

ⓧ I'd like to double check the full code

# The spec is the test

| Spec line | Evidence |
|---|---|
| S-1 | TestS1_RequiresHTTPS |
| S-2 | TestS2_ValidatesCode |
| S-3 | TestS3_ValidatesTTL |
| S-4 | TestS4_ReturnsOKBeforeExpiry |
| S-5 | Unknown-code Lambda invocation |
| S-6 | TestS6_ExpiresAtBoundary |

What this does NOT fix: it answers no farmer; a spec line nobody wrote is still untested.

What Does This Table Prove?

Each row names the evidence expected for one specification line. The table prevents a passing test suite from hiding an untested requirement.

The limitation statement keeps the project's claim narrow. Software traceability only covers rules that someone wrote down.

Before the final test run, decide whether documentation edits should change any Go test result.

  • Run the complete test suite again before committing:
go test -v ./...

Why Run the Tests Again?

The agent instructions require a test run before every commit. This run provides fresh evidence that all five checks still pass at the commit boundary.

You should see the same five named PASS lines. Your implementation and traceability record now agree.

Did the Final Result Change?

Confirm that link.go still matches the full-file reference. Documentation changes in README.md and TASKS.md do not change Go behavior.

Use this if a test now fails: Help me identify which saved Go change altered my previously passing test result.

A Git commit now captures the implementation beside its evidence map. This preserves the red-test commit as an earlier point in the branch history.

  • Stage the completed artifacts and create the implementation commit by running these commands:
git add -A
git commit -m "Add a build step"

What Do These Commands Record?

The first command stages link.go, README.md, and your updated TASKS.md. The second command records them after the earlier red-test commit.

You should see a new commit named Add a build step with the created and updated files listed. Your branch now proves the specification preceded the passing implementation.

Commit Did Not Complete?

Confirm that your terminal is on the build branch. Check that Cursor saved each modified file before you stage it.

Use this for branch or staging help: Help me verify that my implementation files are saved and staged on the build branch.

Your build branch now carries passing expiry rules plus a visible evidence map. Next, you will package this code as a private Lambda and deploy its supporting resources.

Package and Deploy the Private Lambda

Your five Go tests now lock the expiry boundary into executable evidence.

Local tests cannot prove the private invocation path through AWS Identity and Access Management.

They also cannot prove persistence in Amazon DynamoDB.

In this step, you will package the rules for AWS Lambda.

OpenTofu will deploy the function with Amazon CloudWatch Logs. Its policy will grant only the four required actions.

In this step, get ready to:
  • Add the Lambda runtime and request payloads.
  • Build the Linux custom-runtime bootstrap.
  • Deploy five AWS resources through OpenTofu.
Add the Lambda runtime and event payloads

A custom runtime lets Lambda run your compiled Go program through an executable named bootstrap. The runtime connects your existing expiry logic to DynamoDB.

Why a private invocation path?

The service accepts authenticated IAM invocations without exposing a public endpoint. This keeps the deployment focused on specification traceability.

A Lambda Function URL would add a public HTTP surface. The design record reserves that option for a future browser requirement.

  • In Cursor's Explorer sidebar, create main.go inside the existing spec-links folder.
  • Add this Lambda runtime to main.go:
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strconv"
	"time"

	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type event struct { Op string `json:"op"`; Code string `json:"code"`; URL string `json:"url"`; TTLSeconds int64 `json:"ttl_seconds"` }
type reply struct { Status string `json:"status"`; URL string `json:"url,omitempty"`; Error string `json:"error,omitempty"` }
var ( table string; db *dynamodb.Client )
func main() { table = os.Getenv("TABLE"); if table == "" { log.Fatal("TABLE is required") }; cfg, err := config.LoadDefaultConfig(context.Background()); if err != nil { log.Fatalf("load AWS config: %v", err) }; db = dynamodb.NewFromConfig(cfg); lambda.Start(handle) }
func handle(ctx context.Context, input event) (reply, error) { switch input.Op { case "create": return createLink(ctx, input); case "get": return getLink(ctx, input.Code); default: return reply{Status: "bad_request", Error: "op must be create or get"}, nil } }
func createLink(ctx context.Context, input event) (reply, error) { link, err := NewLink(input.Code, input.URL, input.TTLSeconds, time.Now()); if err != nil { return reply{Status: "bad_request", Error: err.Error()}, nil }; _, err = db.PutItem(ctx, &dynamodb.PutItemInput{TableName: &table, Item: map[string]types.AttributeValue{"code": &types.AttributeValueMemberS{Value: link.Code}, "url": &types.AttributeValueMemberS{Value: link.URL}, "expires": &types.AttributeValueMemberN{Value: strconv.FormatInt(link.Expires.Unix(), 10)}}}); if err != nil { return reply{}, fmt.Errorf("put link: %w", err) }; return reply{Status: "created"}, nil }
func getLink(ctx context.Context, code string) (reply, error) { consistent := true; out, err := db.GetItem(ctx, &dynamodb.GetItemInput{TableName: &table, ConsistentRead: &consistent, Key: map[string]types.AttributeValue{"code": &types.AttributeValueMemberS{Value: code}}}); if err != nil { return reply{}, fmt.Errorf("get link: %w", err) }; if len(out.Item) == 0 { return reply{Status: "not_found"}, nil }; urlValue, ok := out.Item["url"].(*types.AttributeValueMemberS); if !ok { return reply{}, fmt.Errorf("stored url is not a string") }; expiresValue, ok := out.Item["expires"].(*types.AttributeValueMemberN); if !ok { return reply{}, fmt.Errorf("stored expires is not a number") }; expires, err := strconv.ParseInt(expiresValue.Value, 10, 64); if err != nil { return reply{}, fmt.Errorf("parse expires: %w", err) }; link := Link{Code: code, URL: urlValue.Value, Expires: time.Unix(expires, 0)}; status := Resolve(link, time.Now()); if status == "ok" { return reply{Status: status, URL: link.URL}, nil }; return reply{Status: status}, nil }

What does this runtime do?

  • The event type defines the create and get request fields.
  • The main() function reads the table name before starting the Lambda handler.
  • The createLink() function validates a link before storing it.
  • The getLink() function reads a record before applying the expiry rule.
  • Save main.go.
  • Switch back to Cursor's built-in terminal.
  • Resolve the pinned dependencies by running these commands:
go get github.com/aws/aws-lambda-go@v1.55.1 github.com/aws/aws-sdk-go-v2/config@v1.33.13 github.com/aws/aws-sdk-go-v2/service/dynamodb@v1.72.3
go mod tidy
gofmt -w main.go link.go link_test.go

What do these commands change?

The first command pins the Lambda library and the AWS SDK modules. The second command resolves their dependency metadata.

The formatter expands main.go into standard Go formatting. Your existing link files are formatted at the same time.

Before you run the tests, do you expect the new runtime to preserve all five passing specification checks?

  • Run the complete Go test suite:
go test -v ./...

What should you see?

You should see named PASS lines for TestS1_RequiresHTTPS, TestS2_ValidatesCode, TestS3_ValidatesTTL, TestS4_ReturnsOKBeforeExpiry, and TestS6_ExpiresAtBoundary.

This result proves that the cloud adapter preserved the tested domain behavior.

🙋‍♀️ Do the dependencies or tests fail?

  • Confirm that the three module versions in go.mod match the dependency command.
  • Confirm that main.go is inside the same spec-links folder as link.go.
  • Rerun the formatter after correcting any pasted code.

Help me diagnose the Go dependency or test failure.

✔️ The five tests pass

Your Lambda adapter compiles without changing the five tested rules. Save main.go before continuing.

ⓧ I'd like to double check main.go

Compare your formatted main.go with this complete file.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strconv"
	"time"

	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type event struct {
	Op         string `json:"op"`
	Code       string `json:"code"`
	URL        string `json:"url"`
	TTLSeconds int64  `json:"ttl_seconds"`
}

type reply struct {
	Status string `json:"status"`
	URL    string `json:"url,omitempty"`
	Error  string `json:"error,omitempty"`
}

var (
	table string
	db    *dynamodb.Client
)

func main() {
	table = os.Getenv("TABLE")
	if table == "" {
		log.Fatal("TABLE is required")
	}

	cfg, err := config.LoadDefaultConfig(context.Background())
	if err != nil {
		log.Fatalf("load AWS config: %v", err)
	}
	db = dynamodb.NewFromConfig(cfg)
	lambda.Start(handle)
}

func handle(ctx context.Context, input event) (reply, error) {
	switch input.Op {
	case "create":
		return createLink(ctx, input)
	case "get":
		return getLink(ctx, input.Code)
	default:
		return reply{Status: "bad_request", Error: "op must be create or get"}, nil
	}
}

func createLink(ctx context.Context, input event) (reply, error) {
	link, err := NewLink(input.Code, input.URL, input.TTLSeconds, time.Now())
	if err != nil {
		return reply{Status: "bad_request", Error: err.Error()}, nil
	}

	_, err = db.PutItem(ctx, &dynamodb.PutItemInput{
		TableName: &table,
		Item: map[string]types.AttributeValue{
			"code":    &types.AttributeValueMemberS{Value: link.Code},
			"url":     &types.AttributeValueMemberS{Value: link.URL},
			"expires": &types.AttributeValueMemberN{Value: strconv.FormatInt(link.Expires.Unix(), 10)},
		},
	})
	if err != nil {
		return reply{}, fmt.Errorf("put link: %w", err)
	}

	return reply{Status: "created"}, nil
}

func getLink(ctx context.Context, code string) (reply, error) {
	consistent := true
	out, err := db.GetItem(ctx, &dynamodb.GetItemInput{
		TableName:      &table,
		ConsistentRead: &consistent,
		Key: map[string]types.AttributeValue{
			"code": &types.AttributeValueMemberS{Value: code},
		},
	})
	if err != nil {
		return reply{}, fmt.Errorf("get link: %w", err)
	}
	if len(out.Item) == 0 {
		return reply{Status: "not_found"}, nil
	}

	urlValue, ok := out.Item["url"].(*types.AttributeValueMemberS)
	if !ok {
		return reply{}, fmt.Errorf("stored url is not a string")
	}
	expiresValue, ok := out.Item["expires"].(*types.AttributeValueMemberN)
	if !ok {
		return reply{}, fmt.Errorf("stored expires is not a number")
	}
	expires, err := strconv.ParseInt(expiresValue.Value, 10, 64)
	if err != nil {
		return reply{}, fmt.Errorf("parse expires: %w", err)
	}

	link := Link{Code: code, URL: urlValue.Value, Expires: time.Unix(expires, 0)}
	status := Resolve(link, time.Now())
	if status == "ok" {
		return reply{Status: status, URL: link.URL}, nil
	}
	return reply{Status: status}, nil
}

This reference includes the complete handler path from request decoding to expiry enforcement.

The event files provide repeatable inputs for the deployed checks. Each payload targets one visible service response.

  • In Cursor's Explorer sidebar, create events/create.json inside the existing events folder.
  • Add this create payload to events/create.json:
{"op":"create","code":"farm1","url":"https://example.org/a","ttl_seconds":60}

What does this payload cover?

This request creates farm1 with a valid HTTPS URL. Its lifetime is exactly 60 seconds.

  • Create events/get.json in the same events folder.
  • Add this lookup payload to events/get.json:
{"op":"get","code":"farm1"}

What does this payload cover?

This request retrieves the link stored under farm1. The next step uses it before expiry and after expiry.

  • Confirm that create.json and get.json are visible under events in Cursor's Explorer sidebar.

You now have a matching create-and-read pair for one short-lived link.

  • Create events/bad.json in the events folder.
  • Add this invalid-URL payload to events/bad.json:
{"op":"create","code":"farm2","url":"http://example.org","ttl_seconds":60}

What does this payload cover?

This request uses an HTTP URL. It exercises the deployed S-1 failure path.

  • Create events/missing.json in the events folder.
  • Add this unknown-code payload to events/missing.json:
{"op":"get","code":"none1"}

What does this payload cover?

This request looks up a code that the project never creates. It becomes the deployed S-5 check.

  • Confirm that bad.json and missing.json are visible under events in Cursor's Explorer sidebar.

Your event bundle now covers successful behavior plus two required failure responses.

  • Create events/key.json in the events folder.
  • Add this DynamoDB key payload to events/key.json:
{"code":{"S":"farm1"}}

What does this key describe?

DynamoDB represents the string key with an S type marker. This payload lets the next step inspect the stored farm1 item directly.

  • Save all five files in the events folder.
  • Confirm that Cursor's Explorer lists bad.json, create.json, get.json, key.json, and missing.json.
Build the Linux bootstrap

Lambda's provided.al2023 runtime expects a Linux executable named bootstrap at the root of its deployment archive. Your function uses the x86_64 architecture.

The build settings cross-compile your Go program for that runtime. Disabling CGO keeps the executable independent of local C libraries.

Before you build, do you expect the output to be a Windows executable or a Linux custom-runtime executable?

  • Build the Linux AMD64 bootstrap by running these commands in Cursor's terminal:
$env:GOOS = "linux"
$env:GOARCH = "amd64"
$env:CGO_ENABLED = "0"
go build -tags lambda.norpc -o infra/build/bootstrap .

What does this build do?

  • The target operating system is Linux.
  • The target processor architecture is AMD64.
  • CGO is disabled so the binary does not depend on your local C environment.
  • The finished executable is written to infra/build/bootstrap.
  • Expand infra in Cursor's Explorer sidebar.
  • Expand build.
  • Confirm that bootstrap is listed inside infra/build.

The package boundary is ready: Lambda now has the Linux executable it needs.

Is bootstrap missing?

  • Confirm that the terminal is still inside the spec-links folder.
  • Confirm that main.go and link.go are saved.
  • Read the build output for the first file or package name that failed.

Help me diagnose why the Lambda bootstrap was not created.

Deploy the five AWS resources

Infrastructure as Code stores the desired cloud state in a file that can create the environment again. This configuration also gives Step 5 one destroy path for every AWS resource.

The table enables Time to Live on the expires attribute. Your application still enforces expiry on every read because physical TTL deletion happens later.

OpenTofu inherits the AWS profile from this terminal. Reset and display the non-production profile before any plan or apply command reaches AWS.

macOS

  • Select your non-production profile for this deployment section and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"

Windows

  • Select your non-production profile for this deployment section and display its name by running:
$env:AWS_PROFILE = '[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
$env:AWS_PROFILE

Linux

  • Select your non-production profile for this deployment section and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"
  • Confirm the deployment identity before running OpenTofu by entering:
aws sts get-caller-identity

Continue only when the account, user ID, and ARN belong to your non-production environment. The later tofu apply uses this same identity.

  • In Cursor's Explorer sidebar, create infra/main.tf inside the existing infra folder.
  • Add the OpenTofu and provider requirements to infra/main.tf:
terraform {
  required_version = ">= 1.13.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

What does this configuration establish?

The version constraint requires OpenTofu 1.13.0 or later. The provider blocks select the AWS and archive provider families.

The AWS provider keeps every resource in us-east-1.

  • Add the DynamoDB table below the provider block:
resource "aws_dynamodb_table" "t" {
  name         = "spec-links"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "code"

  attribute {
    name = "code"
    type = "S"
  }

  ttl {
    attribute_name = "expires"
    enabled        = true
  }
}

How does the table support expiry?

The code attribute identifies each stored link. The expires attribute contains its Unix expiry time.

Pay-per-request billing fits the project's tiny validation workload.

  • Save infra/main.tf.
  • Format the current configuration by running:
tofu -chdir=infra fmt

What does this command check?

The formatter rewrites supported configuration into canonical style. A clean return confirms that OpenTofu can read the file structure so far.

🙋‍♀️ Does formatting fail?

Check the provider and table blocks for a missing brace or quote. The reported line number points near the malformed section.

Help me find the HCL formatting problem.

  • Add the log group and Lambda role below the table resource:
resource "aws_cloudwatch_log_group" "l" {
  name              = "/aws/lambda/spec-links"
  retention_in_days = 1
}

resource "aws_iam_role" "r" {
  name = "spec-links"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
      Action = "sts:AssumeRole"
    }]
  })
}

Why create these resources explicitly?

The log group fixes retention at one day. The role allows the Lambda service to assume its runtime identity.

Explicit resources make both behaviors visible in the same deployment plan.

  • Add the inline policy below the role resource:
resource "aws_iam_role_policy" "p" {
  name = "table-and-logs"
  role = aws_iam_role.r.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem"
        ]
        Resource = aws_dynamodb_table.t.arn
      },
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "${aws_cloudwatch_log_group.l.arn}:*"
      }
    ]
  })
}

How is least privilege enforced?

The table statement permits only reads and writes against this table. The log statement permits only stream creation and event delivery against this log group.

No wildcard action grants the function broader service access.

  • Save infra/main.tf.
  • Format the expanded configuration by running:
tofu -chdir=infra fmt

What does this pass prove?

OpenTofu can now parse the log group, role, and policy blocks. The file returns to canonical formatting after the new resources are added.

  • Add the deployment archive below the policy resource:
data "archive_file" "z" {
  type             = "zip"
  source_file      = "${path.module}/build/bootstrap"
  output_path      = "${path.module}/build/function.zip"
  output_file_mode = "0755"
}

What does the archive data source do?

The archive provider packages infra/build/bootstrap as infra/build/function.zip. The executable mode keeps the custom-runtime entry point runnable.

  • Add the Lambda function below the archive block:
resource "aws_lambda_function" "f" {
  function_name    = "spec-links"
  filename         = data.archive_file.z.output_path
  source_code_hash = data.archive_file.z.output_base64sha256
  role             = aws_iam_role.r.arn
  runtime          = "provided.al2023"
  handler          = "bootstrap"
  architectures    = ["x86_64"]
  memory_size      = 128
  timeout          = 5

  environment {
    variables = {
      TABLE = aws_dynamodb_table.t.name
    }
  }

  depends_on = [
    aws_cloudwatch_log_group.l,
    aws_iam_role_policy.p
  ]
}

How is the function configured?

The function runs the Linux bootstrap with 128 MB of memory. Each invocation has a five-second timeout.

The TABLE environment variable receives the deployed table name. The dependency list waits for logging and permissions.

  • Save infra/main.tf.
  • Format the complete configuration by running:
tofu -chdir=infra fmt

What does the final format pass confirm?

OpenTofu can parse every block in the completed file. The deployment archive and Lambda resource now use canonical formatting.

✔️ The configuration is complete

Your infra/main.tf file now describes exactly five managed AWS resources.

ⓧ I'd like to double check the full code

Compare your formatted infra/main.tf with this complete file.

terraform {
  required_version = ">= 1.13.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_dynamodb_table" "t" {
  name         = "spec-links"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "code"

  attribute {
    name = "code"
    type = "S"
  }

  ttl {
    attribute_name = "expires"
    enabled        = true
  }
}

resource "aws_cloudwatch_log_group" "l" {
  name              = "/aws/lambda/spec-links"
  retention_in_days = 1
}

resource "aws_iam_role" "r" {
  name = "spec-links"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
      Action = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "p" {
  name = "table-and-logs"
  role = aws_iam_role.r.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem"
        ]
        Resource = aws_dynamodb_table.t.arn
      },
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "${aws_cloudwatch_log_group.l.arn}:*"
      }
    ]
  })
}

data "archive_file" "z" {
  type             = "zip"
  source_file      = "${path.module}/build/bootstrap"
  output_path      = "${path.module}/build/function.zip"
  output_file_mode = "0755"
}

resource "aws_lambda_function" "f" {
  function_name    = "spec-links"
  filename         = data.archive_file.z.output_path
  source_code_hash = data.archive_file.z.output_base64sha256
  role             = aws_iam_role.r.arn
  runtime          = "provided.al2023"
  handler          = "bootstrap"
  architectures    = ["x86_64"]
  memory_size      = 128
  timeout          = 5

  environment {
    variables = {
      TABLE = aws_dynamodb_table.t.name
    }
  }

  depends_on = [
    aws_cloudwatch_log_group.l,
    aws_iam_role_policy.p
  ]
}

This reference connects the bootstrap archive to the table, log group, role, policy, and function.

Initialization downloads the provider plugins required by the configuration. The first run can pause while those files download.

  • Initialize the infra directory and validate its configuration by running these commands:
tofu -chdir=infra init
tofu -chdir=infra validate

What should these checks prove?

Initialization prepares the provider plugins for this directory. Validation checks the configuration's syntax, argument names, and value types.

You should see initialization complete followed by a success message for the configuration.

🙋‍♀️ Does initialization or validation fail?

  • Confirm that your network allows the provider download to complete.
  • Confirm that infra/build/bootstrap still exists.
  • Compare the reported resource block with the full configuration above.

Help me diagnose the OpenTofu initialization or validation failure.

📣 This action creates AWS resources

The next command creates billable resources in your AWS account. The stated test run is estimated under $0.01.

Step 5 destroys all five resources in the same session. AWS Billing remains the source for your actual account charge.

Before you apply, do you expect OpenTofu to add five resources or update an existing deployment?

  • Deploy the private Lambda stack by running:
tofu -chdir=infra apply -auto-approve

What should you see?

The final summary should include Apply complete! Resources: 5 added, 0 changed, 0 destroyed..

That count covers the Lambda function, DynamoDB table, log group, IAM role, and inline role policy.

That is the cloud boundary crossed: your tested rules now run behind a private IAM invocation path.

  • Run the required tests before committing the deployment files:
go test -v ./...
git add -A
git commit -m "Add a build step"

What does this checkpoint preserve?

The test run confirms that all five specification checks remain green. The commit records the runtime, payloads, generated dependency metadata, and infrastructure configuration on build.

🙋‍♀️ Does the apply or commit fail?

  • Use the first OpenTofu resource named in the failure to locate the affected block.
  • Confirm that your AWS identity can create the five listed resources.
  • Confirm that all five Go tests pass before retrying the commit.

Help me diagnose the deployment or commit failure.

Your private Lambda stack is deployed from a tested specification. Next, you will capture acceptance evidence before destroying every AWS resource.

Validate, Tear Down, and Release

Your private AWS Lambda service now stores links in Amazon DynamoDB. Amazon CloudWatch Logs captures runtime activity.

A deployment becomes releasable when every success case has evidence. Required failures need evidence too. This step also proves the IAM policy is limited before OpenTofu removes every AWS resource.

In this step, get ready to:
  • Capture evidence for deployed behavior plus the exact expiry boundary.
  • Destroy every AWS resource before producing the close-out artifacts.
  • Merge the private pull request plus tag the release as v1.0.
Capture acceptance evidence

Acceptance evidence connects each written rule to an observed result. check-out.txt becomes the record for command output that contains no AWS identity data.

Validation reads and invokes live AWS resources. Reset the non-production profile before the first command so every evidence check targets the deployed training stack.

macOS

  • Select your non-production profile for validation and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"

Windows

  • Select your non-production profile for validation and display its name by running:
$env:AWS_PROFILE = '[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
$env:AWS_PROFILE

Linux

  • Select your non-production profile for validation and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"
  • Confirm the active validation identity by running:
aws sts get-caller-identity

Continue only when the returned identity belongs to the same non-production account used for deployment.

  • Record the validation start time in TASKS.md by running this command:
Get-Date -Format o

What does this timestamp prove?

This timestamp marks when acceptance validation began. You use the phase timestamps later to calculate the actual elapsed time.

  • Copy the timestamp into the validation start field in TASKS.md.
  • Invoke the create event while appending the command result to check-out.txt by running:
aws lambda invoke out.json --function-name spec-links --cli-binary-format raw-in-base64-out --payload file://events/create.json | Tee-Object -FilePath check-out.txt -Append

What does this invocation prove?

The AWS CLI privately invokes spec-links with the create payload. Invocation metadata is appended to check-out.txt while the function response is written to out.json.

  • Display the create response while appending it to check-out.txt by running:
Get-Content out.json | Tee-Object -FilePath check-out.txt -Append

What should you see?

You should see the function status created. This proves the valid link was accepted and stored.

🙋‍♀️ Create invocation failed?

  • Confirm the terminal still uses AWS region us-east-1.
  • Confirm events/create.json still contains the valid HTTPS payload.
  • Confirm the deployed function name is spec-links.

Help me diagnose this private Lambda invocation.

  • Invoke the existing code before it expires by running:
aws lambda invoke out.json --function-name spec-links --cli-binary-format raw-in-base64-out --payload file://events/get.json | Tee-Object -FilePath check-out.txt -Append

What does this invocation test?

This request reads code farm1 through the private Lambda path. The application checks the stored expiry before returning the URL.

  • Display the get response while appending it to check-out.txt by running:
Get-Content out.json | Tee-Object -FilePath check-out.txt -Append

What should you see?

You should see status ok with https://example.org/a. This proves a valid link resolves before expiry.

  • Invoke the invalid URL event by running:
aws lambda invoke out.json --function-name spec-links --cli-binary-format raw-in-base64-out --payload file://events/bad.json | Tee-Object -FilePath check-out.txt -Append

What does this invocation test?

The payload uses an HTTP URL. S-1 requires an HTTPS URL, so the application must reject this create request.

  • Display the invalid URL response while appending it to check-out.txt by running:
Get-Content out.json | Tee-Object -FilePath check-out.txt -Append

What should you see?

You should see status bad_request. This is the deployed failure evidence for invalid URLs.

  • Invoke the unknown-code event for S-5 by running:
aws lambda invoke out.json --function-name spec-links --cli-binary-format raw-in-base64-out --payload file://events/missing.json | Tee-Object -FilePath check-out.txt -Append

What does this invocation test?

The request looks for code none1. That code has no DynamoDB item, so this deployed call supplies the evidence for S-5.

  • Display the unknown-code response while appending it to check-out.txt by running:
Get-Content out.json | Tee-Object -FilePath check-out.txt -Append

What should you see?

You should see status not_found. This proves an unknown code has a defined deployed response.

The created link uses a 60-second lifetime. This wait lasts just over one minute, so the quiet terminal is expected while the expiry boundary passes.

  • Wait beyond the application expiry by running:
Start-Sleep -Seconds 61

Why wait 61 seconds?

The payload sets a 60-second lifetime. Waiting 61 seconds places the next read beyond expiry without depending on DynamoDB background deletion.

Before you invoke the link again, do you expect the application to return the URL or reject the expired link?

  • Invoke the expired link by running:
aws lambda invoke out.json --function-name spec-links --cli-binary-format raw-in-base64-out --payload file://events/get.json | Tee-Object -FilePath check-out.txt -Append

What does this invocation prove?

This call reads the same item after its application expiry. The result comes from Resolve() even if DynamoDB still holds the row.

  • Display the expired response while appending it to check-out.txt by running:
Get-Content out.json | Tee-Object -FilePath check-out.txt -Append

What should you see?

You should see status gone. The application therefore enforces expiry during every read.

  • Inspect the backing DynamoDB item while appending the observation to check-out.txt by running:
aws dynamodb get-item --table-name spec-links --key file://events/key.json | Tee-Object -FilePath check-out.txt -Append

How should you judge this observation?

The expired item may still appear because DynamoDB TTL deletion runs asynchronously. An item or an empty result is valid observational evidence because physical deletion timing is not an acceptance guarantee.

  • Inspect the inline role policy actions while appending them to check-out.txt by running:
aws iam get-role-policy --role-name spec-links --policy-name table-and-logs --query "PolicyDocument.Statement[].Action" | Tee-Object -FilePath check-out.txt -Append

What should the policy contain?

You should see only dynamodb:GetItem, dynamodb:PutItem, logs:CreateLogStream, plus logs:PutLogEvents. These four actions are the least-privilege evidence for AC-7.

  • Capture the five named unit test results in check-out.txt by running:
go test -v ./... | Tee-Object -FilePath check-out.txt -Append

What should the test evidence show?

You should see one PASS result for each test from S-1, S-2, S-3, S-4, plus S-6. This supplies the five named lines required by AC-3.

  • Capture the commit order in check-out.txt by running:
git log --oneline | Tee-Object -FilePath check-out.txt -Append

What does commit order prove?

The history should place the spec plus design commit before the commits containing tests and implementation. This makes the specification-first sequence inspectable.

The exact-boundary test protects a subtle rule. A link is expired when the current time equals its expiry time.

  • In link.go, locate this comparison:
if !now.Before(link.Expires) {

Why does this comparison matter?

The negation covers times equal to or later than expiry. The S-6 boundary test exists to detect any weaker comparison.

  • Replace !now.Before(link.Expires) with now.After(link.Expires).
  • Save link.go.

Before you run the suite, which single test should expose the weakened exact-boundary rule?

  • Capture the deliberate must-fail result in check-out.txt by running:
go test -v ./... | Tee-Object -FilePath check-out.txt -Append

What should fail?

Exactly TestS6_ExpiresAtBoundary should fail. That failure is intentional evidence that the test detects the one-line boundary regression.

  • Record the single failing test beside the must-fail evidence line in TASKS.md.
  • Restore the expiry comparison so it looks exactly like this:
if !now.Before(link.Expires) {

What does the restored line enforce?

The restored comparison returns gone at the exact expiry time. It also returns gone at every later time.

  • Format the restored Go files by running:
gofmt -w link.go

What does formatting protect?

The formatter returns link.go to standard Go formatting after the surgical edit. This keeps the release diff focused on evidence artifacts.

Before you rerun the suite, do you expect the original five PASS lines to return?

  • Capture the restored green suite in check-out.txt by running:
go test -v ./... | Tee-Object -FilePath check-out.txt -Append

What should the restored run show?

You should see all five named tests pass again. The evidence file now contains both the deliberate failure and the restored result.

Use this checkpoint to confirm the mutation has been removed from link.go.

✔️ The five tests pass again

The exact-boundary rule is restored. Keep the deliberate failing output only in check-out.txt as evidence.

ⓧ I'd like to double check the full code

package main

import (
	"fmt"
	"regexp"
	"strings"
	"time"
)

var codePattern = regexp.MustCompile(`^[a-z0-9]{4,12}$`)

type Link struct {
	Code    string
	URL     string
	Expires time.Time
}

func NewLink(code, rawURL string, ttlSeconds int64, now time.Time) (Link, error) {
	if !strings.HasPrefix(rawURL, "https://") {
		return Link{}, fmt.Errorf("url must start with https://")
	}
	if !codePattern.MatchString(code) {
		return Link{}, fmt.Errorf("code must be 4 to 12 lowercase letters or digits")
	}
	if ttlSeconds < 60 || ttlSeconds > 2592000 {
		return Link{}, fmt.Errorf("ttl_seconds must be 60 to 2592000")
	}

	return Link{
		Code:    code,
		URL:     rawURL,
		Expires: now.Add(time.Duration(ttlSeconds) * time.Second),
	}, nil
}

func Resolve(link Link, now time.Time) string {
	if !now.Before(link.Expires) {
		return "gone"
	}
	return "ok"
}

What should you compare?

Confirm your file validates HTTPS before code format and lifetime. Confirm Resolve() uses the restored negated comparison.

  • Defend Record 1, Record 2, plus Record 3 aloud using docs/decisions.md.
  • Add one evidence line for each spoken defense to TASKS.md.

You should now see separate tracker lines for the must-fail plus all three decision defenses. Your validation evidence is ready for teardown.

Destroy resources and produce the close-out

The deployed resources have finished their job. The close-out becomes trustworthy when the system is gone before the final claims are drafted.

This deletion is required

Destroying the stack removes the Lambda function, DynamoDB table, CloudWatch log group, IAM role, plus inline policy. Your private repository and local source remain available.

Before you destroy the stack, do you expect OpenTofu to remove all five managed AWS resources?

Destroying resources is permanent, so confirm the target account again before OpenTofu removes anything.

macOS

  • Reset your non-production profile for teardown and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"

Windows

  • Reset your non-production profile for teardown and display its name by running:
$env:AWS_PROFILE = '[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
$env:AWS_PROFILE

Linux

  • Reset your non-production profile for teardown and display its name by running:
export AWS_PROFILE='[[AWS_PROFILE_NAME="your non-production AWS CLI profile"]]'
printf '%s\n' "$AWS_PROFILE"
  • Verify the teardown identity immediately before tofu destroy by running:
aws sts get-caller-identity

Run the destroy command only when this identity matches the non-production account that holds spec-links.

  • Destroy the deployed AWS resources by running:
tofu -chdir=infra destroy -auto-approve

What does destroy do?

OpenTofu reads its state and removes the five managed resources. Wait for the destroy summary before running the independent AWS CLI checks.

🙋‍♀️ Destroy did not complete?

  • Confirm the terminal is still inside the local spec-links repository.
  • Confirm the initialized configuration remains inside infra.
  • Confirm your AWS credentials still allow deletion of the five managed resources.

Help me diagnose this OpenTofu destroy failure.

  • Count remaining Lambda functions named spec-links by running:
aws lambda list-functions --query "length(Functions[?FunctionName=='spec-links'])"

What should the Lambda count show?

You should see 0. Record that count in TASKS.md as Lambda teardown evidence.

  • Count remaining DynamoDB tables named spec-links by running:
aws dynamodb list-tables --query "length(TableNames[?@=='spec-links'])"

What should the table count show?

You should see 0. Record that count in TASKS.md as DynamoDB teardown evidence.

  • Count remaining log groups named /aws/lambda/spec-links by running:
aws logs describe-log-groups --query "length(logGroups[?logGroupName=='/aws/lambda/spec-links'])"

What should the log count show?

You should see 0. Record that count in TASKS.md as CloudWatch Logs teardown evidence.

  • Count remaining IAM roles named spec-links by running:
aws iam list-roles --query "length(Roles[?RoleName=='spec-links'])"

What should the role count show?

You should see 0. The inline policy disappears with its role, so this count completes the four-part teardown proof.

That is the risky part finished. All four independent checks now show that no project AWS resources remain.

  • Remove the generated build directory by running:
Remove-Item -Recurse infra/build

Why remove the build directory?

The directory contains generated deployment artifacts. Removing it leaves the repository with source plus evidence while .gitignore continues to exclude future builds.

  • Record the close-out start time in TASKS.md by running:
Get-Date -Format o

How is this timestamp used?

This timestamp marks the start of close-out work. Compare it with the phase starts to calculate the actual AS BUILT duration.

  • Copy the timestamp into the close-out start field in TASKS.md.
  • Inventory the project artifacts by running:
Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName

What should the inventory establish?

The output lists every remaining local file after teardown. Use it to identify missing close-out artifacts without inventing evidence for them.

💡 Use only the evidence bundle

This is the first point where the project permits your existing AI client. Give it only the files below, and never include AWS identity output or credentials.

  • The five design documents are docs/build-spec.md, docs/decisions.md, docs/topology.md, docs/implementation-plan.md, plus docs/test-plan.md.
  • The written contract files are spec.md plus README.md.
  • The run evidence files are TASKS.md plus check-out.txt.
  • Give the permitted evidence bundle to your existing AI client.
  • Instruct it to draft docs/TEACHBACK.md with the title The spec is the test plus one spoken defense for each decision record.
  • Instruct it to draft docs/AAR.md with sections named What worked plus What to change.
  • Instruct it to draft a self-contained readout.html titled The spec is the test.
  • Require every claim to cite a design-document line or pasted command result.
  • Require missing evidence to appear as owed.
  • Require readout.html to include Digital Green's 51.8% F1 attribution from arXiv:2603.03294.
  • Require the first-draft score to use untraceable claims divided by total claims.

The AI produces drafts for you to judge. The evidence bundle remains the authority for every retained claim.

  • Count the first drafts' untraceable claims plus total claims.
  • Record the fraction plus percentage in docs/AAR.md.
  • Remove every untraceable claim from all three close-out artifacts.
  • Mark every missing result as owed.
  • Open readout.html from the spec-links folder in your browser.

You should see the answer first. The page should also show the spec, traceability table, five PASS lines, deliberate must-fail, Digital Green attribution, plus the limitation from README.md.

  • Complete the AS BUILT calculation in TASKS.md from the recorded phase starts.
  • Confirm the build phase received 20 minutes in TASKS.md.
  • List undefined terms in TASKS.md.
  • List undone work with one trade for each item in TASKS.md.
  • Report AC-1 through AC-8 as EVIDENCE or owed.
  • Confirm no AI prompt ran before close-out in TASKS.md.
  • Add the plain overview, point of view, plus ordered task list to the close-out artifacts.
  • Write The build is ready to run in TASKS.md after every item has evidence or owed.
  • Confirm the three new close-out files exist by running the artifact inventory again:
Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName

What should the final inventory include?

You should see docs/TEACHBACK.md, docs/AAR.md, plus readout.html among the retained project files. The removed infra/build directory should be absent.

Merge and tag the release

The close-out now connects written claims to captured evidence. A short-lived private GitHub pull request preserves the review boundary before the release tag is created.

  • Stage every close-out change by running:
git add -A

What does staging include?

Git stages the close-out documents, tracker updates, evidence file, plus removal of generated build artifacts. The restored application code remains the v1.0 implementation.

  • Commit the evidence-backed close-out by running:
git commit -m "Add the close-out"

What does this commit preserve?

The commit records the final evidence artifacts on the build branch. It also keeps the close-out separate from the earlier specification and implementation commits.

  • Push the build branch to the private remote by running:
git push -u origin build

What does this push change?

The private remote now has the complete build branch. The upstream link lets the pull request use that branch as its source.

🙋‍♀️ Push was rejected?

  • Confirm your active branch is build.
  • Confirm GitHub CLI authentication still uses the account that owns the private repository.
  • Confirm the remote repository is named spec-links.

Help me diagnose this private branch push.

  • Create the private pull request from the current branch by running:
gh pr create --fill

What does the pull request contain?

GitHub CLI fills the title plus body from commit information. It returns the private pull request location for review.

  • Merge the current branch pull request with a merge commit by running:
gh pr merge --merge

What does this merge preserve?

The merge commit preserves the build branch boundary in main history. If GitHub CLI asks for confirmation, confirm the pull request for the current build branch.

  • Switch back to the local main branch by running:
git switch main

Why return to main?

The release tag belongs on the merged main history. Switching branches prepares the local repository to receive the merge commit.

  • Bring the merged commit into local main by running:
git pull

What should the pull update?

Local main should now include the merge commit plus all close-out artifacts. This places the local release point at the same history as the private remote.

  • Create the release tag by running:
git tag v1.0

What does the tag identify?

The v1.0 tag gives the merged commit a stable release name. It identifies the tested implementation plus its evidence-backed handoff.

Before the final push, what should the remote gain if the release is complete?

  • Publish the release tag to the private remote by running:
git push origin v1.0

What should the final push prove?

The remote should report that tag v1.0 was pushed. Your private repository now contains the merged release history plus its release tag.

  • Return to the private spec-links repository page.
  • Confirm the build pull request shows a merged state.
  • Confirm the repository lists tag v1.0.

You completed the full evidence loop. The private release is tagged, the handoff is traceable, plus all AWS teardown counts are zero.

Secret mission

Add S-7 for Generated Codes

The released service requires callers to invent every code. Extend the written contract with S-7 on a separate branch. Prove the missing behavior with a red test before generating valid eight-character codes locally.

Clean Up Your Resources

Clean Up Your Resources

Your deployed AWS resources are already destroyed with four teardown counts of 0. Decide whether to keep, pause, or delete the remaining private GitHub repository and local source.

Cost warning

The completed test run is estimated under $0.01. Check AWS Billing because charges vary by account.

Resources you used:

  • AWS Lambda function spec-links, already destroyed.
  • Amazon DynamoDB table spec-links, already destroyed.
  • Amazon CloudWatch Logs log group /aws/lambda/spec-links, already destroyed.
  • AWS Identity and Access Management role spec-links with inline policy table-and-logs, already destroyed.
  • Private spec-links repository with the unchanged v1.0 release.
  • Local ~/spec-links source folder with the undeployed secret-s7 branch.

Keep everything running

No action needed. Choose this if you want to preserve the released system and the optional S-7 extension.

  • Keep the private spec-links repository as the released v1.0 record.
  • Keep ~/spec-links so the six passing tests remain available locally.
  • Leave secret-s7 unmerged to preserve the released version.

Pause - I'll come back to this later

Nothing is running in AWS. Pausing means leaving the remaining project copies unchanged.

  • Leave the private spec-links repository unchanged.
  • Leave ~/spec-links in your Windows user folder.
  • Resume the secret-s7 branch when you want to continue the optional extension.

Delete - I don't want to use this again

Remove both remaining project copies if you no longer need the work. AWS needs no further deletion because the deployed stack is already gone.

Deleting both copies is permanent, so keep one copy if you may return to secret-s7.

  • Review docs/AAR.md to confirm the four teardown counts remain 0.
  • Open the private spec-links repository on GitHub.
  • Open the repository settings page.
  • Find the repository deletion control.
  • Delete the spec-links repository.

The remote copy is gone. Your local folder is now the final copy.

  • Close Cursor so Windows can release the local files.
  • Open File Explorer with Windows search.
  • Open your Windows user folder.
  • Select the spec-links folder.
  • Delete the selected folder.
  • Empty the Recycle Bin to remove the local files permanently.

Cleanup is complete because no local or remote project copy remains.

Nice Work!

Nice Work!

You did it! Your private expiring-link service now proves each written rule through named checks and captured evidence.

What you learned:

  • Turn a written contract into an executable specification with five named Go unit tests plus one deployed end-to-end check. Use a deliberate boundary mutation to prove the exact-expiry test catches the intended failure.
  • Deploy a private AWS Lambda through OpenTofu with Amazon DynamoDB persistence. Enforce expiry on every read. Restrict the runtime policy to four AWS Identity and Access Management actions.
  • Create an evidence-backed handoff that traces each claim to a design line or captured result. Score the first AI draft for untraceable claims. Release the merged work as v1.0. Verify all four AWS teardown counts return 0.
  • Secret Mission: Add S-7 on the separate secret-s7 branch with TestS7_GeneratesCode written before the implementation. Generate eight-character codes that satisfy S-2. Return each generated or supplied code in create replies. Prove all six named tests pass without redeploying AWS. Keep v1.0 unchanged.

Ready to quiz yourself?