Tutorial: Your First Feature
A complete feature cycle: fuzzy idea → clear GH issue → durable spec → one unit at a time → review → merged branch → closed issue. We'll add rate limiting to an API.
Setup
Install litespec (Getting Started), then initialize:
litespec init
git status --porcelain # must print nothing
git rev-parse HEAD # this becomes Base:
git switch -c litespec/add-rate-limiting
litespec-plan in clear mode performs those checks itself — clean tree, Base:, dedicated branch, Branch: — and creates the labeled issue. If gh is unavailable it writes the same body to specs/queues/add-rate-limiting.md instead.
This tutorial follows the new-feature lane. Small fixes skip all of this: read the spec, edit, update the spec if the contract changed, done.
Plan fuzzy
Invoke litespec-plan and describe the idea.
You: Limit each IP to 100 requests per minute, return 429 with a
Retry-Afterheader.Plan (fuzzy): Reads the code, product, glossary. Asks two or three questions, one at a time. Writes no files.
- Configurable limit, or fixed at 100?
- In-memory only, or a shared store?
- Per-IP or per-user?
Fuzzy is ephemeral. When you can answer "what demo proves this?" and "what Verify fails without it?", say ready and move to clear.
Plan clear
Clear mode writes the issue body: ownership lines first, then proposal, design, and queue. Each unit is one boundary or failure policy with identified clauses, scenario mappings, risk accounting, and one Verify: — dry-run on the base tree before filing, so every Verify is known to fail without its outcome.
Base: <full commit SHA printed before branch creation>
Branch: litespec/add-rate-limiting
## Proposal
Add rate limiting to prevent abuse and keep usage fair. Out of scope: distributed stores, per-user quotas.
## Design
In-memory sliding-window counter per IP behind a middleware. The middleware extracts the IP, increments the counter, rejects over-limit requests with 429 and `Retry-After`. Limit comes from `RATE_LIMIT_PER_MINUTE`, default 100.
## Sliding window counter
Boundary: process
Done means:
- [window] The counter counts requests in the last 60 seconds
Scenarios:
- [window] TestCounterWindow
Risk cases:
- timeout: N/A — in-memory count, no deadline
- cleanup: N/A — no temp state per request
- non-ENOENT errors: N/A — no filesystem lookup
- concurrency: [window]
- optional configured dependencies: N/A — counter has no optional services
Verify: `go test ./internal/ratelimit -run TestCounterWindow`
- [ ] pending
## Limiter enforces per-IP cap
Depends: Sliding window counter
Done means:
- [cap] 100 requests per minute per IP pass; the 101st returns 429 with `Retry-After`
Scenarios:
- [cap] TestLimiterEnforcement
Verify: `go test ./internal/ratelimit -run TestLimiterEnforcement`
- [ ] pending
Create it with gh issue create --label litespec --body-file issue.md. Prose-only units are banned — explanation rides with the unit whose behavior it describes.
Draft the spec
Rate limiting outlives the issue, so it's load-bearing: plan drafts specs/rate-limit/spec.md alongside the issue. Contracts, not proposals — SHALL/MUST, WHEN/THEN:
# rate-limit
## Requirements
### Requirement: Enforce per-IP request limit
The rate limiter MUST enforce 100 requests per minute per IP. Requests over the limit SHALL return HTTP 429 with a `Retry-After` header.
#### Scenario: Request under limit
- **WHEN** an IP makes 50 requests within one minute
- **THEN** all requests succeed with HTTP 200
#### Scenario: Request exceeds limit
- **WHEN** an IP makes 101 requests within one minute
- **THEN** the 101st returns HTTP 429 with `Retry-After: 60`
### Requirement: Configurable limit
The limit MUST be configurable via `RATE_LIMIT_PER_MINUTE`. It SHALL default to 100 when unset.
#### Scenario: Default limit
- **WHEN** `RATE_LIMIT_PER_MINUTE` is unset
- **THEN** the limit is 100 requests per minute
Run litespec validate — it reports the file and line for a missing SHALL/MUST or a scenario without WHEN/THEN.
Grill me (optional)
Before building:
You: grill-me
Plan: What if the counter map grows unbounded? What if
X-Forwarded-Forholds multiple IPs? What if the service restarts?
Grilling may tighten the spec or record a durable decision. Cheap now, expensive later.
Build one unit at a time
You: build unit 1
Build: Reads the issue, the spec, product, glossary. On the issue's branch, from a clean tree, runs the exact Verify — it fails because the counter is absent. That clean commit is pre. (If the unit introduces its own test, build commits just the verifier first — at most one verifier-only commit — and uses that as pre.) Then implements the sliding window in one or more implementation/fix commits without amending, and re-runs the same Verify at the final clean commit where
Verify:passes — green. That commit is post.
Then the receipt. Build runs litespec digest for the unit's contract digest and litespec receipt to assemble the evidence — exact command, digest, pre/post SHAs and statuses, both raw outputs unedited, scope lines — into numbered comment files, posts them, and ticks the box with litespec issue check (exactly one flip, ownership lines untouched). Then it stops. Re-invoke for unit 2.
Never amend pre or any implementation commit; fixes go in new commits.
Review
You: review
Review: Reads the issue body first, screens every local path before touching it, then fetches comments. Replays the exact Verify in a detached temporary worktree at pre, a detached temporary worktree at post, and a detached temporary worktree at
HEAD— each removed even when Verify fails, never checking out evidence SHAs in your tree. Red-green evidence does not prove that Verify targets the correct behavior. Then the adversarial pass: does the 101st request really return 429? Does the window reset? Concurrent access? Empty headers?
A passing Verify proves only its scope, so review probes beyond the receipt. Findings route in order: suggestions ride the small-fix lane; unit violations rebuild the unit via build (at most twice per contract — the third routes to plan to reshape it); in-scope findings outside units become a direct fix or a new unit on this issue; out-of-scope findings route without blocking.
Merge, then close
When every box is ticked, every rebuild request and amendment resolved, and review returns PASS:
gh pr create --head litespec/add-rate-limiting
# after merge:
gh issue close 42
Merge first, then close — a closed issue leaves no work stranded on a branch. The queue is gone; specs/rate-limit/spec.md remains as the durable truth.
What you did
litespec-plangrilled the idea, then wroteBase:/Branch:plus proposal, design, and queue into the issue.litespec-plandrafted the load-bearing spec withSHALL/MUSTandWHEN/THEN.litespec-buildimplemented one unit at a time in one or more implementation/fix commits — red at pre, green at the final clean commit whereVerify:passes, receipt, tick, stop.litespec validateconfirmed structure (it never claims the code is correct).litespec-reviewreplayed the evidence and probed the behavior.- You merged the branch, then closed the issue.
What's next
- Workflow — unit shape, evidence protocol, and routing in full
- Concepts — what makes a good spec
- CLI Reference — every command and flag