- best-practices/v1: replace 9 stale symlinks (into planning/v1) with real files synced byte-identical from the canonical best-practices project; add INDEX.md, scripting.md, mechanical-test-generation.md (canonical had drifted heavily, e.g. api-design.md 463->807 lines) - planning/v1: delete duplicated best-practices/ copy (requires: inheritance confirmed via spec/harness.md HC-1/HC-7) - scripts/sync-best-practices.sh: idempotent re-sync from canonical checkout - code-methodology/v1: INDEX.md + scripting.md references now resolve; point test-writing tasks at mechanical-test-generation.md - spec-writing/v1: worked spec exemplar (module layout table, Why: lines, exact error messages, parametrize pattern table) + CLAUDE.md pointer + mount entry
8.0 KiB
Spec Exemplar: Rate-Limit Config Validator
This is a compact worked example of a spec written for mechanical test generation
(see mechanical-test-generation.md in /workspace/best-practices/). It covers a small,
self-contained subsystem end-to-end so every section can be read in full. Use it as the
structural template for real specs — same section order, same requirement shape, same
level of concreteness. The subsystem itself (a rate-limit config validator) is invented
for this exemplar; it is not part of any real codebase in this project.
A test-writer agent given ONLY this document should be able to produce correct imports, correct assertion values, and correct mock boundaries with no other input — no reading the codebase, no guessing module paths, no inventing error message text.
Overview
The rate-limit config validator loads a service's rate-limit policy from a YAML file,
validates it against structural and semantic rules, and produces a RateLimitPolicy
object that the gateway middleware consumes at startup. It exists to catch
misconfiguration (overlapping rules, invalid windows, negative limits) before the
gateway starts serving traffic, rather than failing silently at request time.
Responsibilities
- Parse a rate-limit policy YAML file into typed Pydantic models
- Validate structural correctness (required fields, types, value ranges)
- Validate semantic correctness (no two rules match the same route + method with different limits; window units are one of a fixed set)
- Produce a single validation report listing every error found (not just the first)
- Expose
load_policy(path) -> RateLimitPolicyas the sole public entry point
Dependencies
pydanticv2 (data models and field validation)pyyaml(YAML parsing) — errors from malformed YAML are caught and re-raised asPolicyLoadError, never allowed to propagate as rawyaml.YAMLError- No network calls, no filesystem writes — this is a pure load-and-validate module
Module Layout
| Module | Location | Key exports |
|---|---|---|
| Policy models | gateway/ratelimit/models.py |
RateLimitRule, RateLimitPolicy, WindowUnit (enum: second, minute, hour) |
| Validator | gateway/ratelimit/validator.py |
load_policy(path: str) -> RateLimitPolicy, PolicyLoadError, PolicyValidationError |
| Overlap check | gateway/ratelimit/overlap.py |
find_overlapping_rules(rules: list[RateLimitRule]) -> list[tuple[RateLimitRule, RateLimitRule]] |
| Test file | Spec requirements covered |
|---|---|
tests/ratelimit/test_models.py |
RLV-1, RLV-2 |
tests/ratelimit/test_validator.py |
RLV-3, RLV-4, RLV-5 |
tests/ratelimit/test_overlap.py |
RLV-6 |
Requirements
-
RLV-1:
RateLimitRule.limitMUST be a positive integer (>= 1). A value of0or negative raisespydantic.ValidationErrorat model construction time.- Why: A zero or negative limit is not a valid rate limit — it either blocks everything or is meaningless. Catching it at the model layer means every caller gets the same guarantee for free, without re-checking in the validator.
- Scenario: GIVEN a rule dict
{"route": "/api/orders", "method": "POST", "limit": 0, "window": 60, "window_unit": "second"}, WHENRateLimitRule(**rule)is constructed, THENpydantic.ValidationErroris raised mentioning fieldlimit.
-
RLV-2:
RateLimitRule.window_unitMUST be one ofWindowUnit.second,WindowUnit.minute,WindowUnit.hour. Any other string value raisespydantic.ValidationErrorat construction time — no case-insensitive matching, no aliasing ("secs","s"are rejected).- Why: Silent unit aliasing is how a
window: 5, window_unit: "s"rule quietly becomes a 5-hour window instead of 5 seconds. Reject anything not in the enum. - Scenario: GIVEN a rule dict with
"window_unit": "seconds"(plural, not in the enum), WHENRateLimitRule(**rule)is constructed, THENpydantic.ValidationErroris raised mentioning fieldwindow_unit.
- Why: Silent unit aliasing is how a
-
RLV-3:
load_policy(path)MUST raisePolicyLoadErrorwith the message"failed to parse YAML: <path>"when the file exists but contains malformed YAML. The underlyingyaml.YAMLErrorMUST NOT propagate directly.- Why: The gateway's startup code catches
PolicyLoadErrorspecifically to produce a clean "refusing to start: bad rate-limit config" message. Letting a rawyaml.YAMLErrorthrough breaks that error handling and dumps a parser traceback on operators instead. - Scenario: GIVEN a file containing
route: [unclosed, WHENload_policy(path)is called, THENPolicyLoadErroris raised with message"failed to parse YAML: <path>".
- Why: The gateway's startup code catches
-
RLV-4:
load_policy(path)MUST raiseFileNotFoundError(notPolicyLoadError) whenpathdoes not exist on disk.- Why: Missing file and malformed file are different failure classes for an operator — missing file usually means a deploy/mount problem, malformed file means a config authoring problem. Callers need to tell them apart.
- Scenario: GIVEN
path = "/etc/gateway/does-not-exist.yaml", WHENload_policy(path)is called, THENFileNotFoundErroris raised.
-
RLV-5:
load_policy(path)MUST raisePolicyValidationErrorcollecting ALL semantic validation failures (not just the first) when the parsed rules contain overlapping routes (see RLV-6) or duplicate(route, method)pairs with identical limits.PolicyValidationError.errorsis alist[str], one entry per problem found, each in the form"<route> <method>: <reason>".- Why: Config authors iterate faster when they see every problem in one pass instead of fixing one error, rerunning, hitting the next error.
- Scenario: GIVEN a policy YAML with two rules both matching
POST /api/ordersat different limits, and a third rule withwindow_unit: "hour"andwindow: 0, WHENload_policy(path)is called, THENPolicyValidationErroris raised withlen(errors) == 2.
-
RLV-6:
find_overlapping_rules(rules)MUST return every pair of rules that share the samerouteandmethodbut have a differentlimit,window, orwindow_unit. Rules with identical(route, method)AND identical(limit, window, window_unit)are NOT considered overlapping (harmless duplication, not a conflict) — implementation note: dedupe on the full tuple before pairing, not just(route, method).- Why: Two rules for the same route+method with different limits is ambiguous — which one applies at request time is undefined. Two identical rules are just a copy-paste no-op and shouldn't block a deploy.
- Scenario: GIVEN rules
[{route: "/x", method: "GET", limit: 10, window: 60, window_unit: "second"}, {route: "/x", method: "GET", limit: 20, window: 60, window_unit: "second"}], WHENfind_overlapping_rules(rules)is called, THEN it returns one pair containing both rules.
Pattern Table (parametrize matrix)
window_unit input |
Valid? | Why |
|---|---|---|
"second" |
✓ | matches WindowUnit.second |
"minute" |
✓ | matches WindowUnit.minute |
"hour" |
✓ | matches WindowUnit.hour |
"seconds" |
✗ | plural not aliased (RLV-2) |
"SECOND" |
✗ | no case-insensitive matching (RLV-2) |
"s" |
✗ | no abbreviation aliasing (RLV-2) |
"day" |
✗ | not a supported unit |
"" |
✗ | empty string is not a valid enum member |
Scenarios
Happy path: valid policy loads cleanly
GIVEN a policy YAML with three non-overlapping rules, all fields valid.
WHEN load_policy(path) is called.
THEN it returns a RateLimitPolicy with len(policy.rules) == 3 and no exception
is raised.
Atomic failure: partial policy never returned on validation error
GIVEN a policy YAML where rule 1 is valid and rule 2 has limit: -5.
WHEN load_policy(path) is called.
THEN pydantic.ValidationError propagates from rule 2's construction and
load_policy does not return a partially-built RateLimitPolicy — the caller gets
either a complete, fully-valid policy or an exception, never a partial object.