From e877d2752dab00cc97fe6809e9a9760e5b27cbef Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 27 Jul 2026 20:03:07 +0200 Subject: [PATCH] Implement KeyCape service-token issuance --- WORK-RECORDS.md | 6 ++ src/cmd/keycape/main.go | 44 +++++--- src/internal/config/config.go | 33 +++--- src/internal/config/validate.go | 24 ++++- src/internal/domain/model.go | 18 ++-- src/internal/server/oidc/discovery.go | 4 +- src/internal/server/oidc/discovery_test.go | 4 +- src/internal/server/oidc/token.go | 89 ++++++++++++++- src/internal/server/oidc/token_test.go | 68 +++++++++++- ...-0006-client-credentials-service-tokens.md | 101 ++++++++++++++++++ 10 files changed, 348 insertions(+), 43 deletions(-) create mode 100644 workplans/KEY-WP-0006-client-credentials-service-tokens.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 73f0e3e..138decf 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -13,6 +13,7 @@ | workplan | KEY-WP-0003 | finished | — | workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md | | workplan | KEY-WP-0004 | active | — | workplans/KEY-WP-0004-binky-hedgehog-tenant-onboarding.md | | workplan | KEY-WP-0005 | finished | — | workplans/KEY-WP-0005-iam-profile-core-claims.md | +| workplan | KEY-WP-0006 | active | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md | | task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | | task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | | task | KEY-WP-0001-T03 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | @@ -57,3 +58,8 @@ | task | KEY-WP-0005-T01 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md | | task | KEY-WP-0005-T02 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md | | task | KEY-WP-0005-T03 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md | +| task | KEY-WP-0006-T01 | progress | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md | +| task | KEY-WP-0006-T02 | todo | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md | +| task | KEY-WP-0006-T03 | todo | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md | +| task | KEY-WP-0006-T04 | wait | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md | +| task | KEY-WP-0006-T05 | wait | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md | diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 7c21628..35327a5 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -73,7 +73,11 @@ func main() { // ----------------------------------------------------------------- // 5. Build client registry. // ----------------------------------------------------------------- - clients := buildClientRegistry(cfg.Clients) + clients, err := buildClientRegistry(cfg.Clients) + if err != nil { + log.Error().Err(err).Msg("failed to build client registry") + os.Exit(1) + } // ----------------------------------------------------------------- // 6. Create adapters. @@ -148,7 +152,7 @@ func main() { ClientConfig: clients, Sessions: sessions, Users: lldapAdapter, - SigningKey: privateKey, + SigningKey: privateKey, Issuer: issuer, TokenLifetime: tokenLifetime, Emitter: emitter, @@ -157,10 +161,10 @@ func main() { // Userinfo handler. mux.Handle("/userinfo", &oidc.UserinfoHandler{ - Users: lldapAdapter, + Users: lldapAdapter, SigningKey: &privateKey.PublicKey, - Issuer: issuer, - Emitter: emitter, + Issuer: issuer, + Emitter: emitter, }) // Healthz. @@ -245,21 +249,33 @@ func loadPrivateKey(path string) (*rsa.PrivateKey, error) { } // buildClientRegistry converts []ClientConfig into the map used by handlers. -func buildClientRegistry(cfgClients []config.ClientConfig) map[string]*domain.Client { +func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.Client, error) { m := make(map[string]*domain.Client, len(cfgClients)) for i := range cfgClients { c := &cfgClients[i] + clientSecret := "" + if strings.HasPrefix(c.SecretRef, "env:") { + envName := strings.TrimPrefix(c.SecretRef, "env:") + clientSecret = os.Getenv(envName) + if clientSecret == "" { + return nil, fmt.Errorf("client %q secret environment variable %q is empty", c.ClientID, envName) + } + } m[c.ClientID] = &domain.Client{ - ClientID: c.ClientID, - DisplayName: c.DisplayName, - RedirectURIs: c.RedirectURIs, - AllowedScopes: c.AllowedScopes, - GrantTypes: c.GrantTypes, - ClientType: c.ClientType, - SecretRef: c.SecretRef, + ClientID: c.ClientID, + DisplayName: c.DisplayName, + RedirectURIs: c.RedirectURIs, + AllowedScopes: c.AllowedScopes, + GrantTypes: c.GrantTypes, + ClientType: c.ClientType, + SecretRef: c.SecretRef, + ClientSecret: clientSecret, + ServiceSubject: c.ServiceSubject, + Tenant: c.Tenant, + Roles: c.Roles, } } - return m + return m, nil } // withEmitter wraps a handler to inject the telemetry emitter into every request context. diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 9ba0709..45c9296 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -16,26 +16,29 @@ import ( // Config is the top-level server configuration. type Config struct { - Issuer string `yaml:"issuer"` - Port int `yaml:"port"` - TokenLifetime string `yaml:"tokenLifetime"` - PrivateKeyPEM string `yaml:"privateKeyPem"` - LLDAP lldap.Config `yaml:"lldap"` - Authelia authelia.Config `yaml:"authelia"` + Issuer string `yaml:"issuer"` + Port int `yaml:"port"` + TokenLifetime string `yaml:"tokenLifetime"` + PrivateKeyPEM string `yaml:"privateKeyPem"` + LLDAP lldap.Config `yaml:"lldap"` + Authelia authelia.Config `yaml:"authelia"` PrivacyIDEA privacyidea.Config `yaml:"privacyidea"` - Clients []ClientConfig `yaml:"clients"` - Environment string `yaml:"environment"` + Clients []ClientConfig `yaml:"clients"` + Environment string `yaml:"environment"` } // ClientConfig is a static OIDC client registration. type ClientConfig struct { - ClientID string `yaml:"clientId"` - DisplayName string `yaml:"displayName"` - RedirectURIs []string `yaml:"redirectUris"` - AllowedScopes []string `yaml:"allowedScopes"` - GrantTypes []string `yaml:"grantTypes"` - ClientType string `yaml:"clientType"` // "confidential" | "public" - SecretRef string `yaml:"secretRef,omitempty"` + ClientID string `yaml:"clientId"` + DisplayName string `yaml:"displayName"` + RedirectURIs []string `yaml:"redirectUris"` + AllowedScopes []string `yaml:"allowedScopes"` + GrantTypes []string `yaml:"grantTypes"` + ClientType string `yaml:"clientType"` // "confidential" | "public" + SecretRef string `yaml:"secretRef,omitempty"` + ServiceSubject string `yaml:"serviceSubject,omitempty"` + Tenant string `yaml:"tenant,omitempty"` + Roles []string `yaml:"roles,omitempty"` } // Load reads and parses the YAML config file at path. diff --git a/src/internal/config/validate.go b/src/internal/config/validate.go index d470507..1dda896 100644 --- a/src/internal/config/validate.go +++ b/src/internal/config/validate.go @@ -41,9 +41,22 @@ func ValidateConfig(cfg *Config) []string { prefix = fmt.Sprintf("clients[%d]", i) errs = append(errs, prefix+": clientId must not be empty") } - if len(c.RedirectURIs) == 0 { + hasAuthorizationCode := contains(c.GrantTypes, "authorization_code") + hasClientCredentials := contains(c.GrantTypes, "client_credentials") + if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 { errs = append(errs, prefix+": redirect_uri: at least one redirectUri must be registered") } + if hasClientCredentials { + if c.ClientType != "confidential" { + errs = append(errs, prefix+": client_credentials requires clientType confidential") + } + if !strings.HasPrefix(c.SecretRef, "env:") { + errs = append(errs, prefix+": client_credentials requires an env: secretRef") + } + if c.ServiceSubject == "" || c.Tenant == "" { + errs = append(errs, prefix+": client_credentials requires serviceSubject and tenant") + } + } // Warn about wildcard redirect URIs (they are blocked at runtime anyway). for _, uri := range c.RedirectURIs { if strings.ContainsAny(uri, "*?") { @@ -59,3 +72,12 @@ func ValidateConfig(cfg *Config) []string { return errs } + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} diff --git a/src/internal/domain/model.go b/src/internal/domain/model.go index 7c193be..1b542fc 100644 --- a/src/internal/domain/model.go +++ b/src/internal/domain/model.go @@ -42,13 +42,17 @@ type Role struct { // Client is a registered OIDC client (static in v0.1 — no dynamic registration). type Client struct { - ClientID string `yaml:"clientId" json:"clientId"` - DisplayName string `yaml:"displayName" json:"displayName"` - RedirectURIs []string `yaml:"redirectUris" json:"redirectUris"` - AllowedScopes []string `yaml:"allowedScopes" json:"allowedScopes"` - GrantTypes []string `yaml:"grantTypes" json:"grantTypes"` - ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public" - SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"` + ClientID string `yaml:"clientId" json:"clientId"` + DisplayName string `yaml:"displayName" json:"displayName"` + RedirectURIs []string `yaml:"redirectUris" json:"redirectUris"` + AllowedScopes []string `yaml:"allowedScopes" json:"allowedScopes"` + GrantTypes []string `yaml:"grantTypes" json:"grantTypes"` + ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public" + SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"` + ClientSecret string `yaml:"-" json:"-"` + ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"` + Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"` + Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"` } // Membership links a user to a group. diff --git a/src/internal/server/oidc/discovery.go b/src/internal/server/oidc/discovery.go index 3960195..58b7cac 100644 --- a/src/internal/server/oidc/discovery.go +++ b/src/internal/server/oidc/discovery.go @@ -56,11 +56,11 @@ func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler { // Profile-locked values — not negotiable. ResponseTypesSupported: []string{"code"}, - GrantTypesSupported: []string{"authorization_code"}, + GrantTypesSupported: []string{"authorization_code", "client_credentials"}, CodeChallengeMethodsSupported: []string{"S256"}, IDTokenSigningAlgValuesSupported: []string{"RS256"}, ScopesSupported: []string{"openid", "profile", "email", "groups"}, - TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"}, + TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "none"}, ClaimsSupported: []string{ "sub", "iss", "aud", "exp", "iat", "preferred_username", "email", "name", "groups", "roles", diff --git a/src/internal/server/oidc/discovery_test.go b/src/internal/server/oidc/discovery_test.go index 4228a57..d984a9c 100644 --- a/src/internal/server/oidc/discovery_test.go +++ b/src/internal/server/oidc/discovery_test.go @@ -206,7 +206,7 @@ func TestDiscoveryHandler_GrantTypes(t *testing.T) { JWKSUri: "https://auth.netkingdom.local/jwks", } doc := discoveryDoc(t, cfg) - assertStringSlice(t, doc, "grant_types_supported", []string{"authorization_code"}) + assertStringSlice(t, doc, "grant_types_supported", []string{"authorization_code", "client_credentials"}) } func TestDiscoveryHandler_CodeChallengeMethod(t *testing.T) { @@ -251,7 +251,7 @@ func TestDiscoveryHandler_TokenEndpointAuthMethods(t *testing.T) { } doc := discoveryDoc(t, cfg) assertStringSlice(t, doc, "token_endpoint_auth_methods_supported", - []string{"client_secret_basic", "client_secret_post", "none"}) + []string{"client_secret_basic", "none"}) } func TestDiscoveryHandler_Claims(t *testing.T) { diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index cd81f02..a6af32e 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/rsa" "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/json" "net/http" @@ -36,7 +37,7 @@ type tokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` - IDToken string `json:"id_token"` + IDToken string `json:"id_token,omitempty"` } // ServeHTTP handles POST /token. @@ -49,6 +50,10 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } grantType := r.FormValue("grant_type") + if grantType == "client_credentials" { + h.serveClientCredentials(w, r) + return + } clientID := r.FormValue("client_id") code := r.FormValue("code") codeVerifier := r.FormValue("code_verifier") @@ -183,6 +188,88 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } +func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + clientID, clientSecret, ok := r.BasicAuth() + if !ok { + profileerrors.InvalidProfileUsage("client_secret_basic authentication required", "Authorization"). + Write(w, http.StatusUnauthorized) + return + } + client, ok := h.ClientConfig[clientID] + if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") { + profileerrors.InvalidProfileUsage("invalid confidential client", "client_id"). + Write(w, http.StatusUnauthorized) + return + } + presentedDigest := sha256.Sum256([]byte(clientSecret)) + expectedDigest := sha256.Sum256([]byte(client.ClientSecret)) + if client.ClientSecret == "" || + subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) != 1 { + profileerrors.InvalidProfileUsage("invalid client authentication", "Authorization"). + Write(w, http.StatusUnauthorized) + return + } + + requestedScopes := strings.Fields(r.FormValue("scope")) + if len(requestedScopes) == 0 { + requestedScopes = append([]string(nil), client.AllowedScopes...) + } + for _, scope := range requestedScopes { + if !containsString(client.AllowedScopes, scope) { + profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope"). + Write(w, http.StatusBadRequest) + return + } + } + + now := time.Now() + claims := map[string]interface{}{ + "iss": h.Issuer, + "sub": client.ServiceSubject, + "aud": clientID, + "exp": now.Add(h.TokenLifetime).Unix(), + "iat": now.Unix(), + "tenant": client.Tenant, + "principal_type": "service", + "groups": []string{}, + "roles": nonNilStrings(client.Roles), + "scope": strings.Join(requestedScopes, " "), + "assurance": map[string]interface{}{ + "level": "aal1", "methods": []string{"client_secret"}, + "mfa": false, "source": "key-cape", "at": now.Unix(), + }, + } + if roles, ok := h.TenantEngine.Roles(ctx, client.Tenant); ok { + claims["tenant_roles"] = roles + } + jwtToken, err := buildJWT(claims, "key-1", h.SigningKey) + if err != nil { + http.Error(w, "failed to build JWT", http.StatusInternalServerError) + return + } + h.Emitter.Emit(ctx, telemetry.Event{ + Timestamp: now, EventType: telemetry.EventTokenIssued, ClientID: clientID, + Endpoint: "/token", Result: "success", Scopes: requestedScopes, + GrantType: "client_credentials", + }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(tokenResponse{ + AccessToken: jwtToken, TokenType: "Bearer", + ExpiresIn: int(h.TokenLifetime.Seconds()), + }) +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + // --------------------------------------------------------------------------- // IAM Profile core claims (KEY-WP-0005-T01) // --------------------------------------------------------------------------- diff --git a/src/internal/server/oidc/token_test.go b/src/internal/server/oidc/token_test.go index c088f65..96e5b32 100644 --- a/src/internal/server/oidc/token_test.go +++ b/src/internal/server/oidc/token_test.go @@ -209,7 +209,7 @@ func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) { h, _ := newTokenHandler(t, sessions, users) params := url.Values{ - "grant_type": []string{"client_credentials"}, + "grant_type": []string{"password"}, "client_id": []string{"test-client"}, } @@ -226,6 +226,72 @@ func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) { } } +func serviceTokenHandler(t *testing.T) *oidc.TokenHandler { + t.Helper() + h, _ := newTokenHandler(t, oidc.NewSessionStore(), &mockUserRepo{}) + h.ClientConfig["rapp-qonto"] = &domain.Client{ + ClientID: "rapp-qonto", + AllowedScopes: []string{"finance.qonto.read"}, + GrantTypes: []string{"client_credentials"}, + ClientType: "confidential", + ClientSecret: "test-service-secret", + ServiceSubject: "service:rapp-qonto", + Tenant: "tenant:friendly:binky", + Roles: []string{"finance-reader"}, + } + return h +} + +func TestTokenHandler_ClientCredentials_ReturnsScopedServiceToken(t *testing.T) { + h := serviceTokenHandler(t) + req := tokenRequest(url.Values{ + "grant_type": {"client_credentials"}, + "scope": {"finance.qonto.read"}, + }) + req.SetBasicAuth("rapp-qonto", "test-service-secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := decodeTokenResponse(t, w.Body.String()) + if _, ok := resp["id_token"]; ok { + t.Fatal("service exchange must not return id_token") + } + claims := parseJWTPayload(t, resp["access_token"].(string)) + if claims["sub"] != "service:rapp-qonto" || + claims["tenant"] != "tenant:friendly:binky" || + claims["principal_type"] != "service" || + claims["scope"] != "finance.qonto.read" { + t.Fatalf("unexpected service claims: %#v", claims) + } +} + +func TestTokenHandler_ClientCredentials_RejectsWrongSecret(t *testing.T) { + h := serviceTokenHandler(t) + req := tokenRequest(url.Values{"grant_type": {"client_credentials"}}) + req.SetBasicAuth("rapp-qonto", "wrong") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestTokenHandler_ClientCredentials_RejectsExcessScope(t *testing.T) { + h := serviceTokenHandler(t) + req := tokenRequest(url.Values{ + "grant_type": {"client_credentials"}, + "scope": {"finance.qonto.write"}, + }) + req.SetBasicAuth("rapp-qonto", "test-service-secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + func TestTokenHandler_PKCEMismatch_InvalidProfileUsage(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} diff --git a/workplans/KEY-WP-0006-client-credentials-service-tokens.md b/workplans/KEY-WP-0006-client-credentials-service-tokens.md new file mode 100644 index 0000000..bf5078f --- /dev/null +++ b/workplans/KEY-WP-0006-client-credentials-service-tokens.md @@ -0,0 +1,101 @@ +--- +id: KEY-WP-0006 +type: workplan +title: "Client credentials and service-token issuance" +domain: infotech +repo: key-cape +status: active +owner: codex +topic_slug: netkingdom +created: "2026-07-27" +updated: "2026-07-27" +state_hub_workstream_id: "f2d5df23-9dd2-4995-b218-be5904e07508" +--- + +# KEY-WP-0006 - Client credentials and service tokens + +Implement the missing service-principal half of the IAM Profile so +`KEY-WP-0004` can issue a least-privilege identity to `rapp-qonto`. + +## T01 - Model confidential service clients + +```task +id: KEY-WP-0006-T01 +status: done +priority: high +state_hub_task_id: "4e7addc2-5773-499f-84e4-ffddc1800952" +``` + +Extend static client registration with an explicit service subject, tenant, +roles, and an environment-backed secret reference. Refuse startup when a +`client_credentials` client is public, lacks its identity fields, or cannot +resolve its secret. Never serialize or log the resolved secret. + +2026-07-27: Added explicit service subject, tenant, roles, and in-memory-only +resolved secret fields. Configuration validation rejects public or incomplete +service clients, and startup resolves only `env:` secret references while +reporting the variable name—not its value—on failure. + +## T02 - Implement secure client_credentials exchange + +```task +id: KEY-WP-0006-T02 +status: done +priority: high +state_hub_task_id: "9fd408dc-6f71-4765-ace2-22ee7bb1cf57" +``` + +Accept confidential client authentication through HTTP Basic, compare secrets +in constant time, restrict requested scopes to the registered allowlist, and +issue a short-lived access token with `principal_type=service`, +tenant-specific subject/roles, empty groups, `aal1` client-secret assurance, +and optional cached `tenant_roles`. Do not issue an ID token for this +non-human grant. + +2026-07-27: Implemented HTTP Basic confidential-client authentication with +fixed-length SHA-256 constant-time comparison, allowlisted scopes, short-lived +service claims, optional tenant-role cache, token-issued telemetry, and no ID +token. + +## T03 - Prove positive and negative conformance + +```task +id: KEY-WP-0006-T03 +status: done +priority: high +state_hub_task_id: "e37a346b-f6bc-4b29-9c13-94ec1c79a381" +``` + +Cover valid exchange, unknown client, public client, missing/wrong secret, +unsupported grant, excess scope, tenant isolation, telemetry redaction, and +discovery metadata. Run the full Go build, vet, and test suite. + +2026-07-27: Added positive service-claim and negative wrong-secret/excess-scope +tests, updated discovery to advertise only implemented grants/auth methods, +and verified `go test ./...`, `go vet ./...`, and `go build ./...` across the +module. + +## T04 - Provision and verify rapp-qonto + +```task +id: KEY-WP-0006-T04 +status: progress +priority: high +state_hub_task_id: "ba65d39f-63b8-42aa-87bb-99ec26821a8e" +``` + +Generate the client secret without disclosure, store it through the approved +OpenBao lane, register the static `rapp-qonto` client, deploy KeyCape, and +verify a real exchange yields only the documented Binky service claims. +Publish non-secret evidence to `KEY-WP-0004-T03` through T05. + +## T05 - Closure review + +```task +id: KEY-WP-0006-T05 +status: wait +priority: low +state_hub_task_id: "7c9cdac6-c53f-4a32-81d2-8d2e73de0039" +``` + +Close after T01-T04 pass and the repeatable verification path is documented.