feat(slug): add the ADR-015 slug validation package (S33 Step 2)

What

Step 2 of the S33 Phase 1 plan: the standalone internal/slug package encoding the ADR-015 (internal) code-level slug validation.

  • Validate(candidate string) error: nil for a valid slug, otherwise one errors.Is-matchable sentinel per violated rule, all rules evaluated independently (errors.Join).
  • Syntactic rules: lowercase ASCII letters/digits/hyphens only (homograph defense), alphanumeric edges, no consecutive hyphens, length 3–63 inclusive (MinLength/MaxLength).
  • Protocol-reserved names: api, app, ^v\d+$, matched on the trimmed, lowercased candidate and evaluated independently of the format rules (ADR-015 defense-in-depth).
  • A fuzz:slug CI job runs the differential fuzz target (FuzzValidate, single-expression oracle) for 30s on package changes.

Consumers arrive later: the provisioning endpoint (plan Step 5) and the lease/confirm flow (#311 (closed)).

Stacked MR

Targets jdrpereira/s33-phase1-step-1 (!1010 (merged)) so the diff shows only this step's commits. Step 2 has no code dependency on Step 1; the stacking is branch plumbing. When !1010 (merged) merges, GitLab retargets this MR to main.

Review notes

  • ADR-015 is internal-only (not in docs/adr/); the rules implemented here were taken verbatim from the internal handbook page on 2026-07-22.
  • #268 (bare slug vs URL prefix) is still open. The plan gates Step 2 on it; we decided to proceed per the accepted ADR-015. If #268 later adopts a prefix, the hardcoded reserved list is a three-line follow-up.
  • The namespaces CHECK constraints are looser than ADR-015 (≤255 chars, no consecutive-hyphen rule, min 2). This package is the authoritative encoding; the CHECKs are a deliberate coarser backstop (noted in the package comment). The S33 spec's wording attributing the syntactic rules to the CHECK constraints predates this; the Step 5 MR will note the code-level mechanism.
  • Pre-push passes: /validate-step (advisories fixed in-branch), /simplify (applied), /review-branch (APPROVE; its one warning, the unwired fuzz target, is fixed by the fuzz:slug job).
  • No database changes, so no database review evidence applies.
  • ~460 reviewable LOC, of which ~340 are tests.

Spec coverage

Spec: S33 — Namespace provisioning, slug validation order plus ADR-015 (internal).

ADR-015 syntactic rules

# Rule Tests
R-1 Lowercase ASCII letters, digits, hyphens only (no Unicode; homograph defense) TestValidate/underscore, /dot, /interior_space, /uppercase_ASCII_letter, /non-ASCII_letter, /cyrillic_homograph_of_api
R-2 Start and end with an alphanumeric character TestValidate/leading_hyphen, /trailing_hyphen, /leading_and_trailing_hyphens, /valid_digit_start, /valid_digit_end, /valid_all_digits
R-3 No consecutive hyphens TestValidate/double_hyphen, /triple_hyphen, /leading_double_hyphen_reports_both_rules, /valid_multiple_single_hyphens, /valid_interior_hyphen
R-4 Length 3 to 63 characters, inclusive TestValidate/empty_string, /two_characters_is_below_minimum, /sixty-four_characters_is_above_maximum, /valid_minimum_length, /valid_maximum_length

ADR-015 protocol-reserved slugs

# Pattern Tests
P-1 ^v\d+$ TestValidate/reserved_versioned_root_v10, /reserved_versioned_root_with_leading_zero, /reserved_versioned_root_with_many_digits, /reserved_v2_shorter_than_minimum_length_reports_both_rules, /bare_v_is_not_a_versioned_root, /valid_v_prefix_with_trailing_letter_is_not_a_versioned_root, /valid_hyphen_breaks_the_versioned_root_pattern
P-2 api TestValidate/reserved_api, /valid_superstring_of_api, /valid_reserved_word_as_prefix_only, /uppercase_api_is_normalized_before_reservation_matching, /whitespace-padded_api_is_trimmed_before_reservation_matching
P-3 app TestValidate/reserved_app, /valid_superstring_of_app
P-4 The list is exactly these three patterns TestValidate/valid_name_outside_the_hardcoded_reserved_list

ADR-015 normative statements

# Statement Tests
N-1 Reservation checks normalize the candidate (trimmed, lowercased) before matching TestValidate/uppercase_api_is_normalized_before_reservation_matching, /uppercase_versioned_root_is_normalized_before_reservation_matching, /whitespace-padded_api_is_trimmed_before_reservation_matching
N-2 Reservation checks run independently of format validation (defense-in-depth) TestValidate/reserved_v2_shorter_than_minimum_length_reports_both_rules, /uppercase_api_is_normalized_before_reservation_matching, /leading_double_hyphen_reports_both_rules
N-3 Whole-policy consistency under arbitrary input FuzzValidate (differential single-expression oracle; runs in the fuzz:slug CI job)

Spec statements (S33, Namespace provisioning)

# Statement Coverage
S-1 Validation order 1: syntactic rules per ADR-015 R-1..R-4 above; chain ordering is the Step 5 handler's
S-2 Validation order 2: ADR-015 hardcoded reservations P-1..P-4 above; chain ordering is Step 5's
S-3 Syntactic or reserved-name failure returns 422, no row created Rule detection owned here (per-rule sentinels); the 422 mapping and no-row guarantee are Step 5
S-4 Security: slug input crosses a trust boundary Charset and Unicode rejection (R-1) are the first line; SQL parameterization and CHECK constraints are datastore-owned
S-5 Validation order 3: Brand-List Validator Out of scope; plan Step 5
S-6 Validation order 4: uniqueness via the slug unique index Out of scope; DB-owned, surfaced in Step 5

Resolved ambiguities

  • "Reservation checks run independently of the format rules" → Validate reports every violated rule in one error, not first-only; that is the only encoding making the independence observable ("API" yields both ErrCharset and ErrReserved).
  • Length on non-ASCII input (bytes vs runes) → unpinned; the charset rule rejects every non-ASCII candidate anyway.

Related to #261 (closed)

Merge request reports

Loading
Loading