diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 400800e3d..c7cfab996 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -223,6 +223,11 @@ before any API-only token fallback or optional-auth anonymous fallback so operators can mint relay-mobile credentials and continue onboarding from the hosted runtime itself even after that tenant has already minted managed API tokens. +That same lifecycle-adjacent hosted entitlement path must also preserve the +trial quickstart grant fields already seeded into billing state. Hosted setup +and pairing may depend on the shared `internal/api/` entitlement refresh, but +that refresh must not erase quickstart bootstrap inventory while it rewrites +lease-backed plan and capability data. The same setup boundary also depends on canonical org-management privilege surviving the next step: once the request is scoped to a hosted tenant org, shared `internal/api/security_setup_fix.go` helpers must allow that org's diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2def7e6e0..51585de5e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -142,6 +142,7 @@ Own canonical runtime payload shapes between backend and frontend. 17. Keep hosted tenant browser-session precedence on the shared auth boundary: `internal/api/auth.go`, `internal/api/contract_test.go`, and hosted tenant callers must treat a valid `pulse_session` as authoritative before any API-only token fallback or no-local-auth anonymous fallback, so cloud handoff can continue into protected hosted routes without flattening the operator back to `anonymous` or forcing a browser session through bearer-token-only mode after the tenant has minted API tokens. 18. Keep tenant settings-scope authorization aligned with org management: `internal/api/security_setup_fix.go`, `internal/api/contract_test.go`, and settings-bound hosted callers must allow the current non-default org owner/admin membership to exercise privileged tenant routes, rather than requiring a separate configured local admin identity after hosted handoff. 19. Keep mobile onboarding payload reads aligned with the server-owned relay-mobile credential: `internal/api/router_routes_ai_relay.go`, `internal/api/onboarding_handlers.go`, and `internal/api/contract_test.go` must allow the dedicated `relay:mobile:access` scope to reach the governed QR, deep-link, and connection-validation payloads without reintroducing a broader `settings:read` requirement for token-authenticated pairing clients. +20. Keep hosted billing-state quickstart payload fields on the shared API contract: `internal/api/hosted_entitlement_refresh.go`, `internal/api/subscription_state_handlers.go`, and `internal/api/contract_test.go` must preserve `quickstart_credits_granted`, `quickstart_credits_used`, and `quickstart_credits_granted_at` through hosted signup, hosted lease refresh, and billing-state reads instead of letting lease rewrites silently erase seeded quickstart inventory. ## Forbidden Paths @@ -1235,6 +1236,13 @@ That quickstart transport contract must also preserve the distinction between credit inventory and live runtime path: zero remaining credits alone must not force a blocked or exhausted operator presentation while Patrol is active on a configured non-quickstart provider path. +Hosted billing-state payloads now also carry the canonical quickstart grant +metadata used by hosted bootstrap and refresh flows. Billing reads and contract +proofs must preserve `quickstart_credits_granted`, +`quickstart_credits_used`, and `quickstart_credits_granted_at` as backend- +owned fields, so hosted entitlement refresh cannot silently drop a workspace +back to "no quickstart inventory" just because the lease or trial state was +rewritten. That same Patrol status contract now also carries a canonical `runtime_state` field, so the frontend can distinguish blocked, running, disabled, active, and unavailable Patrol runtime states without deriving operator status from diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 2bef44d81..e4bc253fa 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -583,6 +583,17 @@ bootstrap/admin-role assignment and rollback path for hosted signup failures. Hosted billing-state normalization now follows the same rule: a missing `plan_version` must remain missing instead of being synthesized from `subscription_state`, while explicit trial defaults remain explicit. +Hosted trial bootstrap and hosted entitlement refresh now also own quickstart +credit seeding as part of that same persisted billing boundary. New hosted +trial workspaces and later lease-refresh rewrites must preserve +`quickstart_credits_granted` plus its grant timestamp instead of resetting the +workspace to zero hosted quickstart inventory after signup or entitlement +renewal. +Hosted AI runtime defaults are part of the same boundary as well: when a cloud +tenant falls back to provider defaults, the persisted model identifier must +remain canonical `provider:model` data rather than a bare provider-local alias, +so hosted enterprise runtime startup does not fail before chat or approvals can +initialize. Hosted release builds must also accept the trial-activation public key from runtime environment when `PULSE_HOSTED_MODE=true`, because hosted tenants receive that verification key from control-plane deployment rather than from diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 207ee6779..25708fd82 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -166,6 +166,12 @@ ledger explanation reads: storage- and recovery-adjacent surfaces may coexist with counted monitored-system inventory, but any support-facing count reasoning must come from the canonical unified-resource grouping explanation payload rather than from storage or recovery heuristics. +That same shared hosted-entitlement refresh path must also preserve the +canonical quickstart grant metadata carried in billing state. Storage- and +recovery-adjacent hosted tenants may share Patrol-backed investigation and +recovery context with the rest of the app, but the shared `internal/api/` +lease refresh must not clear quickstart inventory and leave adjacent product +surfaces inferring a fake "unavailable" runtime from rewritten billing state. That adjacent ledger read must also preserve canonical grouped system status, including `warning`, so recovery- and storage-adjacent support views do not flatten governed degraded state into a fake `unknown` label when the shared diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 8abba265e..a1f39f552 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -210,6 +210,39 @@ func TestContract_HostedSignupResponseJSONSnapshot(t *testing.T) { assertJSONSnapshot(t, got, want) } +func TestContract_BillingStateQuickstartJSONSnapshot(t *testing.T) { + grantedAt := time.Date(2026, 3, 25, 14, 30, 0, 0, time.UTC).Unix() + + payload := billingState{ + Capabilities: []string{"ai_autofix", "ai_patrol"}, + Limits: map[string]int64{"max_monitored_systems": 25}, + MetersEnabled: []string{}, + PlanVersion: "cloud_starter", + SubscriptionState: subscriptionStateActiveValue, + QuickstartCreditsGranted: true, + QuickstartCreditsUsed: 3, + QuickstartCreditsGrantedAt: &grantedAt, + } + + got, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal billing state: %v", err) + } + + const want = `{ + "capabilities":["ai_autofix","ai_patrol"], + "limits":{"max_monitored_systems":25}, + "meters_enabled":[], + "plan_version":"cloud_starter", + "subscription_state":"active", + "quickstart_credits_granted":true, + "quickstart_credits_used":3, + "quickstart_credits_granted_at":1774449000 + }` + + assertJSONSnapshot(t, got, want) +} + func TestContract_StripeWebhookHandlersUseCanonicalRuntimeDataDir(t *testing.T) { envDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", envDir) diff --git a/internal/api/hosted_entitlement_refresh.go b/internal/api/hosted_entitlement_refresh.go index 88732cb36..ebf785f81 100644 --- a/internal/api/hosted_entitlement_refresh.go +++ b/internal/api/hosted_entitlement_refresh.go @@ -357,6 +357,7 @@ func (h *LicenseHandlers) refreshHostedEntitlementLeaseOnce(orgID string, servic updated.TrialStartedAt = leaseClaims.TrialStartedAt updated.TrialEndsAt = nil updated.TrialExtendedAt = nil + updated.GrantQuickstartCredits() if err := billingStore.SaveBillingState(orgID, updated); err != nil { return false, false, fmt.Errorf("save refreshed entitlement lease: %w", err) } diff --git a/internal/api/hosted_entitlement_refresh_quickstart_test.go b/internal/api/hosted_entitlement_refresh_quickstart_test.go new file mode 100644 index 000000000..63a16c672 --- /dev/null +++ b/internal/api/hosted_entitlement_refresh_quickstart_test.go @@ -0,0 +1,75 @@ +package api + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + entitlements "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" +) + +func TestRefreshHostedEntitlementLeaseOnce_GrantsQuickstartCredits(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + t.Setenv(entitlements.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub)) + + refreshServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/entitlements/refresh" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(hostedTrialLeaseRefreshResponse{ + EntitlementJWT: issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now()), + }) + })) + defer refreshServer.Close() + + h := NewLicenseHandlers(mtp, false, &config.Config{ + PublicURL: "https://pulse.example.com", + ProTrialSignupURL: refreshServer.URL + "/start-pro-trial", + }) + + store := config.NewFileBillingStore(baseDir) + startedAt := time.Now().Add(-13 * 24 * time.Hour).Unix() + expiredLease := issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now().Add(-15*24*time.Hour)) + if err := store.SaveBillingState("default", &entitlements.BillingState{ + EntitlementJWT: expiredLease, + EntitlementRefreshToken: "etr_test_default", + TrialStartedAt: &startedAt, + }); err != nil { + t.Fatalf("SaveBillingState: %v", err) + } + + refreshed, permanent, err := h.refreshHostedEntitlementLeaseOnce("default", nil) + if err != nil { + t.Fatalf("refreshHostedEntitlementLeaseOnce: %v", err) + } + if !refreshed || permanent { + t.Fatalf("refreshed=%v permanent=%v, want refreshed=true permanent=false", refreshed, permanent) + } + + state, err := store.GetBillingState("default") + if err != nil { + t.Fatalf("GetBillingState: %v", err) + } + if state == nil { + t.Fatal("expected billing state after hosted entitlement refresh") + } + if !state.QuickstartCreditsGranted { + t.Fatal("expected hosted entitlement refresh to grant quickstart credits") + } + if state.QuickstartCreditsGrantedAt == nil { + t.Fatal("expected hosted entitlement refresh to record quickstart grant timestamp") + } +} diff --git a/internal/api/hosted_lifecycle_integration_test.go b/internal/api/hosted_lifecycle_integration_test.go index 6f25e34ad..eff82acf7 100644 --- a/internal/api/hosted_lifecycle_integration_test.go +++ b/internal/api/hosted_lifecycle_integration_test.go @@ -75,6 +75,12 @@ func TestHostedLifecycle(t *testing.T) { if stored.TrialEndsAt == nil { t.Fatal("expected trial_ends_at to be populated") } + if !stored.QuickstartCreditsGranted { + t.Fatal("expected hosted signup billing state to grant quickstart credits") + } + if stored.QuickstartCreditsGrantedAt == nil { + t.Fatal("expected hosted signup quickstart grant timestamp to be populated") + } expectedCaps := append([]string(nil), cloudCapabilitiesFromLicensing()...) sort.Strings(expectedCaps) diff --git a/internal/config/ai.go b/internal/config/ai.go index d7adf4052..e896fc3d1 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -141,7 +141,7 @@ const ( func NewDefaultAIConfig() *AIConfig { return &AIConfig{ Enabled: false, - Model: DefaultAIModelAnthropic, + Model: DefaultModelForProvider(AIProviderAnthropic), AuthMethod: AuthMethodAPIKey, // Patrol defaults - enabled when AI is enabled // Default to 6 hour intervals (much more token-efficient than 15 min) @@ -346,19 +346,8 @@ func (c *AIConfig) GetModel() string { // This handles the case where user configures Ollama but doesn't explicitly select a model configured := c.GetConfiguredProviders() if len(configured) == 1 { - switch configured[0] { - case AIProviderAnthropic: - return DefaultAIModelAnthropic - case AIProviderOpenAI: - return DefaultAIModelOpenAI - case AIProviderOpenRouter: - return DefaultAIModelOpenRouter - case AIProviderOllama: - return DefaultAIModelOllama - case AIProviderDeepSeek: - return DefaultAIModelDeepSeek - case AIProviderGemini: - return DefaultAIModelGemini + if defaultModel := DefaultModelForProvider(configured[0]); defaultModel != "" { + return defaultModel } } diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go index 89716040b..2f4110e4a 100644 --- a/internal/config/ai_config_test.go +++ b/internal/config/ai_config_test.go @@ -394,42 +394,42 @@ func TestAIConfig_GetModel(t *testing.T) { config: AIConfig{ AnthropicAPIKey: "key", }, - expected: DefaultAIModelAnthropic, + expected: DefaultModelForProvider(AIProviderAnthropic), }, { name: "single provider configured - openai", config: AIConfig{ OpenAIAPIKey: "key", }, - expected: DefaultAIModelOpenAI, + expected: DefaultModelForProvider(AIProviderOpenAI), }, { name: "single provider configured - openrouter", config: AIConfig{ OpenRouterAPIKey: "key", }, - expected: DefaultAIModelOpenRouter, + expected: DefaultModelForProvider(AIProviderOpenRouter), }, { name: "single provider configured - deepseek", config: AIConfig{ DeepSeekAPIKey: "key", }, - expected: DefaultAIModelDeepSeek, + expected: DefaultModelForProvider(AIProviderDeepSeek), }, { name: "single provider configured - gemini", config: AIConfig{ GeminiAPIKey: "key", }, - expected: DefaultAIModelGemini, + expected: DefaultModelForProvider(AIProviderGemini), }, { name: "single provider configured - ollama", config: AIConfig{ OllamaBaseURL: "http://localhost:11434", }, - expected: DefaultAIModelOllama, + expected: DefaultModelForProvider(AIProviderOllama), }, { name: "multiple providers configured (no default)", @@ -444,7 +444,7 @@ func TestAIConfig_GetModel(t *testing.T) { config: AIConfig{ OllamaBaseURL: "http://localhost:11434", }, - expected: DefaultAIModelOllama, + expected: DefaultModelForProvider(AIProviderOllama), }, { name: "no model/provider", @@ -674,6 +674,9 @@ func TestNewDefaultAIConfig(t *testing.T) { if config.PatrolIntervalMinutes != 360 { t.Errorf("Default patrol interval should be 360, got %d", config.PatrolIntervalMinutes) } + if config.Model != DefaultModelForProvider(AIProviderAnthropic) { + t.Errorf("Default model should be %q, got %q", DefaultModelForProvider(AIProviderAnthropic), config.Model) + } if !config.PatrolEnabled { t.Error("Default patrol should be enabled") } diff --git a/pkg/licensing/trial_start.go b/pkg/licensing/trial_start.go index 98985fa54..97332139a 100644 --- a/pkg/licensing/trial_start.go +++ b/pkg/licensing/trial_start.go @@ -86,7 +86,7 @@ func BuildTrialBillingStateWithPlan(now time.Time, capabilities []string, planVe planVersion = string(SubStateTrial) } - return &BillingState{ + state := &BillingState{ Capabilities: append([]string(nil), capabilities...), Limits: map[string]int64{}, MetersEnabled: []string{}, @@ -95,4 +95,6 @@ func BuildTrialBillingStateWithPlan(now time.Time, capabilities []string, planVe TrialStartedAt: &startedAt, TrialEndsAt: &endsAt, } + state.GrantQuickstartCredits() + return state } diff --git a/pkg/licensing/trial_start_test.go b/pkg/licensing/trial_start_test.go index 9aae5de82..689da2a26 100644 --- a/pkg/licensing/trial_start_test.go +++ b/pkg/licensing/trial_start_test.go @@ -234,4 +234,10 @@ func TestBuildTrialBillingStateWithPlan(t *testing.T) { if *state.TrialEndsAt != now.Add(72*time.Hour).Unix() { t.Fatalf("trial_ends_at=%d, want %d", *state.TrialEndsAt, now.Add(72*time.Hour).Unix()) } + if !state.QuickstartCreditsGranted { + t.Fatal("expected quickstart credits to be granted for new trial workspaces") + } + if state.QuickstartCreditsGrantedAt == nil { + t.Fatal("expected quickstart credits grant timestamp to be populated") + } }