feat: claim capabilities split — wildcard (cert parent) + catchall (route) (#151, woodstar coordination)
Two orthogonal additive fields per the woodstar/#82 review: wildcard opts into delegated-ACME cert parenthood alone; catchall derives the *.<host> alias and is refused without wildcard at the attach surface. Declared here for writer-side round-trip; converge preserves both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+19
−10
internal/domains/claims.go+15 −7internal/domains/claims_test.go+4 −3
diff --git a/internal/domains/claims.go b/internal/domains/claims.go| index 51c5abae3..d2689d59a 100644 |
| --- a/internal/domains/claims.go |
| +++ b/internal/domains/claims.go |
| @@ -39,14 +39,22 @@ type Claim struct { |
| Route string `json:"route,omitempty"` |
| CreatedAt string `json:"created_at"` |
| CreatedBy string `json:"created_by,omitempty"` |
| - // Wildcard marks a claim whose *.<host> labels also resolve to the |
| - // primary route, with delegated DNS-01 issuance for the wildcard cert |
| - // (woodstar-carloku migration spec §4-5 — that branch gives it meaning; |
| - // nothing in this package acts on it). Declared here so a converge |
| - // rewrite by this binary round-trips the value instead of stripping a |
| - // field it doesn't know — the additive-wire rule applied to writers, |
| - // not just readers. |
| + // Wildcard and Catchall are the woodstar-carloku branch's two claim |
| + // capabilities (its migration spec §4-5 gives them meaning; nothing in |
| + // this package acts on either). They are deliberately orthogonal: |
| + // Wildcard opts the domain into a delegated-ACME wildcard cert (one |
| + // *.<host> cert covering named aliases, no per-host CT-logged certs); |
| + // Catchall derives the *.<host> catch-all alias entry to the primary |
| + // route, and the attach surface refuses it without Wildcard (a |
| + // catch-all nobody can serve TLS for is a config error, not a state). |
| + // Both are declared here so a converge rewrite by this binary |
| + // round-trips them instead of stripping fields it doesn't know — the |
| + // additive-wire rule applied to writers, not just readers. Converge |
| + // preserves only DECLARED fields (json.Unmarshal drops unknown ones), |
| + // which is exactly why any future claim field must be added here in |
| + // the same change that first writes it. |
| Wildcard bool `json:"wildcard,omitempty"` |
| + Catchall bool `json:"catchall,omitempty"` |
| } |
| // ErrHostUnavailable is returned by AttachClaim when host is already claimed |
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go| index f542ace87..e4f08a64f 100644 |
| --- a/internal/domains/claims_test.go |
| +++ b/internal/domains/claims_test.go |
| @@ -59,7 +59,7 @@ func TestAttachClaimConvergesRoute(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| ctx := context.Background() |
| claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| - CreatedAt: "2026-08-12T00:00:00Z", Wildcard: true} |
| + CreatedAt: "2026-08-12T00:00:00Z", Wildcard: true, Catchall: true} |
| if _, err := AttachClaim(ctx, s, claim); err != nil { |
| t.Fatal(err) |
| } |
| @@ -78,8 +78,9 @@ func TestAttachClaimConvergesRoute(t *testing.T) { |
| if got.Route != "canary/next" { |
| t.Fatalf("Route = %q, want canary/next", got.Route) |
| } |
| - if !got.Wildcard || got.CreatedAt != "2026-08-12T00:00:00Z" { |
| - t.Fatalf("converge must preserve Wildcard+CreatedAt, got wildcard=%v created_at=%q", got.Wildcard, got.CreatedAt) |
| + if !got.Wildcard || !got.Catchall || got.CreatedAt != "2026-08-12T00:00:00Z" { |
| + t.Fatalf("converge must preserve Wildcard+Catchall+CreatedAt, got wildcard=%v catchall=%v created_at=%q", |
| + got.Wildcard, got.Catchall, got.CreatedAt) |
| } |
| } |