Files
Paul O'Reilly dab3db3727 fix(best-practices): restore canonical content, dedupe, sync script, spec exemplar
- 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
2026-07-02 21:27:43 +12:00

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) -> RateLimitPolicy as the sole public entry point

Dependencies

  • pydantic v2 (data models and field validation)
  • pyyaml (YAML parsing) — errors from malformed YAML are caught and re-raised as PolicyLoadError, never allowed to propagate as raw yaml.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.limit MUST be a positive integer (>= 1). A value of 0 or negative raises pydantic.ValidationError at 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"}, WHEN RateLimitRule(**rule) is constructed, THEN pydantic.ValidationError is raised mentioning field limit.
  • RLV-2: RateLimitRule.window_unit MUST be one of WindowUnit.second, WindowUnit.minute, WindowUnit.hour. Any other string value raises pydantic.ValidationError at 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), WHEN RateLimitRule(**rule) is constructed, THEN pydantic.ValidationError is raised mentioning field window_unit.
  • RLV-3: load_policy(path) MUST raise PolicyLoadError with the message "failed to parse YAML: <path>" when the file exists but contains malformed YAML. The underlying yaml.YAMLError MUST NOT propagate directly.

    • Why: The gateway's startup code catches PolicyLoadError specifically to produce a clean "refusing to start: bad rate-limit config" message. Letting a raw yaml.YAMLError through breaks that error handling and dumps a parser traceback on operators instead.
    • Scenario: GIVEN a file containing route: [unclosed, WHEN load_policy(path) is called, THEN PolicyLoadError is raised with message "failed to parse YAML: <path>".
  • RLV-4: load_policy(path) MUST raise FileNotFoundError (not PolicyLoadError) when path does 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", WHEN load_policy(path) is called, THEN FileNotFoundError is raised.
  • RLV-5: load_policy(path) MUST raise PolicyValidationError collecting 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.errors is a list[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/orders at different limits, and a third rule with window_unit: "hour" and window: 0, WHEN load_policy(path) is called, THEN PolicyValidationError is raised with len(errors) == 2.
  • RLV-6: find_overlapping_rules(rules) MUST return every pair of rules that share the same route and method but have a different limit, window, or window_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"}], WHEN find_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.