From 75c0a93b6cbe55a85477f46be1fcc580fc890313 Mon Sep 17 00:00:00 2001 From: "Scion Agent (sn-metaauth-inv)" Date: Fri, 28 Aug 2026 21:14:33 +0000 Subject: [PATCH 1/2] fix(hub): close two GCP SA verification gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defect 1 — handlers_gcp_identity.go: when runGCPServiceAccountVerification detects an impersonation failure and the subsequent store.UpdateGCPServiceAccount also fails, the error was silently discarded with `_ =`. This left the SA marked Verified=true in the database while the API returned a 502 "verification failed" — keeping the agent-creation gate open for an SA that cannot actually mint tokens. Now the handler returns HTTP 500 with a distinct error code (gcp_verification_persist_failed) and logs both the verification and persistence errors. Defect 2 — handlers_agent_lifecycle.go: createAgentInProject (:770) and updateAgent (:2245) both guard against unverified SAs, but handleAgentLifecycle (start/restart) did not. An SA that was marked unverified after agent creation could still be started. Now start and restart look up the assigned SA and reject the request if it is not verified. Stop and suspend are exempt. Tests: - TestVerification_PersistFailure_DoesNotReportCleanFailure (Defect 1) - TestAgentLifecycle_Start_UnverifiedSA (Defect 2) - TestAgentLifecycle_Start_VerifiedSA (no false positive) - TestAgentLifecycle_Start_NoGCPIdentity (nil safety) --- pkg/hub/handlers_agent_lifecycle.go | 26 ++++ pkg/hub/handlers_agent_test.go | 186 ++++++++++++++++++++++++++ pkg/hub/handlers_gcp_identity.go | 20 ++- pkg/hub/handlers_gcp_identity_test.go | 70 ++++++++++ 4 files changed, 300 insertions(+), 2 deletions(-) diff --git a/pkg/hub/handlers_agent_lifecycle.go b/pkg/hub/handlers_agent_lifecycle.go index 35cd117de6..8e297f4194 100644 --- a/pkg/hub/handlers_agent_lifecycle.go +++ b/pkg/hub/handlers_agent_lifecycle.go @@ -228,6 +228,32 @@ func (s *Server) handleAgentLifecycle(w http.ResponseWriter, r *http.Request, id return } + // For start and restart, verify the assigned GCP service account is still + // verified. Create (handlers_agents_core.go:770) and PATCH (:2245) both + // check this; the lifecycle handler did not, so an SA that was marked + // unverified after the agent was created could still be started. Stop and + // suspend are exempt: you must always be able to shut down an agent + // regardless of its SA's verification state. + if action == api.AgentActionStart || action == api.AgentActionRestart { + if agent.AppliedConfig != nil && + agent.AppliedConfig.GCPIdentity != nil && + agent.AppliedConfig.GCPIdentity.MetadataMode == store.GCPMetadataModeAssign && + agent.AppliedConfig.GCPIdentity.ServiceAccountID != "" { + gcpID := agent.AppliedConfig.GCPIdentity + sa, err := s.store.GetGCPServiceAccount(ctx, gcpID.ServiceAccountID) + if err != nil { + slog.Warn("lifecycle: could not look up assigned GCP service account", + "agent_id", id, "sa_id", gcpID.ServiceAccountID, "error", err) + ValidationError(w, "assigned GCP service account is not available; re-verify or re-assign it before starting", nil) + return + } + if !sa.Verified { + ValidationError(w, "GCP service account is not verified; verify it before starting the agent", nil) + return + } + } + } + var newPhase string var dispatchErr error diff --git a/pkg/hub/handlers_agent_test.go b/pkg/hub/handlers_agent_test.go index 8d3a7ded1b..8144699409 100644 --- a/pkg/hub/handlers_agent_test.go +++ b/pkg/hub/handlers_agent_test.go @@ -5511,3 +5511,189 @@ func TestStopAllAgents_SetsExitCodeZero(t *testing.T) { assert.Equal(t, 0, *updated.ExitCode, "agent %s ExitCode should be 0 for clean stop", id) } } + +// TestAgentLifecycle_Start_UnverifiedSA verifies that starting an agent whose +// assigned GCP service account is not verified returns a validation error. +// This is the lifecycle-path counterpart of the check in createAgentInProject +// (handlers_agents_core.go:770) and updateAgent (:2245). +func TestAgentLifecycle_Start_UnverifiedSA(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("project-lifecycle-sa"), + Name: "Lifecycle SA Project", + Slug: "lifecycle-sa-project", + } + require.NoError(t, s.CreateProject(ctx, project)) + + broker := &store.RuntimeBroker{ + ID: tid("broker-lifecycle-sa"), + Name: "Lifecycle SA Broker", + Slug: "lifecycle-sa-broker", + Status: store.BrokerStatusOnline, + } + require.NoError(t, s.CreateRuntimeBroker(ctx, broker)) + + // Create a GCP SA that is NOT verified. + sa := &store.GCPServiceAccount{ + ID: tid("sa-lifecycle-unverified"), + Scope: store.ScopeProject, + ScopeID: project.ID, + Email: "unverified@proj.iam.gserviceaccount.com", + ProjectID: "gcp-proj", + Verified: false, + VerificationStatus: store.GCPVerificationFailed, + CreatedAt: time.Now(), + } + require.NoError(t, s.CreateGCPServiceAccount(ctx, sa)) + + // Create an agent with the unverified SA assigned. We write directly to + // the store rather than going through the HTTP handler because the + // handler would reject the unverified SA. This simulates an agent that + // was created when the SA was verified and later had its SA unverified. + agent := &store.Agent{ + ID: tid("agent-lifecycle-unverified-sa"), + Slug: "agent-lifecycle-unverified-sa", + Name: "Agent Unverified SA", + ProjectID: project.ID, + RuntimeBrokerID: broker.ID, + Phase: string(state.PhaseStopped), + AppliedConfig: &store.AgentAppliedConfig{ + GCPIdentity: &store.GCPIdentityConfig{ + MetadataMode: store.GCPMetadataModeAssign, + ServiceAccountID: sa.ID, + ServiceAccountEmail: sa.Email, + }, + }, + } + require.NoError(t, s.CreateAgent(ctx, agent)) + + // Start must be rejected because the SA is not verified. + rec := doRequest(t, srv, http.MethodPost, "/api/v1/agents/"+agent.ID+"/start", nil) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + var errResp ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp)) + assert.Equal(t, ErrCodeValidationError, errResp.Error.Code) + assert.Contains(t, errResp.Error.Message, "not verified") + + // Restart must also be rejected. + rec = doRequest(t, srv, http.MethodPost, "/api/v1/agents/"+agent.ID+"/restart", nil) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp)) + assert.Equal(t, ErrCodeValidationError, errResp.Error.Code) + assert.Contains(t, errResp.Error.Message, "not verified") + + // Stop must still succeed — you must always be able to stop an agent. + rec = doRequest(t, srv, http.MethodPost, "/api/v1/agents/"+agent.ID+"/stop", nil) + assert.Equal(t, http.StatusOK, rec.Code, "stop must succeed even with unverified SA: %s", rec.Body.String()) +} + +// TestAgentLifecycle_Start_VerifiedSA verifies that starting an agent whose +// GCP SA is verified proceeds normally (no false positive from the new check). +func TestAgentLifecycle_Start_VerifiedSA(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("project-lifecycle-sa-ok"), + Name: "Lifecycle SA OK Project", + Slug: "lifecycle-sa-ok-project", + } + require.NoError(t, s.CreateProject(ctx, project)) + + broker := &store.RuntimeBroker{ + ID: tid("broker-lifecycle-sa-ok"), + Name: "Lifecycle SA OK Broker", + Slug: "lifecycle-sa-ok-broker", + Status: store.BrokerStatusOnline, + } + require.NoError(t, s.CreateRuntimeBroker(ctx, broker)) + + sa := &store.GCPServiceAccount{ + ID: tid("sa-lifecycle-verified"), + Scope: store.ScopeProject, + ScopeID: project.ID, + Email: "verified@proj.iam.gserviceaccount.com", + ProjectID: "gcp-proj", + Verified: true, + VerificationStatus: store.GCPVerificationVerified, + CreatedAt: time.Now(), + } + require.NoError(t, s.CreateGCPServiceAccount(ctx, sa)) + + agent := &store.Agent{ + ID: tid("agent-lifecycle-verified-sa"), + Slug: "agent-lifecycle-verified-sa", + Name: "Agent Verified SA", + ProjectID: project.ID, + RuntimeBrokerID: broker.ID, + Phase: string(state.PhaseStopped), + AppliedConfig: &store.AgentAppliedConfig{ + GCPIdentity: &store.GCPIdentityConfig{ + MetadataMode: store.GCPMetadataModeAssign, + ServiceAccountID: sa.ID, + ServiceAccountEmail: sa.Email, + }, + }, + } + require.NoError(t, s.CreateAgent(ctx, agent)) + + // Start should pass the SA check (it will fail later because there's no + // actual dispatcher, but it must NOT fail with a validation error about + // the SA). + rec := doRequest(t, srv, http.MethodPost, "/api/v1/agents/"+agent.ID+"/start", nil) + // The response may be 200 (no dispatcher) or something else, but it + // must NOT be 400 with a "not verified" message. + if rec.Code == http.StatusBadRequest { + var errResp ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp)) + assert.NotContains(t, errResp.Error.Message, "not verified", + "a verified SA must not be rejected by the lifecycle check") + } +} + +// TestAgentLifecycle_Start_NoGCPIdentity verifies that starting an agent +// without any GCP identity assigned proceeds normally (no nil-pointer panic +// or false validation error from the SA check). +func TestAgentLifecycle_Start_NoGCPIdentity(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("project-lifecycle-no-gcp"), + Name: "Lifecycle No GCP Project", + Slug: "lifecycle-no-gcp-project", + } + require.NoError(t, s.CreateProject(ctx, project)) + + broker := &store.RuntimeBroker{ + ID: tid("broker-lifecycle-no-gcp"), + Name: "Lifecycle No GCP Broker", + Slug: "lifecycle-no-gcp-broker", + Status: store.BrokerStatusOnline, + } + require.NoError(t, s.CreateRuntimeBroker(ctx, broker)) + + agent := &store.Agent{ + ID: tid("agent-lifecycle-no-gcp"), + Slug: "agent-lifecycle-no-gcp", + Name: "Agent No GCP", + ProjectID: project.ID, + RuntimeBrokerID: broker.ID, + Phase: string(state.PhaseStopped), + // No GCPIdentity in AppliedConfig + } + require.NoError(t, s.CreateAgent(ctx, agent)) + + // Start must not panic or return a SA-related error. + rec := doRequest(t, srv, http.MethodPost, "/api/v1/agents/"+agent.ID+"/start", nil) + if rec.Code == http.StatusBadRequest { + var errResp ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp)) + assert.NotContains(t, errResp.Error.Message, "verified", + "an agent without GCP identity must not hit the SA check") + assert.NotContains(t, errResp.Error.Message, "service account", + "an agent without GCP identity must not hit the SA check") + } +} diff --git a/pkg/hub/handlers_gcp_identity.go b/pkg/hub/handlers_gcp_identity.go index 580b512c46..ce58b95ebc 100644 --- a/pkg/hub/handlers_gcp_identity.go +++ b/pkg/hub/handlers_gcp_identity.go @@ -604,11 +604,27 @@ func (s *Server) runGCPServiceAccountVerification(w http.ResponseWriter, r *http // Attempt to verify impersonation via the GCP token generator if err := s.gcpTokenGenerator.VerifyImpersonation(r.Context(), sa.Email); err != nil { - // Persist the failure status + // Persist the failure status so that the assign gate + // (handlers_agents_core.go, createAgentInProject) refuses to create + // new agents with this SA. If we cannot persist the failure, the + // database still reads Verified=true and the gate stays open — so a + // persistence failure here is a security-relevant event, not an + // ignorable cleanup error. sa.Verified = false sa.VerificationStatus = store.GCPVerificationFailed sa.VerificationError = err.Error() - _ = s.store.UpdateGCPServiceAccount(r.Context(), sa) + if updateErr := s.store.UpdateGCPServiceAccount(r.Context(), sa); updateErr != nil { + slog.Error("verification failed AND the failure could not be persisted — "+ + "the service account may still appear verified in the database", + "sa_id", sa.ID, "sa_email", sa.Email, + "verify_error", err.Error(), "persist_error", updateErr.Error()) + writeError(w, http.StatusInternalServerError, "gcp_verification_persist_failed", + "Verification failed but the failure could not be recorded; "+ + "the service account may still appear as verified. "+ + "Retry verification or check the service account status manually. "+ + "Verification error: "+err.Error(), nil) + return + } details := map[string]interface{}{ "hubServiceAccountEmail": s.gcpTokenGenerator.ServiceAccountEmail(), diff --git a/pkg/hub/handlers_gcp_identity_test.go b/pkg/hub/handlers_gcp_identity_test.go index b32e7ca985..8f5143d31e 100644 --- a/pkg/hub/handlers_gcp_identity_test.go +++ b/pkg/hub/handlers_gcp_identity_test.go @@ -1259,3 +1259,73 @@ func TestGCPServiceAccount_VerifyClearsPreviousFailure(t *testing.T) { assert.Equal(t, store.GCPVerificationVerified, stored.VerificationStatus) assert.Empty(t, stored.VerificationError, "the stale failure message must not outlive the failure") } + +// failingGCPSAUpdateStore wraps a store and makes UpdateGCPServiceAccount +// return an error, simulating a transient database failure during the +// verification persistence step. +type failingGCPSAUpdateStore struct { + store.Store + updateErr error +} + +func (f *failingGCPSAUpdateStore) UpdateGCPServiceAccount(_ context.Context, _ *store.GCPServiceAccount) error { + return f.updateErr +} + +// TestVerification_PersistFailure_DoesNotReportCleanFailure verifies that +// when verification fails AND the persistence of that failure also fails, the +// endpoint returns a distinct error (gcp_verification_persist_failed / 500) +// rather than the normal gcp_verification_failed / 502. The latter would tell +// the operator the failure was recorded when it was not, leaving the SA's +// Verified flag true in the database and the assign gate open. +func TestVerification_PersistFailure_DoesNotReportCleanFailure(t *testing.T) { + srv, s := testServer(t) + projectID := createTestProjectForSA(t, srv, s) + + // Register and manually verify a SA so it starts as Verified=true. + srv.SetGCPTokenGenerator(&mockGCPTokenGenerator{email: "hub@test.iam.gserviceaccount.com"}) + rec := doRequest(t, srv, http.MethodPost, + fmt.Sprintf("/api/v1/projects/%s/gcp-service-accounts", projectID), + map[string]string{ + "email": "agent@my-project.iam.gserviceaccount.com", + "projectId": "my-project", + }) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + var created store.GCPServiceAccount + require.NoError(t, json.NewDecoder(rec.Body).Decode(&created)) + require.True(t, created.Verified, "precondition: SA should be verified after auto-verify") + + // Now make verification fail AND the store update fail. + srv.SetGCPTokenGenerator(&mockGCPTokenGeneratorVerifyFail{ + email: "hub@test.iam.gserviceaccount.com", + verifyErr: fmt.Errorf("hub service account cannot impersonate agent@my-project.iam.gserviceaccount.com"), + }) + srv.store = &failingGCPSAUpdateStore{ + Store: s, + updateErr: fmt.Errorf("database is locked"), + } + + rec = doRequest(t, srv, http.MethodPost, + fmt.Sprintf("/api/v1/projects/%s/gcp-service-accounts/%s/verify", projectID, created.ID), nil) + + // Must NOT be 502 / gcp_verification_failed — that would imply the + // failure was recorded. + assert.Equal(t, http.StatusInternalServerError, rec.Code, + "when persistence fails, the status must be 500, not 502") + var errResp ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp)) + assert.Equal(t, "gcp_verification_persist_failed", errResp.Error.Code, + "error code must distinguish a persist failure from a clean verification failure") + assert.Contains(t, errResp.Error.Message, "could not be recorded", + "the message must tell the operator the failure was not persisted") + + // The SA must still be Verified=true in the real store, proving the + // persist failure was real. + srv.store = s // restore real store + stored, err := s.GetGCPServiceAccount(context.Background(), created.ID) + require.NoError(t, err) + assert.True(t, stored.Verified, + "SA must still be Verified=true because the update failed — "+ + "this is the security-relevant assertion: the database state "+ + "did not match the API response") +} From 0623e9e770014bd29203e3c5f342eec144f24543 Mon Sep 17 00:00:00 2001 From: "Scion Agent (sn-metaauth-inv)" Date: Fri, 28 Aug 2026 21:19:31 +0000 Subject: [PATCH 2/2] fix(hub): handle success-path persist failure and make lifecycle errors actionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the verification gap fixes, prompted by architect review: 1. Success-path persist failure (handlers_gcp_identity.go): when the verification probe succeeds but store.UpdateGCPServiceAccount fails, the handler previously called writeErrorFromErr which returned a generic 500 "Internal server error". The operator had no idea the probe succeeded or that the SA remained unverified in the DB. Now returns HTTP 500 with error code gcp_verification_persist_failed and a message telling the operator to retry verification. This matters because the Defect 2 lifecycle check (previous commit) means an SA stuck at Verified=false will now block start/restart. The operator must know the verify result was not persisted so they can retry, rather than discovering it via an opaque "not verified" error at start time. 2. Lifecycle error messages (handlers_agent_lifecycle.go): the "not verified" and "not available" validation errors now include the SA email (or SA ID when the lookup fails), making them actionable from the error alone. Test: - TestVerification_SuccessPersistFailure_DoesNotReportCleanSuccess: probe succeeds, store update fails → asserts HTTP 500 (not 200), error code gcp_verification_persist_failed, SA still Verified=false in real store. Mutation: removing the error handling makes the test fail (200 instead of 500). --- pkg/hub/handlers_agent_lifecycle.go | 10 ++++- pkg/hub/handlers_gcp_identity.go | 9 +++- pkg/hub/handlers_gcp_identity_test.go | 61 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/pkg/hub/handlers_agent_lifecycle.go b/pkg/hub/handlers_agent_lifecycle.go index 8e297f4194..0d3f192bfb 100644 --- a/pkg/hub/handlers_agent_lifecycle.go +++ b/pkg/hub/handlers_agent_lifecycle.go @@ -244,11 +244,17 @@ func (s *Server) handleAgentLifecycle(w http.ResponseWriter, r *http.Request, id if err != nil { slog.Warn("lifecycle: could not look up assigned GCP service account", "agent_id", id, "sa_id", gcpID.ServiceAccountID, "error", err) - ValidationError(w, "assigned GCP service account is not available; re-verify or re-assign it before starting", nil) + ValidationError(w, fmt.Sprintf( + "assigned GCP service account %s is not available; "+ + "re-verify or re-assign it before starting", + gcpID.ServiceAccountID), nil) return } if !sa.Verified { - ValidationError(w, "GCP service account is not verified; verify it before starting the agent", nil) + ValidationError(w, fmt.Sprintf( + "GCP service account %s is not verified; "+ + "verify it before starting the agent", + sa.Email), nil) return } } diff --git a/pkg/hub/handlers_gcp_identity.go b/pkg/hub/handlers_gcp_identity.go index ce58b95ebc..66dc676d96 100644 --- a/pkg/hub/handlers_gcp_identity.go +++ b/pkg/hub/handlers_gcp_identity.go @@ -641,7 +641,14 @@ func (s *Server) runGCPServiceAccountVerification(w http.ResponseWriter, r *http sa.VerificationError = "" if err := s.store.UpdateGCPServiceAccount(r.Context(), sa); err != nil { - writeErrorFromErr(w, err, "") + slog.Error("verification succeeded but the success could not be persisted — "+ + "the service account will still appear unverified in the database", + "sa_id", sa.ID, "sa_email", sa.Email, + "persist_error", err.Error()) + writeError(w, http.StatusInternalServerError, "gcp_verification_persist_failed", + "Verification succeeded but the result could not be recorded; "+ + "the service account may still appear as unverified. "+ + "Retry verification to persist the result.", nil) return } diff --git a/pkg/hub/handlers_gcp_identity_test.go b/pkg/hub/handlers_gcp_identity_test.go index 8f5143d31e..86a5497e66 100644 --- a/pkg/hub/handlers_gcp_identity_test.go +++ b/pkg/hub/handlers_gcp_identity_test.go @@ -1329,3 +1329,64 @@ func TestVerification_PersistFailure_DoesNotReportCleanFailure(t *testing.T) { "this is the security-relevant assertion: the database state "+ "did not match the API response") } + +// TestVerification_SuccessPersistFailure_DoesNotReportCleanSuccess verifies +// that when the verification probe succeeds but the persistence of that +// success fails, the endpoint returns 500 / gcp_verification_persist_failed +// rather than 200. Without this, the operator sees "Verified ✓" but the +// database still reads Verified=false, and the lifecycle handler (Defect 2 +// fix) will reject start/restart with an opaque "not verified" error. +func TestVerification_SuccessPersistFailure_DoesNotReportCleanSuccess(t *testing.T) { + srv, s := testServer(t) + projectID := createTestProjectForSA(t, srv, s) + + // Register the SA with a failing token generator so auto-verify fails + // and the SA starts as Verified=false. + srv.SetGCPTokenGenerator(&mockGCPTokenGeneratorVerifyFail{ + email: "hub@test.iam.gserviceaccount.com", + verifyErr: fmt.Errorf("IAM policy not yet propagated"), + }) + rec := doRequest(t, srv, http.MethodPost, + fmt.Sprintf("/api/v1/projects/%s/gcp-service-accounts", projectID), + map[string]string{ + "email": "agent@my-project.iam.gserviceaccount.com", + "projectId": "my-project", + }) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + var created store.GCPServiceAccount + require.NoError(t, json.NewDecoder(rec.Body).Decode(&created)) + require.False(t, created.Verified, "precondition: SA should be unverified after failed auto-verify") + + // Now the IAM policy propagates — the probe will succeed. But the store + // update will fail, simulating a transient DB error. + srv.SetGCPTokenGenerator(&mockGCPTokenGenerator{email: "hub@test.iam.gserviceaccount.com"}) + srv.store = &failingGCPSAUpdateStore{ + Store: s, + updateErr: fmt.Errorf("database is locked"), + } + + rec = doRequest(t, srv, http.MethodPost, + fmt.Sprintf("/api/v1/projects/%s/gcp-service-accounts/%s/verify", projectID, created.ID), nil) + + // Must NOT be 200 — that would tell the operator verification succeeded + // when the result was never persisted. + assert.Equal(t, http.StatusInternalServerError, rec.Code, + "when the success cannot be persisted, the status must be 500, not 200") + var errResp ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp)) + assert.Equal(t, "gcp_verification_persist_failed", errResp.Error.Code, + "error code must distinguish a persist failure from a clean success") + assert.Contains(t, errResp.Error.Message, "could not be recorded", + "the message must tell the operator the success was not persisted") + + // The SA must still be Verified=false in the real store, proving the + // persist failure was real and the operator's view is not stale. + srv.store = s // restore real store + stored, err := s.GetGCPServiceAccount(context.Background(), created.ID) + require.NoError(t, err) + assert.False(t, stored.Verified, + "SA must still be Verified=false because the update failed — "+ + "this is the interaction assertion: the Defect 2 lifecycle "+ + "check would reject start for this SA, and the operator must "+ + "know that from the verify response, not discover it later") +}