Loading
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 oneerrors.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:slugCI 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
namespacesCHECK 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 thefuzz:slugjob). - 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" →
Validatereports every violated rule in one error, not first-only; that is the only encoding making the independence observable ("API"yields bothErrCharsetandErrReserved). - Length on non-ASCII input (bytes vs runes) → unpinned; the charset rule rejects every non-ASCII candidate anyway.
Related to #261 (closed)