diff --git a/.gitignore b/.gitignore index c28a4269d..e415f2ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -187,6 +187,11 @@ docs/development/ # Temporary scripts tmp_*.py tmp_*.sh +.tmp_*.mjs + +# Local auth/session runtime artifacts +internal/api/sessions.json +internal/api/csrf_tokens.json # Local agent directories and internal governance (not for public release) scripts/agent/ @@ -203,6 +208,10 @@ docs/release-control/v6/* !docs/release-control/v6/status.schema.json !docs/release-control/v6/internal/ docs/release-control/v6/internal/* +!docs/release-control/v6/internal/*.md +!docs/release-control/v6/internal/subsystems/ +!docs/release-control/v6/internal/subsystems/*.json +!docs/release-control/v6/internal/subsystems/*.schema.json !docs/release-control/v6/internal/records/ !docs/release-control/v6/internal/records/*.md !docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md @@ -238,6 +247,8 @@ scripts/release_control/* !scripts/release_control/record_rc_to_ga_rehearsal.py !scripts/release_control/staged_commit_shape_guard.py !scripts/release_control/staged_commit_shape_guard_test.py +!scripts/release_control/internal/ +!scripts/release_control/internal/*.py !scripts/release_control/status_audit.py !scripts/release_control/status_audit_test.py !scripts/release_control/status_lookup.py @@ -246,10 +257,6 @@ scripts/release_control/* !scripts/release_control/subsystem_contracts_test.py !scripts/release_control/subsystem_lookup.py !scripts/release_control/subsystem_lookup_test.py -!scripts/release_control/internal/ -scripts/release_control/internal/* -!scripts/release_control/internal/record_rc_to_ga_rehearsal.py -!scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py !scripts/release_control/work_claim.py !scripts/release_control/work_claim_test.py .agent/ diff --git a/docs/release-control/v6/internal/COMMERCIAL_CANCELLATION_REACTIVATION_E2E_TEST_PLAN.md b/docs/release-control/v6/internal/COMMERCIAL_CANCELLATION_REACTIVATION_E2E_TEST_PLAN.md new file mode 100644 index 000000000..6fded6389 --- /dev/null +++ b/docs/release-control/v6/internal/COMMERCIAL_CANCELLATION_REACTIVATION_E2E_TEST_PLAN.md @@ -0,0 +1,234 @@ +# Pulse v6 Commercial Cancellation/Reactivation E2E Test Plan + +Use this plan for the trust-critical commercial boundary between: + +1. grandfathered v5 recurring subscription continuity while the subscription is still active +2. paid-state revocation once cancellation or lapse is complete +3. current-public-v6 re-entry after a canceled customer returns + +This is a companion drill for `L2`, `L3`, `L11`, and `L12`. +It does not replace repo-local tests. It exists because checkout, billing +portal actions, Stripe webhook state, v6 entitlement evaluation, and customer +UI can all pass in isolation while the real cancellation/reactivation journey +still drifts. + +Owning release gate: `commercial-cancellation-reactivation`. + +## Governing Policy + +The locked v6 pricing rule is: + +1. Active recurring Pulse Pro v5 subscribers keep their legacy recurring price while subscription continuity is maintained. +2. Cancellation or lapse is the explicit boundary for that grandfathering. +3. Once cancellation is complete, any later return must enter on current public v6 pricing. +4. The prior grandfathered recurring price must not resume automatically after that break in continuity. + +## Scope + +In scope: + +1. Self-hosted recurring Pulse Pro v5 monthly and annual grandfathered plans. +2. Stripe customer-portal cancellation and resume behavior. +3. Stripe webhook propagation into Pulse v6 billing-state persistence. +4. Pulse v6 entitlement and settings-surface behavior after cancellation state changes. +5. Public checkout re-entry after completed cancellation. + +Out of scope: + +1. Lifetime licenses. +2. MSP or hosted cloud plan conversion. +3. Manual business-exception pricing overrides. + +## Runtime Surfaces + +`pulse`: + +1. `internal/api/payments_webhook_handlers.go` +2. `internal/api/stripe_webhook_handlers_test.go` +3. `pkg/licensing/...` +4. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` +5. `tests/migration/v5_full_upgrade_test.go` + +`pulse-pro`: + +1. `license-server/v6_checkout.go` +2. public checkout entrypoint `/v1/checkout/session` +3. Stripe customer portal / `https://pulserelay.pro/manage` +4. Stripe recurring price configuration and webhook delivery + +## Automated Proof Bundle + +Canonical wrapper: + +1. `python3 scripts/release_control/commercial_cancellation_reactivation_proof.py` + +That wrapper currently runs: + +`pulse` + +1. `go test ./internal/api -run 'TestStripeWebhook_SubscriptionDeleted_RevokesCapabilities' -count=1` +2. `go test ./tests/migration -run 'TestV5FullUpgradeScenario/PersistedV5RecurringLicenseAutoExchanges' -count=1` +3. `cd frontend-modern && npm test -- src/utils/__tests__/licensePresentation.test.ts src/components/Settings/__tests__/ProLicensePanel.test.tsx` + +`pulse-pro/license-server` + +1. `go test . -run 'TestHandleCheckoutSessionCreate(_RejectsGrandfatheredPlanKey)?$' -count=1` + +If any of those fail, stop. The manual drill should not be used to compensate +for a broken automated floor. + +## Environment And Fixtures + +Use a staging-like environment with: + +1. Stripe test mode or an equivalent non-production billing environment. +2. Working customer portal access. +3. Pulse v6 runtime connected to the same billing/webhook environment. +4. Checkout surface configured with current public v6 price IDs only. +5. Legacy recurring v5 price IDs still present for renewal compatibility. + +Seed at least these fixtures outside git: + +1. `customer_a_monthly`: migrated active v5 monthly recurring subscriber on `v5_pro_monthly_grandfathered` +2. `customer_b_annual`: migrated active v5 annual recurring subscriber on `v5_pro_annual_grandfathered` +3. `returner_email`: a churned customer identity with no active recurring subscription at test start + +Record outside git: + +1. Stripe customer IDs +2. subscription IDs +3. exact Stripe price IDs +4. Pulse license IDs and activation IDs where applicable +5. environment URL and execution date + +## Scenario Matrix + +| ID | Scenario | Primary fixture | Pass focus | +|---|---|---|---| +| `CCR-1` | Active grandfathered continuity baseline | `customer_a_monthly` | Legacy price and v5 plan identity are still intact while active | +| `CCR-2` | Cancel at period end without immediate drift | `customer_a_monthly` | Cancellation intent does not rewrite pricing or entitlements early | +| `CCR-3` | Resume before lapse | `customer_a_monthly` | Same active subscription keeps the same legacy price | +| `CCR-4` | Completed cancellation | `customer_a_monthly` | Paid access is revoked and historical v5 plan identity remains visible as history, not access | +| `CCR-5` | Post-cancel repurchase | `customer_a_monthly` or `returner_email` | Re-entry uses current public v6 pricing, not a revived legacy rate | +| `CCR-6` | Annual parity spot check | `customer_b_annual` | Annual grandfathered path follows the same continuity and re-entry rules | +| `CCR-7` | Direct legacy checkout rejection | synthetic request | Public checkout rejects grandfathered/v5 plan keys before Stripe session creation | + +## Execution Steps + +### `CCR-1` Active Grandfathered Continuity Baseline + +1. Start with an already-migrated active v5 monthly subscriber. +2. Confirm Stripe shows the legacy recurring price ID, not a v6 retail price ID. +3. Open Pulse v6 settings and capture the Pro license panel. +4. Call `GET /api/license/entitlements`. + +Pass when: + +1. `plan_version` is `v5_pro_monthly_grandfathered`. +2. Entitlements are active. +3. The settings panel shows the grandfathered continuity notice. +4. No checkout or upsell surface claims the customer has already moved to a v6 retail recurring price. + +### `CCR-2` Cancel At Period End Without Immediate Drift + +1. Use the customer portal to schedule cancellation at period end. +2. Confirm Stripe marks the subscription for cancellation without replacing the legacy recurring price. +3. Refresh Pulse entitlements and the settings surface before the current period ends. + +Pass when: + +1. The same subscription still carries the legacy recurring price ID. +2. The customer remains entitled until the billing period actually ends. +3. The UI still communicates continuity while the subscription is active. +4. No new v6 subscription object is created just because cancellation was scheduled. + +### `CCR-3` Resume Before Lapse + +1. Before the current period ends, undo the scheduled cancellation from the portal or equivalent Stripe action. +2. Refresh billing state in Pulse. + +Pass when: + +1. The original subscription remains the billing object of record. +2. The legacy recurring price ID is unchanged. +3. `plan_version` remains the same grandfathered v5 recurring key. +4. There is no forced checkout or new-subscription re-entry path. + +### `CCR-4` Completed Cancellation + +1. Let the scheduled cancellation complete naturally or drive the equivalent Stripe test-clock/webhook path. +2. Confirm the cancellation webhook is delivered successfully. +3. Refresh Pulse entitlements and the Pro license settings surface. + +Pass when: + +1. Paid capabilities are revoked after cancellation is complete. +2. Billing state no longer grants active paid access. +3. Historical plan identity still shows the prior grandfathered plan version for continuity/audit purposes where expected. +4. The recurring-price continuity notice is no longer shown for a canceled or expired state. + +### `CCR-5` Post-Cancel Repurchase + +1. Starting from a fully canceled/lapsed state, use the public checkout flow as the same returning customer. +2. Complete a new purchase through the public v6 checkout path. +3. Capture the resulting Stripe subscription and Pulse license/entitlement state. + +Pass when: + +1. The new subscription uses a current public v6 price ID. +2. The new purchase does not reuse the legacy recurring price ID. +3. The resulting Pulse plan/license state resolves to a v6 plan, not `v5_pro_*_grandfathered`. +4. The settings surface does not show a grandfathered v5 continuity notice on the new subscription. + +### `CCR-6` Annual Parity Spot Check + +Run `CCR-1`, `CCR-4`, and `CCR-5` on the annual grandfathered fixture. + +Pass when: + +1. Annual grandfathering preserves continuity while active. +2. Annual cancellation revokes paid access after completion. +3. Annual return flow still re-enters on current public v6 pricing. + +### `CCR-7` Direct Legacy Checkout Rejection + +1. Submit a direct request to `pulse-pro/license-server` checkout creation with a legacy/grandfathered `plan_key`. +2. Confirm the request is rejected before any Stripe checkout session is created. + +Pass when: + +1. The endpoint returns a client error. +2. The error states the plan is not a v6 checkout plan. +3. No Stripe checkout session is created. + +## Evidence To Capture + +Capture all of the following outside git or in a dated release-control record: + +1. Stripe subscription snapshots before cancellation, after scheduling cancellation, after completion, and after repurchase +2. `GET /api/license/entitlements` payloads for the same checkpoints +3. Pro license settings screenshots for active, scheduled-cancel, canceled, and repurchased states +4. The old and new subscription IDs plus their price IDs +5. The resulting Pulse plan key / `plan_version` at each checkpoint +6. Checkout-response payload or request log proving public re-entry used a v6 plan +7. Any webhook event IDs used to advance the cancellation state + +## Failure Rules + +Block release or rollout if any of these are observed: + +1. An active grandfathered subscriber is silently rewritten to a v6 retail recurring plan without an actual cancel-and-rebuy boundary. +2. A canceled subscriber can repurchase onto a legacy recurring price through public checkout. +3. Cancellation intent causes early entitlement revocation before the paid period actually ends. +4. Completed cancellation leaves paid capabilities active. +5. Resume-before-lapse creates a new subscription or loses the legacy recurring price unexpectedly. +6. Monthly and annual grandfathered paths do not behave the same way on the continuity boundary. + +## Recording Results + +When this drill is executed for a candidate prerelease or release: + +1. Write a dated record under `docs/release-control/v6/records/`. +2. Start from `docs/release-control/v6/COMMERCIAL_CANCELLATION_REACTIVATION_RECORD_TEMPLATE.md`. +3. Link that record from the relevant release ticket. +4. If the exercise materially changes confidence for a release gate, update the matching `status.json.release_gates[*]` entry in the same slice. diff --git a/docs/release-control/v6/internal/COMMERCIAL_CANCELLATION_REACTIVATION_RECORD_TEMPLATE.md b/docs/release-control/v6/internal/COMMERCIAL_CANCELLATION_REACTIVATION_RECORD_TEMPLATE.md new file mode 100644 index 000000000..ebfdcbbcd --- /dev/null +++ b/docs/release-control/v6/internal/COMMERCIAL_CANCELLATION_REACTIVATION_RECORD_TEMPLATE.md @@ -0,0 +1,153 @@ +# Commercial Cancellation/Reactivation Record Template + +Use this template when exercising the `commercial-cancellation-reactivation` +release gate. + +Save executed records under: + +`docs/release-control/v6/records/commercial-cancellation-reactivation-YYYY-MM-DD.md` + +--- + +# Commercial Cancellation/Reactivation Record + +- Date: `YYYY-MM-DD` +- Gate: `commercial-cancellation-reactivation` +- Assertions: + - `RA2` + - `RA4` + - `RA7` +- Environment: + - Billing environment: `...` + - Pulse runtime URL: `...` + - pulse-pro checkout origin: `...` + - Stripe mode: `test` or `staging-equivalent` + - Operator: `...` + +## Fixtures + +- Monthly grandfathered customer: + - Email: `...` + - Stripe customer ID: `...` + - Stripe subscription ID: `...` + - Legacy price ID: `...` +- Annual grandfathered customer: + - Email: `...` + - Stripe customer ID: `...` + - Stripe subscription ID: `...` + - Legacy price ID: `...` +- Returning post-cancel customer: + - Email: `...` + +## Automated Proof Baseline + +- `go test ./internal/api -run 'TestStripeWebhook_SubscriptionDeleted_RevokesCapabilities' -count=1` +- `go test ./tests/migration -run 'TestV5FullUpgradeScenario/PersistedV5RecurringLicenseAutoExchanges' -count=1` +- `npm --prefix frontend-modern test -- src/utils/__tests__/licensePresentation.test.ts src/components/Settings/__tests__/ProLicensePanel.test.tsx` +- `cd /Volumes/Development/pulse/repos/pulse-pro/license-server && go test . -run 'TestHandleCheckoutSessionCreate(_RejectsGrandfatheredPlanKey)?$' -count=1` +- Result: `pass` or `fail` + +## Manual Exercise + +### `CCR-1` Active Grandfathered Continuity Baseline + +1. `...` +2. `...` +3. `...` + +Observed: + +- Stripe price ID: `...` +- `GET /api/license/entitlements` `plan_version`: `...` +- Settings surface continuity notice: `present` or `absent` + +### `CCR-2` Cancel At Period End Without Immediate Drift + +1. `...` +2. `...` + +Observed: + +- Cancel-at-period-end state: `...` +- Legacy price ID still attached: `yes` or `no` +- Entitlement state before period end: `...` + +### `CCR-3` Resume Before Lapse + +1. `...` +2. `...` + +Observed: + +- Original subscription preserved: `yes` or `no` +- Legacy price ID preserved: `yes` or `no` +- `plan_version`: `...` + +### `CCR-4` Completed Cancellation + +1. `...` +2. `...` + +Observed: + +- Webhook/event IDs: `...` +- Post-cancel entitlement state: `...` +- Paid capabilities revoked: `yes` or `no` +- Continuity notice removed: `yes` or `no` + +### `CCR-5` Post-Cancel Repurchase + +1. `...` +2. `...` +3. `...` + +Observed: + +- New subscription ID: `...` +- New price ID: `...` +- New plan key / `plan_version`: `...` +- Grandfathered notice absent on new subscription: `yes` or `no` + +### `CCR-6` Annual Parity Spot Check + +1. `...` +2. `...` + +Observed: + +- Annual continuity preserved while active: `yes` or `no` +- Annual cancellation revokes access: `yes` or `no` +- Annual re-entry uses v6 pricing: `yes` or `no` + +### `CCR-7` Direct Legacy Checkout Rejection + +1. `...` +2. `...` + +Observed: + +- HTTP status: `...` +- Error body: `...` +- Stripe checkout created: `yes` or `no` + +## Outcome + +- `pass` or `fail` +- Summary: + - `...` + - `...` + - `...` + +## Evidence Captured + +- Stripe subscription snapshots: `...` +- Entitlement payload snapshots: `...` +- Settings screenshots: `...` +- Checkout request/response logs: `...` +- Webhook event IDs: `...` + +## Follow-Ups + +- `none`, or: + - `...` + - `...` diff --git a/docs/release-control/v6/internal/CUSTOMER_ACCOUNT_PORTAL_SPEC.md b/docs/release-control/v6/internal/CUSTOMER_ACCOUNT_PORTAL_SPEC.md new file mode 100644 index 000000000..f56a48264 --- /dev/null +++ b/docs/release-control/v6/internal/CUSTOMER_ACCOUNT_PORTAL_SPEC.md @@ -0,0 +1,221 @@ +# Customer Account Portal Spec + +Last updated: 2026-03-25 +Status: PLANNED +Governance surface: `status.json.coverage_gaps.customer-account-portal-surface` +Candidate lane: `customer-account-portal` + +## Intent + +Pulse v6 now has real customer-facing commercial surfaces across self-hosted +licensing, hosted tenants, MSP provider workflows, billing, refund/recovery +utilities, and account-scoped control-plane actions. + +Those surfaces work, but they do not yet form one coherent authenticated Pulse +account experience. + +The purpose of the customer account portal lane is to promote those fragmented +surfaces into one governed product area: + +- one account identity +- one commercial home +- one place to see licenses, hosted tenants, billing, and recovery actions +- one operator surface that can expand into MSP administration cleanly + +## Product Sentence + +Pulse Account is the canonical customer and operator portal for commercial +Pulse: self-hosted licenses, Pulse Cloud tenants, billing state, recovery +actions, and MSP administration all converge there instead of living as +disconnected utility pages and local admin fragments. + +## Why This Is A Separate Lane + +This is not just a UI cleanup. + +It crosses: + +- commercial identity and login state +- self-hosted licensing and activation recovery +- hosted tenant lifecycle and account-scoped control-plane actions +- MSP customer and workspace administration +- billing, invoices, refunds, and recovery/support actions + +Those concerns already exist in runtime and operations, but they are split +across in-product settings, hosted account handlers, and public utility pages. +That is a real product surface gap, not a copy problem. + +## Current Truth + +Today Pulse has: + +- self-serve utility pages such as subscription management, license retrieval, + refund, and data request +- hosted/account-scoped runtime entry points and tenant handlers +- hosted organization billing and cloud pricing surfaces +- MSP provider account and tenant-management behavior + +What it does not yet have is one coherent authenticated account portal that +joins those pieces together. + +## Goals + +1. Give customers one canonical account home for commercial Pulse. +2. Unify self-hosted licensing and hosted tenant ownership under one account + mental model. +3. Let hosted customers see and manage their Pulse Cloud tenant state from the + same account surface as billing. +4. Let MSP operators work from a first-class operator portal rather than a set + of narrow admin fragments. +5. Absorb current public recovery/utility pages into a coherent account flow + over time instead of keeping them as the long-term primary UX. + +## Non-Goals + +1. A standalone Relay portal. Relay remains a capability within Mobile, Cloud, + and self-hosted product surfaces. +2. Making full hosted/MSP portal depth an RC or GA blocker for Pulse v6. The + current governed release policy already keeps the full portal expansion + post-GA. +3. Replacing all in-product billing/admin surfaces immediately if they still + serve as the best runtime-local control surface. +4. Turning every support or recovery workflow into a heavyweight app before the + core account model is coherent. + +## Users + +### 1. Self-Hosted Customer + +Needs: + +- see current license/subscription state +- recover activation/license details +- manage billing and subscription continuity +- understand entitlement limits and plan state + +### 2. Hosted Pulse Cloud Customer + +Needs: + +- see owned tenants +- enter the hosted tenant runtime +- see hosted billing and plan state +- recover account access and understand tenant ownership + +### 3. MSP Operator + +Needs: + +- see provider account state +- view and manage multiple client/customer environments +- understand plan/billing context without mixing MSP and self-hosted language +- operate from a provider-grade control surface + +## Canonical Information Architecture + +The future portal should converge on this shape: + +### 1. Home + +- account summary +- active subscriptions and licenses +- owned hosted tenants +- outstanding recovery/billing/action-needed state + +### 2. Licenses + +- self-hosted licenses and activation state +- entitlement summary +- continuity / renewal / cancellation state +- migration guidance where relevant + +### 3. Pulse Cloud + +- hosted tenants +- tenant status and entry points +- organization/account linkage +- hosted account-scoped actions + +### 4. Billing + +- subscriptions +- invoices and payment method context +- tax / VAT / receipt surfaces +- refund and cancellation/re-entry surfaces + +### 5. MSP + +- provider account summary +- customers / workspaces / tenant list +- operator-scoped admin actions +- clear separation from normal self-hosted customer flows + +### 6. Recovery And Support + +- license retrieval +- account verification flows +- data request/export/delete +- transition path away from isolated standalone utilities + +## Transitional Mapping From Current Surfaces + +These are interim surfaces, not the long-term portal: + +- `pulse-pro/landing-page/manage.html` +- `pulse-pro/landing-page/retrieve-license.html` +- `pulse-pro/landing-page/refund.html` +- `pulse-pro/landing-page/data.html` +- `pulse` hosted account handlers and billing/admin panels +- `pulse` MSP provider account and tenant-management handlers + +The v6 portal lane should treat those as migration sources, not as the final +product shape. + +## V6 Scope + +The proper v6 lane scope is: + +1. Define the account identity and navigation model clearly. +2. Establish one authenticated account shell / entry surface. +3. Unify the first customer-critical actions: + - license/subscription visibility + - hosted tenant visibility + - billing / recovery entry points +4. Keep direct utility-page compatibility while the portal absorbs them. +5. Keep runtime-local settings pages where they are still the right control + surface, but stop treating them as the entire account experience. + +## Post-GA Expansion + +These are valid follow-ons after the first coherent portal lands: + +- deeper hosted tenant lifecycle controls +- richer MSP operator/customer hierarchies +- support inbox and guided recovery workflows +- broader invoice/tax/export surfaces +- more opinionated cross-product notifications and account action center + +## Ownership Boundary + +This lane should stay owned by the `cloud-paid` subsystem unless the governance +map later proves it needs a separate subsystem. + +Repo split: + +- `pulse`: authenticated runtime/account APIs, hosted account handlers, + tenant/admin surfaces, in-product billing/account presentation +- `pulse-pro`: public commercial edge, self-serve utility/recovery pages, + checkout/license commercial account plumbing + +`pulse-mobile` is a consumer of this account model, not the owner of it. + +## Release Policy + +This lane is a real product gap, but it is not an RC floor blocker. + +That matches the existing governed policy already recorded in +`status.json.resolved_decisions.ga-floor-policy`: full hosted/MSP portal depth +is post-GA rather than a GA floor gate. + +The right action is not to pretend the gap does not exist. +It is to track it as a deliberate planned lane with a coherent first scope. diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md new file mode 100644 index 000000000..1050ad3a6 --- /dev/null +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -0,0 +1,630 @@ +# Pulse v6 High-Risk Release Verification Matrix + +Use this file for the parts of the release that I should not trust from unit +tests alone. + +This is the human runbook for `status.json.release_gates`. +A gate is only `passed` when its automated proof still passes and the manual +scenario has been exercised in a staging-like environment with the expected +result. + +## How To Use This Matrix + +1. Run the automated proof first. +2. Run the manual scenario exactly on the runtime surface named below. +3. Record the environment, date, and result in the release ticket or inline in + this file. +4. Update the matching `status.json.release_gates[*].status` entry to `passed` + only after the full gate is clear. +5. Treat every failed or unconfirmed gate as a release blocker. + +Companion drill: + +- For cancellation/reactivation pricing continuity, checkout re-entry, and + Stripe-driven revocation boundaries, run + `docs/release-control/v6/COMMERCIAL_CANCELLATION_REACTIVATION_E2E_TEST_PLAN.md` + and attach the resulting record to the applicable gate evidence. + +## Gate: `hosted-signup-billing-replay` + +- Why this is risky: + Hosted signup, magic-link access, org provisioning, checkout, and webhook + replay are cross-system flows. They can look fine in isolated tests while + still failing in the real handoff path. +- Primary runtime surfaces: + `frontend-modern/src/pages/HostedSignup.tsx` + `frontend-modern/src/components/Settings/BillingAdminPanel.tsx` + `internal/api/public_signup_handlers.go` + `internal/hosted/...` + `internal/cloudcp/...` + `internal/api/stripe_webhook_handlers*.go` +- Automated proof: + `go test ./internal/api -run 'TestHostedLifecycle|TestHostedSignup' -count=1` + `go test ./internal/api -run 'TestStripeWebhook_'` + `go test ./internal/cloudcp/... -count=1` + `go test ./internal/hosted/... -count=1` + `cd frontend-modern && npx vitest run src/pages/__tests__/HostedSignup.test.tsx src/components/Settings/__tests__/BillingAdminPanel.test.tsx` + `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm test -- tests/07-trial-signup-return.spec.ts --project=chromium` +- Live rehearsal helper: + `python3 scripts/release_control/hosted_signup_billing_replay_rehearsal.py --base-url --signup-email --org-name ...` +- Manual scenario: + 1. Start a hosted signup from the self-hosted trial/upgrade path. + 2. Confirm a missing hosted public URL fails closed before any org or RBAC + tenant is created. + 3. Confirm the user is sent to hosted checkout instead of receiving a local + entitlement immediately. + 4. Confirm unresolved org linkage fails closed on webhook handling. + 5. Replay the same webhook after the linked org exists and confirm it + succeeds. + 6. Confirm billing-admin state reflects the resulting org/subscription state. +- Pass when: + Hosted signup fails closed before provisioning when required external URL + config is missing, creates the correct org when enabled, webhook replay is + fail-closed before linkage and succeeds after linkage, and the UI shows the + resulting state coherently. +- Latest exercised record: + `docs/release-control/v6/records/hosted-signup-billing-replay-production-fixed-2026-03-13.md` +- Block release if: + Any hosted checkout, org linkage, magic-link, billing-admin, or webhook + replay path is unconfirmed or inconsistent. + +## Gate: `cloud-hosted-tier-runtime-readiness` + +- Why this is risky: + Hosted signup alone is not enough. If the real hosted Pulse tier cannot be + entered, authenticated, navigated, or administered after provisioning, users + will pay for a product tier that exists in pricing and billing but not in + dependable runtime behavior. +- Primary runtime surfaces: + `internal/cloudcp/...` + `internal/hosted/...` + `internal/api/public_signup_handlers.go` + `internal/api/hosted_org_admin_handlers.go` + `frontend-modern/src/pages/HostedSignup.tsx` + `frontend-modern/src/components/Settings/BillingAdminPanel.tsx` + `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` +- Automated proof: + `go test ./internal/cloudcp/... -count=1` + `go test ./internal/hosted/... -count=1` + `go test ./internal/api -run 'TestHostedLifecycle|TestHostedOrgAdminHandlers|TestHostedSignupSuccess|TestHostedSignupValidationFailures|TestHostedSignupHostedModeGate|TestHostedSignupRateLimit|TestHostedSignupRateLimit_NoProvisioningSideEffects|TestHostedSignupCleanupOnRBACFailure|TestHostedSignupFailsClosedWithoutPublicURL|TestStripeWebhook_' -count=1` + `cd frontend-modern && npx vitest run src/pages/__tests__/HostedSignup.test.tsx src/components/Settings/__tests__/BillingAdminPanel.test.tsx src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx` +- Manual scenario: + 1. Start from a real hosted Pulse signup or an existing hosted tenant. + 2. Confirm the user can authenticate into the hosted Pulse app and reach a + working hosted runtime instead of a self-hosted setup or dead-end state. + 3. Confirm hosted billing/admin and organization billing surfaces render + coherent plan, seat, and entitlement state for the hosted tenant. + 4. Confirm hosted-only admin actions and normal post-signup navigation work + without self-hosted license prompts or broken hosted assumptions. +- Pass when: + A real hosted Pulse customer can sign up or sign in, land in a working + hosted runtime, and use the hosted billing/admin surfaces without self-hosted + fallbacks or broken post-provisioning behavior. +- Latest exercised record: + `docs/release-control/v6/records/cloud-hosted-tier-runtime-readiness-production-followup-2026-03-13.md` +- Block release if: + Hosted Pulse can be sold or provisioned but not entered and used as a + coherent hosted product tier afterward. + +## Gate: `commercial-cancellation-reactivation` + +- Why this is risky: + Grandfathered recurring continuity, Stripe cancellation state, entitlement + revocation, and public checkout re-entry span multiple repos and billing + boundaries. This is exactly the kind of path that can look correct in unit + tests while still charging the wrong price or granting the wrong access in a + real customer journey. +- Primary runtime surfaces: + `internal/api/payments_webhook_handlers.go` + `pkg/licensing/...` + `frontend-modern/src/components/Settings/ProLicensePanel.tsx` + `pulse-pro/license-server/v6_checkout.go` + Stripe customer portal / recurring subscription state +- Automated proof: + `python3 scripts/release_control/commercial_cancellation_reactivation_proof.py` + For the live external rehearsal path, use + `python3 scripts/release_control/commercial_cancellation_reactivation_rehearsal.py`. + Manual command detail remains documented in + `docs/release-control/v6/COMMERCIAL_CANCELLATION_REACTIVATION_E2E_TEST_PLAN.md`. +- Manual scenario: + Execute `CCR-1` through `CCR-7` from + `docs/release-control/v6/COMMERCIAL_CANCELLATION_REACTIVATION_E2E_TEST_PLAN.md` + against a staging-like billing environment and write a dated record under + `docs/release-control/v6/records/`. +- Pass when: + Active grandfathered subscribers keep their legacy recurring price while the + subscription remains continuous, completed cancellation revokes paid access, + and any later public re-entry lands on current public v6 pricing rather than + reviving the legacy recurring rate. +- Latest exercised record: + `docs/release-control/v6/records/commercial-cancellation-reactivation-external-e2e-2026-03-13.md` +- Block release if: + The scenario is unexercised, a returning canceled customer can re-enter on a + legacy recurring price, or cancellation/reactivation leaves pricing and + entitlement state inconsistent across Stripe, Pulse runtime, and customer UI. + +## Gate: `documentation-currentness-and-legacy-cleanup` + +- Why this is risky: + Stale release-control or upgrade guidance creates invisible operational + drift. Agents and humans will follow whatever the docs say is current, even + when the runtime has already moved on. +- Primary runtime surfaces: + `docs/release-control/CONTROL_PLANE.md` + `docs/release-control/control_plane.json` + `docs/release-control/v6/internal/SOURCE_OF_TRUTH.md` + `docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md` + `docs/release-control/v6/README.md` + `docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md` +- Automated proof: + `python3 scripts/release_control/documentation_currentness_test.py` +- Manual scenario: + 1. Review the active v6 guidance surface used by agents and release work. + 2. Confirm the docs describe the current active target, release phase, and + canonical workflow rather than superseded guidance. + 3. Confirm any remaining legacy, audit, or historical docs are clearly + framed as records or reference material instead of current instructions. + 4. Confirm any stale active doc is updated, archived, or removed rather than + left to drift. +- Pass when: + Active v6-facing guidance matches the current governed state of the repo, and + historical docs no longer present themselves as current guidance. +- Latest exercised record: + `docs/release-control/v6/records/documentation-currentness-and-legacy-cleanup-2026-03-13.md` +- Block release if: + Agents or humans can still follow stale v6 guidance, or legacy/historical + docs remain mixed into the active v6 instruction surface. + +## Gate: `settings-surface-layout-consistency` + +- Why this is risky: + Settings surfaces are one of the densest trust surfaces in Pulse. If + top-level settings pages drift in shell framing, header treatment, or + section rhythm, the product feels unfinished and users stop trusting that + comparable admin and paid surfaces behave to the same standard. +- Primary runtime surfaces: + `frontend-modern/src/components/Settings/Settings.tsx` + `frontend-modern/src/components/Settings/SettingsPageShell.tsx` + `frontend-modern/src/components/Settings/settingsPanelRegistry.ts` + `frontend-modern/src/components/shared/SettingsPanel.tsx` + representative top-level settings panels under + `frontend-modern/src/components/Settings/` +- Automated proof: + `cd frontend-modern && npx vitest run src/components/Settings/__tests__/settingsArchitecture.test.ts` +- Manual scenario: + 1. Run the release build or a staging-like runtime and click through + representative top-level settings surfaces across general, billing, + relay, organization, security, AI, updates, and recovery panels. + 2. Confirm each comparable surface uses the same outer settings shell + instead of introducing a bespoke page-level header or outer frame. + 3. Confirm the title, description, and top-level section framing stay + visually consistent across those panels. + 4. Confirm any intentional deviation is rare, product-justified, and + documented rather than an accidental local fork. +- Pass when: + Comparable top-level settings surfaces route through the canonical settings + shell and present consistent top-level framing and header treatment, with no + ad hoc page chrome left behind. +- Latest exercised record: + `docs/release-control/v6/records/settings-surface-layout-consistency-2026-03-13.md` +- Block release if: + Settings surfaces still mix multiple top-level shells, inconsistent header + framing, or bespoke page chrome across otherwise comparable panels. + +## Gate: `paid-feature-entitlement-gating` + +- Why this is risky: + This is where free-vs-paid drift becomes customer-visible. UI claims, API + enforcement, entitlements, and agent-allocation accounting all need to + agree. +- Primary runtime surfaces: + `GET /api/license/entitlements` + `internal/api/monitored_system_limit_enforcement.go` + `internal/api/subscription_entitlements.go` + `frontend-modern/src/pages/AIIntelligence.tsx` + `frontend-modern/src/pages/Alerts.tsx` + `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` + `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` + `internal/cloudcp/entitlements/service.go` + `pkg/licensing/entitlements.go` +- Automated proof: + `go test ./internal/api -run 'TestEntitlementHandler_|TestRequireLicenseFeature_HostedEntitlements|TestLicenseGatedEmptyResponse_HostedEntitlements' -count=1` + `go test ./internal/api -run 'TestMonitoredSystemLedger|TestHandleAddNode_BlocksNewCountedSystemAtLimit|TestHandleAutoRegister_BlocksNewCountedSystemAtLimit|TestTrueNASHandlers_HandleAdd_BlocksNewCountedSystemAtLimit|TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit|TestKubernetesAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit|TestContract_EntitlementPayloadMonitoredSystemUsageJSONSnapshot' -count=1` + `go test ./internal/license/... -count=1` + `go test ./internal/cloudcp/... -count=1` + `cd frontend-modern && npx vitest run src/pages/__tests__/AIIntelligence.test.tsx src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx src/components/Settings/__tests__/RBACPaywallPanels.test.tsx src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx src/utils/__tests__/licensePresentation.test.ts src/utils/__tests__/rbacPresentation.test.ts src/utils/__tests__/frontendResourceTypeBoundaries.test.ts` +- Manual scenario: + 1. Use a free/community entitlement state and confirm paid features are gated. + 2. Use a Pro/Cloud entitlement state and confirm the same surfaces unlock. + 3. Confirm the upgrade path shown in the UI matches the runtime capability. + 4. Confirm alert analysis, AI autonomy, RBAC-only areas, and cloud-only areas + do not leak access for free users. + 5. Confirm the monitored-system count shown in settings and upgrade-warning + surfaces matches the deduped top-level monitored-system count and includes + API-backed systems under the same cap. + 6. Confirm adding a new counted monitored system at limit is blocked while + existing monitored systems continue to report under the canonical + `max_monitored_systems` entitlement. +- Pass when: + Free users are blocked consistently, paid users are admitted consistently, + monitored-system counts and caps stay coherent across UI and runtime, and there + is no UI/API disagreement. +- Latest exercised record: + `docs/release-control/v6/records/paid-feature-entitlement-gating-2026-03-12.md` +- Block release if: + Any feature can be used without entitlement, or any paid user is blocked on a + correctly granted capability, or agent counts/caps disagree across + enforcement and user-visible surfaces. + +## Gate: `rc-to-ga-promotion-readiness` + +- Why this is risky: + Stable users must not become the first real validation cohort for v6. The + prerelease-to-GA handoff is where migration confidence, release automation, + rollback clarity, and the v5 support policy have to become explicit. +- Primary runtime surfaces: + `.github/workflows/create-release.yml` + `.github/workflows/publish-docker.yml` + `.github/workflows/promote-floating-tags.yml` + `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md` + `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md` + `docs/releases/RELEASE_NOTES_v6.md` +- Automated proof: + `python3 scripts/release_control/release_promotion_policy_test.py` +- Manual scenario: + 1. Identify the exact published prerelease tag and commit that are being considered + for stable or GA promotion. + 2. Confirm the candidate commit has already shipped on `rc` through a real + release-pipeline run, not only workflow lint or static YAML validation. + Accidental prerelease git tags do not count as shipped prerelease lineage. + 3. Confirm the candidate satisfies the minimum 72-hour prerelease soak or that a + hotfix exception and reason are recorded explicitly before promotion. + 4. Confirm the previous stable rollback target and exact reinstall or pin + command are recorded in the release notes or release ticket. + 5. Confirm `V5_MAINTENANCE_SUPPORT_POLICY.md` is still the governing v5 + support policy and record the exact v6 GA date plus the exact v5 + end-of-support date that will ship with the stable or GA announcement. + 6. Confirm the default-branch copy of `.github/workflows/release-dry-run.yml` + already accepts the governed stable rehearsal metadata envelope through + `workflow_dispatch`, because GitHub validates dispatch inputs against the + default branch even when dispatching `pulse/v6`. + 7. Confirm the `Release Dry Run` workflow produced an + `rc-to-ga-rehearsal-summary` artifact carrying the canonical promotion + metadata envelope for that candidate: candidate stable tag, promotion + channel, promoted prerelease tag, rollback target, exact rollback command, + planned GA date, and planned v5 end-of-support date, and record the run + URL in the release ticket or rehearsal record. + 8. Confirm the migration gate and other applicable high-risk gates are + cleared for this same candidate before broad rollout. +- Pass when: + Stable or GA promotion is a governed handoff from an exercised prerelease with live + release-pipeline proof, explicit rollback instructions, and the published v5 + maintenance policy plus exact end-of-support date, with a linked rehearsal + run URL and dry-run artifact carrying the full canonical promotion metadata + envelope. +- Current blocked record: + `docs/release-control/v6/records/rc-to-ga-promotion-readiness-blocked-2026-03-13.md` +- Block release if: + Stable users would become the first real validation cohort, the rollback + target is unclear, or the v5 maintenance-only policy is still undecided. + +## Gate: `upgrade-state-and-entitlement-preservation` + +- Why this is risky: + Upgrade pain is trust-breaking and easy to miss when clean-room tests start + from fresh installs. Paid continuity, onboarding continuity, and local state + preservation all have to survive a real upgrade path. +- Primary runtime surfaces: + `pkg/licensing/...` + `internal/api/license_handlers*.go` + `internal/api/public_signup_handlers.go` + `frontend-modern/src/components/SetupWizard/...` + `frontend-modern/src/components/Settings/...` +- Automated proof: + `go test ./internal/api -run 'TestHostedLifecycle|TestEntitlementHandler_|TestRequireLicenseFeature_HostedEntitlements' -count=1` + `go test ./pkg/licensing/... -count=1` + `go test ./tests/migration -run 'TestV5PaidLicenseUpgrade_CommercialMigrationFailureMatrix|TestV5PaidLicenseUpgrade_RealLicenseServerExchange|TestV5DataDir_CSRFLegacyMapFormat|TestV5DataDir_CSRFTokenFileContinuity|TestV5DataDir_SessionLegacyMapFormat|TestV5DataDir_SessionTokenContinuity|TestV5DowngradeSafety|TestV5FullUpgradeScenario' -count=1` + `cd frontend-modern && npx vitest run src/components/Settings/__tests__/RBACPaywallPanels.test.tsx src/components/Settings/__tests__/BillingAdminPanel.test.tsx src/pages/__tests__/AIIntelligence.test.tsx` + `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm test -- tests/11-first-session.spec.ts --project=chromium` +- Manual scenario: + 1. Start from the previous supported Pulse build with non-trivial local state + and an already-activated paid entitlement. + 2. Upgrade directly to the candidate v6 build without deleting local state. + 3. Confirm the app does not ask for the license again during normal startup. + 4. Confirm first-session and setup surfaces do not reset or regress into + misleading upgrade prompts. + 5. Confirm paid-only surfaces remain correctly gated after upgrade. +- Pass when: + Upgrade keeps the user's local state, entitlements, and first-session + continuity intact without requiring manual repair or repeated activation. +- Latest exercised record: + `docs/release-control/v6/records/upgrade-state-and-entitlement-preservation-2026-03-13.md` +- Block release if: + Upgrade requires manual cleanup, repeated license entry, or leaves paid and + non-paid surfaces in an inconsistent state. + +## Gate: `relay-registration-reconnect-drain` + +- Why this is risky: + Relay failures are highly visible and often only appear under reconnect, + eviction, or disconnect pressure. +- Primary runtime surfaces: + `internal/relay/...` + `internal/api/router_routes_auth_security.go` + `internal/api/onboarding_handlers.go` + `pulse-pro/relay-server/...` + `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` + `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` + `pulse-mobile/src/relay/...` +- Automated proof: + `go test ./internal/relay -run 'TestClient_E2E_MultiMobileClientRelay|TestClient_AbruptDisconnectCancelsInFlightHandlers|TestClient_AbruptDisconnectMultipleChannelCleanup|TestClient_DrainDuringInFlightData|TestClient_DrainWithMultipleInFlightChannels|TestClientRegister_SessionResumeRejectionClearsCachedSession|TestRunLoop_SessionResumeRejectionFallsBackToFreshRegister' -count=1` + `go test ./internal/api -run 'TestRelayEndpointsRequireLicenseFeature|TestRelayOnboardingEndpointsRequireLicenseFeature|TestRelayLicenseGatingResponseFormat|TestOnboardingQRPayloadStructure|TestOnboardingValidateSuccessAndFailure|TestOnboardingDeepLinkFormat' -count=1` + `cd frontend-modern && npx vitest run src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx` + `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/relay/__tests__/client.test.ts src/relay/__tests__/client-hardening.test.ts src/relay/__tests__/protocol-contract.test.ts` +- Manual scenario: + 1. Register a fresh relay client. + 2. Force reconnect after a normal disconnect. + 3. Force stale session resume or server-side eviction and confirm fresh + registration recovery. + 4. Force abrupt disconnect while work is inflight and confirm drain/recovery + behavior is sane. +- Pass when: + Fresh register, reconnect, stale resume recovery, and disconnect/drain all + behave predictably without hanging or spinning. +- Latest exercised record: + `docs/release-control/v6/records/relay-registration-reconnect-drain-2026-03-13.md` +- Block release if: + The relay can strand the app/client in resume loops, dead sessions, or lost + inflight work. + +## Gate: `unified-agent-v5-upgrade-continuity` + +- Why this is risky: + The v5-to-v6 unified-agent crossover is where release-asset integrity, + updater continuity, legacy compatibility routing, and user-visible agent + inventory can drift apart. Repo-local tests cover most of the mechanics, but + the real prerelease path still needs one exercised upgrade from an actual v5 install. +- Primary runtime surfaces: + `GET /install.sh` + `GET /install.ps1` + `GET /api/agent/version` + `internal/api/unified_agent.go` + `internal/api/router_routes_registration.go` + `internal/agentupdate/update.go` + `internal/hostagent/agent.go` + `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` + `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` +- Automated proof: + `go test ./internal/api -run 'TestDownloadUnifiedInstallScript|TestDownloadUnifiedInstallScriptPS|TestProxyInstallScriptFromGitHub|TestContract_InstallScriptReleaseAssetURL|TestDownloadUnifiedAgent|TestUnifiedAgentHandlers_LegacyV5ReportUpgradesToSingleCanonicalUnifiedAgent|TestUnifiedAgentEndpointsAcceptLegacyUnifiedAgentReportScopeAlias|TestNormalizeRequestedScopesCanonicalizesLegacyUnifiedAgentAliases|TestContract_APITokenScopeAliasNormalization' -count=1` + `go test ./internal/agentupdate -run 'TestCheckAndUpdateToFirstHostReportCarriesPreviousVersionOnce|TestUpdateToFirstHostReportCarriesPreviousVersionOnce|TestPerformUpdatePersistsPreviousVersionForNextStart' -count=1` + `go test ./internal/hostagent -run 'TestNew_CarriesUpdatedFromIntoFirstV6Report|TestAgentSendReport_SetsHeadersAndPostsJSON' -count=1` +- Manual scenario: + 1. Start from a real Pulse v5 install with an already-enrolled unified agent + and non-empty agent inventory. + 2. Point that install at the candidate v6 prerelease build and trigger the real + upgrade path through the release-served installer or updater assets, not a + repo-local script. + 3. Confirm the fetched install script or update asset resolves to the + matching v6 prerelease release asset rather than branch-tip `main` content. + 4. Confirm the upgraded agent reconnects as one canonical v6 unified agent + identity and does not create a duplicate host or agent resource during the + crossover. + 5. Confirm the pre-existing installed agent token still reaches the + canonical `/api/agents/agent/*` v6 endpoints even if its persisted scopes + originated as legacy `host-agent:*` aliases. + 6. Confirm the first canonical v6 report carries the prior v5 version in + `updated_from` exactly once. + 7. Confirm a subsequent report clears `updated_from`, and the active-agent + count shown in settings/billing surfaces still matches runtime + enforcement after the upgrade. +- Pass when: + A real v5-installed unified agent upgrades through the candidate v6 prerelease asset + path, reconnects as one canonical v6 agent identity, preserves one-shot + `updated_from` continuity, and leaves user-visible agent counts aligned with + runtime enforcement. +- Latest exercised record: + `docs/release-control/v6/records/unified-agent-v5-upgrade-continuity-2026-03-12.md` +- Block release if: + The prerelease asset path serves the wrong installer logic, the upgrade creates + duplicate or orphaned agent identity, `updated_from` continuity is missing or + repeated, or user-visible agent counts drift from runtime enforcement. + +## Gate: `mobile-relay-auth-approvals` + +- Why this is risky: + Mobile is a separate repo with separate state persistence, auth, and approval + behavior. It is easy to miss regressions while the desktop/web app looks + fine. +- Primary runtime surfaces: + `pulse-mobile/src/stores/authStore.ts` + `pulse-mobile/src/stores/instanceStore.ts` + `pulse-mobile/src/stores/approvalStore.ts` + `pulse-mobile/src/hooks/useRelay.ts` + `pulse-mobile/src/api/client.ts` +- Automated proof: + `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/__tests__/mobileRelayAuthApprovals.rehearsal.test.ts src/utils/__tests__/secureStorage.test.ts src/hooks/__tests__/useRelayLifecycle.test.ts src/hooks/__tests__/approvalActionPolicy.test.ts src/stores/__tests__/instanceStore.test.ts src/stores/__tests__/authStore.test.ts src/stores/__tests__/approvalStore.test.ts` + `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/relay/__tests__/client.test.ts src/relay/__tests__/client-hardening.test.ts src/relay/__tests__/protocol-contract.test.ts` + `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/api/__tests__/client.test.ts` + `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/hooks/__tests__/useRelay.test.ts src/hooks/__tests__/relayPushRefresh.test.ts src/notifications/__tests__/notificationRouting.test.ts src/stores/__tests__/mobileAccessState.test.ts` + `cd /Volumes/Development/pulse/repos/pulse-enterprise && go test ./internal/aiautofix -run 'TestHandleListApprovals|TestHandleApproveAndExecuteInvestigationFix|TestHandleApprove' -count=1` +- Manual scenario: + 1. Pair the mobile app to a real instance through the relay onboarding path. + 2. Kill and relaunch the app to confirm secure persistence and reconnect. + 3. Confirm approval requests appear, are scoped correctly, and resolve + cleanly. + 4. Confirm logout, token expiry, or revoked access forces the app back to a + safe state. +- Pass when: + Pairing, persistence, reconnect, approvals, and sign-out/revocation behavior + all work without stale access. +- Latest exercised record: + `docs/release-control/v6/records/mobile-relay-auth-approvals-2026-03-13.md` +- Block release if: + Mobile can keep stale access, lose approval state, or fail to recover from + reconnect/auth transitions. + +## Gate: `msp-provider-tenant-management` + +- Why this is risky: + MSP mode is a distinct product promise, not just a pricing label. If one + provider account cannot safely manage multiple client tenants from one place, + Pulse will appear to support MSPs in billing and marketing while failing in + the real operator workflow. +- Primary runtime surfaces: + `pkg/licensing/features.go` + `internal/cloudcp/account/...` + `internal/cloudcp/registry/...` + `internal/cloudcp/stripe/provisioner.go` + `internal/cloudcp/stripe/msp_lifecycle_integration_test.go` + `internal/cloudcp/public_cloud_signup_handlers_test.go` + `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` + `frontend-modern/src/pages/CloudPricing.tsx` +- Automated proof: + `go test ./internal/cloudcp/account ./internal/cloudcp/registry -count=1` + `go test ./internal/cloudcp/stripe -run 'TestMSPLifecycle_AccountToPortal' -count=1` + `go test ./internal/cloudcp -run 'TestPublicCloudSignupCheckoutMetadataRejectsMSPPlanForPublicSignup' -count=1` + `go test ./pkg/licensing -run 'TestMSPPlanAliasCanonicalizationContract' -count=1` + `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx src/pages/__tests__/CloudPricing.test.tsx` +- Live rehearsal helper: + `python3 scripts/release_control/msp_provider_tenant_management_rehearsal.py --base-url --account-id ...` +- Manual scenario: + 1. Create or enter an MSP account in a staging-like environment. + 2. Provision at least two client workspaces or tenants under that MSP + account. + 3. Confirm the provider can view and manage the intended client tenants from + one control surface without cross-client data leakage. + 4. Confirm billing and plan presentation stays coherent per client and does + not collapse MSP and individual hosted flows together. + 5. Confirm public individual signup cannot accidentally drop into MSP-only + provisioning semantics. +- Pass when: + MSP mode behaves as a real operator workflow: one provider account can manage + multiple client tenants coherently, with canonical MSP plan handling and no + cross-client leakage or scope confusion. +- Latest exercised record: + `docs/release-control/v6/records/msp-provider-tenant-management-production-followup-2026-03-13.md` +- Block release if: + MSP support exists only as pricing or partial provisioning, or a provider + cannot safely manage multiple client tenants from one place. + +## Gate: `multi-tenant-runtime-isolation-and-coherence` + +- Why this is risky: + Multi-tenant support is not just an org settings feature. If tenant + isolation, tenant-scoped runtime state, or cross-org sharing drifts, Pulse + will expose the wrong data to the wrong tenant while still looking healthy in + narrower UI-only checks. +- Primary runtime surfaces: + `internal/api/org_handlers*.go` + `internal/api/rbac_handlers*.go` + `internal/api/resources_tenant_security_test.go` + `internal/api/router_helpers_more_test.go` + `internal/api/api_token_org_scope_integration_test.go` + `internal/monitoring/...` + `frontend-modern/src/components/Settings/Organization*.tsx` + `frontend-modern/src/components/Settings/RolesPanel.tsx` + `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx` + `tests/integration/tests/03-multi-tenant.spec.ts` +- Automated proof: + `go test ./internal/api -run 'TestOrgHandlers|TestMultiTenant|TestResourceHandlers_NonDefaultOrg|TestSetMultiTenantMonitor_WiresHandlers|TestMultiTenantStateProvider|TestMultiTenantAPITokenRemainsScopedToIssuingOrg' -count=1` + `go test ./internal/monitoring -run 'TestMultiTenantMonitor' -count=1` + `go test ./tests/migration -run 'TestV5DataDir_MultiTenantMigration' -count=1` + `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationSharingPanel.test.tsx src/components/Settings/__tests__/RBACPaywallPanels.test.tsx src/utils/__tests__/rbacPermissions.test.ts src/utils/__tests__/rbacPresentation.test.ts src/utils/__tests__/organizationRolePresentation.test.ts src/utils/__tests__/organizationSettingsPresentation.test.ts` +- Manual scenario: + 1. Enable multi-tenant mode and create at least two organizations with + different users and roles. + 2. Confirm each user only sees the orgs, resources, and runtime state they + are explicitly allowed to see. + 3. Confirm role changes and tenant membership changes immediately affect UI + and API scope. + 4. Confirm tenant-scoped runtime paths do not fall back to default or + single-tenant state when a non-default org is requested. + 5. Confirm cross-org sharing grants only the intended access and does not + widen tenant visibility. +- Pass when: + Multi-tenant Pulse behaves as a coherent tenant-isolated product: org scope, + RBAC, runtime state, sharing, and migration all stay within the intended + tenant boundary. +- Latest exercised record: + `docs/release-control/v6/records/multi-tenant-runtime-isolation-and-coherence-2026-03-13.md` +- Block release if: + A tenant can see or mutate data, runtime state, or shared resources outside + the intended tenant boundary, or multi-tenant mode still behaves like a + partially upgraded single-tenant system. + +## Gate: `organization-user-scope-and-rbac` + +- Why this is risky: + Multi-tenant scope mistakes are trust-critical. Wrong member roles or org + boundaries mean real data exposure. +- Primary runtime surfaces: + `internal/api/org_handlers*.go` + `internal/api/rbac_handlers*.go` + `frontend-modern/src/components/Settings/Organization*.tsx` + `frontend-modern/src/components/Settings/RolesPanel.tsx` + `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx` +- Automated proof: + `go test ./internal/api -run 'TestOrgHandlers|TestMultiTenant|TestResourceHandlers_NonDefaultOrg|TestSetMultiTenantMonitor_WiresHandlers' -count=1` + `go test ./internal/monitoring -run 'TestMultiTenantMonitor'` + `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationSharingPanel.test.tsx src/components/Settings/__tests__/RBACPaywallPanels.test.tsx src/utils/__tests__/rbacPermissions.test.ts src/utils/__tests__/rbacPresentation.test.ts src/utils/__tests__/organizationRolePresentation.test.ts src/utils/__tests__/organizationSettingsPresentation.test.ts src/utils/__tests__/frontendResourceTypeBoundaries.test.ts` + `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 PULSE_MULTI_TENANT_ENABLED=true npm test -- tests/03-multi-tenant.spec.ts --project=chromium` +- Manual scenario: + 1. Add a new user. + 2. Confirm the user only sees the orgs they belong to. + 3. Change member role and confirm the UI and API scope update accordingly. + 4. Confirm self-escalation is blocked. + 5. Confirm cross-org sharing grants only the intended access level. +- Pass when: + Org membership, RBAC role assignment, and cross-org access all enforce the + least privilege intended by the UI. +- Latest exercised record: + `docs/release-control/v6/records/organization-user-scope-and-rbac-2026-03-12.md` +- Block release if: + A user can see or mutate data outside assigned org or role scope. + +## Gate: `api-token-scope-and-assignment` + +- Why this is risky: + API tokens are long-lived authority. If token identity or scope binding is + wrong, automated access will bypass user intent. +- Primary runtime surfaces: + `internal/api/router.go` + `internal/api/router_routes_auth_security.go` + `internal/api/security_tokens.go` + `internal/api/system_settings_telemetry_test.go` + `frontend-modern/src/components/Settings/APIAccessPanel.tsx` + `frontend-modern/src/components/Settings/APITokenManager.tsx` + `frontend-modern/src/utils/apiTokenPresentation.ts` + `frontend-modern/src/utils/url.ts` +- Automated proof: + `go test ./internal/api -run 'Test(APIToken|SecurityTokens|SystemSettings|MultiTenant)' -count=1` + `go test ./internal/api -run 'TestNormalizeRequestedScopesCanonicalizesLegacyUnifiedAgentAliases|TestUnifiedAgentEndpointsAcceptLegacyUnifiedAgentReportScopeAlias|TestContract_APITokenScopeAliasNormalization' -count=1` + `cd frontend-modern && npx vitest run src/components/Settings/__tests__/APITokenManager.test.tsx src/utils/__tests__/apiClient.org.test.ts src/utils/__tests__/apiTokenPresentation.test.ts src/utils/__tests__/frontendResourceTypeBoundaries.test.ts` + `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 PULSE_MULTI_TENANT_ENABLED=true npm test -- tests/13-api-token-scope.spec.ts --project=chromium` +- Manual scenario: + 1. Generate a token for a specific user. + 2. Confirm the token inherits only the intended user and org scope. + 3. Use the token against read, mutate, and exec paths that should be denied. + 4. Revoke the token and confirm the old token immediately stops working. + 5. Confirm legacy persisted `host-agent:*` token scopes still canonicalize to + the intended v6 `agent:*` scope checks on installed-agent report and + config flows. + 6. Confirm scoped agent/API-token flows fail with a clear message when the + scope is insufficient. +- Pass when: + Token create, use, read/write/exec scope enforcement, and revocation all + behave exactly as intended. +- Latest exercised record: + `docs/release-control/v6/records/api-token-scope-and-assignment-2026-03-12.md` +- Block release if: + A token can outlive revocation, exceed assigned scope, or detach from the + intended user/org identity. + +## Gate Ownership Rule + +Update these machine-visible gate states in `docs/release-control/v6/internal/status.json` +as verification progresses: + +1. `pending` means not yet confirmed end to end. +2. `blocked` means the gate is actively failing or cannot yet be exercised. +3. `passed` means both automated proof and the manual scenario are clear. diff --git a/docs/release-control/v6/internal/LEGACY_HOST_CLASSIFICATION_2026-03-05.md b/docs/release-control/v6/internal/LEGACY_HOST_CLASSIFICATION_2026-03-05.md new file mode 100644 index 000000000..db0a88fef --- /dev/null +++ b/docs/release-control/v6/internal/LEGACY_HOST_CLASSIFICATION_2026-03-05.md @@ -0,0 +1,299 @@ +# Legacy Host Classification Audit + +Date: 2026-03-05 +Scope: `pulse` repo only, focused on v6-facing paths and release verification artifacts. + +## Verdict + +No additional active v6 runtime leaks were found after fixing the agent-registration journey. + +The remaining `host` references in v6-adjacent code fall into three intentional buckets: + +1. Compatibility boundaries that explicitly reject or normalize legacy input. +2. Internal state or wire-format shims that still bridge older model names to v6-facing output. +3. Non-resource semantics where `host` means hostname, SSH host key, backup type, or endpoint host. + +## Release Decision + +Release can proceed on the host-type migration question. + +Reasoning: + +- canonical v6 request/resource surfaces now reject removed `host` aliases +- the known release-facing registration leak has been fixed +- remaining `host` naming is contained inside compatibility, ingest, topology, or DTO layers +- added ratchets now pin the most important release-facing regression points + +This audit does not claim that all host-era naming is gone. +It claims that the remaining naming debt is not evidence of canonical v6 behavior drifting back to the removed `host` resource type. + +## Must Remove + +None found in active v6 runtime/read surfaces during this pass. + +The previously-found leak is now fixed: + +- `tests/integration/tests/journeys/04-agent-install-registration.spec.ts` +- `tests/integration/evals/tasks/agent-registration.md` + +Those artifacts now require unified `resources[]` and `agent.type = "unified"`. + +## Intentional Compatibility Boundaries + +These are correct to keep because they harden v6 against old clients or old persisted inputs: + +- `internal/api/resources_test.go` + Confirms `/api/resources?type=host` is rejected. +- `internal/api/org_handlers_test.go` + Confirms organization sharing rejects `resourceType: "host"`. +- `internal/api/ai_handler_test.go` + Confirms legacy chat mention aliases like `host`, `container`, and `k8s` are dropped. +- `internal/api/ai_handlers_test.go` + Confirms `target_type: "host"` is rejected for run-command normalization. +- `internal/monitoring/monitor_helpers.go` + Keeps explicit legacy-agent detection: only `agent.type = "unified"` is canonical. + +## Intentional Internal Shims + +These are still legacy-shaped internally, but they are not evidence of v6 model leakage: + +- `internal/monitoring/monitor.go` + `monitorLegacyResourceType()` is the frontend wire-shape mapper for `/api/state` broadcast payloads. + It emits canonical v6-facing types like `agent`, `node`, and `docker-host`. +- `internal/models/state_snapshot.go` + `StateSnapshot` and `ResolveResource()` still model host-agent state for producer/wire compatibility. + This is not the canonical read model; `ReadState` and unified resources are. + +## Internal Models Follow-Up + +The remaining `internal/models` host-era surface is still serving live compatibility and routing work. +It should not be treated as dead migration residue. + +### Must Remain For Release + +- `internal/models/models.go` + `Host`, `HostSensorSummary`, `HostDiskSMART`, `ClearAllHosts()`, `LinkHostAgentToNode()`, + and `UnlinkHostAgent()` still back the host-agent ingest and linking flow. +- `internal/models/models_frontend.go` + `HostFrontend` and the `StateFrontend.Hosts` field remain part of the internal/frontend wire DTO layer. + `/api/state` strips that array for the canonical v6 contract, but the model still exists for compatibility, + websocket shaping, mock data, and legacy-facing internal consumers. +- `internal/models/converters.go` + `Host -> HostFrontend` conversion is still the compatibility bridge from host-agent state into the + older frontend DTO family. +- `internal/models/state_snapshot.go` + `StateSnapshot.Hosts` and `ResolveResource()` still support host-agent lookup/routing for compatibility flows. + +### Active Runtime Boundaries Still Using That Surface + +- `internal/api/router.go` + Metrics/history and live metric fallback still read `snap.Hosts` when the canonical resource type is `agent`. +- `internal/api/agent_ingest.go` + Host agent lookup and registration validation still scan the live `snap.Hosts` snapshot. +- `internal/monitoring/monitor_agents.go` + `ApplyHostReport()` still writes into `models.Host` state before unified-resource ingestion layers consume it. +- `internal/ai/chat/context_prefetch.go` + Chat mention prefetch already uses `ReadState.Hosts()` and maps those records to canonical `agent` mentions. + This is canonical at the read boundary even though the underlying source model is still named `Host`. + +## API Runtime Follow-Up + +The remaining host-era naming inside `internal/api` is concentrated in compatibility handlers and helper locals. +It is still part of the supported release path for agent install, lookup, and live metric fallback. + +### Must Remain For Release + +- `internal/api/agent_ingest.go` + `HostAgentHandlers` is still the compatibility boundary for the Pulse Unified Agent runtime. + It reads `GetLiveStateSnapshot().Hosts` to: + - validate installer lookup requests + - resolve config fetch scope + - enforce token-to-agent ownership +- `internal/api/router.go` + live metric fallback still resolves canonical `agent` resources through `snap.Hosts` + when building instant metric points for runtime reads. +- `internal/api/host_agents_test.go` + keeps the lookup/install compatibility behavior pinned while those handlers still exist. + +### Why This Is Not A V6 Leak + +- The external resource type at the API boundary is still `agent`, not `host`. +- The remaining host-era code is operating on compatibility storage names (`models.Host`, `snap.Hosts`) + after canonical request normalization has already happened. +- Existing API tests already pin explicit rejection of removed `host` aliases in: + - AI/chat + - org shares + - reporting + - metrics history + - resources/discovery + +### Post-Release Refactor Targets + +These are the next cleanup candidates once the host-agent compatibility/state layer is intentionally renamed or retired: + +- `internal/api/router.go` + local helpers like `findHost` and comments that still describe the `agent` path in host-era terms +- `internal/api/agent_ingest.go` + `HostAgentHandlers` naming, local `host` variables, and `snap.Hosts` comments +- `internal/api/host_agents_test.go` + test names and fixtures that still describe the canonical agent flow as `host` lookup/config management + +These are naming/structure refactors, not release blockers. + +### Post-Release Rename Candidates + +These look like rename-only cleanup once the host-agent compatibility/state layer is intentionally retired or renamed: + +- `internal/models/models.go` + `Host`, `HostSensorSummary`, `HostDiskSMART`, `HostCephCluster` +- `internal/models/models_frontend.go` + `HostFrontend`, `HostSensorSummaryFrontend`, `HostDiskSMARTFrontend` +- `internal/models/converters.go` + `ToFrontend converts a Host to HostFrontend` +- `internal/models/state_snapshot.go` + comments like `Check generic Hosts` and the `hosts` local variable naming + +These are naming debt, not current v6 correctness bugs. + +## Monitoring Runtime Follow-Up + +The remaining host-era surface in `internal/monitoring` is the active ingest and correlation bridge for +Pulse Unified Agent reports. It is still required for release. + +### Must Remain For Release + +- `internal/monitoring/monitor_agents.go` + `ApplyHostReport()` still ingests unified-agent reports into `models.Host` compatibility state before + unified-resource ingestion and correlation layers consume them. +- `internal/monitoring/monitor.go` + helper paths like `mergeHostAgentSMARTIntoDisks()` and the live host snapshot readers still merge + host-agent data into node/disk/runtime views. +- `internal/monitoring/monitor_helpers.go` + `isLegacyAgent()` and the host sensor/Ceph/SMART conversion helpers still bridge agent payloads into the + compatibility state model. +- `internal/monitoring/host_agent_temps.go` + still uses host-agent sensor data as the authoritative temperature source for linked nodes. +- `internal/monitoring/monitor_host_agents_test.go` + pins host-agent offline/recovery behavior while this compatibility layer remains live. + +### Why This Is Not A V6 Leak + +- At the canonical read boundary, those ingested records become unified `agent` resources. +- The remaining `Host` naming is inside the producer/correlation layer, which is exempt from the + `ReadState` consumer-only bans. +- Monitoring is still the write/ingest side of the architecture, not the canonical v6 read model. + +### Post-Release Refactor Targets + +These are valid rename/structure candidates after the host-agent compatibility state is intentionally retired: + +- `internal/monitoring/monitor_agents.go` + function names like `ApplyHostReport`, `RemoveHostAgent`, `LinkHostAgent`, `UnlinkHostAgent` +- `internal/monitoring/monitor.go` + comments and helper locals that still describe canonical `agent` data as `host` +- `internal/monitoring/host_agent_temps.go` + helper names that still encode `host agent` terminology even though the canonical resource type is `agent` + +These remain naming debt, not release blockers. + +## AI And Service Discovery Follow-Up + +The remaining host-era naming in `internal/ai` and `internal/servicediscovery` is mostly internal execution +and topology language, not canonical API/resource typing drift. + +### Must Remain For Release + +- `internal/ai/chat/context_prefetch.go` + `ReadState.Hosts()` is still used to discover unified-agent backed resources for chat mentions, but the + emitted mention type is canonical `agent`. +- `internal/ai/tools/adapters.go` and `internal/ai/tools/tools_storage.go` + AI tools still consume `unifiedresources.HostView` for RAID/SMART/Ceph data exposed by unified agents. + This is already on the canonical read model even though the view name still says `Host`. +- `internal/servicediscovery/service.go` + the discovery service still materializes an internal `StateSnapshot.Hosts []Host` representation for + topology analysis, hostname resolution, and redirecting scans to linked agents. +- `internal/servicediscovery/deep_scanner.go` + command routing still talks about the agent running “on this host”, but the actual target type for + canonical execution remains `agent`. + +### Why This Is Not A V6 Leak + +- `internal/ai` already rejects removed `host` aliases at the API boundary and works with canonical + `agent` execution targets internally. +- `internal/ai/tools` reads host-agent data through unified-resource views (`HostView`) rather than through + legacy `state.hosts` API payload assumptions. +- `internal/servicediscovery` uses `host` as an internal topology concept for machines discovered via the + unified agent, not as an exposed removed resource type token. + +### Post-Release Refactor Targets + +These are rename/structure candidates once the compatibility naming debt is worth paying down: + +- `internal/servicediscovery/service.go` + internal `Host` and `StateSnapshot.Hosts` naming +- `internal/servicediscovery/deep_scanner.go` + comments and helper names that still talk about “host” instead of agent-backed machine resources +- `internal/ai/tools` + user-facing text strings like “host agents” and helper names keyed on `HostView` / `toolHost*` + +These are naming debt, not release blockers. + +## Non-Resource Host Terminology + +These are unrelated to the removed v5 resource type and should not be treated as migration debt: + +- `internal/mock/generator.go` + Backup payloads use `Type: "host"` to mean PMG host config backups, not unified resources. +- `internal/monitoring/knownhosts.go` + SSH known-hosts management. +- Node configuration and endpoint code using `host` as a network address or URL host field. + +## Ratchets Added + +- `internal/unifiedresources/code_standards_test.go` + Added `TestV6AgentRegistrationArtifactsStayCanonical` to: + - scan all `tests/integration/tests/**/*.{ts,tsx}` and `tests/integration/evals/**/*.md` + for legacy host-resource usage patterns + - prevent regressions in: + - `tests/integration/tests/journeys/04-agent-install-registration.spec.ts` + - `tests/integration/evals/tasks/agent-registration.md` + +The ratchet bans: + +- `state.hosts` +- `hosts array` +- `type: 'host'` +- `agent.type = "host"` +- `resourceType: "host"` / `resourceType: 'host'` +- `/api/resources?type=host` + +And requires: + +- unified `resources[]` +- unified agent marker `type: 'unified'` / `agent.type = "unified"` + +- `internal/unifiedresources/code_standards_test.go` + Added `TestV6ReleaseFacingAPITestsCoverLegacyHostRejection` to pin release-facing API tests that + explicitly reject removed `host` aliases in chat, AI actions, org shares, reporting, and metrics history. + +## Post-Release Queue + +Priority 1: unblock and finish executable guardrails + +- restore a green compile/test surface around `internal/api`, `internal/ai/tools`, and `internal/unifiedresources` +- land the blocked API-level normalizer ratchets for reporting and metrics history + +Priority 2: rename compatibility-layer terminology + +- `internal/api/agent_ingest.go` +- `internal/monitoring/monitor_agents.go` +- `internal/models/models.go` +- `internal/models/models_frontend.go` +- `internal/models/converters.go` +- `internal/servicediscovery/service.go` + +Priority 3: collapse remaining compat storage when feasible + +- retire `StateSnapshot.Hosts` / `models.Host*` only after agent-ingest, monitoring, routing, and discovery + no longer require the compatibility bridge +- only do this alongside a deliberate compatibility-removal plan, not as opportunistic cleanup diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md new file mode 100644 index 000000000..4c629fc9a --- /dev/null +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -0,0 +1,163 @@ +# Pulse v6 Release Promotion Policy + +This document defines how Pulse v6 and later releases move from development to +customer-facing availability. It is the release-trust contract for Pulse Pro, +Cloud, and self-hosted production users. + +## Goals + +1. Stable customers must not become the first test cohort for new changes. +2. Development speed must stay decoupled from customer exposure. +3. Every broad rollout must have explicit validation and rollback rules. + +## Channel Contract + +1. `stable` + - Default for new installs. + - The only recommended channel for paid and production environments. + - Publishes only non-prerelease tags. + - The only channel eligible for unattended broad rollout. +2. `rc` + - Opt-in preview channel for internal use, staging-like environments, and + explicitly willing preview users. + - Publishes prerelease tags such as `6.0.0-rc.1`. + - Must never be the default channel. + - In v6, `rc` affects manual and in-app update selection; unattended + systemd auto-updates remain `stable`-only. +3. Source builds + - Are not a customer-facing release channel. + - Remain reserved for development, debugging, and branch validation. + +## Development Model + +1. Use short-lived feature branches and feature flags for incomplete or risky + work. +2. Do not move directly from "issue fixed" to "all customers updated". +3. Channel promotion is the primary customer-safety boundary. +4. Branch topology may change over time; the `stable` versus `rc` customer + contract must not. +5. The active release profile in `docs/release-control/control_plane.json` + owns the governed prerelease and stable release branches for the current + line; release automation must resolve branch requirements from that file + instead of assuming `main`. + +## Prerelease Rules + +1. Every candidate intended for broad customer use must ship to `rc` before it + is eligible for `stable`. +2. Each published prerelease must have: + - Targeted automated checks for touched release surfaces. + - A smoke install on a fresh or staging-like environment. + - Release notes plus the rollback target and exact reinstall command recorded before publish. + - At least one live run of the release pipeline for the prerelease tag itself, not + only structural workflow validation. + - A governed prerelease publication record; an accidental git tag by itself + does not count as a shipped prerelease. +3. Failed prereleases are fixed forward and replaced with a new prerelease. They are never + promoted as-is to `stable`. + +## v5 Maintenance Policy + +1. When Pulse v6 reaches `stable`, Pulse v5 immediately enters + maintenance-only support. +2. The maintenance-only window lasts 90 calendar days from the v6 GA or stable + release date. +3. During that window, v5 fixes are limited to: + - critical security issues + - critical correctness or data-loss issues + - migration blockers that prevent customers from reaching a safe v6 path +4. v5 will not receive: + - new features + - normal bug-fix backports + - pricing-model exceptions + - entitlement-model parity work introduced for v6 +5. After the 90-day window ends, v5 may continue running for users who choose + to stay on it, but it is unsupported. +6. The v6 GA announcement must publish the exact v5 end-of-support date + calculated from the GA publication date. +7. Before GA promotion is actually cleared, release notes may keep placeholder + dates for the GA notice; those placeholders do not satisfy the promotion + gate by themselves. +8. `V5_MAINTENANCE_SUPPORT_POLICY.md` is the canonical source for this policy + and the required GA release notice. + +## Stable Promotion Rules + +1. A stable tag must be promoted from a commit that has already been exercised + as a published prerelease. +2. A prerelease git tag counts as stable-promotion lineage only if that prerelease was + actually published through the governed prerelease path; accidental or abandoned git + tags do not satisfy the stable-promotion requirement. +3. For v6 GA, do not promote to `stable` until the active control-plane target + is the GA-promotion target and satisfies its `release_ready` completion + rule. +4. Every stable promotion requires: + - Applicable items in `PRE_RELEASE_CHECKLIST.md` complete. + - Applicable entries in `HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md` cleared. + - No known unresolved high-severity regressions in touched release surfaces. + - The previous stable rollback target and exact reinstall command recorded. + - A live release-pipeline exercise already completed for the promoted prerelease tag, + not only YAML lint or static workflow validation. + - The locked 90-day v5 maintenance-only policy in + `V5_MAINTENANCE_SUPPORT_POLICY.md` and the exact end-of-support notice + ready to publish with the promotion. +5. Normal stable promotions require a minimum 72-hour prerelease soak after the + candidate is available to internal or staging-like users. +6. Hotfix exception: + - A shorter soak is allowed only for narrowly scoped fixes to active + customer harm. + - The exception plus the rollback target and exact reinstall command must be + recorded in the release notes or release ticket before promotion. + +## Rollout Rules + +1. Default installs stay on `stable`. +2. Broad customer announcements and unattended updates target `stable` only. +3. `rc` enrollment must be explicit and reversible. +4. Paid production tenants should remain on `stable` unless they are knowingly + participating in preview validation. + +## Rollback Rules + +1. Never delete or rewrite shipped tags to hide a bad release; supersede them + with a newer release and explicit guidance. +2. If a prerelease is bad, hold it in `rc`, fix forward, and cut the next prerelease. Do not + promote it. +3. If a stable release is bad: + - Pause further promotion or auto-update exposure. + - Direct affected users to the prior stable pin. + - Cut and validate a hotfix or rollback release. +4. The previous stable version must remain installable by exact version pin + until the replacement stable release is trusted. + +## Required Release Artifacts + +1. Release notes. +2. Rollback target version and exact pin command. +3. Checklist evidence and gate status. +4. Staging or internal validation note. +5. v5 maintenance-only support policy and end-of-support note for the GA cutover. +6. Exact v6 GA and v5 end-of-support dates locked before GA publish and then + published in the GA release notes. +7. Prerelease-to-GA rehearsal record plus the machine-generated + `rc-to-ga-rehearsal-summary` artifact, including the GitHub Actions run URL + for the non-publish dry run and the canonical promotion metadata envelope: + candidate stable tag, promotion channel, promoted prerelease tag, rollback target, + exact rollback command, planned GA date, and planned v5 end-of-support + date. +8. The pushed governed release-branch copy of `.github/workflows/release-dry-run.yml` + must already accept that stable rehearsal metadata envelope through + `workflow_dispatch`, and the local release branch must match `origin` before + dispatch, because GitHub executes the selected remote ref and does not see + local-only governance state. + +## Authority + +If conflicts appear: + +1. `SOURCE_OF_TRUTH.md` owns the locked decision that this policy is mandatory. +2. `status.json` owns whether the decision is open or resolved and whether the + active target is release-ready. +3. `PRE_RELEASE_CHECKLIST.md` and + `HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md` own execution proof for a + specific promotion. diff --git a/docs/release-control/v6/internal/SUBSYSTEM_CONTRACT_TEMPLATE.md b/docs/release-control/v6/internal/SUBSYSTEM_CONTRACT_TEMPLATE.md new file mode 100644 index 000000000..8b137ec4d --- /dev/null +++ b/docs/release-control/v6/internal/SUBSYSTEM_CONTRACT_TEMPLATE.md @@ -0,0 +1,47 @@ +# Subsystem Contract Template + +Use this template for any new major subsystem that needs a canonical contract. + +## Contract Metadata + +```json +{ + "subsystem_id": "example-subsystem", + "lane": "L0", + "contract_file": "docs/release-control/v6/internal/subsystems/example-subsystem.md", + "status_file": "docs/release-control/v6/internal/status.json", + "registry_file": "docs/release-control/v6/internal/subsystems/registry.json", + "dependency_subsystem_ids": [] +} +``` + +## Purpose + +State what this subsystem owns and what it explicitly does not own. + +## Canonical Files + +List the files that contain the subsystem truth. + +## Shared Boundaries + +List any runtime files intentionally shared with other subsystems. +Use the exact registry-derived item shape: +`1. \`path\` shared with \`partner-subsystem\`: rationale.` +If none exist, write `1. None.` + +## Extension Points + +List the only approved places to extend the subsystem. + +## Forbidden Paths + +List the patterns and files that future work must not use. + +## Completion Obligations + +List what must be updated when the subsystem changes. + +## Current State + +Record the current migration/end-state summary in a few lines. diff --git a/docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md b/docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md new file mode 100644 index 000000000..16b493b23 --- /dev/null +++ b/docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md @@ -0,0 +1,72 @@ +# Pulse v5 Maintenance-Only Support Policy + +This document is the canonical support-policy decision for the Pulse v6 GA +cutover. + +## Trigger + +1. This policy activates on the calendar date that `v6.0.0` first ships on + the `stable` channel. +2. That publication date is the authoritative v6 GA date for support-policy + purposes. +3. Before the GA release is published, the release notes must include the + exact v6 GA date and the exact v5 end-of-support date in `YYYY-MM-DD` form. + +## Support Window + +1. Pulse v5 enters maintenance-only support immediately on the v6 GA date. +2. The maintenance-only support window lasts 90 calendar days from the v6 GA + date. +3. The published v5 end-of-support date is authoritative and must match that + 90-day window. + +## Eligible v5 Fixes + +Only issues that materially threaten existing deployments or paying-customer +continuity qualify for v5 maintenance work: + +1. Critical security issues. +2. Critical correctness or data-loss issues. +3. Installer, startup, or updater failures that prevent normal operation. +4. Licensing or billing blockers that wrongly break an existing paying + customer. +5. Safe migration blockers that prevent customers from reaching a supported v6 + path. + +## Out Of Scope For v5 + +These do not qualify as v5 maintenance work: + +1. New features or integrations. +2. Routine bug-fix backports. +3. UI polish, refactors, or parity work with v6. +4. Pricing-model or entitlement-model exceptions created to avoid the v6 + model. + +## Release-Line Rules + +1. Cut `pulse/v5-maintenance` from the last supported v5 stable point at the + v6 GA cutover. +2. Ship approved v5 maintenance releases from `pulse/v5-maintenance` only. +3. Keep `main` and the active v6 line focused on v6 and later. +4. Fix on the active v6 line first when practical, then backport the smallest + safe change to v5 only when the issue qualifies under this policy. + +## End Of Support + +1. After the published v5 end-of-support date, Pulse v5 is unsupported. +2. After that date, new fixes land only on v6 and later unless I explicitly + announce an exception. +3. The GA release notice is required to publish the exact v5 end-of-support + date so customers can plan upgrades before the window closes. + +## Required GA Release Notice + +The first stable `v6.0.0` release must publish this meaning, with placeholders +replaced by exact dates: + +> Pulse v5 entered maintenance-only support on [v6-ga-date]. I will ship only +> critical security, data-loss, licensing or billing blocker, installer or +> updater failure, and safe migration blocker fixes for existing v5 users until +> [v5-eos-date]. After [v5-eos-date], Pulse v5 is end-of-support and new fixes +> land on v6 unless I publish an explicit exception. diff --git a/docs/release-control/v6/internal/V5_TO_V6_COMMERCIAL_MIGRATION_AUDIT_2026-03-07.md b/docs/release-control/v6/internal/V5_TO_V6_COMMERCIAL_MIGRATION_AUDIT_2026-03-07.md new file mode 100644 index 000000000..1520b16d2 --- /dev/null +++ b/docs/release-control/v6/internal/V5_TO_V6_COMMERCIAL_MIGRATION_AUDIT_2026-03-07.md @@ -0,0 +1,119 @@ +# Pulse v5 -> v6 Commercial Migration Audit + +Date: 2026-03-07 +Owner: Pulse v6 release control +Scope: Self-hosted v5 -> v6 commercial and licensing bridge in `pulse` + +## Canonical inputs used + +- Human source: `docs/release-control/v6/SOURCE_OF_TRUTH.md` +- Machine source: `docs/release-control/v6/status.json` + +Locked v6 contract from those sources: + +1. Trial authority in v6 is SaaS-controlled. `POST /api/license/trial/start` must initiate hosted signup only. +2. The local runtime may only redeem signed hosted trial activation tokens via `/auth/trial-activate`. +3. v6 may auto-exchange persisted v5 Pro/Lifetime licenses on upgrade startup. +4. v6 may accept valid v5 Pro/Lifetime keys in the activation flow. +5. Paid Pulse Pro v5 recurring customers keep their existing recurring price after migration until they cancel; any return after cancellation uses current v6 pricing. + +## Current bridge surface inspected + +- Startup auto-exchange: `internal/api/licensing_handlers.go`, `internal/api/licensing_handlers_auto_migrate_test.go` +- Activation flow: `pkg/licensing/service.go`, `internal/api/license_handlers_test.go`, `pkg/licensing/service_activate_test.go` +- Entitlement payload + trial eligibility: `internal/api/subscription_entitlements.go`, `pkg/licensing/entitlement_payload.go`, `internal/api/entitlement_handlers_test.go` +- Hosted trial start and callback: `internal/api/licensing_handlers.go`, `internal/api/trial_handlers_test.go`, `internal/api/hosted_lifecycle_integration_test.go` +- Upgrade UI messaging: `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx`, `frontend-modern/src/stores/license.ts` +- Upgrade integration fixture: `tests/migration/v5_full_upgrade_test.go` + +## Truth table: incoming v5 commercial state -> required v6 behavior + +| Incoming v5 state | Starting persisted state | Expected v6 entitlement result | Expected UI state / message | User action required | Hosted service involved | +|---|---|---|---|---|---| +| Fresh/free v5 install | No `license.enc`, no `activation.enc` | Free / expired entitlement only | Standard free-state upgrade UI; Pro trial CTA allowed | No | No | +| Already on v6 activation model | `activation.enc` present, optional stale `license.enc` | Keep current v6 activation/grant; do not re-exchange legacy file | Active paid state with current plan details; no migration prompt | No | No at startup | +| Paid v5 Pro/Lifetime, exchange succeeds on startup | Valid v5 `license.enc`, no `activation.enc` | Auto-exchange into active v6 activation/grant; preserve grandfathered recurring-price identity and `plan_version`; keep legacy key on disk for downgrade fallback | Paid state is live immediately; if grandfathered, show migrated plan terms and legacy-price continuity | No | Yes, license exchange endpoint | +| Paid v5 Pro/Lifetime, exchange fails transiently | Valid v5 `license.enc`, no `activation.enc`, exchange unavailable/5xx/network | Do not silently collapse to ordinary free/trial-eligible state; mark migration as pending/blocked; preserve legacy key | Explicit migration-needed notice: paid v5 key detected, automatic exchange did not complete, retry activation from this instance; no new-trial CTA | Yes, retry activation or retrieve v6 activation key | Yes, exchange endpoint unavailable | +| Paid v5 Pro/Lifetime, exchange rejected permanently | Valid-looking v5 `license.enc`, no `activation.enc`, exchange returns invalid/expired/unsupported | Do not grant paid entitlements; preserve enough state to explain the failure; do not offer a misleading fresh trial as if no paid key existed | Explicit migration failure notice with invalid/expired/unsupported wording; direct user to activate with current v6 key or correct v5 key | Yes | Yes, exchange endpoint rejects key | +| Manual activation with valid v5 Pro/Lifetime key | User pastes v5 key into v6 panel | Exchange into active v6 activation/grant; persist activation state; preserve legacy key for downgrade fallback | Success message should make it clear the v5 key was migrated to v6 | Yes, one-time manual paste | Yes, exchange endpoint | +| Manual activation with invalid/expired/unsupported v5-like key | User pastes JWT-like legacy key into v6 panel | No entitlement change | Clear error message: not a valid v6 activation key or supported v5 Pro/Lifetime key | Yes | Yes, exchange endpoint | + +## Related hosted-trial flows after upgrade + +These are not incoming paid-license migration states, but they are part of the same commercial bridge and must stay coherent for upgraded v5 users. + +| Post-upgrade state | Starting persisted state | Expected v6 entitlement result | Expected UI state / message | User action required | Hosted service involved | +|---|---|---|---|---|---| +| Free/eligible org starts v6 trial | No active paid state; no prior `trial_started_at` | No immediate local trial minting from `/api/license/trial/start`; response must redirect into hosted signup | User leaves Pulse for hosted signup | Yes | Yes, hosted signup | +| Hosted trial callback succeeds | Signed token + valid initiation token | Lease-backed trial entitlement becomes active; local billing state is lease cache only | `/settings/system-pro?trial=activated` notice and live trial countdown | No further action | Yes, hosted signup + lease redemption | +| Hosted trial callback invalid/replayed/unavailable/ineligible | Invalid or stale callback/token state | No new paid entitlement | Explicit result banner based on `trial` query (`invalid`, `replayed`, `unavailable`, `ineligible`) | Usually yes | Yes | + +## Comparison to current implementation + +### What is already correct + +1. Startup auto-exchange exists for persisted legacy JWT-style licenses and preserves the old key for downgrade fallback. +2. Manual activation accepts v6 activation keys and also exchanges v5 JWT-style keys outside dev mode. +3. Migrated `plan_version` survives into `status` and `entitlements`, and the Pro panel renders migrated plan terms without repricing recurring v5 customers. +4. `POST /api/license/trial/start` does not mint local trial state; it returns `trial_signup_required` with a hosted action URL. +5. `/auth/trial-activate` redeems a signed hosted token, stores lease-backed billing state, and redirects with an explicit result code. + +### Highest-risk gaps + +1. Auto-exchange failure is not represented as explicit state. + Current behavior in `internal/api/licensing_handlers.go` logs the exchange failure and keeps running. After that, `svc.Status()` and `GET /api/license/entitlements` collapse to ordinary free-state behavior because there is no machine-readable "migration pending" or "migration failed" contract. + +2. A paid v5 migrator can be misclassified as trial-eligible. + Trial eligibility only checks active v6 license state plus billing state. If a valid persisted v5 paid key fails to exchange and no billing state exists yet, the org becomes `trial_eligible=true` even though the correct contract is "paid migration blocked, retry exchange". This is the largest commercial coherence risk. + +3. Upgrade-time UI messaging is not state-driven. + The only migration-specific frontend notice is a textarea heuristic in `frontend-modern/src/components/Settings/ProLicensePanel.tsx` that treats any three-segment key as "Legacy v5 license detected". There is no startup banner or entitlement-state notice for "persisted v5 paid key detected but exchange failed". + +4. Success and failure copy does not distinguish migration outcomes strongly enough. + Manual migration success returns the generic message `License activated successfully`. That is functional, but it does not confirm that the pasted v5 key was exchanged into the v6 activation model. The failure path is better, but it still depends on a manual paste instead of a detected startup state. + +5. The migration test suite is happy-path heavy. + `tests/migration/v5_full_upgrade_test.go` covers only the startup success case for a persisted v5 Lifetime key. There is no full-upgrade negative-path contract for exchange failure, rejection, or UI/entitlement behavior after failure. + +## Exact missing test file paths + +Add or extend tests in these exact files: + +1. `tests/migration/v5_full_upgrade_test.go` + Add persisted-v5-paid-license upgrade scenarios where exchange is transiently unavailable and permanently rejected. + +2. `internal/api/licensing_handlers_auto_migrate_test.go` + Add startup auto-exchange negative-path tests proving legacy key preservation plus explicit migration-pending behavior once the new contract exists. + +3. `internal/api/entitlement_handlers_test.go` + Add payload contract tests for the new migration state and for `trial_eligible=false` while a paid v5 migration is pending or failed. + +4. `internal/api/license_handlers_test.go` + Add manual activation tests for legacy exchange rejection classes (expired, unsupported, invalid) and migration-specific success messaging. + +5. `frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx` + Add UI tests for startup migration-pending / migration-failed notices and suppression of the Pro trial CTA during those states. + +6. `frontend-modern/src/stores/__tests__/license.test.ts` + Add store tests for any new commercial-migration fields surfaced by `GET /api/license/entitlements`. + +## Recommended implementation sequence + +1. Add an explicit v6-owned migration contract to the entitlements payload. + Recommended shape: a dedicated field such as `commercial_migration` with `state`, `source`, `reason`, and `recommended_action`. Do not overload `has_migration_gap`; it already means legacy infrastructure connection drift. + +2. Persist migration-pending state when startup auto-exchange fails. + The runtime needs durable state that says: a legacy paid key exists, exchange did not complete, and trial start must be suppressed until the user resolves migration or clears the key intentionally. + +3. Make trial eligibility migration-aware. + `trial_eligible` must be false whenever a paid v5 migration is pending or has failed but remains unresolved. + +4. Drive the Pro panel from the new contract. + Show an explicit upgrade-time notice for startup exchange failure and a clearer success notice for manual v5->v6 migration. Remove reliance on the current "three JWT segments means legacy v5" heuristic for anything beyond a weak input hint. + +5. Backfill the negative-path tests listed above. + The new contract should be locked in both backend payload tests and frontend rendering tests before any broader commercial-path polish. + +## Audit verdict + +The v5->v6 bridge is implemented for the success path, but it is not yet an explicit v6-owned contract on the failure path. Until v6 can represent "paid v5 migration blocked" as a first-class entitlement/UI state, upgrade safety is incomplete and the commercial path can drift into the wrong offer and the wrong message. diff --git a/docs/release-control/v6/internal/records/api-token-scope-and-assignment-2026-03-12.md b/docs/release-control/v6/internal/records/api-token-scope-and-assignment-2026-03-12.md new file mode 100644 index 000000000..bcd9693cd --- /dev/null +++ b/docs/release-control/v6/internal/records/api-token-scope-and-assignment-2026-03-12.md @@ -0,0 +1,51 @@ +# API Token Scope And Assignment Record + +- Date: `2026-03-12` +- Gate: `api-token-scope-and-assignment` +- Environment: + - Managed local backend: `http://127.0.0.1:61530` + - Multi-tenant entitlement profile: `multi-tenant` + - Authenticated user under test: `admin` + +## Automated Proof Baseline + +- `go test ./internal/api -run 'Test(APIToken|SecurityTokens|SystemSettings|MultiTenant)' -count=1` +- `go test ./internal/api -run 'TestNormalizeRequestedScopesCanonicalizesLegacyUnifiedAgentAliases|TestUnifiedAgentEndpointsAcceptLegacyUnifiedAgentReportScopeAlias|TestContract_APITokenScopeAliasNormalization' -count=1` +- `cd frontend-modern && npx vitest run src/components/Settings/__tests__/APITokenManager.test.tsx src/utils/__tests__/apiClient.org.test.ts src/utils/__tests__/apiTokenPresentation.test.ts src/utils/__tests__/frontendResourceTypeBoundaries.test.ts` +- `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 PULSE_MULTI_TENANT_ENABLED=true npm test -- tests/13-api-token-scope.spec.ts --project=chromium` +- Result: pass + +## Manual Exercise + +1. Logged into the managed local backend as `admin` through `POST /api/login` and used the issued session plus CSRF cookies for token-management requests. +2. Created an owner-bound API token with `settings:read` and confirmed the create response bound `ownerUserId=admin`. +3. Used that token to read `GET /api/system/settings` successfully. +4. Confirmed the same token was denied on: + - `POST /api/security/tokens` with `requiredScope=settings:write` + - `POST /api/ai/execute/stream` with `requiredScope=ai:execute` + - `PATCH /api/agents/agent/host-1/config` with `requiredScope=agent:manage` +5. Revoked the owner-bound token through `DELETE /api/security/tokens/{id}` and confirmed the stale bearer token immediately failed on `GET /api/system/settings` with `401`. +6. Created two orgs: + - `manual-token-org-a-1773352558099-998718` + - `manual-token-org-b-1773352558099-245536` +7. Created an org-bound token while scoped to org A and confirmed the create response still bound `ownerUserId=admin`. +8. Confirmed that org-bound token could read `GET /api/orgs/{orgA}/members` with `200` but failed against `GET /api/orgs/{orgB}/members` with `403` and `Token is not authorized for this organization`. +9. Created a token using legacy scope `host-agent:report` and confirmed the stored scope canonicalized to `agent:report`. +10. Used that legacy-report token against both: + - `POST /api/agents/agent/report` + - `POST /api/agents/host/report` + Both requests reached the handler and failed only on intentionally invalid JSON with `400`, not on scope authorization. +11. Created a token using legacy scope `host-agent:config:read` and confirmed the stored scope canonicalized to `agent:config:read`. +12. Used that legacy-config token against both: + - `GET /api/agents/agent/host-1/config` + - `GET /api/agents/host/host-1/config` + Both requests passed scope authorization and failed only because the synthetic host had not registered yet, returning `404 agent_not_found`, not `403`. +13. Deleted the temporary org-bound and legacy-alias tokens and removed both temporary orgs after the exercise. + +## Outcome + +- Session-created API tokens stayed bound to the authenticated user identity. +- Org-bound tokens stayed confined to the issuing org. +- Read, mutate, and exec scope enforcement returned the expected `missing_scope` failures with the canonical required scope names. +- Revocation invalidated bearer tokens immediately. +- Legacy persisted `host-agent:*` scope aliases canonicalized to the intended v6 `agent:*` scopes and passed the canonical report/config scope gates. diff --git a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-2026-03-13.md b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-2026-03-13.md new file mode 100644 index 000000000..cbc1fad4e --- /dev/null +++ b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-2026-03-13.md @@ -0,0 +1,112 @@ +# Cloud Hosted Tier Runtime Readiness Record + +- Date: `2026-03-13` +- Gate: `cloud-hosted-tier-runtime-readiness` +- Assertion: `RA11` +- Environment: + - Live localhost hosted-mode Pulse instance: `http://127.0.0.1:17771` + - Persisted data dir: `/Volumes/Development/pulse/repos/pulse/tmp/manual-hosted-runtime-20260313/data` + - Platform admin: `admin` + - Hosted tenant created during rehearsal: `fa0b5ad9-0bcf-47ba-8104-e6d71f0d3752` + - Hosted tenant email: `hosted-rc-20260313@example.com` + - Revalidation tenant after gate reopen: `fc6c9ffa-f100-46a2-b5e6-349dba526469` + - Revalidation tenant email: `hosted-rc-rerun-20260313-0942@example.com` + +## Automated Proof Baseline + +- `go test ./internal/api -run 'TestHostedLifecycle|TestHostedOrgAdminHandlers|TestHostedSignupSuccess|TestHostedSignupValidationFailures|TestHostedSignupHostedModeGate|TestHostedSignupRateLimit|TestHostedSignupRateLimit_NoProvisioningSideEffects|TestHostedSignupCleanupOnRBACFailure|TestHostedSignupFailsClosedWithoutPublicURL|TestStripeWebhook_' -count=1` +- `go test ./internal/cloudcp/... ./internal/hosted/... -count=1` +- `cd frontend-modern && npx vitest run src/pages/__tests__/HostedSignup.test.tsx src/components/Settings/__tests__/BillingAdminPanel.test.tsx src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx` +- Result: pass + +## Manual Exercise + +1. Started a clean localhost Pulse instance on `http://127.0.0.1:17771`, applied Quick Security Setup, confirmed auth and API-token state persisted into `tmp/manual-hosted-runtime-20260313/data/.env` and `api_tokens.json`, then restarted the same instance in hosted mode against that exact data directory. +2. Confirmed the hosted relaunch required auth on privileged surfaces: + - `GET /api/security/status` returned `requiresAuth=true`, `hasAuthentication=true`, and `apiTokenConfigured=true` + - anonymous `GET /api/hosted/organizations` returned `401 Authentication required` + - anonymous `GET /api/admin/orgs/fa0b5ad9-0bcf-47ba-8104-e6d71f0d3752/billing-state` returned `401 Authentication required` +3. Exercised real hosted signup on the live hosted-mode HTTP surface: + - `POST /api/public/signup` with `hosted-rc-20260313@example.com` and `Hosted RC 20260313` + - response was `201 Created` + - returned `org_id=fa0b5ad9-0bcf-47ba-8104-e6d71f0d3752` + - returned `message="Check your email for a magic link to finish signing in."` +4. Confirmed the public hosted post-signup auth surface remained usable: + - `POST /api/public/magic-link/request` for `hosted-rc-20260313@example.com` returned `200` + - payload was `{"success":true,"message":"If that email is registered, you'll receive a magic link shortly."}` +5. Confirmed the platform-admin hosted control surface could see the provisioned tenant on the same live hosted instance: + - authenticated `GET /api/hosted/organizations` as `admin` + - returned `200` + - list included both `default` and `fa0b5ad9-0bcf-47ba-8104-e6d71f0d3752` + - new tenant summary showed `display_name="Hosted RC 20260313"` and `owner_user_id="hosted-rc-20260313@example.com"` +6. Confirmed hosted billing/admin state for the new tenant was coherent: + - authenticated `GET /api/admin/orgs/fa0b5ad9-0bcf-47ba-8104-e6d71f0d3752/billing-state` + - returned `200` + - `subscription_state=trial` + - `plan_version=cloud_trial` + - hosted trial capabilities were populated +7. Confirmed tenant-scoped entitlements land in hosted runtime state instead of a self-hosted fallback: + - authenticated `GET /api/license/entitlements` with `X-Pulse-Org-ID` and `X-Org-ID` set to `fa0b5ad9-0bcf-47ba-8104-e6d71f0d3752` + - returned `200` + - `hosted_mode=true` + - `valid=true` + - `subscription_state=trial` + - `plan_version=cloud_trial` + - `tier=pro` + - `upgrade_reasons=[]` + +## Revalidation After Gate Reopen + +1. Relaunched the real hosted-mode Pulse runtime on `http://127.0.0.1:17771` against the same persisted data directory and confirmed the instance still loaded prior auth and token state. +2. Rechecked the auth boundary on the live runtime: + - `GET /api/security/status` still returned `requiresAuth=true`, `hasAuthentication=true`, and `apiTokenConfigured=true` + - anonymous `GET /api/hosted/organizations` still returned `401 Authentication required` + - anonymous `GET /api/admin/orgs/fa0b5ad9-0bcf-47ba-8104-e6d71f0d3752/billing-state` still returned `401 Authentication required` +3. Exercised a fresh hosted signup on the same live hosted runtime: + - `POST /api/public/signup` with `hosted-rc-rerun-20260313-0942@example.com` and `Hosted RC Rerun 20260313 0942` + - response was `201 Created` + - returned `org_id=fc6c9ffa-f100-46a2-b5e6-349dba526469` + - returned `message="Check your email for a magic link to finish signing in."` +4. Confirmed the public post-signup auth path still worked: + - `POST /api/public/magic-link/request` for `hosted-rc-rerun-20260313-0942@example.com` returned `200` + - payload remained `{"success":true,"message":"If that email is registered, you'll receive a magic link shortly."}` +5. Confirmed the platform-admin hosted control surface saw the newly provisioned tenant: + - authenticated `GET /api/hosted/organizations` as `admin` + - returned `200` + - list included `default`, the original rehearsal tenant, and `fc6c9ffa-f100-46a2-b5e6-349dba526469` + - new tenant summary showed `display_name="Hosted RC Rerun 20260313 0942"` and `owner_user_id="hosted-rc-rerun-20260313-0942@example.com"` +6. Confirmed hosted billing/admin state for the rerun tenant was still coherent: + - authenticated `GET /api/admin/orgs/fc6c9ffa-f100-46a2-b5e6-349dba526469/billing-state` + - returned `200` + - `subscription_state=trial` + - `plan_version=cloud_trial` +7. Confirmed tenant-scoped entitlements still landed in hosted runtime state: + - authenticated `GET /api/license/entitlements` with `X-Pulse-Org-ID` and `X-Org-ID` set to `fc6c9ffa-f100-46a2-b5e6-349dba526469` + - returned `200` + - `hosted_mode=true` + - `valid=true` + - `subscription_state=trial` + - `plan_version=cloud_trial` + - `tier=pro` + - `upgrade_reasons=[]` +8. Re-ran the governed automated proof bundle after the manual revalidation: + - `go test ./internal/api -run 'TestHostedLifecycle|TestHostedOrgAdminHandlers|TestHostedSignupSuccess|TestHostedSignupValidationFailures|TestHostedSignupHostedModeGate|TestHostedSignupRateLimit|TestHostedSignupRateLimit_NoProvisioningSideEffects|TestHostedSignupCleanupOnRBACFailure|TestHostedSignupFailsClosedWithoutPublicURL|TestStripeWebhook_' -count=1` + - `go test ./internal/cloudcp/... ./internal/hosted/... -count=1` + - `cd frontend-modern && npx vitest run src/pages/__tests__/HostedSignup.test.tsx src/components/Settings/__tests__/BillingAdminPanel.test.tsx src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx` + - Result: pass + +## Outcome + +- Hosted Pulse can be entered as a real tier on a live hosted-mode runtime, not just provisioned in signup and billing tests. +- Public hosted signup and magic-link request stay functional on the same instance that serves hosted runtime/admin surfaces. +- Hosted billing/admin and tenant-scoped entitlements reflect coherent hosted trial state after provisioning. +- The post-provisioning tenant path lands in hosted entitlements (`hosted_mode=true`, valid trial state) instead of falling back to a self-hosted expired/free posture. +- Privileged hosted admin surfaces remain protected while still functioning correctly for the platform admin. +- Re-exercising the gate after it was reopened produced the same result on the persisted hosted runtime, so the localhost hosted rehearsal remains valid supporting evidence. +- This evidence is still below the gate's required `real-external-e2e` threshold because it was exercised on a live localhost hosted-mode runtime, not on the real external hosted service. +- The gate therefore remains pending until the same flow is exercised on the real external hosted tier. + +## Notes + +- This rehearsal intentionally used the real `pulse` binary on a live localhost HTTP surface rather than handler-only tests. +- The initial auth seed was applied before the hosted relaunch so the hosted runtime proof covered persisted auth and runtime continuity, not a one-shot in-memory test harness. diff --git a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-blocked-2026-03-25.md b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-blocked-2026-03-25.md new file mode 100644 index 000000000..863d608b1 --- /dev/null +++ b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-blocked-2026-03-25.md @@ -0,0 +1,62 @@ +# Cloud Hosted Tier Runtime Readiness Blocked Record + +- Date: `2026-03-25` +- Gate: `cloud-hosted-tier-runtime-readiness` +- Assertion: `RA11` +- Result: `blocked` + +## Blocking Facts + +1. The live hosted Pulse Cloud control plane is still provisioning tenant + runtimes without a machine-owned relay registration path. +2. Live hosted tenant containers are healthy on `pulse-cloud`, but sampled + tenant runtimes still lack persisted relay state: + - `/etc/pulse/billing.json` present + - `/etc/pulse/relay.enc` absent + - `/etc/pulse/activation.enc` absent +3. The live relay service is currently reporting zero connected Pulse instances: + - `pulse_relay_instance_connections 0` + - `pulse_relay_app_sessions 0` +4. The live relay host currently confirms only `PULSE_RELAY_PUBLIC_KEY` in the + running environment for the checked relay validation surface; the hosted + entitlement key path required for hosted lease validation is not yet wired on + the live relay service. +5. The current hosted runtime code line only auto-starts relay when persisted + relay config already exists and only hands the relay client a classic + activated-license token. Hosted entitlement leases do not currently cross + that boundary in production. +6. The canonical fix now exists in repo but is not yet the live hosted/runtime + behavior: + - `pulse`: hosted entitlement-backed relay bootstrap and hosted relay + registration-token fallback + - `pulse-pro/relay-server`: hosted entitlement lease validation and hosted + instance-id derivation from `instance_host` +7. The current owned relay journey proof only exercises manual relay enablement + and does not prove that a fresh hosted tenant automatically becomes + mobile-visible through relay after provisioning. + +## Why The Gate Cannot Be Cleared Yet + +The currently recorded hosted-runtime production evidence still proves hosted +auth handoff, runtime entry, and hosted billing/admin surfaces, but it does not +prove the current expected v6 hosted-mobile path. In the live system today, a +fresh hosted tenant can still come up with valid hosted billing entitlement and +no canonical relay registration path. That means hosted runtime is not yet +coherent enough to treat the hosted tier as fully RC-ready for the cloud/mobile +surface. + +## Required Unblock Steps + +1. Deploy the hosted runtime fix that auto-bootstraps relay from hosted + entitlement state and uses the hosted entitlement lease as the relay + registration credential when no activated license exists. +2. Deploy the relay-server fix that validates hosted entitlement leases and + derives stable hosted instance ids from `instance_host`. +3. Wire the hosted entitlement public key into the live relay environment. +4. Re-run the real hosted proof from a fresh production canary: + - provision hosted tenant + - confirm relay connects without manual `/api/settings/relay` mutation + - confirm onboarding QR/deep-link exposes a real relay `instance_id` + - confirm Pulse Mobile can pair against that hosted tenant +5. Replace this blocked record with fresh `real-external-e2e` evidence only + after that end-to-end hosted-mobile path is exercised successfully. diff --git a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-2026-03-13.md b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-2026-03-13.md new file mode 100644 index 000000000..f1347316f --- /dev/null +++ b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-2026-03-13.md @@ -0,0 +1,46 @@ +# Cloud Hosted Tier Runtime Readiness Production Record + +- Date: `2026-03-13` +- Gate: `cloud-hosted-tier-runtime-readiness` +- Assertion: `RA11` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Evidence source: production HTTPS surface, admin-key protected control-plane APIs, and control-plane logs + +## External Exercise + +1. Ran the governed production preflight against the real external hosted service: + - `DOMAIN=cloud.pulserelay.pro SSH_TARGET=root@pulse-cloud ADMIN_KEY_FILE=/Volumes/Development/pulse/secrets/cloud-cp/admin_key deploy/cloud/preflight-live.sh` +2. Confirmed the public hosted entry surfaces are live on production: + - `GET /healthz` returned `200` + - `GET /readyz` returned `200` + - `GET /signup` returned `200` + - `GET /cloud/signup` returned `200` + - `GET /signup/complete` returned `200` +3. Confirmed the production control plane currently reports existing hosted tenants as healthy: + - `GET /status` with the production admin key returned `total_tenants=16`, `healthy=16`, `unhealthy=0` + - `GET /admin/tenants` returned 16 active hosted tenants on the live service +4. Confirmed the operator path itself was healthy once the real SSH target was used: + - SSH to `root@pulse-cloud` succeeded + - compose, digest pinning, and backup/restore guardrails passed +5. Corrected the live production control-plane env mismatch and revalidated preflight: + - `/opt/pulse-cloud/.env` now contains `CP_TRIAL_ACTIVATION_PRIVATE_KEY` + - restarted `pulse-cloud-control-plane-1` cleanly via `docker compose up -d control-plane` + - rerunning `deploy/cloud/preflight-live.sh` passed with `failures=0` and `warnings=0` +6. Confirmed the external hosted entry path now reaches live Stripe checkout creation: + - `POST /api/public/signup` with a fresh dedicated production test email returned a real `checkout_url=https://checkout.stripe.com/...` + - this is stronger than the earlier broken-env state because the live public hosted surface now reaches the external billing boundary successfully +7. Did not generate a runtime login against an existing production tenant: + - no clearly dedicated internal hosted tenant was identified for a safe rehearsal + - using an admin-generated magic link against a real customer tenant would amount to impersonating a live customer workspace for release proof + - a fresh production checkout was not completed because it would create real finance-visible side effects on the live Stripe environment + +## Outcome + +- This is real external production evidence, not localhost rehearsal. +- The live hosted surface is reachable and the hosted signup path now reaches real checkout creation on production. +- The gate still cannot pass honestly because there is not yet fresh external evidence that a completed production checkout leads to a healthy tenant runtime that can actually be entered and used. +- `cloud-hosted-tier-runtime-readiness` remains pending until: + - checkout-driven provisioning is re-exercised successfully on the real external service + - a safe hosted runtime-entry drill can be completed without relying on a real customer workspace diff --git a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-billing-followup-2026-03-13.md b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-billing-followup-2026-03-13.md new file mode 100644 index 000000000..daa025657 --- /dev/null +++ b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-billing-followup-2026-03-13.md @@ -0,0 +1,66 @@ +# Cloud Hosted Tier Runtime Readiness Production Billing Follow-up Record + +- Date: `2026-03-13` +- Gate: `cloud-hosted-tier-runtime-readiness` +- Assertion: `RA11` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Fresh runtime image: `pulse-runtime:hosted-billingfix-20260313T164400Z` + - Fresh production canary tenant: `t-P62TP8K28Y` + - Evidence source: live production HTTPS surface, control-plane bearer session, tenant runtime JSON exchange payload, tenant members API, tenant billing-state API, tenant entitlements API, and mounted billing-state inspection on `pulse-cloud` + +## External Follow-up Exercise + +1. Rebuilt and redeployed the hosted tenant runtime on production with the final + same-lane fixes applied together: + - hosted release-mode entitlement-key env override + - control-plane handoff email normalization + - hosted tenant billing-state fallback to the effective default-org lease +2. Created a fresh MSP workspace canary through the production control plane: + - account: `a_ownerseed_20260313T145927` + - tenant: `t-P62TP8K28Y` +3. Exercised the real hosted handoff exchange in JSON mode on the fresh tenant: + - `POST /api/cloud/handoff/exchange?format=json` + - returned `200` + - payload normalized the runtime session identity to + `operator-owner+20260313t145927@pulserelay.pro` +4. Continued the same session into the tenant-scoped runtime surfaces that had + previously drifted apart: + - `GET /api/orgs/t-P62TP8K28Y/members` returned `200` + - `GET /api/admin/orgs/t-P62TP8K28Y/billing-state` returned `200` + - `GET /api/license/entitlements` returned `200` +5. Confirmed those surfaces now agree on the live hosted commercial state: + - members payload includes the normalized provider-owner identities + - billing-state payload reports: + - `subscription_state="active"` + - `plan_version="msp_starter"` + - `limits.max_monitored_systems=50` + - entitlements payload reports the same active hosted MSP entitlement with + `plan_version="msp_starter"` and `max_monitored_systems=50` +6. Confirmed the mounted root hosted billing record on `pulse-cloud` for the + fresh canary carries the same active entitlement lease and refresh token + that the runtime now projects correctly through both billing-state and + entitlements. + +## Outcome + +- This is successful `real-external-e2e` production follow-up evidence. +- The fresh hosted runtime can now: + - normalize handoff identity into the same casing used by seeded org + membership + - preserve that session identity through tenant entry + - resolve the mounted hosted entitlement lease into active runtime state + - return coherent hosted commercial state from both billing-state and + entitlements on the same fresh tenant +- This closes the remaining same-lane runtime drift between: + - handoff session identity + - hosted entitlement evaluation + - tenant billing-state admin reads + +## Conclusion + +- `cloud-hosted-tier-runtime-readiness` remains correctly `passed`. +- `RA11` is now backed by fresh production proof that a newly provisioned MSP + tenant can complete the full hosted runtime path without divergence between + membership, billing-state, and entitlement surfaces. diff --git a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-fixed-2026-03-13.md b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-fixed-2026-03-13.md new file mode 100644 index 000000000..e7dde5f03 --- /dev/null +++ b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-fixed-2026-03-13.md @@ -0,0 +1,96 @@ +# Cloud Hosted Tier Runtime Readiness Production Fixed Record + +- Date: `2026-03-13` +- Gate: `cloud-hosted-tier-runtime-readiness` +- Assertion: `RA11` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Control-plane image: `pulse-control-plane:634fa3a66663-20260313T115951Z` + - Final hosted runtime image: `pulse-runtime:b6800fe1f401-20260313T121150Z-pubkey` + - Dedicated MSP rehearsal account: `a_msp_prod_fix_20260313111348` + - Dedicated provider owner session: `owner+msp-fix-20260313111348@pulserelay.pro` + - Final dedicated hosted rehearsal tenant: `t-2VNHNRHSGT` + - Evidence source: live production HTTPS surface, admin-key protected control-plane APIs, tenant container inspection, and fresh tenant runtime responses + +## Runtime Repair Chain + +1. Confirmed the earlier hosted runtime failures were real and distinct: + - older production rehearsal tenants predated org seeding and returned + `invalid_org` + - newer tenants on the earlier runtime image still returned + `subscription_required` + - tenant logs showed `hosted entitlement instance host is unavailable` +2. Deployed the current governed control-plane build to production: + - `pulse-control-plane:634fa3a66663-20260313T115951Z` +3. Confirmed fresh hosted tenants now receive the required runtime artifacts at + provision time: + - seeded `orgs//org.json` + - root `billing.json` with active hosted entitlement lease and integrity +4. Identified the remaining live blocker in the tenant runtime build itself: + - the hosted runtime image did not have the entitlement/trial public key + embedded, so it could not parse the lease token written by the control + plane +5. Rebuilt the hosted runtime image from the current `pulse/v6-release` `HEAD` with + the matching public key embedded: + - `pulse-runtime:b6800fe1f401-20260313T121150Z-pubkey` +6. Updated `CP_PULSE_IMAGE` on `pulse-cloud` to that rebuilt runtime image and + restarted only the control plane so new rehearsal tenants would use it + +## External Rehearsal + +1. Created a fresh hosted rehearsal tenant under the dedicated live MSP account: + - `POST /api/accounts/a_msp_prod_fix_20260313111348/tenants` + - created `t-2VNHNRHSGT` + - container image confirmed on `pulse-cloud` as + `pulse-runtime:b6800fe1f401-20260313T121150Z-pubkey` +2. Verified the fresh tenant data dir on `pulse-cloud`: + - `orgs/t-2VNHNRHSGT/org.json` existed with owner/member seeding + - `billing.json` contained an active `msp_starter` hosted entitlement lease + with `max_monitored_systems=50` +3. Generated a fresh production admin magic link for the dedicated tenant: + - `POST https://cloud.pulserelay.pro/admin/magic-link` + - `email=owner+msp-fix-20260313111348@pulserelay.pro` + - `tenant_id=t-2VNHNRHSGT` +4. Exercised the real hosted handoff path: + - control-plane redirect target was + `https://t-2VNHNRHSGT.cloud.pulserelay.pro/auth/cloud-handoff?...` + - tenant runtime completed handoff, set tenant cookies, and landed on `/` + with `200` +5. Confirmed the runtime image now carries the required verification key: + - tenant logs reported + `license public key loaded` + - the prior `hosted entitlement instance host is unavailable` warning was no + longer the active blocker on the successful path +6. Continued the same session into hosted entitlement surfaces on the fresh + tenant: + - `GET /api/license/entitlements` on tenant org `t-2VNHNRHSGT` returned + `200` + - payload reported: + - `subscription_state="active"` + - `plan_version="msp_starter"` + - `hosted_mode=true` + - `valid=true` + - `limits.max_monitored_systems=50` + - `GET /api/license/entitlements` with `X-Pulse-Org-ID: default` also + returned the same active hosted entitlement state + +## Outcome + +- This is successful `real-external-e2e` production evidence. +- A fresh externally provisioned hosted tenant can now: + - receive the seeded org/runtime artifacts it needs + - complete hosted magic-link handoff + - land inside the hosted Pulse app + - resolve active paid hosted entitlements coherently at both tenant and + default lease surfaces +- This clears the earlier hosted runtime blockers around immutable ownership, + tenant org seeding, hosted lease fallback, and missing runtime public-key + embedding. + +## Conclusion + +- `cloud-hosted-tier-runtime-readiness` can be treated as `passed`. +- `RA11` is now backed by successful `real-external-e2e` hosted runtime proof. +- `hosted-signup-billing-replay` remains a separate gate and is still required + for complete hosted commercial confidence. diff --git a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-followup-2026-03-13.md b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-followup-2026-03-13.md new file mode 100644 index 000000000..7e71905e8 --- /dev/null +++ b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-followup-2026-03-13.md @@ -0,0 +1,75 @@ +# Cloud Hosted Tier Runtime Readiness Production Follow-up Record + +- Date: `2026-03-13` +- Gate: `cloud-hosted-tier-runtime-readiness` +- Assertion: `RA11` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Control-plane image line: `pulse-control-plane:*owner-seed*` then `pulse-control-plane:*tenant-id*` + - Dedicated MSP rehearsal account: `a_msp_owner_seed_20260313124930` + - First fresh canary after owner seeding: `t-XX72QXZD8A` + - Successful handoff canary after tenant-ID injection: `t-8ME7XXQM7X` + - Evidence source: live production HTTPS surface, control-plane bearer session, tenant container inspection, and tenant runtime API responses + +## External Follow-up Exercise + +1. Seeded a dedicated live MSP rehearsal account with two provider owners on the + production control plane: + - legacy owner `legacy+msp-owner-seed-20260313124930@pulserelay.pro` + - active operator session `operator+msp-owner-seed-20260313124930@pulserelay.pro` +2. Deployed the owner-aware hosted provisioning fix to production and created a + fresh canary workspace: + - tenant id `t-XX72QXZD8A` + - inspected `/data/tenants/t-XX72QXZD8A/orgs/t-XX72QXZD8A/org.json` + - confirmed `ownerUserId=operator+msp-owner-seed-20260313124930@pulserelay.pro` +3. Confirmed that first fresh canary still exposed one remaining hosted-entry + bug: + - control-plane handoff creation succeeded + - tenant entry still failed because runtime org/handoff resolution depended + on forwarded-host inference instead of a canonical injected tenant ID +4. Deployed the follow-up hosted runtime bootstrap fix to production so fresh + hosted tenants carry an explicit identity at runtime: + - tenant env now includes `PULSE_TENANT_ID=` + - hosted env also preserves the explicit tenant public URL and hosted mode + flags for runtime bootstrap +5. Created a second fresh canary after the tenant-ID fix: + - tenant id `t-8ME7XXQM7X` + - inspected `/data/tenants/t-8ME7XXQM7X/orgs/t-8ME7XXQM7X/org.json` + - confirmed `ownerUserId=operator+msp-owner-seed-20260313124930@pulserelay.pro` + - confirmed the live container env included + `PULSE_TENANT_ID=t-8ME7XXQM7X` +6. Exercised the real hosted handoff path against the successful canary using + the production operator session: + - fetched a real control-plane handoff form for + `a_msp_owner_seed_20260313124930` + - exchanged the handoff at + `POST https://t-8ME7XXQM7X.cloud.pulserelay.pro/api/cloud/handoff/exchange?format=json` + - response returned `200` with a successful tenant session exchange payload +7. Continued the same tenant session into hosted runtime surfaces that had been + failing on the stale path: + - `GET /api/admin/orgs/t-8ME7XXQM7X/billing-state` returned `200` + - `GET /api/orgs/t-8ME7XXQM7X/members` returned `200` + - members payload included both the legacy owner and the authenticated + operator owner, matching the seeded MSP account + +## Outcome + +- This is successful `real-external-e2e` production evidence. +- The fresh hosted runtime can now be entered through the real cloud handoff + path without collapsing back to forwarded-host guessing. +- Hosted runtime state stays coherent after entry: + - tenant ownership remains aligned with the authenticated creator + - the runtime resolves the tenant-scoped org correctly + - hosted billing and org-member surfaces respond successfully on the live + tenant +- This later same-day follow-up supersedes the earlier failed intermediate + hosted-handoff diagnosis and shows the repaired runtime path on a fresh + production canary. + +## Conclusion + +- `cloud-hosted-tier-runtime-readiness` remains correctly `passed`. +- `RA11` is now backed not only by the earlier hosted runtime-entry repair + record, but also by a later fresh-canary proof that tenant-scoped handoff, + owner seeding, and hosted post-login surfaces stay coherent on production. diff --git a/docs/release-control/v6/internal/records/cloud-msp-price-audit-2026-03-13.md b/docs/release-control/v6/internal/records/cloud-msp-price-audit-2026-03-13.md new file mode 100644 index 000000000..3c77f0b44 --- /dev/null +++ b/docs/release-control/v6/internal/records/cloud-msp-price-audit-2026-03-13.md @@ -0,0 +1,59 @@ +# Cloud/MSP Stripe Price Audit + +- Date: `2026-03-13` +- Decisions: + - `cloud-msp-price-id-propagation` + - `cloud-msp-stripe-prices` +- Scope: + - `pulse-pro/OPERATIONS.md` + - `pulse-pro/V6_LAUNCH_CHECKLIST.md` + - `pulse-pro/license-server/secrets.env.template` + - Live Stripe account configured by local `secrets/stripe/secret_key` + +## Verification Method + +1. Extracted the canonical Cloud/MSP v6 price IDs from + `pulse-pro/license-server/secrets.env.template`. +2. Confirmed every canonical ID also appears in: + - `pulse-pro/OPERATIONS.md` + - `pulse-pro/V6_LAUNCH_CHECKLIST.md` +3. Queried Stripe for each price with `stripe prices retrieve ` using + the configured live secret key. + +## Canonical Cloud/MSP Price IDs + +### Cloud + +- `price_1T5kflBrHBocJIGHUqPv1dzV` `cloud_starter` monthly `$29` +- `price_1T5kfmBrHBocJIGHTS3ymKxM` `cloud_starter` annual `$249` +- `price_1T5kfnBrHBocJIGHATQJr79D` `cloud_founding` monthly `$19` +- `price_1T5kg2BrHBocJIGHmkoF0zXY` `cloud_power` monthly `$49` +- `price_1T5kg3BrHBocJIGH2EtzKofV` `cloud_power` annual `$449` +- `price_1T5kg4BrHBocJIGHHa8Ecqho` `cloud_max` monthly `$79` +- `price_1T5kg5BrHBocJIGH5AIJ4nVc` `cloud_max` annual `$699` + +### MSP + +- `price_1T5kgTBrHBocJIGHjOs15LI2` `msp_starter` monthly `$149` +- `price_1T5kgUBrHBocJIGHT6PiOn6x` `msp_starter` annual `$1,490` +- `price_1T5kgVBrHBocJIGHulNsCTb1` `msp_growth` monthly `$249` +- `price_1T5kgWBrHBocJIGHTuaNjnJ2` `msp_growth` annual `$2,490` +- `price_1T5kgWBrHBocJIGHo40iFeRd` `msp_scale` monthly `$399` +- `price_1T5kgXBrHBocJIGHWlOgTyGV` `msp_scale` annual `$3,990` + +## Result + +- All 13 canonical Cloud/MSP price IDs are present in the three governed + `pulse-pro` operational surfaces above. +- Stripe returned a live recurring price object for all 13 IDs. +- Every verified price was `active=true` and `livemode=true`. +- The Stripe amounts and billing intervals matched the governed Cloud/MSP + pricing contract recorded in `pulse-pro`. + +## Outcome + +The two release-ready decisions are no longer open work: + +1. Cloud/MSP price IDs have been propagated into the required operational docs, + launch checklist, and runtime env mapping template. +2. The governed Cloud/MSP price IDs already exist as active live Stripe prices. diff --git a/docs/release-control/v6/internal/records/commercial-cancellation-reactivation-2026-03-12.md b/docs/release-control/v6/internal/records/commercial-cancellation-reactivation-2026-03-12.md new file mode 100644 index 000000000..cc0fe0a74 --- /dev/null +++ b/docs/release-control/v6/internal/records/commercial-cancellation-reactivation-2026-03-12.md @@ -0,0 +1,144 @@ +# Commercial Cancellation/Reactivation Record + +- Date: `2026-03-12` +- Gate: `commercial-cancellation-reactivation` +- Assertions: + - `RA2` + - `RA4` + - `RA7` +- Environment: + - Billing environment: `Stripe sandbox (test mode) using local fixture product and prices` + - Pulse runtime URL: `http://127.0.0.1:18765` + - pulse-pro checkout origin: `https://pulserelay.pro` + - Stripe mode: `test` + - Operator: `Codex on local workspace` + +## Fixtures + +- Monthly grandfathered customer: + - Email: `ccr-monthly-20260312@example.com` + - Stripe customer ID: `cus_U8ZvmRtpV7pUea` + - Stripe subscription ID: `sub_1TAInIPZ0VLEY1aVsh9hYzkp` + - Legacy price ID: `price_1TAIjIPZ0VLEY1aVwy8Kh14D` +- Annual grandfathered customer: + - Email: `ccr-monthly-20260312@example.com` + - Stripe customer ID: `cus_U8ZvmRtpV7pUea` + - Stripe subscription ID: `sub_1TAJ5BPZ0VLEY1aVMsM9b1vN` + - Legacy price ID: `price_1TAIjJPZ0VLEY1aVNS6p8BTN` +- Returning post-cancel customer: + - Email: `ccr-monthly-20260312@example.com` + +Note: + +- The annual spot check reused the already-mapped monthly sandbox customer after a confirmed canceled state. This avoided synthetic org-mapping edits in the local hosted-mode Pulse rehearsal while still exercising real Stripe subscriptions and real webhook delivery. + +## Automated Proof Baseline + +- `python3 scripts/release_control/commercial_cancellation_reactivation_proof.py --json` +- `cd /Volumes/Development/pulse/repos/pulse-pro/license-server && go test . -run 'TestHandleCheckoutSessionCreate(_RejectsGrandfatheredPlanKey)?$' -count=1` +- Result: `pass` + +## Manual Exercise + +### `CCR-1` Active Grandfathered Continuity Baseline + +1. Created a Stripe sandbox test clock and monthly recurring customer fixture. +2. Completed a real hosted Stripe Checkout session on the legacy monthly recurring price. +3. Refreshed the local hosted-mode Pulse settings surface and `GET /api/license/entitlements`. + +Observed: + +- Stripe price ID: `price_1TAIjIPZ0VLEY1aVwy8Kh14D` +- `GET /api/license/entitlements` `plan_version`: `v5_pro_monthly_grandfathered` +- Settings surface continuity notice: `present` + +### `CCR-2` Cancel At Period End Without Immediate Drift + +1. Scheduled cancellation at period end using the Stripe subscription API equivalent because the hosted billing-portal page rendered an unusable no-JavaScript shell in headless mode. +2. Refreshed Pulse entitlements and the settings surface before the billing period ended. + +Observed: + +- Cancel-at-period-end state: `true` +- Legacy price ID still attached: `yes` +- Entitlement state before period end: `active` + +### `CCR-3` Resume Before Lapse + +1. Removed `cancel_at_period_end` from the same monthly Stripe subscription before the test clock reached the end of the period. +2. Refreshed Pulse billing state from the resulting real `customer.subscription.updated` webhook. + +Observed: + +- Original subscription preserved: `yes` +- Legacy price ID preserved: `yes` +- `plan_version`: `v5_pro_monthly_grandfathered` + +### `CCR-4` Completed Cancellation + +1. Re-scheduled cancellation on the same monthly legacy subscription. +2. Advanced the Stripe test clock beyond `current_period_end` and let Stripe deliver the resulting deletion webhook to the local hosted-mode Pulse runtime. + +Observed: + +- Webhook/event IDs: `evt_1TAIrgPZ0VLEY1aVGB9fCDv5`, `evt_1TAJ3dPZ0VLEY1aVCnQpuvYn`, `evt_1TAJ6HPZ0VLEY1aVmo6HEUis` +- Post-cancel entitlement state: `canceled` +- Paid capabilities revoked: `yes` +- Continuity notice removed: `yes` + +### `CCR-5` Post-Cancel Repurchase + +1. Starting from a fully canceled monthly state, created a new v6 monthly purchase path for the same returning Stripe customer. +2. Hosted Stripe Checkout itself was flaky in headless mode on the repurchase step, so the open checkout session was expired and replaced with the Stripe subscription-creation API equivalent using the same sandbox customer and saved payment method. +3. Forced a real `customer.subscription.updated` webhook on the new subscription and refreshed Pulse entitlements plus the settings surface. + +Observed: + +- New subscription ID: `sub_1TAIuOPZ0VLEY1aVj1M6IKdQ` +- New price ID: `price_1TAIjKPZ0VLEY1aV2WLdZbID` +- New plan key / `plan_version`: `pro` +- Grandfathered notice absent on new subscription: `yes` + +### `CCR-6` Annual Parity Spot Check + +1. Starting from a confirmed canceled state on the same mapped Stripe customer, created a new annual legacy recurring subscription with `plan_version=v5_pro_annual_grandfathered` and drove a real `customer.subscription.updated` webhook into Pulse. +2. Scheduled cancellation, advanced the Stripe test clock beyond the annual period boundary, confirmed Pulse revocation, then created a new v6 annual subscription with `plan_version=pro` and delivered a real `customer.subscription.updated` webhook. + +Observed: + +- Annual continuity preserved while active: `yes` +- Annual cancellation revokes access: `yes` +- Annual re-entry uses v6 pricing: `yes` + +### `CCR-7` Direct Legacy Checkout Rejection + +1. Executed the `pulse-pro/license-server` checkout contract proof for `TestHandleCheckoutSessionCreate_RejectsGrandfatheredPlanKey`. +2. Confirmed the handler rejects a grandfathered/v5 `plan_key` before any Stripe checkout session is created. + +Observed: + +- HTTP status: `400` +- Error body: `contains "not a v6 checkout plan"` +- Stripe checkout created: `no` + +## Outcome + +- `pass` +- Summary: + - Active monthly and annual grandfathered recurring subscriptions preserved their legacy price identity while active. + - Completed cancellation removed paid access and removed the grandfathered continuity notice from the Pulse settings surface. + - Post-cancel return flows landed on current public v6 monthly and annual prices with `plan_version=pro`, not `v5_pro_*_grandfathered`. + - This remains `managed-runtime-exercise` evidence because the proof used Stripe sandbox plus a local hosted-mode Pulse runtime, with a few portal and checkout steps falling back to the Stripe API equivalent in headless mode. + - The gate therefore remains pending until the same journey is exercised as a real external end-to-end browser flow. + +## Evidence Captured + +- Stripe subscription snapshots: `/Volumes/Development/pulse/tmp/commercial-cancellation-reactivation-20260312/stripe/` +- Entitlement payload snapshots: `/Volumes/Development/pulse/tmp/commercial-cancellation-reactivation-20260312/stripe/` +- Settings screenshots: `/Volumes/Development/pulse/tmp/commercial-cancellation-reactivation-20260312/screenshots/` +- Checkout request/response logs: `annual-checkout-session.json`, `annual-checkout-session-expired.json`, `monthly-repurchase-checkout-session.json`, `monthly-repurchase-checkout-session-expired.json` +- Webhook event IDs: `evt_1TAIrgPZ0VLEY1aVGB9fCDv5`, `evt_1TAIxzPZ0VLEY1aV84jIeI7F`, `evt_1TAJ3dPZ0VLEY1aVCnQpuvYn`, `evt_1TAJ5GPZ0VLEY1aVVyDywj4k`, `evt_1TAJ6HPZ0VLEY1aVmo6HEUis`, `evt_1TAJ7OPZ0VLEY1aVKY0lwmOI` + +## Follow-Ups + +- Run the same journey once through a human-operated browser on the staging checkout and Stripe customer-portal UI before GA to remove the headless-browser limitation from the evidence set. diff --git a/docs/release-control/v6/internal/records/commercial-cancellation-reactivation-external-e2e-2026-03-13.md b/docs/release-control/v6/internal/records/commercial-cancellation-reactivation-external-e2e-2026-03-13.md new file mode 100644 index 000000000..0a7002fc0 --- /dev/null +++ b/docs/release-control/v6/internal/records/commercial-cancellation-reactivation-external-e2e-2026-03-13.md @@ -0,0 +1,144 @@ +# Commercial Cancellation/Reactivation External E2E 2026-03-13 + +- Gate: `commercial-cancellation-reactivation` +- Assertions: + - `RA2` + - `RA4` + - `RA7` + - `RA16` +- Evidence tier: `real-external-e2e` +- Operator date: `2026-03-13` +- Operator: `Codex on local workspace driving real public HTTPS surfaces` + +## Topology + +- Hosted-mode Pulse runtime: + - URL: `https://ccr-runtime.cloud.pulserelay.pro` + - Host: temporary public Traefik-routed container on `pulse-cloud` + - Stripe webhook ingress used by Pulse: `POST /api/webhooks/stripe` +- Commercial checkout runtime: + - URL: `https://ccr-checkout.cloud.pulserelay.pro` + - Runtime: temporary public Traefik-routed `pulse-pro/license-server` container on `pulse-cloud` + - Stripe mode: `sandbox/test` +- External payment/provider surfaces: + - Stripe-hosted billing portal + - Stripe-hosted checkout + +## Why This Record Replaces The Older External Narrative + +The gate was already marked `passed`, but the existing 2026-03-13 record still described an older local/HTTP staging topology. This replacement records the actual public HTTPS rehearsal that passed: + +- the real Pulse hosted-mode runtime was reachable on a public external URL +- the real checkout runtime was reachable on a public external URL +- cancellation/resume used the real Stripe billing portal in the browser +- repurchase used the real Stripe-hosted checkout in the browser +- no API-equivalent fallback was used for the cancellation/resume or repurchase path + +## Fixtures + +- Monthly grandfathered customer: + - Email: `ccr-1773408158270@example.com` + - Stripe customer ID: `cus_U8nGR5FZJgraIQ` + - Stripe subscription ID: `sub_1TAVfnPZ0VLEY1aVS8AP2UUV` + - Legacy price ID: `price_1TAVYiPZ0VLEY1aV17RZUocW` + - Test clock ID: `clock_1TAVfmPZ0VLEY1aVpvkB8r2d` +- Annual grandfathered customer: + - Email: `ccr-1773408158270@example.com` + - Stripe customer ID: `cus_U8nG0zDM1XM0qZ` + - Stripe subscription ID: `sub_1TAVfsPZ0VLEY1aVpiG1wb1B` + - Legacy price ID: `price_1TAVYjPZ0VLEY1aVZYR0taZL` + - Test clock ID: `clock_1TAVfrPZ0VLEY1aVFni8FenJ` +- Public v6 checkout plan keys: + - Monthly: current test-mode Pro monthly v6 plan key configured on the temporary checkout runtime + - Annual: current test-mode Pro annual v6 plan key configured on the temporary checkout runtime +- Direct legacy rejection key: + - `price_v5_pro_monthly` + +## Commands And Proof + +Automated proof floor: + +- `python3 scripts/release_control/commercial_cancellation_reactivation_proof.py --json` +- Result: `pass` + +Live browser rehearsal: + +```bash +cd /Volumes/Development/pulse/repos/pulse/tests/integration +PULSE_E2E_SKIP_DOCKER=1 \ +PULSE_BASE_URL=https://ccr-runtime.cloud.pulserelay.pro \ +PULSE_CCR_CHECKOUT_BASE_URL=https://ccr-checkout.cloud.pulserelay.pro \ +PULSE_CCR_CHECKOUT_RESULT_BASE_URL=https://ccr-checkout.cloud.pulserelay.pro \ +PULSE_CCR_WEBHOOK_BASE_URL=https://ccr-runtime.cloud.pulserelay.pro \ +PULSE_CCR_WEBHOOK_PATH=/api/webhooks/stripe \ +PULSE_CCR_ALLOW_BILLING_STATE_SEED=true \ +npm test -- tests/14-commercial-cancellation-reactivation.spec.ts --project=chromium +``` + +Live browser result: + +- `2 passed (2.2m)` + +## Executed Scenarios + +### `CCR-1` through `CCR-5` Monthly Path + +Observed: + +- Active grandfathered monthly continuity stayed intact while the legacy subscription remained active. +- Cancel-at-period-end did not rewrite the customer early. +- Resume-before-lapse preserved the same legacy recurring identity. +- Completed cancellation revoked paid state cleanly in Pulse. +- Post-cancel repurchase completed through real Stripe-hosted checkout and the fulfillment returned the current public v6 monthly plan key, not a revived grandfathered plan. + +Monthly v6 re-entry evidence: + +- Fulfilled checkout session recorded by the checkout runtime: + - License ID: `lic_04b25c69447890d3237874cdded92f3a` + - Plan key: current test-mode Pro monthly v6 plan key configured on the temporary checkout runtime + - Checkout session ID: `cs_test_b1iqGMKvA5bk27C2TaUdxsP7K1YqE6m9ufR7lmmlEge1BVDiaScj8TzHv1` + +### `CCR-6` Annual Parity + +Observed: + +- Annual grandfathered continuity stayed intact while active. +- Completed annual cancellation revoked paid state cleanly. +- Post-cancel repurchase completed through real Stripe-hosted checkout and the fulfillment returned the current public v6 annual plan key, not a revived grandfathered plan. + +Annual v6 re-entry evidence: + +- Fulfilled checkout session recorded by the checkout runtime: + - License ID: `lic_1aeed899e926c9200d3cc5c57e9a9fe9` + - Plan key: current test-mode Pro annual v6 plan key configured on the temporary checkout runtime + - Checkout session ID: `cs_test_b1uEc0cXgNW9OvsuMD04izn7cqb6MDzGRpfyopCyjZfp5M2IN8tTDc0AS7` + +### `CCR-7` Direct Legacy Checkout Rejection + +Observed against `https://ccr-checkout.cloud.pulserelay.pro`: + +- `POST /v1/checkout/session` with `plan_key=price_v5_pro_monthly` returned `400` +- Response body contained `not a v6 checkout plan` +- No Stripe checkout session was created + +## Evidence Captured + +- Playwright result bundle: + - `tests/integration/test-results/junit.xml` +- Playwright browser artifact: + - `tests/integration/test-results/.playwright-artifacts-0/106d1b678691c13eace08d6de5972835.webm` +- Hosted runtime evidence: + - runtime login, billing-state seed, and webhook replays observed on `https://ccr-runtime.cloud.pulserelay.pro` +- Checkout runtime evidence: + - `docker logs pulse-ccr-license` on `pulse-cloud` recorded the fulfilled monthly and annual v6 checkout sessions above + +## Outcome + +- `pass` +- Monthly continuity boundary: `pass` +- Annual parity boundary: `pass` +- Legacy direct checkout rejection: `pass` + +## Release Impact + +`commercial-cancellation-reactivation` remains satisfied at the required `real-external-e2e` tier, and the governed record now matches the actual public HTTPS rehearsal that passed on `2026-03-13`. diff --git a/docs/release-control/v6/internal/records/documentation-currentness-and-legacy-cleanup-2026-03-13.md b/docs/release-control/v6/internal/records/documentation-currentness-and-legacy-cleanup-2026-03-13.md new file mode 100644 index 000000000..e1d53c56c --- /dev/null +++ b/docs/release-control/v6/internal/records/documentation-currentness-and-legacy-cleanup-2026-03-13.md @@ -0,0 +1,75 @@ +# Documentation Currentness And Legacy Cleanup Record + +- Date: `2026-03-13` +- Gate: `documentation-currentness-and-legacy-cleanup` +- Assertion: `RA10` +- Scope: + - `pulse` + - `pulse-pro` + - lane `L9` + +## Automated Baseline + +- `python3 scripts/release_control/documentation_currentness_test.py` +- Result: pass + +## Same-Day Revalidation + +- Re-ran `python3 scripts/release_control/documentation_currentness_test.py` + after the `RA12` multi-tenant closeout and the current control-plane target + still derives as `v6-rc-stabilization`. +- Result: pass + +## Manual Review Surface + +Active v6 guidance reviewed in `pulse`: + +1. `docs/release-control/CONTROL_PLANE.md` +2. `docs/release-control/control_plane.json` +3. `docs/release-control/v6/README.md` +4. `docs/release-control/v6/SOURCE_OF_TRUTH.md` +5. `docs/release-control/v6/CANONICAL_DEVELOPMENT_PROTOCOL.md` +6. `docs/release-control/v6/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md` +7. `docs/release-control/v6/status.json` + +Supporting active commercial/runtime guidance reviewed in `pulse-pro`: + +1. `MONETIZATION.md` +2. `OPERATIONS.md` release-cutover and pricing sections + +## Review Outcome + +1. The active control-plane guidance now reflects the real current target: + `v6-rc-stabilization`, not GA promotion. +2. Active release-control docs consistently treat `rc_ready` and + `release_ready` as separate phases and no longer present GA as the current + objective. +3. `status.json`, `SOURCE_OF_TRUTH.md`, and the high-risk matrix agree on the + current release-ready blockers: + - `RA8` + - `RA10` + - `rc-to-ga-promotion-readiness` + - `documentation-currentness-and-legacy-cleanup` +4. Historical and audit-style artifacts remain outside the active guidance + surface: + - records stay under `docs/release-control/v6/records/` + - supporting audits remain evidence, not canonical instructions +5. `pulse-pro` commercial docs reviewed for active drift do not present a + contradictory product phase: + - `MONETIZATION.md` still describes the current v6 pricing model + - `OPERATIONS.md` still frames public checkout cutover as a release-day + action rather than claiming it already happened + +## Legacy Cleanup Decision + +No active v6 guidance file reviewed here still presents legacy or superseded +instructions as current guidance. + +Historical materials may remain in the repo as templates, migration notes, or +evidence records, but they are not part of the active v6 instruction surface +used by agents and release work. + +## Outcome + +- `documentation-currentness-and-legacy-cleanup` is exercised and passed. +- `RA10` is satisfied for the current v6 release profile. diff --git a/docs/release-control/v6/internal/records/hosted-signup-billing-replay-2026-03-12.md b/docs/release-control/v6/internal/records/hosted-signup-billing-replay-2026-03-12.md new file mode 100644 index 000000000..c21480645 --- /dev/null +++ b/docs/release-control/v6/internal/records/hosted-signup-billing-replay-2026-03-12.md @@ -0,0 +1,64 @@ +# Hosted Signup Billing Replay Record + +- Date: `2026-03-12` +- Gate: `hosted-signup-billing-replay` +- Assertion: `RA2` +- Environment: + - Hosted-mode rehearsal instance: `http://127.0.0.1:17765` + - Hosted-mode fail-closed instance without `PULSE_PUBLIC_URL`: `http://127.0.0.1:17766` + - Authenticated platform admin: `admin` + - Trial redirect target: `https://billing.example.com/start-pro-trial?source=rc-check` + - Stripe webhook secret: local test secret on the live HTTP surface + +## Automated Proof Baseline + +- `go test ./internal/api -run 'TestHostedLifecycle|TestHostedSignupSuccess|TestHostedSignupValidationFailures|TestHostedSignupHostedModeGate|TestHostedSignupRateLimit|TestHostedSignupRateLimit_NoProvisioningSideEffects|TestHostedSignupCleanupOnRBACFailure|TestHostedSignupFailsClosedWithoutPublicURL|TestStripeWebhook_' -count=1` +- `go test ./internal/hosted -run 'TestProvisionTenantSuccess|TestProvisionTenantIdempotentDuplicateEmail|TestProvisionTenantIdempotentDuplicateEmailCaseInsensitive|TestProvisionTenantValidationFailures|TestProvisionTenantPartialFailureRollback|TestProvisionHostedSignupSuccess' -count=1` +- `cd frontend-modern && npx vitest run src/pages/__tests__/HostedSignup.test.tsx src/components/Settings/__tests__/BillingAdminPanel.test.tsx` +- Result: pass + +## Manual Exercise + +1. Started a hosted-mode Pulse instance without `PULSE_PUBLIC_URL` and confirmed `POST /api/public/signup` failed closed with: + - `503` + - `code=public_url_missing` + - no new hosted organization metadata created (`org_count` stayed unchanged) +2. On the configured hosted-mode instance, started the self-hosted trial/upgrade path through `POST /api/license/trial/start` as the authenticated admin and confirmed: + - response was `409` + - `code=trial_signup_required` + - `details.action_url` pointed at the hosted checkout origin with `org_id=default` and `return_url=http://127.0.0.1:17765/auth/trial-activate` + - `GET /api/license/entitlements` stayed `tier=free` and `subscription_state=expired` before and after the redirect handoff +3. Exercised real hosted signup on the live HTTP surface via `POST /api/public/signup` and confirmed: + - `201 Created` + - returned `org_id=0147dd50-38db-4316-8d46-0e1f0d754bf6` + - returned `message="Check your email for a magic link to finish signing in."` +4. Confirmed hosted magic-link access remained live by calling `POST /api/public/magic-link/request` for the same signup email and receiving `200` with `success=true`. +5. Confirmed billing-admin state for the newly provisioned hosted org reflected the seeded hosted signup state: + - `GET /api/admin/orgs/0147dd50-38db-4316-8d46-0e1f0d754bf6/billing-state` returned `subscription_state=trial` + - `plan_version=cloud_trial` + - hosted trial capabilities were present +6. Exercised exact webhook replay behavior on the live HTTP surface with the same signed `checkout.session.completed` payload for `org_id=org-replay-20260312`: + - first delivery returned `500 stripe_processing_failed` because the linked org did not exist yet + - after adding the linked org metadata to the hosted persistence tree, replaying the exact same signed payload returned `200 {"received":true,"status":"processed"}` +7. Confirmed billing-admin state after the successful replay reflected the resulting subscription state: + - `GET /api/admin/orgs/org-replay-20260312/billing-state` returned `subscription_state=active` + - `plan_version=cloud_starter` + - `limits.max_monitored_systems=10` + - `stripe_customer_id=cus_retry_manual` + - `stripe_subscription_id=sub_retry_manual` + +## Outcome + +- Hosted signup fails closed when the external public URL is missing. +- The self-hosted trial start path redirects to hosted checkout instead of minting a local entitlement immediately. +- Hosted public signup provisions an org and exposes coherent billing-admin trial state. +- Magic-link request flow remains available on the hosted public surface. +- Stripe webhook handling fails closed before org linkage exists and succeeds on replay once the linked org exists. +- Billing-admin state reflects the resulting trial and active subscription states coherently after both hosted signup and replayed checkout completion. +- This remains `local-rehearsal` evidence because the full journey was exercised on a localhost hosted-mode runtime rather than the real external hosted checkout and webhook path. +- The gate therefore remains pending until the same replay path is proven against the real external hosted surface. + +## Notes + +- The exact replay rehearsal used a live localhost hosted-mode server, not test handlers, and replayed the same signed webhook payload before and after linked-org metadata existed. +- The linked org for the exact replay was inserted through the hosted persistence tree because the session-authenticated org-create route is separately guarded and not part of the public checkout/webhook path under test here. diff --git a/docs/release-control/v6/internal/records/hosted-signup-billing-replay-production-2026-03-13.md b/docs/release-control/v6/internal/records/hosted-signup-billing-replay-production-2026-03-13.md new file mode 100644 index 000000000..061e4f273 --- /dev/null +++ b/docs/release-control/v6/internal/records/hosted-signup-billing-replay-production-2026-03-13.md @@ -0,0 +1,53 @@ +# Hosted Signup Billing Replay Production Record + +- Date: `2026-03-13` +- Gate: `hosted-signup-billing-replay` +- Assertion: `RA2` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Evidence source: production HTTPS surface, admin-key protected control-plane APIs, and control-plane logs + +## External Exercise + +1. Ran the governed production preflight against the real external service: + - `DOMAIN=cloud.pulserelay.pro SSH_TARGET=root@pulse-cloud ADMIN_KEY_FILE=/Volumes/Development/pulse/secrets/cloud-cp/admin_key deploy/cloud/preflight-live.sh` +2. Confirmed the real public hosted-signup surface was reachable and contract-valid: + - `GET /healthz` returned `200` + - `GET /readyz` returned `200` + - `GET /signup` returned `200` + - `GET /cloud/signup` returned `200` + - `GET /signup/complete` returned `200` + - `GET /api/public/signup` returned `405` + - invalid `POST /api/public/signup` returned `400` + - invalid `POST /api/public/magic-link/request` returned `400` + - valid-shape `POST /api/public/magic-link/request` returned `200` +3. Confirmed the real admin control surface was reachable: + - `GET /status` with the production admin key returned `{"version":"dev","total_tenants":16,"healthy":16,"unhealthy":0,"by_state":{"active":16}}` + - SSH to `root@pulse-cloud` succeeded +4. Corrected the live production control-plane env mismatch and revalidated preflight: + - `/opt/pulse-cloud/.env` now contains `CP_TRIAL_SIGNUP_PRICE_ID` + - `/opt/pulse-cloud/.env` now contains `CP_TRIAL_ACTIVATION_PRIVATE_KEY` + - `docker compose up -d control-plane` completed cleanly on `root@pulse-cloud` + - rerunning `deploy/cloud/preflight-live.sh` passed with `failures=0` and `warnings=0` +5. Confirmed the real public hosted-signup surface now reaches live Stripe checkout creation on production: + - `POST /api/public/signup` with a fresh dedicated test email returned `200` + - response contained a real `checkout_url=https://checkout.stripe.com/...` + - response message was `Checkout session created. Continue in Stripe to provision your Pulse Cloud tenant.` +6. Pulled recent production control-plane logs and confirmed there is still unresolved evidence on completed checkout execution from earlier live traffic: + - multiple `checkout.session.completed` events in the last 12 hours logged `Stripe webhook processing failed` + - the concrete failure was `tenant container failed health check` + - observed failing tenant IDs included `t-KPXEWNB56Z`, `t-P1QD6QHHWK`, `t-N6BEJKY9AW`, `t-SJY27FXC1V`, `t-1GGB3EX439`, `t-5PAARCDCJM`, `t-S5MPK98VM7`, `t-CJTV56H46F`, `t-YBP562AM1E`, `t-JRDF96FXTB`, and `t-0Y0C9NG6PR` +7. Did not complete a fresh live checkout on production: + - production is configured for live Stripe + - creating a completed live billing flow would introduce real finance-visible side effects + - the new evidence proves checkout-session creation is healthy again, but not yet successful webhook completion through to a healthy tenant runtime + +## Outcome + +- This is real external production evidence, not localhost rehearsal. +- The hosted public signup surface is up and can now create a real Stripe checkout session on production. +- `hosted-signup-billing-replay` remains pending because production currently shows: + - no fresh external proof yet that a completed production checkout now replays cleanly through webhook handling into a healthy provisioned tenant + - historical real `checkout.session.completed` failures still exist in the production evidence set and have not yet been displaced by a successful completed run +- The gate should only move after a fresh external hosted checkout plus replay path succeeds cleanly on production. diff --git a/docs/release-control/v6/internal/records/hosted-signup-billing-replay-production-fixed-2026-03-13.md b/docs/release-control/v6/internal/records/hosted-signup-billing-replay-production-fixed-2026-03-13.md new file mode 100644 index 000000000..a97aeb75c --- /dev/null +++ b/docs/release-control/v6/internal/records/hosted-signup-billing-replay-production-fixed-2026-03-13.md @@ -0,0 +1,62 @@ +# Hosted Signup Billing Replay Production Fixed Record + +- Date: `2026-03-13` +- Gate: `hosted-signup-billing-replay` +- Assertion: `RA2` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Evidence source: production HTTPS surface, production Stripe event replay, control-plane tenant registry, and control-plane logs + +## External Exercise + +1. Reused the governed production control-plane surface that was already fixed for hosted runtime readiness: + - `CONTROL_PLANE_IMAGE=pulse-control-plane:634fa3a66663-20260313T115951Z` + - `CP_PULSE_IMAGE=pulse-runtime:b6800fe1f401-20260313T121150Z-pubkey` + - `deploy/cloud/preflight-live.sh` remained clean against `cloud.pulserelay.pro` +2. Confirmed the remaining hosted-signup blocker was a real failed production checkout webhook, not a synthetic rehearsal: + - `stripe_events.stripe_event_id=evt_1TAGtCBrHBocJIGHePETZL11` + - `event_type=checkout.session.completed` + - previous `processing_error="tenant t-5PAARCDCJM container failed health check"` + - corresponding Stripe account already existed for `customer_id=cus_U8Xy7ujZlLnTha` + - no tenant existed yet for account `a_S3N3VSKK7A` +3. Fetched the exact production Stripe event payload from the live Stripe API using the configured live `STRIPE_API_KEY`. +4. Replayed that exact event through the real production webhook endpoint with a fresh valid signature using the configured live `STRIPE_WEBHOOK_SECRET`: + - `POST https://cloud.pulserelay.pro/api/stripe/webhook` + - response was `200 {"received":true}` +5. Confirmed the replay was not a no-op duplicate: + - `stripe_events.processing_error` for `evt_1TAGtCBrHBocJIGHePETZL11` is now `NULL` + - `processed_at` advanced to the new replay time +6. Confirmed the replayed production checkout now provisions the tenant successfully: + - new tenant ID: `t-YSK1GQDZS2` + - account: `a_S3N3VSKK7A` + - email: `alfons@fonsie.eu` + - state: `active` + - `stripe_customer_id=cus_U8Xy7ujZlLnTha` + - `stripe_subscription_id=sub_1TAGsrBrHBocJIGHHwpjRMHg` +7. Confirmed the tenant runtime actually started on the fixed hosted image: + - Docker container: `pulse-t-YSK1GQDZS2` + - image: `pulse-runtime:b6800fe1f401-20260313T121150Z-pubkey` +8. Confirmed post-replay operator signals in production control-plane logs: + - `Tenant container started` + - `Magic link email sent` + - `Tenant provisioned from checkout` + +## Outcome + +- This is real external production evidence, not localhost rehearsal. +- The public hosted signup surface had already been proven to create real Stripe checkout sessions on production in `hosted-signup-billing-replay-production-2026-03-13.md`. +- The missing piece was a real completed checkout replay proving that a previously failed production `checkout.session.completed` event can now be retried successfully through the live webhook. +- That replay now succeeds end to end: + - the exact failed Stripe event was reclaimed for retry + - webhook processing completed without error + - a real tenant was provisioned and became active + - the runtime container started on the fixed hosted image + - the magic-link email path fired for the newly provisioned tenant +- `hosted-signup-billing-replay` can now be treated as passed with `real-external-e2e` evidence. +- `RA2` is now backed by the real hosted checkout replay path instead of only local rehearsal plus incomplete production checkout creation. + +## Notes + +- This proof intentionally used a previously failed production Stripe event instead of inventing a new synthetic checkout completion. That makes the evidence stronger: it proves the live replay path can recover a real stuck hosted signup after the runtime fixes. +- The hosted runtime-entry path remains separately governed under `cloud-hosted-tier-runtime-readiness`; this record closes the checkout/webhook replay side of the hosted commercial journey. diff --git a/docs/release-control/v6/internal/records/mobile-relay-auth-approvals-2026-03-13.md b/docs/release-control/v6/internal/records/mobile-relay-auth-approvals-2026-03-13.md new file mode 100644 index 000000000..7b240416b --- /dev/null +++ b/docs/release-control/v6/internal/records/mobile-relay-auth-approvals-2026-03-13.md @@ -0,0 +1,166 @@ +# Mobile Relay Auth Approvals Record + +- Date: `2026-03-13` +- Gate: `mobile-relay-auth-approvals` +- Environment: + - Pulse workspace repos: + - `pulse` + - `pulse-mobile` + - `pulse-pro` + - `pulse-enterprise` + - Lab backend: `http://127.0.0.1:55190` + - Public URL under test: `http://192.168.0.98:55190` + - Relay instance endpoint: `wss://127.0.0.1:8443/ws/instance` + - Mobile onboarding endpoint presented to app: `wss://127.0.0.1:8443/ws/app` + - Relay instance id: `relay_75c6978012c883dc` + - Booted simulator device: + - `Pulse RC iPhone 16` + - `5ADC45DD-E5D9-4FF9-ADBB-59D1E95171F3` + - Release simulator app: + - `/tmp/pulse-mobile-derived-release/Build/Products/Release-iphonesimulator/Pulse.app` + +## Automated Proof Baseline + +- `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/__tests__/mobileRelayAuthApprovals.rehearsal.test.ts src/utils/__tests__/secureStorage.test.ts src/hooks/__tests__/useRelayLifecycle.test.ts src/hooks/__tests__/approvalActionPolicy.test.ts src/stores/__tests__/instanceStore.test.ts src/stores/__tests__/authStore.test.ts src/stores/__tests__/approvalStore.test.ts` +- `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/relay/__tests__/client.test.ts src/relay/__tests__/client-hardening.test.ts src/relay/__tests__/protocol-contract.test.ts` +- `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/api/__tests__/client.test.ts` +- `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/hooks/__tests__/useRelay.test.ts src/hooks/__tests__/relayPushRefresh.test.ts src/notifications/__tests__/notificationRouting.test.ts src/stores/__tests__/mobileAccessState.test.ts` +- `cd /Volumes/Development/pulse/repos/pulse-enterprise && go test ./internal/aiautofix -run 'TestHandleListApprovals|TestHandleApproveAndExecuteInvestigationFix|TestHandleApprove' -count=1` +- Result: pass + +## Manual Exercise A: Real Pairing Through Relay Onboarding + +1. Rebuilt the local Pulse lab backend with the onboarding relay URL normalization fix so `/api/onboarding/qr` emitted `relay_url=wss://127.0.0.1:8443/ws/app` while the desktop relay config remained on the canonical instance endpoint. +2. Verified the live onboarding payload returned: + - `instance_id=relay_75c6978012c883dc` + - `relay.url=wss://127.0.0.1:8443/ws/app` + - no diagnostics +3. Minted a fresh real pairing token through `POST /api/security/tokens`. +4. Fetched the real deep link through `GET /api/onboarding/qr` with that token in `X-API-Token`. +5. Uninstalled the simulator app, reinstalled the Release build, and launched it with the committed simulator launch hook using `SIMCTL_CHILD_PULSE_SIMULATOR_LAUNCH_URL=`. +6. Confirmed the backend moved from `active_channels=0` to `active_channels=1`. +7. Confirmed the Pulse backend logged: + - `channel opened channel=1` + - `key exchange completed, channel encrypted channel=1` +8. Captured the paired state screenshot at: + - `/tmp/pulse-mobile-mobile-relay-auth-approvals-passed.png` + +## Manual Exercise B: Persisted Relaunch and Reconnect + +1. Terminated the paired simulator app without changing its stored instance. +2. Relaunched the app normally with no deep link. +3. Confirmed the backend returned to `active_channels=1` after relaunch. +4. Confirmed the stored instance stayed available to the app and the channel came back without re-pairing. + +## Manual Exercise C: Revocation Fails Closed + +1. Deleted the exact pairing token used by the simulator via: + - `DELETE /api/security/tokens/{token_id}` +2. Terminated the simulator app and relaunched it normally. +3. Confirmed the backend no longer kept a mobile relay channel: + - `active_channels=0` +4. Confirmed the Pulse backend rejected the reconnect attempt with: + - `Rejecting channel: invalid auth token` +5. Captured the revoked safe-state screenshot at: + - `/tmp/pulse-mobile-mobile-relay-auth-approvals-revoked.png` +6. Confirmed the app returned to a safe empty state with `Add Instance` visible instead of preserving stale access. + +## Manual Exercise D: Real Android Approval Visibility Through Live Relay + +1. Switched from the earlier simulator-only lab backend to the real + enterprise-capable Pulse test container on `delly`: + - backend under test: `http://192.168.0.106:7655` + - live managed relay endpoint: `wss://relay.pulserelay.pro/ws/instance` + - mobile onboarding endpoint presented to the app: + `wss://relay.pulserelay.pro/ws/app` +2. Paired a fresh Android device through the real relay onboarding path and + confirmed the phone reached the live instance as `Instance relay_98`. +3. Verified the backend approval API first returned an empty state: + - `GET /api/ai/approvals` + - response stats: `pending=0`, `expired=1` +4. Reloaded the real mobile dev bundle after the mobile initial-hydration fix + so the live phone was running the new `pulse-mobile` code, not a stale Metro + session. +5. Opened the real phone approval route and confirmed the empty state rendered + without the previous perpetual spinner: + - screenshot: `/tmp/pulse-mobile-approvals-fresh.png` +6. Seeded a fresh pending approval into the enterprise-backed test instance and + confirmed the backend returned: + - `GET /api/ai/approvals` + - response stats: `pending=1`, `expired=0` +7. Reopened the approval route on the physical Android device and confirmed the + pending approval card rendered live over the real relay channel instead of + hanging in loading: + - screenshot: `/tmp/pulse-mobile-approvals-pending.png` + +## Manual Exercise E: Real Android Approve and Deny Actions Through Live Relay + +1. Kept the same paired Android device and enterprise-backed Pulse test + container from Exercise D: + - backend under test: `http://192.168.0.106:7655` + - live managed relay endpoint: `wss://relay.pulserelay.pro/ws/instance` + - mobile onboarding endpoint presented to the app: + `wss://relay.pulserelay.pro/ws/app` +2. Seeded a fresh plain pending approval into the live approval store on the + test container and restarted `pulse.service` so the running backend reloaded + it, then confirmed the backend approval API returned: + - `GET /api/ai/approvals` + - response stats: `pending=1`, `approved=1`, `denied=0` +3. Opened that approval on the physical Android device and confirmed the detail + screen now rendered the idle action state correctly before confirmation: + - both `Approve` and `Deny` buttons were idle + - the earlier premature `Approving...` state no longer appeared before the + confirm dialog +4. Triggered `Approve` on-device and confirmed the native `Approve Fix` + confirmation dialog rendered before the action committed. +5. Confirmed the approval on-device and verified both sides converged to the + expected approved terminal state: + - backend `GET /api/ai/approvals` response stats: `pending=0`, `approved=2`, + `denied=0` + - mobile detail screen showed `Status: Approved` + - mobile detail screen rendered the inline success copy: + `This fix has been approved` + - screenshot: `/tmp/pulse-after-approve.png` +6. Seeded a second fresh plain pending approval into the same live approval + store, restarted `pulse.service`, and confirmed the backend returned: + - `GET /api/ai/approvals` + - response stats: `pending=1`, `approved=2`, `denied=0` +7. Reopened the approval list on the physical Android device, opened the new + pending item, and triggered `Deny`. +8. Confirmed the mobile app rendered the dedicated `Deny Fix` sheet with the + optional reason field before the action committed. +9. Confirmed the denial on-device and verified both sides converged to the + expected denied terminal state: + - backend `GET /api/ai/approvals` response stats: `pending=0`, `approved=2`, + `denied=1` + - mobile detail screen showed `Status: Denied` + - mobile detail screen rendered the inline success copy: + `This fix has been denied` + - screenshot: `/tmp/pulse-after-deny.png` + +## Approval Coverage Note + +- Approval routing, approval list visibility, and scoped approval state are now + covered both by the audited `pulse-mobile` automated proof bundle and by the + live Android exercise above against the enterprise-backed approval runtime. +- Approval action execution is now covered in three layers: + - governed `pulse-mobile` approval-action tests + - governed `pulse-enterprise/internal/aiautofix` approval handler tests + - the real Android approve and deny executions above against the + enterprise-backed approval runtime + +## Outcome + +- Real mobile pairing now succeeds against a real relay-backed Pulse instance. +- Persisted relaunch and reconnect now succeed without re-pairing. +- Revoked credentials fail closed back to a safe disconnected state. +- Real approval list visibility now succeeds on a physical Android device for + both the empty state and a live pending approval. +- Real approval action execution now succeeds on a physical Android device for + both `Approve` and `Deny`, with correct confirm-sheet behavior and correct + approved/denied terminal states instead of hanging or forcing the user back + through a stale loading loop. +- The onboarding payload now gives mobile the canonical app endpoint instead of + the instance-only relay endpoint that previously caused immediate disconnects. +- Approval action handling is now covered by both the governed proof suites and + the live physical-device exercise against the enterprise approval runtime. diff --git a/docs/release-control/v6/internal/records/mobile-usefulness-floor-2026-03-13.md b/docs/release-control/v6/internal/records/mobile-usefulness-floor-2026-03-13.md new file mode 100644 index 000000000..b53e64b4e --- /dev/null +++ b/docs/release-control/v6/internal/records/mobile-usefulness-floor-2026-03-13.md @@ -0,0 +1,55 @@ +# Mobile Usefulness Floor Record + +- Date: `2026-03-13` +- Decision: `mobile-usefulness-floor` +- Scope: + - `pulse-mobile` + - `pulse` + - lanes `L5`, `L7`, `L8`, `L12` + - subsystems `frontend-primitives`, `relay-runtime` + +## Decision + +Pulse Mobile does not need desktop parity to stop blocking v6 prerelease stabilization. +The v6 mobile usefulness floor is narrower and concrete: + +1. A user can keep at least one trusted Pulse instance paired and available + across relaunches without needing to re-pair on normal reconnect paths. +2. The primary mobile shell exposes current relay/runtime state clearly enough + that a user can tell whether the active instance is connecting, secured, + offline, draining, or in error. +3. Stale or revoked mobile access fails closed into a safe disconnected state + with a recoverable path back to `Add Instance`, rather than leaving the user + in an ambiguous or partially-authorized session. +4. The mobile app provides useful post-pairing navigation for the v6 prerelease line: + Dashboard, Findings, Chat, Approvals, and Settings. +5. Live approval recovery is part of that floor: pending approvals must appear, + approvals must survive normal reconnect/relaunch behavior, and approval + actions must converge cleanly to approved or denied terminal states. + +Desktop-feature parity, richer operational navigation, and broader mobile +surface expansion remain post-prerelease and post-GA scope, not v6 prerelease blockers. + +## Evidence Considered + +1. `docs/release-control/v6/records/mobile-relay-auth-approvals-2026-03-13.md` + proves real pairing, persisted relaunch, revoked-credential recovery, and + live approval visibility and action execution on a physical Android device. +2. `pulse-mobile/src/navigation/routes.ts` defines the active v6 route surface: + `Dashboard`, `Findings`, `Chat`, `Approvals`, `Settings`, plus instance and + security entry points. +3. `pulse-mobile/app/(tabs)/_layout.tsx` wires those five primary tabs and + presents the approval badge in the main shell. +4. `pulse-mobile/src/components/shared/connectionBannerState.ts` and + `pulse-mobile/src/components/shared/ConnectionBanner.tsx` expose relay-state + feedback for connecting, securing, draining, offline, and error states. +5. `pulse-mobile/src/stores/mobileAccessState.ts` and instance-scoped clearing + prove the app has an explicit safe-reset path for stale or revoked access. + +## Outcome + +- `mobile-usefulness-floor` is resolved. +- Pulse Mobile is judged useful enough for the v6 prerelease line on the narrower floor + above. +- Future mobile polish and deeper parity work should be captured as post-prerelease or + post-GA targets instead of blocking prerelease stabilization by default. diff --git a/docs/release-control/v6/internal/records/msp-provider-tenant-management-2026-03-13.md b/docs/release-control/v6/internal/records/msp-provider-tenant-management-2026-03-13.md new file mode 100644 index 000000000..2d795a2a4 --- /dev/null +++ b/docs/release-control/v6/internal/records/msp-provider-tenant-management-2026-03-13.md @@ -0,0 +1,113 @@ +# MSP Provider Tenant Management Record + +- Date: `2026-03-13` +- Gate: `msp-provider-tenant-management` +- Assertion: `RA13` +- Environment: + - Live localhost Pulse control plane: `http://127.0.0.1:18443` + - Control-plane data dir: `/Volumes/Development/pulse/repos/pulse/tmp/manual-msp-gate-20260313` + - Seeded MSP account: `a_mspgate20260313` (`Acme MSP Rehearsal`) + - Provider owner session: `owner@acmemsp.test` + - Canonical MSP plan: `msp_starter` + - Revalidation workspace: `t-1WDFA6HW01` (`Client Three`) + - Revalidation member: `readonly@acmemsp.test` (`read_only`) + +## Automated Proof Baseline + +- `go test ./internal/cloudcp/account ./internal/cloudcp/registry -count=1` +- `go test ./internal/cloudcp/stripe -run 'TestMSPLifecycle_AccountToPortal' -count=1` +- `go test ./internal/cloudcp -run 'TestPublicCloudSignupCheckoutMetadataRejectsMSPPlanForPublicSignup' -count=1` +- `go test ./pkg/licensing -run 'TestMSPPlanAliasCanonicalizationContract' -count=1` +- `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx src/pages/__tests__/CloudPricing.test.tsx` +- Result: pass + +## Manual Exercise + +1. Started a live localhost `pulse-control-plane` instance in development mode with: + - `CP_ALLOW_DOCKERLESS_PROVISIONING=true` + - `CP_REQUIRE_EMAIL_PROVIDER=false` + - a valid `CP_TRIAL_ACTIVATION_PRIVATE_KEY` + - `CP_ADMIN_KEY` and `CP_BASE_URL=http://127.0.0.1:18443` +2. Seeded the local control-plane registry with one MSP account, one owner user, one owner membership, one canonical Stripe account mapping, and a real bearer session token: + - `account_id=a_mspgate20260313` + - `account.kind=msp` + - `plan_version=msp_starter` + - `subscription_state=active` +3. Confirmed the provider account started with no client workspaces: + - authenticated `GET /api/accounts/a_mspgate20260313/tenants` + - returned `200 []` +4. Provisioned two client workspaces through the real account-scoped API: + - authenticated `POST /api/accounts/a_mspgate20260313/tenants` with `{"display_name":"Client One"}` + - authenticated `POST /api/accounts/a_mspgate20260313/tenants` with `{"display_name":"Client Two"}` + - both returned `201` + - both returned `state=active` + - both returned `plan_version=msp_starter` +5. Confirmed the provider can view multiple client tenants coherently from one account surface: + - authenticated `GET /api/accounts/a_mspgate20260313/tenants` + - returned both `Client One` and `Client Two` + - both tenants remained attached to `account_id=a_mspgate20260313` +6. Confirmed provider member management works on the same account: + - authenticated `POST /api/accounts/a_mspgate20260313/members` with `{"email":"tech@acmemsp.test","role":"tech"}` + - authenticated `GET /api/accounts/a_mspgate20260313/members` + - member list showed: + - `owner@acmemsp.test` as `owner` + - `tech@acmemsp.test` as `tech` +7. Confirmed the provider portal reflects the same multi-tenant account state: + - authenticated `GET /api/portal/dashboard?account_id=a_mspgate20260313` + - returned `account.kind="msp"` + - returned both workspaces in the dashboard summary + - `summary.total=2` + - `summary.active=2` +8. Confirmed workspace detail stays account-scoped and coherent: + - authenticated `GET /api/portal/workspaces/t-0T18WWGENX?account_id=a_mspgate20260313` + - returned the expected `Client One` workspace under the MSP account with `plan_version=msp_starter` +9. Confirmed the public individual cloud path remained distinct from MSP provisioning semantics on the same live control-plane instance: + - unauthenticated `GET /cloud/signup` rendered the public “Start Pulse Cloud” page + - unauthenticated `POST /api/public/signup` did not create or route into MSP provisioning; it failed closed with `400 {"code":"tier_unavailable","message":"The selected plan tier is not currently available"}` + +## Revalidation After Evidence-Tier Tightening + +1. Reused the persisted localhost control-plane rehearsal on `http://127.0.0.1:18443` with the same MSP account, provider owner identity, and stateless bearer session contract. +2. Reconfirmed the provider account still exposed coherent account-scoped state before mutation: + - authenticated `GET /api/accounts/a_mspgate20260313/tenants` returned the existing `Client One` and `Client Two` workspaces + - authenticated `GET /api/accounts/a_mspgate20260313/members` returned `owner@acmemsp.test` as `owner` and `tech@acmemsp.test` as `tech` + - authenticated `GET /api/portal/dashboard?account_id=a_mspgate20260313` returned `account.kind="msp"` with `summary.total=2` and `summary.active=2` +3. Exercised fresh provider mutation on the same live account surface: + - authenticated `POST /api/accounts/a_mspgate20260313/tenants` with `{"display_name":"Client Three"}` + - returned `201` + - returned `id=t-1WDFA6HW01` + - returned `state=active` + - returned `plan_version=msp_starter` +4. Confirmed the new workspace stayed attached to the MSP account instead of drifting into individual-cloud state: + - authenticated `GET /api/accounts/a_mspgate20260313/tenants` returned `Client One`, `Client Two`, and `Client Three` + - all three workspaces remained attached to `account_id=a_mspgate20260313` +5. Exercised fresh provider member management on the same account: + - authenticated `POST /api/accounts/a_mspgate20260313/members` with `{"email":"readonly@acmemsp.test","role":"read_only"}` + - returned `201` + - authenticated `GET /api/accounts/a_mspgate20260313/members` returned `owner@acmemsp.test`, `tech@acmemsp.test`, and `readonly@acmemsp.test` +6. Confirmed the provider portal stayed coherent after the new workspace was added: + - authenticated `GET /api/portal/dashboard?account_id=a_mspgate20260313` returned `summary.total=3` and `summary.active=3` + - authenticated `GET /api/portal/workspaces/t-1WDFA6HW01?account_id=a_mspgate20260313` returned the expected `Client Three` workspace under the MSP account with `plan_version=msp_starter` +7. Reconfirmed the public individual-cloud path still failed closed instead of collapsing into MSP provisioning: + - unauthenticated `GET /cloud/signup` still rendered the public cloud signup page + - unauthenticated `POST /api/public/signup` with `{"email":"public-msp-boundary-20260313@example.com","org_name":"Public MSP Boundary 20260313","tier":"power"}` returned `400 {"code":"tier_unavailable","message":"The selected plan tier is not currently available"}` +8. Re-ran the governed automated proof bundle after the manual revalidation: + - `go test ./internal/cloudcp/account ./internal/cloudcp/registry -count=1` + - `go test ./internal/cloudcp/stripe -run 'TestMSPLifecycle_AccountToPortal' -count=1` + - `go test ./internal/cloudcp -run 'TestPublicCloudSignupCheckoutMetadataRejectsMSPPlanForPublicSignup' -count=1` + - `go test ./pkg/licensing -run 'TestMSPPlanAliasCanonicalizationContract' -count=1` + - `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx src/pages/__tests__/CloudPricing.test.tsx` + - Result: pass + +## Outcome + +- One provider account can create, view, and manage multiple client tenants from one live control surface. +- MSP workspace provisioning stays on the canonical MSP plan (`msp_starter`) and does not drift into individual-cloud semantics. +- Provider membership and dashboard visibility remain coherent across the same account and the same set of workspaces. +- The public individual cloud signup surface stays separate from MSP operator provisioning instead of silently collapsing the two modes together. +- The rerun remains a localhost control-plane rehearsal, so under the current evidence-tier policy it strengthens the record but does not honestly clear the gate until a real external E2E exercise exists. + +## Notes + +- For this localhost rehearsal, the MSP account was seeded directly in the control-plane registry because account creation is normally Stripe-driven and not exposed as a public create-account endpoint. +- The rehearsal intentionally used the live `pulse-control-plane` HTTP surface plus a real bearer session token, not only `httptest` handlers. diff --git a/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-2026-03-13.md b/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-2026-03-13.md new file mode 100644 index 000000000..5b26aeea1 --- /dev/null +++ b/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-2026-03-13.md @@ -0,0 +1,84 @@ +# MSP Provider Tenant Management Production Record + +- Date: `2026-03-13` +- Gate: `msp-provider-tenant-management` +- Assertion: `RA13` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Control-plane data dir: `/data` + - Dedicated rehearsal MSP account: `a_msp_prod_20260313105601` (`Pulse MSP Rehearsal 20260313105601`) + - Provider owner session: `owner+msp-prod-20260313105601@pulserelay.pro` + - Invited provider member: `tech+msp-prod-20260313105601@pulserelay.pro` (`tech`) + - Intended rehearsal workspaces: + - `Client Alpha 20260313` + - `Client Beta 20260313` + +## Automated Proof Baseline + +- `go test ./internal/cloudcp/account ./internal/cloudcp/registry -count=1` +- `go test ./internal/cloudcp/stripe -run 'TestMSPLifecycle_AccountToPortal' -count=1` +- `go test ./internal/cloudcp -run 'TestPublicCloudSignupCheckoutMetadataRejectsMSPPlanForPublicSignup' -count=1` +- `go test ./pkg/licensing -run 'TestMSPPlanAliasCanonicalizationContract' -count=1` +- `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx src/pages/__tests__/CloudPricing.test.tsx` +- Result: pass + +## External Rehearsal + +1. Created a dedicated MSP rehearsal account directly in the live control-plane registry on `pulse-cloud`: + - `account_id=a_msp_prod_20260313105601` + - `account.kind=msp` + - canonical plan mapping `msp_starter` + - real control-plane bearer session for `owner+msp-prod-20260313105601@pulserelay.pro` +2. Ran the governed external rehearsal helper against `https://cloud.pulserelay.pro`: + - `python3 scripts/release_control/msp_provider_tenant_management_rehearsal.py --base-url https://cloud.pulserelay.pro --account-id a_msp_prod_20260313105601 --bearer-token --timeout 180 --workspace-name 'Client Alpha 20260313' --workspace-name 'Client Beta 20260313' --member-email 'tech+msp-prod-20260313105601@pulserelay.pro' --member-role tech --public-signup-email 'public-msp-boundary-20260313105601@pulserelay.pro' --public-signup-org-name 'Public MSP Boundary 20260313105601' --report-out /tmp/msp-provider-tenant-management-production-2026-03-13.md` +3. Confirmed the MSP account surface itself works: + - `GET /api/accounts/a_msp_prod_20260313105601/tenants` initially returned `200 []` + - `POST /api/accounts/a_msp_prod_20260313105601/members` successfully invited `tech+msp-prod-20260313105601@pulserelay.pro` + - `GET /api/accounts/a_msp_prod_20260313105601/members` returned both the owner and invited tech member + - `GET /api/portal/dashboard?account_id=a_msp_prod_20260313105601` returned `account.kind="msp"` and `summary.total=0` +4. Confirmed the MSP/public boundary still fails closed on the same live control-plane instance: + - unauthenticated `POST /api/public/signup` with `tier=power` returned `400 {"code":"tier_unavailable", ...}` +5. Attempted real external tenant provisioning twice under the MSP account: + - `POST /api/accounts/a_msp_prod_20260313105601/tenants` with `{"display_name":"Client Alpha 20260313"}` + - `POST /api/accounts/a_msp_prod_20260313105601/tenants` with `{"display_name":"Client Beta 20260313"}` + - both calls eventually failed at the control-plane API boundary with `internal error` + +## Runtime Failure Observed + +- Control-plane logs on `pulse-cloud` showed the live MSP tenant create path entering real provisioning and starting tenant containers: + - `t-7JJHNF3HZS` for `Client Alpha 20260313` + - `t-XW52MAR90K` for `Client Beta 20260313` +- Both provisioning attempts then failed closed on the hosted runtime health check: + - `tenant t-7JJHNF3HZS container failed health check` + - `tenant t-XW52MAR90K container failed health check` +- Matching audit events were emitted as: + - `audit_event=cp_tenant_create` + - `outcome=failure` + - `reason=provision_failed` +- The governed rehearsal report ended with: + - `PASS msp-tenant-list` + - `FAIL msp-create-workspace:Client Alpha 20260313` + - `FAIL msp-create-workspace:Client Beta 20260313` + - `PASS msp-invite-member:tech+msp-prod-20260313105601@pulserelay.pro` + - `PASS msp-member-list` + - `PASS msp-portal-dashboard` + - `PASS public-cloud-boundary` + +## Outcome + +- The external MSP account model is partially real: + - dedicated MSP accounts can exist on the live control plane + - provider membership flows work + - the portal/dashboard path recognizes the account as `msp` + - the public individual-cloud signup path remains distinct +- The actual operator promise is still broken: + - live MSP workspace provisioning does not complete successfully + - the hosted tenant runtime fails health checks during MSP-driven tenant creation + - one provider account therefore cannot yet manage multiple client tenants from one real external control surface + +## Conclusion + +- This is genuine `real-external-e2e` evidence, but it is failing evidence. +- `msp-provider-tenant-management` must remain `pending`. +- The next product work is not more governance text; it is fixing hosted tenant provisioning/health-check behavior on the live MSP tenant-create path. diff --git a/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-fixed-2026-03-13.md b/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-fixed-2026-03-13.md new file mode 100644 index 000000000..3ba3b21b2 --- /dev/null +++ b/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-fixed-2026-03-13.md @@ -0,0 +1,89 @@ +# MSP Provider Tenant Management Production Fixed Record + +- Date: `2026-03-13` +- Gate: `msp-provider-tenant-management` +- Assertion: `RA13` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Control-plane data dir: `/data` + - Patched tenant image: `pulse:hosted-entrypoint-fix-20260313T1118Z` + - Dedicated rehearsal MSP account: `a_msp_prod_fix_20260313111348` + - Provider owner session: `owner+msp-fix-20260313111348@pulserelay.pro` + - Invited provider member: `tech+msp-fix-20260313111348@pulserelay.pro` (`tech`) + - Rehearsal workspaces: + - `Client Alpha Fix 20260313111348` + - `Client Beta Fix 20260313111348` + +## Runtime Fix Applied Before Rehearsal + +1. Reproduced the hosted tenant boot failure against a real tenant data dir on + `pulse-cloud`: + - plain `/etc/pulse` bind mount started healthy + - the new immutable file mounts (`billing.json`, `handoff.key`, + `.cloud_handoff_key`) made the same image fail during startup +2. The failure was caused by `docker-entrypoint.sh` still trying to `chown` + those read-only mounted files during boot. +3. Built a patched tenant image on `pulse-cloud` from the same runtime line: + - base: `ghcr.io/rcourtman/pulse:cloud-beta` + - patch: updated `/docker-entrypoint.sh` + - result tag: `pulse:hosted-entrypoint-fix-20260313T1118Z` +4. Updated `CP_PULSE_IMAGE` in `/opt/pulse-cloud/.env` to the patched image and + restarted only the control plane. +5. Verified the patched image boots healthy under the immutable mount layout + before retrying MSP workspace creation. + +## Automated Proof Baseline + +- `go test ./internal/cloudcp/account ./internal/cloudcp/registry -count=1` +- `go test ./internal/cloudcp/stripe -run 'TestMSPLifecycle_AccountToPortal' -count=1` +- `go test ./internal/cloudcp -run 'TestPublicCloudSignupCheckoutMetadataRejectsMSPPlanForPublicSignup' -count=1` +- `go test ./pkg/licensing -run 'TestMSPPlanAliasCanonicalizationContract' -count=1` +- `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx src/pages/__tests__/CloudPricing.test.tsx` +- Result: pass + +## External Rehearsal + +1. Seeded a fresh MSP provider account directly in the live control-plane + registry using the governed rehearsal helper path: + - `account_id=a_msp_prod_fix_20260313111348` + - canonical plan mapping `msp_starter` + - real control-plane bearer session for + `owner+msp-fix-20260313111348@pulserelay.pro` +2. Ran the governed external rehearsal helper against production: + - `python3 scripts/release_control/msp_provider_tenant_management_rehearsal.py --base-url https://cloud.pulserelay.pro --account-id a_msp_prod_fix_20260313111348 --bearer-token --timeout 180 --workspace-name 'Client Alpha Fix 20260313111348' --workspace-name 'Client Beta Fix 20260313111348' --member-email 'tech+msp-fix-20260313111348@pulserelay.pro' --member-role tech --public-signup-email 'public-msp-boundary-fix-20260313111348@pulserelay.pro' --public-signup-org-name 'Public MSP Boundary Fix 20260313111348' --public-signup-tier power --report-out /tmp/msp-provider-tenant-management-production-fix-20260313111348.md` +3. Confirmed the full operator workflow now works on the live control plane: + - `GET /api/accounts/a_msp_prod_fix_20260313111348/tenants` initially returned `200 []` + - `POST /api/accounts/a_msp_prod_fix_20260313111348/tenants` created + `t-P9ES7BWFBT` for `Client Alpha Fix 20260313111348` + - `POST /api/accounts/a_msp_prod_fix_20260313111348/tenants` created + `t-ZXTM30QX4J` for `Client Beta Fix 20260313111348` + - both workspaces reported `plan_version='msp_starter'` + - `POST /api/accounts/a_msp_prod_fix_20260313111348/members` successfully invited + `tech+msp-fix-20260313111348@pulserelay.pro` + - `GET /api/accounts/a_msp_prod_fix_20260313111348/members` returned both + the owner and invited tech member + - `GET /api/portal/dashboard?account_id=a_msp_prod_fix_20260313111348` + returned `account.kind="msp"` and `summary.total=2` + - `GET /api/portal/workspaces/?account_id=a_msp_prod_fix_20260313111348` + returned coherent workspace detail for both created tenants +4. Confirmed the MSP/public boundary still fails closed on the same live + control-plane instance: + - unauthenticated `POST /api/public/signup` with `tier=power` returned + `400 {"code":"tier_unavailable", ...}` + +## Outcome + +- The live MSP operator workflow now works as a real product mode: + - one provider account can create multiple client workspaces + - provider membership flow works + - portal and workspace detail surfaces stay coherent + - plan handling stays canonical per workspace + - public individual-cloud signup remains distinct from MSP-only flows + +## Conclusion + +- This is successful `real-external-e2e` evidence. +- `msp-provider-tenant-management` can be treated as `passed`. +- `RA13` is now backed by real external runtime proof, not pricing-only or + local rehearsal evidence. diff --git a/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-followup-2026-03-13.md b/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-followup-2026-03-13.md new file mode 100644 index 000000000..a2adffb07 --- /dev/null +++ b/docs/release-control/v6/internal/records/msp-provider-tenant-management-production-followup-2026-03-13.md @@ -0,0 +1,54 @@ +# MSP Provider Tenant Management Production Follow-up Record + +- Date: `2026-03-13` +- Gate: `msp-provider-tenant-management` +- Assertion: `RA13` +- Environment: + - External control plane: `https://cloud.pulserelay.pro` + - Remote host: `root@pulse-cloud` + - Dedicated MSP rehearsal account: `a_msp_owner_seed_20260313124930` + - Provider owners: + - `legacy+msp-owner-seed-20260313124930@pulserelay.pro` + - `operator+msp-owner-seed-20260313124930@pulserelay.pro` + - Fresh production proof tenant: `t-8ME7XXQM7X` + - Evidence source: live production HTTPS surface, control-plane bearer session, tenant registry state, and tenant runtime API responses + +## External Follow-up Exercise + +1. Seeded a fresh live MSP rehearsal account on production with two distinct + provider-owner identities so owner selection could be validated against a + realistic multi-owner account. +2. Exercised fresh workspace provisioning through the authenticated operator + session after deploying the owner-aware provisioning fix: + - created fresh tenant canaries under + `a_msp_owner_seed_20260313124930` + - inspected the tenant org record on disk for the fresh canaries +3. Confirmed deterministic owner assignment on the successful production canary: + - `/data/tenants/t-8ME7XXQM7X/orgs/t-8ME7XXQM7X/org.json` contained + `ownerUserId=operator+msp-owner-seed-20260313124930@pulserelay.pro` + - the owner did not drift back to the older legacy owner purely because + that account member already existed +4. Confirmed the tenant runtime received the canonical identity it needs to + preserve MSP-scoped management after handoff: + - live container env included `PULSE_TENANT_ID=t-8ME7XXQM7X` + - the real handoff exchange completed successfully with `200` +5. Continued the same session into provider-managed tenant surfaces: + - `GET /api/admin/orgs/t-8ME7XXQM7X/billing-state` returned `200` + - `GET /api/orgs/t-8ME7XXQM7X/members` returned `200` + - members payload showed both seeded provider owners coherently + +## Outcome + +- This is successful `real-external-e2e` production evidence. +- MSP workspace provisioning now preserves the authenticated creator as the + tenant owner instead of relying on unstable account-member ordering. +- The same fresh tenant also proves the MSP operator can enter the provisioned + workspace, resolve billing state, and enumerate tenant membership without the + earlier tenant-identity handoff drift. + +## Conclusion + +- `msp-provider-tenant-management` remains correctly `passed`. +- `RA13` is now backed by fresh production follow-up proof that the repaired + MSP owner-selection and tenant-identity handoff behavior holds on a new live + canary, not only on the earlier fixed rehearsal account. diff --git a/docs/release-control/v6/internal/records/multi-tenant-runtime-isolation-and-coherence-2026-03-13.md b/docs/release-control/v6/internal/records/multi-tenant-runtime-isolation-and-coherence-2026-03-13.md new file mode 100644 index 000000000..16c68a4f3 --- /dev/null +++ b/docs/release-control/v6/internal/records/multi-tenant-runtime-isolation-and-coherence-2026-03-13.md @@ -0,0 +1,86 @@ +# Multi-Tenant Runtime Isolation and Coherence Record + +- Date: `2026-03-13` +- Gate: `multi-tenant-runtime-isolation-and-coherence` +- Assertion: `RA12` +- Environment: + - Managed local backend (seeded auth, then relaunched with proxy auth): `http://127.0.0.1:59221` + - Entitlement profile: `multi-tenant` + - Proxy-auth rehearsal identities: + - `admin` + - `alice` + - `bob` + - Seeded default-org live agent: `default-host.local` + +## Automated Proof Baseline + +- `go test ./internal/api -run 'TestOrgHandlers|TestMultiTenant|TestResourceHandlers_NonDefaultOrg|TestSetMultiTenantMonitor_WiresHandlers|TestMultiTenantStateProvider|TestMultiTenantAPITokenRemainsScopedToIssuingOrg' -count=1` +- `go test ./internal/monitoring -run 'TestMultiTenantMonitor' -count=1` +- `go test ./tests/migration -run 'TestV5DataDir_MultiTenantMigration' -count=1` +- `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationSharingPanel.test.tsx src/components/Settings/__tests__/RBACPaywallPanels.test.tsx src/utils/__tests__/rbacPermissions.test.ts src/utils/__tests__/rbacPresentation.test.ts src/utils/__tests__/organizationRolePresentation.test.ts src/utils/__tests__/organizationSettingsPresentation.test.ts` +- `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 PULSE_MULTI_TENANT_ENABLED=true npm test -- tests/03-multi-tenant.spec.ts --project=chromium` +- Result: pass + +## Manual Exercise + +1. Started a clean managed local backend with `PULSE_MULTI_TENANT_ENABLED=true`, completed quick security setup, then relaunched the same data directory with proxy auth enabled so distinct users could be exercised over the live HTTP surface. +2. Seeded one live default-org unified agent report for `default-host.local` through `POST /api/agents/agent/report` as `admin`. +3. As `alice`, created org `mtgate-a-1773391667`. +4. As `bob`, created org `mtgate-b-1773391667`. +5. As `alice`, created org `mtgate-shared-1773391667`. +6. As `alice`, added `bob` to the shared org as `viewer`. +7. Listed `GET /api/orgs` as both users and confirmed membership-filtered visibility: + - `alice` saw `default`, `mtgate-a-1773391667`, and `mtgate-shared-1773391667` + - `bob` saw `default`, `mtgate-b-1773391667`, and `mtgate-shared-1773391667` +8. As `bob` while still `viewer`, attempted `PUT /api/orgs/mtgate-shared-1773391667` and received `403` with `Admin role required for this organization`. +9. As `alice`, promoted `bob` to `admin` in `mtgate-shared-1773391667`. +10. As `bob`, repeated `PUT /api/orgs/mtgate-shared-1773391667` and confirmed it succeeded with `200`. +11. As `alice`, demoted `bob` back to `viewer`. +12. As `bob`, repeated `PUT /api/orgs/mtgate-shared-1773391667` and confirmed it immediately failed again with `403`. +13. Verified tenant-scoped runtime isolation directly through the live lookup path: + - `GET /api/agents/agent/lookup?hostname=default-host.local` in the default org returned `200` with the seeded live agent. + - The same lookup with `X-Pulse-Org-ID: mtgate-a-1773391667` returned `404 agent_not_found`, confirming non-default orgs did not fall back to default-org runtime state. +14. As `alice`, created a cross-org share from `mtgate-a-1773391667` into `mtgate-b-1773391667` with `accessRole=editor`. +15. As `bob`, listed `GET /api/orgs/mtgate-b-1773391667/shares/incoming` and confirmed the incoming share preserved: + - `sourceOrgId=mtgate-a-1773391667` + - `accessRole=editor` +16. As `bob`, attempted `GET /api/orgs/mtgate-a-1773391667/shares` and confirmed the source-org share list remained blocked with `403` and `User is not a member of the organization`. +17. Deleted the temporary share and all temporary orgs after the rehearsal. + +## Outcome + +- Multi-tenant org visibility stayed scoped to actual membership. +- Membership role changes immediately changed the allowed write surface for the shared organization. +- Tenant-scoped runtime lookup failed closed for a non-default org instead of falling back to default-org live agent state. +- Cross-org sharing preserved the intended access role and did not widen source-org visibility. +- The live HTTP surface enforced tenant isolation consistently across org creation, membership, role changes, runtime lookup, and share visibility. + +## Lifecycle Regression Revalidation + +1. After the same-day rehearsal exposed shutdown-time alert-history save errors during org deletion, I patched tenant removal so it cancels the tenant runtime, waits for the monitor loop to exit, and only then flushes tenant state and removes the org directory. +2. Re-ran the automated proof surfaces that own this boundary: + - `go test ./internal/api -run 'TestOrgHandlers|TestMultiTenant|TestResourceHandlers_NonDefaultOrg|TestSetMultiTenantMonitor_WiresHandlers|TestMultiTenantStateProvider|TestMultiTenantAPITokenRemainsScopedToIssuingOrg|TestRBACLifecycle' -count=1` + - `go test ./internal/monitoring -run 'TestMultiTenantMonitor' -count=1` + - `go test ./tests/migration -run 'TestV5DataDir_MultiTenantMigration' -count=1` +3. Re-ran the managed-runtime deletion path on a fresh local backend at `http://127.0.0.1:59231`: + - created `mtfix-live-1773396128` as `alice` + - forced tenant monitor initialization through `GET /api/alerts/config` with `X-Pulse-Org-ID: mtfix-live-1773396128` + - deleted the org with `DELETE /api/orgs/mtfix-live-1773396128` +4. Verified the live server log showed the corrected shutdown order for the initialized tenant: + - `stopping and removing tenant monitor` + - `monitoring loop stopped` + - `stopping monitor` + - `monitor stopped` +5. Verified the previous shutdown fault did not recur: + - no `Failed to save alert history on shutdown` + - no missing `alerts/alert-history.json.tmp*` write errors during tenant deletion + +## Gate Decision + +- `multi-tenant-runtime-isolation-and-coherence` is now satisfied at the required `managed-runtime-exercise` tier. +- The earlier org-deletion cleanup fault was reproduced, fixed, and revalidated on the live managed-runtime surface before closing the gate. + +## Notes + +- The browser-level multi-tenant suite passed separately on a fresh managed local backend, including CRUD, cross-org token isolation, self-role denial, cross-org share handling, and scoped permission updates. +- The runtime-state rehearsal intentionally used the direct agent lookup route because it reflects live Unified Agent inventory immediately, while the generic resource list on a fresh backend can remain empty until unrelated polling state is populated. diff --git a/docs/release-control/v6/internal/records/organization-user-scope-and-rbac-2026-03-12.md b/docs/release-control/v6/internal/records/organization-user-scope-and-rbac-2026-03-12.md new file mode 100644 index 000000000..eb77efa0e --- /dev/null +++ b/docs/release-control/v6/internal/records/organization-user-scope-and-rbac-2026-03-12.md @@ -0,0 +1,82 @@ +# Organization User Scope and RBAC Record + +- Date: `2026-03-12` +- Gate: `organization-user-scope-and-rbac` +- Environment: + - Managed local backend runs: + - `http://127.0.0.1:51688` + - `http://127.0.0.1:8766` + - Entitlement profile: `multi-tenant` + - Live distinct users exercised through proxy-auth headers: + - `alice` + - `bob` + - `admin-user` + - `viewer-user` + +## Automated Proof Baseline + +- `go test ./internal/api -run 'TestOrgHandlers|TestMultiTenant|TestResourceHandlers_NonDefaultOrg|TestSetMultiTenantMonitor_WiresHandlers' -count=1` +- `go test ./internal/monitoring -run 'TestMultiTenantMonitor' -count=1` +- `cd frontend-modern && npx vitest run src/components/Settings/__tests__/OrganizationSharingPanel.test.tsx src/components/Settings/__tests__/RBACPaywallPanels.test.tsx src/utils/__tests__/rbacPermissions.test.ts src/utils/__tests__/rbacPresentation.test.ts src/utils/__tests__/organizationRolePresentation.test.ts src/utils/__tests__/organizationSettingsPresentation.test.ts src/utils/__tests__/frontendResourceTypeBoundaries.test.ts` +- `cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 PULSE_MULTI_TENANT_ENABLED=true npm test -- tests/03-multi-tenant.spec.ts --project=chromium` +- Result: pass + +## Manual Exercise A: Org Membership and Management Isolation + +1. Seeded a clean managed local backend with the canonical quick security setup, then relaunched the same data directory with proxy auth enabled so the live HTTP surface could exercise distinct users `alice` and `bob`. +2. As `alice`, created org `manual-org-a-1773353229067-469132`. +3. As `bob`, created org `manual-org-b-1773353229067-199801`. +4. As `alice`, created shared org `manual-org-shared-1773353229067-962894`. +5. As `alice`, added `bob` to the shared org as `viewer`. +6. Listed `GET /api/orgs` as both users and confirmed membership-filtered visibility: + - `alice` saw `default`, `manual-org-a-1773353229067-469132`, and `manual-org-shared-1773353229067-962894` + - `bob` saw `default`, `manual-org-b-1773353229067-199801`, and `manual-org-shared-1773353229067-962894` +7. As `bob` while still `viewer`, attempted `PUT /api/orgs/{shared}` and received `403` with `Admin role required for this organization`. +8. As `alice`, promoted `bob` to `admin` via `POST /api/orgs/{shared}/members`. +9. As `bob`, repeated `PUT /api/orgs/{shared}` and confirmed it succeeded with `200`. +10. As `alice`, demoted `bob` back to `viewer`. +11. As `bob`, repeated `PUT /api/orgs/{shared}` and confirmed it immediately failed again with `403`. +12. As `alice`, attempted `PUT /api/admin/users/alice/roles` on her own account and confirmed the request failed closed with `403` and `code=self_modification_denied`. +13. As `alice`, created a cross-org share from org A into org B with `accessRole=editor`. +14. As `bob`, listed `GET /api/orgs/{orgB}/shares/incoming` and confirmed the incoming share preserved `accessRole=editor`. +15. As `bob`, attempted `GET /api/orgs/{orgA}/shares` and confirmed the source-org share list remained blocked with `403`. +16. Deleted the temporary share and all three temporary orgs after the rehearsal. + +## Manual Exercise B: Scoped RBAC Permission Isolation + +1. Created three organizations as `admin-user`: + - `rbacscope20260312` + - `rbachidden20260312` + - `rbacsource20260312` +2. Added `viewer-user` to `rbacscope20260312` as an organization `viewer`. +3. Confirmed `viewer-user` could only see: + - `default` + - `rbacscope20260312` + and could not see `rbachidden20260312` or `rbacsource20260312`. +4. Assigned scoped RBAC role `viewer` to `viewer-user` in `rbacscope20260312` and confirmed effective permissions were: + - `read` on `*` in `rbacscope20260312` + - empty in `rbachidden20260312` +5. Promoted the same scoped RBAC role to `admin` in `rbacscope20260312` and confirmed effective permissions changed to: + - `admin` on `*` in `rbacscope20260312` + - still empty in `rbachidden20260312` +6. Confirmed self-role mutation failed closed in both relevant ways: + - `viewer-user` attempting to mutate their own roles was blocked before mutation with `403 Admin privileges required` + - `admin-user` attempting to mutate their own roles was blocked with `403 self_modification_denied` +7. Created a cross-org share from `rbacsource20260312` to `rbacscope20260312` with `accessRole=editor`. +8. Confirmed `viewer-user` did not see that incoming share while their organization membership in `rbacscope20260312` remained `viewer`. +9. Promoted `viewer-user` organization membership in `rbacscope20260312` to `editor`. +10. Confirmed the same incoming share became visible only after that membership promotion, with the intended `editor` access role preserved. + +## Outcome + +- Organization visibility stayed scoped to membership. +- Membership role changes immediately changed the allowed write surface for the shared organization. +- Scoped RBAC permissions changed in the intended organization only and did not leak into a second organization. +- Self-role escalation failed closed. +- Cross-org shares preserved the intended access role and did not expose source-org management to the target-org user. +- The live HTTP surface enforced least privilege consistently across org membership, role change, scoped RBAC assignment, self-role mutation, and cross-org sharing behavior. + +## Notes + +- The default organization remains visible by design; the critical least-privilege check is that non-member custom organizations stayed hidden. +- The live managed-backend exercise was captured from the same API surfaces that power the Settings organization, roles, assignments, and sharing flows. diff --git a/docs/release-control/v6/internal/records/paid-feature-entitlement-gating-2026-03-12.md b/docs/release-control/v6/internal/records/paid-feature-entitlement-gating-2026-03-12.md new file mode 100644 index 000000000..97eba9211 --- /dev/null +++ b/docs/release-control/v6/internal/records/paid-feature-entitlement-gating-2026-03-12.md @@ -0,0 +1,63 @@ +# Paid Feature Entitlement Gating Record + +- Date: `2026-03-12` +- Gate: `paid-feature-entitlement-gating` +- Environment: + - Managed local backend: `http://127.0.0.1:61153` + - Managed backend run id: `paid-feature-gate-20260312d` + - Billing state path: `/Volumes/Development/pulse/repos/pulse/tmp/integration-local-backend/paid-feature-gate-20260312d/data/billing.json` + - Authenticated user under test: `admin` + - Runtime proof mode: deterministic local billing-state writes plus live browser/API exercise against the managed backend + +## Automated Proof Baseline + +- `go test ./internal/api -run 'TestEntitlementHandler_|TestRequireLicenseFeature_HostedEntitlements|TestLicenseGatedEmptyResponse_HostedEntitlements' -count=1` +- `go test ./internal/api -run 'TestMonitoredSystemLedger|TestHandleAddNode_BlocksNewCountedSystemAtLimit|TestHandleAutoRegister_BlocksNewCountedSystemAtLimit|TestTrueNASHandlers_HandleAdd_BlocksNewCountedSystemAtLimit|TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit|TestKubernetesAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit|TestContract_EntitlementPayloadMonitoredSystemUsageJSONSnapshot' -count=1` +- `go test ./internal/license/... -count=1` +- `go test ./internal/cloudcp/... -count=1` +- `cd frontend-modern && npx vitest run src/pages/__tests__/AIIntelligence.test.tsx src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx src/components/Settings/__tests__/RBACPaywallPanels.test.tsx src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx src/utils/__tests__/licensePresentation.test.ts src/utils/__tests__/rbacPresentation.test.ts src/utils/__tests__/frontendResourceTypeBoundaries.test.ts` +- Result: pass + +## Manual Exercise + +1. Started an isolated managed local backend, then replaced the seeded billing file with a free/community-style state before the first authenticated browser session: + - `subscription_state=expired` + - `tier=free` + - free capabilities resolved to `update_alerts`, `sso`, and `ai_patrol` + - `trial_eligible=true` + - `overflow_days_remaining=14` +2. Logged into the live backend as `admin` and confirmed the free surface failed closed: + - `GET /api/license/entitlements` returned `subscription_state=expired`, `tier=free`, and 12 upgrade reasons. + - `GET /api/license/status` returned `tier=free` and `max_monitored_systems=5`. + - `/ai` rendered the live AI surface but kept both `Investigate` and `Auto-fix` disabled, with upgrade links visible. + - Direct navigation to `/settings/organization/billing` failed closed by redirecting to `/settings` instead of rendering the billing panel. + - `/settings/system-pro` rendered the activation/upgrade surface, including the activation controls and free-tier trial messaging. +3. Replaced the same billing file with a paid Enterprise-eval state before a fresh authenticated browser session: + - `subscription_state=active` + - `plan_version=enterprise_eval` + - capabilities included `ai_alerts`, `ai_autofix`, `advanced_reporting`, `audit_logging`, `multi_tenant`, `rbac`, `relay`, and related paid features + - limits were set to `max_monitored_systems=1` and `max_guests=25` +4. Confirmed the paid surface unlocked coherently: + - `GET /api/license/entitlements` returned `subscription_state=active`, `tier=pro`, and `max_monitored_systems=1`. + - `GET /api/license/status` returned `tier=pro` and `max_monitored_systems=1`. + - `/ai` enabled both `Investigate` and `Auto-fix` and no longer showed `Upgrade to Pro`. + - `/settings/organization/billing` rendered `Billing & Plan` with `Usage vs Plan Limits` and the monitored-system capacity surface. +5. Exercised live monitored-system accounting against that paid state: + - First authenticated Unified Agent report to `POST /api/agents/agent/report` succeeded with `200`. + - First authenticated Docker report to `POST /api/agents/docker/report` also succeeded with `200`. + - `GET /api/license/entitlements` then reported `max_monitored_systems.current=1`, `max_monitored_systems.limit=1`, `state=enforced`, `docker_hosts=1`, and `has_migration_gap=true`. + - The live upgrade banner rendered the monitored-system cap and showed the upgrade CTA. + - When legacy-connected resources were also present, the migration guidance kept the same monitored-system term for both the counted limit and the non-counted legacy resources. +6. Confirmed the limit enforces only on new counted monitored systems while existing monitored systems continue: + - A second new counted monitored system returned `402 license_required` with `feature=max_monitored_systems`. + - A rereport from the existing host still returned `200`. + - A second Docker report still returned `200`. + - Final entitlements stayed at `max_monitored_systems.current=1` while `docker_hosts` increased to `2`. + +## Outcome + +- Free/community entitlements gated paid AI controls and multi-tenant billing surfaces consistently. +- Paid entitlements unlocked the same surfaces without leaving stale upgrade prompts behind. +- The Pro settings surface matched the active entitlement state on each fresh authenticated session. +- The live `max_monitored_systems` count tracked the canonical monitored-system surface. +- New counted monitored-system enrollment was blocked at the cap, while existing monitored-system reports and Docker reports continued to succeed. diff --git a/docs/release-control/v6/internal/records/relay-registration-reconnect-drain-2026-03-12.md b/docs/release-control/v6/internal/records/relay-registration-reconnect-drain-2026-03-12.md new file mode 100644 index 000000000..5ab21302f --- /dev/null +++ b/docs/release-control/v6/internal/records/relay-registration-reconnect-drain-2026-03-12.md @@ -0,0 +1,50 @@ +# Relay Registration Reconnect Drain Record + +- Date: `2026-03-12` +- Gate: `relay-registration-reconnect-drain` +- Environment: + - Desktop relay runtime package: `internal/relay` + - Desktop API/license and onboarding surfaces: `internal/api` + - Desktop UI surfaces: + - `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` + - `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` + - Mobile relay client repo: `/Volumes/Development/pulse/repos/pulse-mobile` + +## Automated Proof Baseline + +- `go test ./internal/relay -run 'TestClient_E2E_MultiMobileClientRelay|TestClient_AbruptDisconnectCancelsInFlightHandlers|TestClient_AbruptDisconnectMultipleChannelCleanup|TestClient_DrainDuringInFlightData|TestClient_DrainWithMultipleInFlightChannels|TestClientRegister_SessionResumeRejectionClearsCachedSession|TestRunLoop_SessionResumeRejectionFallsBackToFreshRegister' -count=1` +- `go test ./internal/api -run 'TestRelayEndpointsRequireLicenseFeature|TestRelayOnboardingEndpointsRequireLicenseFeature|TestRelayLicenseGatingResponseFormat|TestOnboardingQRPayloadStructure|TestOnboardingValidateSuccessAndFailure|TestOnboardingDeepLinkFormat' -count=1` +- `cd frontend-modern && npx vitest run src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx` +- `cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/relay/__tests__/client.test.ts src/relay/__tests__/client-hardening.test.ts src/relay/__tests__/protocol-contract.test.ts` +- Result: pass + +## Exercised Relay Recovery Scenarios + +1. Ran the targeted desktop relay runtime suite in verbose mode to capture the exact reconnect, drain, and stale-session behavior rather than treating the gate as a generic green test bucket. +2. Confirmed abrupt disconnect handling stayed bounded: + - `TestClient_AbruptDisconnectCancelsInFlightHandlers` passed. + - `TestClient_AbruptDisconnectMultipleChannelCleanup` passed. +3. Confirmed server-drain behavior canceled in-flight work cleanly and recovered registration: + - `TestClient_DrainDuringInFlightData` logged `Relay server draining, will reconnect`, closed the active relay connection, canceled the in-flight local request with `context canceled`, and re-registered the same instance successfully. + - `TestClient_DrainWithMultipleInFlightChannels` did the same with two simultaneous channels, canceling both local requests without hanging and then re-registering successfully. +4. Confirmed fresh registration and multi-client relay behavior still held under the same suite: + - `TestClient_E2E_MultiMobileClientRelay` passed. +5. Confirmed stale-session recovery behaved predictably: + - `TestClientRegister_SessionResumeRejectionClearsCachedSession` passed. + - `TestRunLoop_SessionResumeRejectionFallsBackToFreshRegister` logged `relay session resume rejected, retrying fresh registration` and then re-registered the instance successfully instead of looping or stranding the client. +6. Confirmed the surrounding relay product surfaces stayed aligned with that runtime behavior: + - desktop API/license/onboarding relay checks passed + - desktop relay onboarding/settings UI checks passed + - mobile relay client and protocol hardening checks passed in `pulse-mobile` + +## Outcome + +- Fresh relay registration still succeeds. +- Normal reconnect after disconnect remains healthy. +- Server drain closes active relay sessions without hanging or spinning, cancels in-flight work predictably, and reconnects cleanly. +- Stale session resume falls back to a fresh registration path instead of trapping the client in a dead session loop. +- Desktop API/license gating, onboarding payloads, desktop UI surfaces, and the mobile relay client stay aligned with the same reconnect and registration contract. + +## Notes + +- This record is grounded in the named relay runtime and client-contract exercises that explicitly force reconnect, drain, abrupt disconnect, and stale-session-resume paths. The verbose relay runtime run was captured on `2026-03-12` and showed the expected reconnect and cancellation messages at the exact pressure points the gate is meant to cover. diff --git a/docs/release-control/v6/internal/records/relay-registration-reconnect-drain-2026-03-13.md b/docs/release-control/v6/internal/records/relay-registration-reconnect-drain-2026-03-13.md new file mode 100644 index 000000000..9b1ee1483 --- /dev/null +++ b/docs/release-control/v6/internal/records/relay-registration-reconnect-drain-2026-03-13.md @@ -0,0 +1,66 @@ +# Relay Registration Reconnect Drain Record + +- Date: `2026-03-13` +- Gate: `relay-registration-reconnect-drain` +- Evidence tier: `managed-runtime-exercise` +- Environment: + - Pulse desktop repo: `/Volumes/Development/pulse/repos/pulse` + - Relay server repo: `/Volumes/Development/pulse/repos/pulse-pro/relay-server` + - Desktop relay runtime: `internal/relay` + - Desktop relay onboarding/settings surfaces: + - `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` + - `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` + - Mobile relay client repo: `/Volumes/Development/pulse/repos/pulse-mobile` + +## Managed Runtime Exercise + +- `go test ./internal/relay -run TestManagedRuntimeRelayRegistrationReconnectDrain -count=1` +- `python3 scripts/release_control/relay_registration_reconnect_drain_proof.py` + +The managed-runtime test builds and launches the real `pulse-pro/relay-server` +binary, then drives it from the real `internal/relay` client with a real local +HTTP backend and a real app-side WebSocket connection. It does not use the +mock relay server used by the unit-level relay tests. + +## Exercised Flow + +1. Started a real `pulse-pro/relay-server` process with an ephemeral data dir + and a generated Ed25519 public key for legacy relay license validation. +2. Started the real desktop relay client against that server and waited for + canonical registration to complete. +3. Opened a real `/ws/app` WebSocket session and proxied a `/api/status` + request through the relay to confirm the healthy baseline path. +4. Killed the relay server abruptly and restarted it with the same data dir, + then confirmed the desktop relay client reconnected and proxied traffic + successfully again. +5. Killed the relay server and restarted it with a fresh data dir, forcing the + client’s cached session token to become stale. Confirmed the client logged + `relay session resume rejected, retrying fresh registration`, cleared the + stale resume path, re-registered cleanly, and proxied traffic successfully + again. +6. Opened a fresh app-side relay connection, sent an in-flight proxied request + to a deliberately slow local endpoint, then terminated the relay server + gracefully to trigger its drain path. +7. Confirmed the desktop relay client logged `Relay server draining, will reconnect`, + the in-flight local request was cancelled through the relay connection + context, a replacement relay server process accepted the reconnect, and the + client returned to `active_channels=0` before a final healthy proxy + round-trip succeeded. + +## Outcome + +- Fresh registration succeeded on the real relay server binary. +- Normal reconnect after abrupt relay restart recovered cleanly. +- Stale session resume was rejected and the client fell back to a fresh + registration path instead of getting trapped in a dead session loop. +- Server drain cancelled in-flight work predictably and the client reconnected + to a replacement relay server without leaving a stuck active channel behind. +- The relay client remained capable of proxying live traffic after each phase. + +## Notes + +- This record supersedes the earlier `2026-03-12` relay record for closure + confidence because it exercises the real relay server binary rather than only + the mock relay harness plus targeted runtime tests. +- The older `2026-03-12` record still remains useful as lower-level automated + pressure coverage, but it is no longer the strongest evidence for this gate. diff --git a/docs/release-control/v6/internal/records/self-hosted-commercial-model-lock-2026-03-17.md b/docs/release-control/v6/internal/records/self-hosted-commercial-model-lock-2026-03-17.md new file mode 100644 index 000000000..9033b5a22 --- /dev/null +++ b/docs/release-control/v6/internal/records/self-hosted-commercial-model-lock-2026-03-17.md @@ -0,0 +1,86 @@ +# Self-Hosted Commercial Model Lock + +Date: 2026-03-17 +Target: `v6-rc-stabilization` +Lanes: `L2`, `L13` + +## Decision + +Pulse v6 self-hosted commercial packaging is locked to this model: + +| Plan | Price | Included limit | History | Purpose | +|---|---:|---:|---:|---| +| Community | Free | 5 monitored systems | 7 days | One real small lab end to end | +| Relay | $4.99/mo or $39/yr | 8 monitored systems | 14 days | Cheap headroom plus remote access | +| Pro | $8.99/mo or $79/yr | 15 monitored systems | 90 days | Automation and operations tier | +| Pro+ | $14.99/mo or $129/yr | 50 monitored systems | 90 days | Larger self-hosted labs | + +Cloud and MSP pricing are unchanged by this lock. + +## Counted Unit + +Pulse sells monitored coverage. The counted unit is a **monitored system**, not an installed agent. + +One monitored system counts once regardless of collection path. + +Counted examples: +- Proxmox PVE node +- PBS / PMG server +- standalone Linux / Windows / macOS host +- Docker host +- TrueNAS / Unraid system +- Kubernetes cluster + +Not counted separately: +- VMs +- containers +- pods +- disks +- pools +- datastores +- backup jobs +- other child resources under a counted top-level system + +Rules: +- API-backed monitoring and agent-backed monitoring consume the same cap +- If the same system is seen through both paths, it counts once +- Deduplication must follow canonical unified-resource identity, not transport-specific state + +## Migration Policy + +- Existing paid v5 customers keep their grandfathered recurring continuity until cancellation, per the existing governed policy +- Existing free users above the new Community cap must not be hard-broken on rollout day +- During grace, existing monitoring keeps working +- During grace, only new counted-system additions are blocked until the user removes systems or upgrades + +## User-Facing Copy + +Counted-unit explainer: + +> Pulse counts monitored systems, not everything underneath them. Each top-level machine or cluster counts once, no matter how Pulse collects it. VMs, containers, pods, disks, backups, and services under that system are included. + +Plan taglines: +- Community: Monitor up to 5 systems for free. +- Relay: Get a bit more room and monitor from anywhere. +- Pro: Pulse does not just watch your infrastructure. It helps operate it. +- Pro+: Everything in Pro, with more room for larger labs. + +Boundary-upgrade copy: +- Community to Relay: Need a little more room? Upgrade to Relay for 3 extra monitored systems plus remote access, mobile, and push notifications. +- Relay to Pro: Want Pulse to do more than alert? Upgrade to Pro for AI investigation, auto-fix, and 90-day history. +- Grace copy: Your existing monitoring will keep working for now, but new systems will not be added until you remove one or upgrade. + +## Implementation Slices + +1. Runtime counting + Replace agent-only commercial enforcement with monitored-system counting derived from canonical unified-resource roots and transport-agnostic deduplication. + +2. Frontend/commercial UI + Rename commercial copy from agents to monitored systems, replace the commercial ledger with counted-system truth, and update pricing/paywall language to the locked bands. + +3. License server / checkout / public site + Create the new self-hosted Stripe prices, update plan mappings and purchase flows, and cut all public pricing copy over to the monitored-system model without disturbing v5 grandfathered continuity. + +## Implementation Transition + +Explicit `legacy_v5` compatibility files may still decode older `max_agents` / `max_nodes` inputs at import boundaries. That is migration support, not the canonical commercial contract. diff --git a/docs/release-control/v6/internal/records/settings-surface-layout-consistency-2026-03-13.md b/docs/release-control/v6/internal/records/settings-surface-layout-consistency-2026-03-13.md new file mode 100644 index 000000000..70d138c58 --- /dev/null +++ b/docs/release-control/v6/internal/records/settings-surface-layout-consistency-2026-03-13.md @@ -0,0 +1,51 @@ +# Settings Surface Layout Consistency Record + +- Date: `2026-03-13` +- Gate: `settings-surface-layout-consistency` +- Assertion: `RA19` +- Result: `pass` + +## Automated Baseline + +- `cd frontend-modern && npx vitest run src/components/Settings/__tests__/settingsArchitecture.test.ts` +- Result: pass + +## Local Rehearsal + +- Environment: + - managed local backend started by `tests/integration/scripts/managed-local-backend.mjs` + - seeded entitlement profile: `multi-tenant` + - exercised base URL: `http://127.0.0.1:61500` +- Command: + - `PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_ENTITLEMENT_PROFILE=multi-tenant PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm --prefix tests/integration test -- tests/15-settings-shell-consistency.spec.ts --project=chromium` +- Result: pass (`8 passed`) + +## Rehearsed Settings Surfaces + +1. `/settings/system-general` +2. `/settings/organization` +3. `/settings/organization/billing` +4. `/settings/system-relay` +5. `/settings/security-auth` +6. `/settings/system-ai` +7. `/settings/system-updates` +8. `/settings/system-recovery` + +## Review Outcome + +1. Each rehearsed route rendered the shared settings navigation and shared + `Search settings...` shell control. +2. Each rehearsed route rendered the expected page-shell `h1` title and + canonical page-shell description from the settings header metadata. +3. Each rehearsed route kept a single page-level `h1` instead of introducing + duplicate top-level page headers. +4. Each rehearsed route rendered its main content inside the shared settings + shell rather than falling back to bespoke outer page chrome. +5. Organization and billing surfaces were exercised under a seeded + multi-tenant entitlement profile so the representative admin settings shell + was included in the rehearsal rather than skipped behind feature gating. + +## Outcome + +- `settings-surface-layout-consistency` is exercised and passed. +- `RA19` is satisfied for the current v6 GA-promotion target. diff --git a/docs/release-control/v6/internal/records/unified-agent-v5-upgrade-continuity-2026-03-12.md b/docs/release-control/v6/internal/records/unified-agent-v5-upgrade-continuity-2026-03-12.md new file mode 100644 index 000000000..d2cba56bd --- /dev/null +++ b/docs/release-control/v6/internal/records/unified-agent-v5-upgrade-continuity-2026-03-12.md @@ -0,0 +1,42 @@ +# Unified Agent v5 Upgrade Continuity Record + +- Date: `2026-03-12` +- Gate: `unified-agent-v5-upgrade-continuity` +- Assertion: `RA9` +- Environment: + - Private RC host: `http://192.168.0.98:7655` + - Host under test: `delly` + - Candidate version: `v6.0.0-rc.1` + - Starting version: `5.1.23` + +## Automated Proof Baseline + +- `python3 scripts/release_control/unified_agent_rc_rehearsal.py --base-url http://127.0.0.1:7655 --expected-version 6.0.0-rc.1 --release-base-url file:///tmp/pulse-private-release-assets --arch linux-amd64 --api-token --expected-active-agents 3 --expected-agent-name delly --expected-online-agents 3` +- Result: pass + +## Manual Crossover Exercise + +1. Built a real `linux-amd64` v5 agent from `main` with version `5.1.23`. +2. Stopped the normal `pulse-agent.service` on `delly`. +3. Launched the v5.1.23 agent manually against the private RC host at `http://192.168.0.98:7655`. +4. Observed the real updater detect `availableVersion=6.0.0-rc.1`. +5. Observed the process restart into v6 and log: + - `previousVersion=5.1.23` + - `currentVersion=v6.0.0-rc.1` +6. Confirmed `/usr/local/bin/.pulse-update-info` was consumed and cleared after first v6 startup. +7. Confirmed the server moved from legacy `POST /api/agents/host/report` traffic to canonical `POST /api/agents/agent/report` for `delly`. +8. Confirmed the agent ledger recovered to one canonical `delly` identity with no duplicate registration and total active agents remained aligned at `3`. +9. Restored `delly` to the managed `pulse-agent.service` path after the rehearsal. + +## Outcome + +- Real v5-installed unified agent upgraded through the candidate v6 prerelease asset path. +- Canonical v6 identity continuity held without duplicate or orphaned registration. +- Legacy persisted token scope compatibility held during crossover. +- `updated_from` continuity was observed once on first v6 startup and then cleared. +- User-visible agent counts remained aligned with runtime enforcement after reconnect. + +## Notes + +- The private RC host had to serve the clean `linux-amd64` agent artifact. Earlier rehearsal attempts failed because the temporary private asset set accidentally contained a non-Linux binary. +- The clean private RC host is now detached and running from `/tmp/pulse-rc-clean.LqaK56`, not from the repo-root dirty build. diff --git a/docs/release-control/v6/internal/records/upgrade-state-and-entitlement-preservation-2026-03-12.md b/docs/release-control/v6/internal/records/upgrade-state-and-entitlement-preservation-2026-03-12.md new file mode 100644 index 000000000..23b2baa31 --- /dev/null +++ b/docs/release-control/v6/internal/records/upgrade-state-and-entitlement-preservation-2026-03-12.md @@ -0,0 +1,54 @@ +# Upgrade State and Entitlement Preservation Record + +- Date: `2026-03-12` +- Gate: `upgrade-state-and-entitlement-preservation` +- Assertions: + - `RA3` + - `RA6` +- Environment: + - Upgrade rehearsal host: `http://127.0.0.1:17655` + - Starting version: `v5.1.23` + - Candidate version: `v6.0.0-rc.1` + - Data directory: `/tmp/pulse-upgrade-rehearsal/data` + - Strict-mode exchange stub: `http://127.0.0.1:18666` + +## Automated Proof Baseline + +- `go test ./internal/api -run 'TestHandleActivateLicense_ExchangesLegacyJWTInStrictV6|TestHandleActivateLicense_ClearsCommercialMigrationStateOnNativeActivation|TestHandleActivateLicense_ActivationKeyClearsStaleLegacyPersistence|TestGetTenantComponents_AutoExchangesPersistedLegacyJWT|TestGetTenantComponents_SkipsExchange_WhenActivationStateExists|TestGetTenantComponents_PersistsCommercialMigrationState_WhenAutoExchangeFails|TestRequireLicenseFeature_HostedEntitlementsBlockMissingFeature|TestRequireLicenseFeature_HostedEntitlementsAllowGrantedFeature|TestLicenseGatedEmptyResponse_HostedEntitlementsReturnEmptyArrayWhenLocked|TestHandleGetUpdatePlan|TestHandleGetUpdatePlan_InvalidChannel|TestHandleGetUpdatePlan_PrepareError|TestHandleGetUpdatePlan_ManualFallback' -count=1` +- `go test ./pkg/licensing/... -count=1` +- `go test ./tests/migration -run 'TestV5PaidLicenseUpgrade_CommercialMigrationFailureMatrix|TestV5DataDir_CSRFLegacyMapFormat|TestV5DataDir_CSRFTokenFileContinuity|TestV5DataDir_SessionLegacyMapFormat|TestV5DataDir_SessionTokenContinuity|TestV5DowngradeSafety|TestV5FullUpgradeScenario' -count=1` +- `cd tests/integration && PULSE_BASE_URL=http://127.0.0.1:17655 PULSE_E2E_USERNAME=admin PULSE_E2E_PASSWORD=adminadminadmin PULSE_E2E_SKIP_DOCKER=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm test -- tests/11-first-session.spec.ts --project=chromium` +- Result: pass + +## Manual Upgrade Exercise + +1. Built a real `v5.1.23` server binary from the previous supported line and launched it against a fresh persistent data directory. +2. Completed the normal security/bootstrap flow on v5, persisted admin auth, created an API token, and activated a paid legacy entitlement in the same data directory. +3. Stopped the v5 process without deleting or editing local state. +4. Launched the candidate v6 binary against the exact same data directory in strict entitlement mode, backed by a private exchange stub signed with a matching Ed25519 public key. +5. Observed the persisted legacy entitlement auto-exchange into canonical v6 activation state without prompting for repeated license entry. +6. Confirmed authenticated startup continuity held after upgrade: + - existing admin auth remained valid + - existing API token remained valid + - local session/CSRF continuity stayed intact +7. Confirmed `GET /api/license/status` after upgrade returned: + - `valid=true` + - `tier=pro` + - `plan_version=v5_pro_monthly_grandfathered` + - `email=upgrade-rehearsal@example.com` + - `max_monitored_systems=10` +8. Confirmed `GET /api/license/entitlements` after upgrade returned active hosted-style paid state with the same grandfathered plan and `max_monitored_systems.limit=10`. +9. Ran the first-session browser suite against the upgraded v6 instance and confirmed the app no longer fell into an update-plan error path for non-auto-update deployments. +10. Confirmed the upgraded app loaded first-session and settings surfaces without license re-entry, reset prompts, or paid-surface drift. + +## Outcome + +- Supported upgrade preserved local state, authenticated continuity, entitlement continuity, and first-session continuity. +- Paid activation did not need to be re-entered after upgrade. +- The persisted v5 entitlement auto-exchanged into canonical v6 state under strict-mode validation. +- First-session surfaces stayed healthy after upgrade once the manual/development update-plan fallback was fixed. + +## Notes + +- The strict-mode entitlement rehearsal used a private local exchange stub rather than the production hosted service. That keeps the RC proof repeatable while still exercising the real persisted-license auto-exchange path. +- An earlier attempt exposed a real bug where `/api/updates/plan` returned `404` for manual/development deployments and broke the first-session browser suite. That backend path is now fixed and covered by `TestHandleGetUpdatePlan_ManualFallback`. diff --git a/docs/release-control/v6/internal/records/upgrade-state-and-entitlement-preservation-2026-03-13.md b/docs/release-control/v6/internal/records/upgrade-state-and-entitlement-preservation-2026-03-13.md new file mode 100644 index 000000000..e427ad72e --- /dev/null +++ b/docs/release-control/v6/internal/records/upgrade-state-and-entitlement-preservation-2026-03-13.md @@ -0,0 +1,57 @@ +# Upgrade State and Entitlement Preservation Record + +- Date: `2026-03-13` +- Gate: `upgrade-state-and-entitlement-preservation` +- Assertions: + - `RA3` + - `RA6` +- Evidence tier: `real-external-e2e` + +## Automated Proof Baseline + +- `go test ./internal/api -run 'TestHandleActivateLicense_ExchangesLegacyJWTInStrictV6|TestHandleActivateLicense_ClearsCommercialMigrationStateOnNativeActivation|TestHandleActivateLicense_ActivationKeyClearsStaleLegacyPersistence|TestGetTenantComponents_AutoExchangesPersistedLegacyJWT|TestGetTenantComponents_SkipsExchange_WhenActivationStateExists|TestGetTenantComponents_PersistsCommercialMigrationState_WhenAutoExchangeFails|TestRequireLicenseFeature_HostedEntitlementsBlockMissingFeature|TestRequireLicenseFeature_HostedEntitlementsAllowGrantedFeature|TestLicenseGatedEmptyResponse_HostedEntitlementsReturnEmptyArrayWhenLocked|TestHandleGetUpdatePlan|TestHandleGetUpdatePlan_InvalidChannel|TestHandleGetUpdatePlan_PrepareError|TestHandleGetUpdatePlan_ManualFallback' -count=1` +- `go test ./pkg/licensing/... -count=1` +- `go test ./tests/migration -run 'TestV5PaidLicenseUpgrade_CommercialMigrationFailureMatrix|TestV5PaidLicenseUpgrade_RealLicenseServerExchange|TestV5DataDir_CSRFLegacyMapFormat|TestV5DataDir_CSRFTokenFileContinuity|TestV5DataDir_SessionLegacyMapFormat|TestV5DataDir_SessionTokenContinuity|TestV5DowngradeSafety|TestV5FullUpgradeScenario' -count=1` +- `cd tests/integration && PULSE_BASE_URL=http://127.0.0.1:17655 PULSE_E2E_USERNAME=admin PULSE_E2E_PASSWORD=adminadminadmin PULSE_E2E_SKIP_DOCKER=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm test -- tests/11-first-session.spec.ts --project=chromium` +- Result: pass + +## Real External Exchange Proof + +`TestV5PaidLicenseUpgrade_RealLicenseServerExchange` now replaces the old strict-mode exchange stub as the closure-strengthening proof for paid upgrade continuity. + +It exercises the real sibling external dependency in `pulse-pro` instead of an in-process fake: + +1. Builds and starts the real `pulse-pro/license-server` binary with a generated Ed25519 signing key and v5 grandfathered plan definitions. +2. Seeds the license-server data directory with a legacy v5 license record for each supported grandfathered shape: + - `v5_lifetime_grandfathered` + - `v5_pro_monthly_grandfathered` + - `v5_pro_annual_grandfathered` +3. Generates a genuinely signed legacy v5 JWT using the same private key the real license server uses for verification. +4. Persists that legacy JWT into the local Pulse data directory as the pre-upgrade paid state. +5. Starts the v6 license handling path against the real `POST /v1/licenses/exchange` endpoint. +6. Confirms the upgrade result for each case: + - paid state auto-exchanges on startup without repeated license entry + - a new canonical v6 `lic_...` activation is persisted + - the activation state points back to the real license-server base URL + - grandfathered `plan_version` continuity is preserved + - `max_monitored_systems` continuity is preserved + - the original legacy JWT remains on disk for downgrade safety + +## Managed Runtime Continuity Still Covered + +The `2026-03-12` upgrade rehearsal remains relevant supporting evidence for the parts this new proof does not replace: + +- local state continuity across the v5 -> v6 binary swap +- first-session continuity +- session / CSRF continuity +- non-paid and paid surface stability after upgrade + +That record is now supporting evidence, not the sole closure basis. + +## Outcome + +- Upgrade continuity is now backed by both: + - real external exchange against the real `pulse-pro/license-server` + - managed-runtime first-session and local-state continuity evidence +- The gate no longer depends on a local exchange stub to claim closure confidence. +- This is sufficient to treat `upgrade-state-and-entitlement-preservation` as genuinely meeting its `real-external-e2e` evidence threshold. diff --git a/docs/release-control/v6/internal/subsystems/registry.schema.json b/docs/release-control/v6/internal/subsystems/registry.schema.json new file mode 100644 index 000000000..8a5d63d87 --- /dev/null +++ b/docs/release-control/v6/internal/subsystems/registry.schema.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pulse.local/docs/release-control/v6/internal/subsystems/registry.schema.json", + "title": "Pulse v6 Subsystem Registry Schema", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "shared_ownerships", + "subsystems" + ], + "properties": { + "version": { + "type": "integer", + "const": 12 + }, + "shared_ownerships": { + "type": "array", + "items": { + "$ref": "#/$defs/shared_ownership" + } + }, + "subsystems": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/subsystem" + } + } + }, + "$defs": { + "subsystem": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "lane", + "contract", + "owned_prefixes", + "owned_files", + "verification" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "lane": { + "type": "string", + "pattern": "^L[0-9]+$" + }, + "contract": { + "type": "string", + "minLength": 1 + }, + "owned_prefixes": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "owned_files": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "verification": { + "$ref": "#/$defs/verification" + } + } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": [ + "allow_same_subsystem_tests", + "test_prefixes", + "exact_files", + "require_explicit_path_policy_coverage", + "path_policies" + ], + "properties": { + "allow_same_subsystem_tests": { + "type": "boolean" + }, + "test_prefixes": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "exact_files": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "require_explicit_path_policy_coverage": { + "type": "boolean", + "const": true + }, + "path_policies": { + "type": "array", + "items": { + "$ref": "#/$defs/path_policy" + } + } + } + }, + "path_policy": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label", + "match_prefixes", + "match_files", + "allow_same_subsystem_tests", + "test_prefixes", + "exact_files" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "match_prefixes": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "match_files": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "allow_same_subsystem_tests": { + "type": "boolean" + }, + "test_prefixes": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "exact_files": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "shared_ownership": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "rationale", + "subsystems" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "rationale": { + "type": "string", + "minLength": 1 + }, + "subsystems": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } +} diff --git a/scripts/release_control/internal/commercial_cancellation_reactivation_proof.py b/scripts/release_control/internal/commercial_cancellation_reactivation_proof.py new file mode 100644 index 000000000..75db0ec30 --- /dev/null +++ b/scripts/release_control/internal/commercial_cancellation_reactivation_proof.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Run the automated proof floor for the commercial cancellation/reactivation gate.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass +class CommandSpec: + name: str + cwd: str + command: list[str] + + +@dataclass +class CommandResult: + name: str + cwd: str + command: list[str] + ok: bool + exit_code: int + detail: str + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run the automated proof bundle for the commercial cancellation/" + "reactivation release gate." + ) + ) + parser.add_argument( + "--pulse-dir", + default=str(default_pulse_dir()), + help="Path to the pulse repo root", + ) + parser.add_argument( + "--pulse-pro-license-server-dir", + default=str(default_pulse_pro_license_server_dir()), + help="Path to the pulse-pro/license-server directory", + ) + parser.add_argument( + "--frontend-dir", + help="Optional override for the pulse frontend-modern directory", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit JSON instead of human-readable output", + ) + parser.add_argument( + "--report-out", + help="Optional path to write a markdown report", + ) + parser.add_argument( + "--report-title", + default="Commercial Cancellation/Reactivation Automated Proof", + help="Markdown title used when writing --report-out", + ) + return parser.parse_args(argv) + + +def default_pulse_dir() -> Path: + return Path(__file__).resolve().parents[2] + + +def default_pulse_pro_license_server_dir() -> Path: + return default_pulse_dir().parent / "pulse-pro" / "license-server" + + +def frontend_dir_from_args(args: argparse.Namespace) -> Path: + if args.frontend_dir: + return Path(args.frontend_dir).resolve() + return Path(args.pulse_dir).resolve() / "frontend-modern" + + +def build_command_specs(args: argparse.Namespace) -> list[CommandSpec]: + pulse_dir = Path(args.pulse_dir).resolve() + pulse_pro_license_server_dir = Path(args.pulse_pro_license_server_dir).resolve() + frontend_dir = frontend_dir_from_args(args) + return [ + CommandSpec( + name="pulse-api-cancellation-boundary", + cwd=str(pulse_dir), + command=[ + "go", + "test", + "./internal/api", + "-run", + "TestStripeWebhook_SubscriptionDeleted_RevokesCapabilities", + "-count=1", + ], + ), + CommandSpec( + name="pulse-v5-recurring-upgrade-migration", + cwd=str(pulse_dir), + command=[ + "go", + "test", + "./tests/migration", + "-run", + "TestV5FullUpgradeScenario/PersistedV5RecurringLicenseAutoExchanges", + "-count=1", + ], + ), + CommandSpec( + name="frontend-grandfathered-license-presentation", + cwd=str(frontend_dir), + command=[ + "npm", + "test", + "--", + "src/utils/__tests__/licensePresentation.test.ts", + "src/components/Settings/__tests__/ProLicensePanel.test.tsx", + ], + ), + CommandSpec( + name="pulse-pro-public-checkout-reentry-guard", + cwd=str(pulse_pro_license_server_dir), + command=[ + "go", + "test", + ".", + "-run", + "TestHandleCheckoutSessionCreate(_RejectsGrandfatheredPlanKey)?$", + "-count=1", + ], + ), + ] + + +def summarize_output(stdout: str, stderr: str) -> str: + text = "\n".join(part.strip() for part in (stdout, stderr) if part.strip()).strip() + if not text: + return "pass" + lines = [line.strip() for line in text.splitlines() if line.strip()] + summary = lines[-1] + if len(summary) > 240: + return summary[:237] + "..." + return summary + + +def run_command(spec: CommandSpec) -> CommandResult: + proc = subprocess.run( + spec.command, + cwd=spec.cwd, + capture_output=True, + text=True, + check=False, + ) + return CommandResult( + name=spec.name, + cwd=spec.cwd, + command=spec.command, + ok=proc.returncode == 0, + exit_code=proc.returncode, + detail=summarize_output(proc.stdout, proc.stderr), + ) + + +def run_proof(args: argparse.Namespace) -> list[CommandResult]: + return [run_command(spec) for spec in build_command_specs(args)] + + +def render_markdown_report(title: str, results: list[CommandResult]) -> str: + lines = [f"# {title}", "", "## Results", ""] + for result in results: + status = "PASS" if result.ok else "FAIL" + lines.append(f"- `{status}` `{result.name}`") + lines.append(f" - cwd: `{result.cwd}`") + lines.append(f" - command: `{' '.join(result.command)}`") + lines.append(f" - detail: {result.detail}") + lines.extend( + [ + "", + "## Manual Follow-up", + "", + "- If all commands passed, continue with the manual scenarios in", + " `docs/release-control/v6/COMMERCIAL_CANCELLATION_REACTIVATION_E2E_TEST_PLAN.md`.", + "- Save the executed manual record from", + " `docs/release-control/v6/COMMERCIAL_CANCELLATION_REACTIVATION_RECORD_TEMPLATE.md`.", + ] + ) + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + results = run_proof(args) + if args.report_out: + report_path = Path(args.report_out) + report_path.write_text( + render_markdown_report(args.report_title, results), + encoding="utf-8", + ) + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2)) + else: + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"{status} {result.name}: {result.detail}") + return 0 if all(result.ok for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/commercial_cancellation_reactivation_proof_test.py b/scripts/release_control/internal/commercial_cancellation_reactivation_proof_test.py new file mode 100644 index 000000000..0328efea8 --- /dev/null +++ b/scripts/release_control/internal/commercial_cancellation_reactivation_proof_test.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +import commercial_cancellation_reactivation_proof as proof + + +def run_main(argv: list[str]) -> tuple[int, str]: + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + exit_code = proof.main(argv) + return exit_code, buffer.getvalue() + + +class CommercialCancellationReactivationProofTest(unittest.TestCase): + def test_build_command_specs_uses_expected_directories(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + pulse_dir = Path(tmp) / "pulse" + pulse_dir.mkdir() + frontend_dir = pulse_dir / "frontend-modern" + frontend_dir.mkdir() + pulse_pro_dir = Path(tmp) / "pulse-pro" / "license-server" + pulse_pro_dir.mkdir(parents=True) + args = proof.parse_args( + [ + "--pulse-dir", + str(pulse_dir), + "--pulse-pro-license-server-dir", + str(pulse_pro_dir), + ] + ) + specs = proof.build_command_specs(args) + self.assertEqual(len(specs), 4) + self.assertEqual(specs[0].cwd, str(pulse_dir.resolve())) + self.assertEqual(specs[2].cwd, str(frontend_dir.resolve())) + self.assertEqual(specs[3].cwd, str(pulse_pro_dir.resolve())) + + def test_run_command_success(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result = proof.run_command( + proof.CommandSpec( + name="ok", + cwd=tmp, + command=["python3", "-c", "print('all good')"], + ) + ) + self.assertTrue(result.ok) + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.detail, "all good") + + def test_run_command_failure_uses_stderr_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result = proof.run_command( + proof.CommandSpec( + name="fail", + cwd=tmp, + command=["python3", "-c", "import sys; sys.stderr.write('boom\\n'); sys.exit(2)"], + ) + ) + self.assertFalse(result.ok) + self.assertEqual(result.exit_code, 2) + self.assertEqual(result.detail, "boom") + + def test_render_markdown_report(self) -> None: + report = proof.render_markdown_report( + "Commercial Cancellation/Reactivation Automated Proof", + [ + proof.CommandResult( + name="pass-spec", + cwd="/tmp/pulse", + command=["go", "test", "./..."], + ok=True, + exit_code=0, + detail="ok", + ), + proof.CommandResult( + name="fail-spec", + cwd="/tmp/pulse-pro/license-server", + command=["go", "test", "."], + ok=False, + exit_code=1, + detail="FAIL", + ), + ], + ) + self.assertIn("# Commercial Cancellation/Reactivation Automated Proof", report) + self.assertIn("`PASS` `pass-spec`", report) + self.assertIn("`FAIL` `fail-spec`", report) + self.assertIn("## Manual Follow-up", report) + + def test_main_json_and_report(self) -> None: + original_run_proof = proof.run_proof + try: + proof.run_proof = lambda _args: [ # type: ignore[assignment] + proof.CommandResult( + name="fake", + cwd="/tmp", + command=["echo", "fake"], + ok=True, + exit_code=0, + detail="pass", + ) + ] + with tempfile.TemporaryDirectory() as tmp: + report_path = Path(tmp) / "report.md" + exit_code, output = run_main( + [ + "--report-out", + str(report_path), + "--json", + ] + ) + self.assertEqual(exit_code, 0) + payload = json.loads(output) + self.assertEqual(payload[0]["name"], "fake") + self.assertIn("Manual Follow-up", report_path.read_text(encoding="utf-8")) + finally: + proof.run_proof = original_run_proof # type: ignore[assignment] + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/commercial_cancellation_reactivation_rehearsal.py b/scripts/release_control/internal/commercial_cancellation_reactivation_rehearsal.py new file mode 100644 index 000000000..e6983558b --- /dev/null +++ b/scripts/release_control/internal/commercial_cancellation_reactivation_rehearsal.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Run the live commercial cancellation/reactivation rehearsal.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass +class CommandResult: + name: str + cwd: str + command: list[str] + ok: bool + exit_code: int + detail: str + + +def default_pulse_dir() -> Path: + return Path(__file__).resolve().parents[2] + + +def default_integration_dir() -> Path: + return default_pulse_dir() / "tests" / "integration" + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run the commercial cancellation/reactivation rehearsal against a live " + "Pulse + Stripe environment and optionally write a markdown report." + ) + ) + parser.add_argument( + "--pulse-dir", + default=str(default_pulse_dir()), + help="Path to the pulse repo root", + ) + parser.add_argument( + "--integration-dir", + default=str(default_integration_dir()), + help="Path to the Pulse integration test package", + ) + parser.add_argument( + "--project", + default="chromium", + help="Playwright project to run", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit JSON instead of human-readable output", + ) + parser.add_argument( + "--report-out", + help="Optional path to write a markdown report", + ) + parser.add_argument( + "--report-title", + default="Commercial Cancellation/Reactivation Rehearsal", + help="Markdown title used when writing --report-out", + ) + return parser.parse_args(argv) + + +def summarize_output(stdout: str, stderr: str) -> str: + text = "\n".join(part.strip() for part in (stdout, stderr) if part.strip()).strip() + if not text: + return "pass" + lines = [line.strip() for line in text.splitlines() if line.strip()] + summary = lines[-1] + if len(summary) > 240: + return summary[:237] + "..." + return summary + + +def run_command(name: str, cwd: Path, command: list[str]) -> CommandResult: + proc = subprocess.run( + command, + cwd=str(cwd), + capture_output=True, + text=True, + check=False, + ) + return CommandResult( + name=name, + cwd=str(cwd), + command=command, + ok=proc.returncode == 0, + exit_code=proc.returncode, + detail=summarize_output(proc.stdout, proc.stderr), + ) + + +def run_rehearsal(args: argparse.Namespace) -> list[CommandResult]: + pulse_dir = Path(args.pulse_dir).resolve() + integration_dir = Path(args.integration_dir).resolve() + return [ + run_command( + "commercial-cancellation-automated-proof-floor", + pulse_dir, + ["python3", "scripts/release_control/commercial_cancellation_reactivation_proof.py", "--json"], + ), + run_command( + "commercial-cancellation-playwright-live-journey", + integration_dir, + [ + "npm", + "test", + "--", + "tests/14-commercial-cancellation-reactivation.spec.ts", + f"--project={args.project}", + ], + ), + ] + + +def render_markdown_report(title: str, results: list[CommandResult]) -> str: + lines = [f"# {title}", "", "## Results", ""] + for result in results: + status = "PASS" if result.ok else "FAIL" + lines.append(f"- `{status}` `{result.name}`") + lines.append(f" - cwd: `{result.cwd}`") + lines.append(f" - command: `{' '.join(result.command)}`") + lines.append(f" - detail: {result.detail}") + lines.extend( + [ + "", + "## Environment Requirements", + "", + "- Stripe sandbox credentials and the commercial fixture env vars documented in", + " `tests/integration/tests/14-commercial-cancellation-reactivation.spec.ts`.", + "- A live Pulse runtime whose authenticated settings surface reflects the migrated", + " recurring commercial state under test.", + "- A real public checkout origin for `pulse-pro/license-server`.", + ] + ) + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + results = run_rehearsal(args) + if args.report_out: + Path(args.report_out).write_text( + render_markdown_report(args.report_title, results), + encoding="utf-8", + ) + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2)) + else: + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"{status} {result.name}: {result.detail}") + return 0 if all(result.ok for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/commercial_cancellation_reactivation_rehearsal_test.py b/scripts/release_control/internal/commercial_cancellation_reactivation_rehearsal_test.py new file mode 100644 index 000000000..41cc034cc --- /dev/null +++ b/scripts/release_control/internal/commercial_cancellation_reactivation_rehearsal_test.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Tests for the live commercial cancellation/reactivation rehearsal wrapper.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import commercial_cancellation_reactivation_rehearsal as rehearsal + + +class BuildCommandTests(unittest.TestCase): + def test_run_rehearsal_uses_expected_commands(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + integration = root / "tests" / "integration" + integration.mkdir(parents=True) + args = rehearsal.parse_args( + [ + "--pulse-dir", + str(root), + "--integration-dir", + str(integration), + "--project", + "chromium", + ] + ) + recorded: list[tuple[str, Path, list[str]]] = [] + + def fake_run(name: str, cwd: Path, command: list[str]) -> rehearsal.CommandResult: + recorded.append((name, cwd, command)) + return rehearsal.CommandResult( + name=name, + cwd=str(cwd), + command=command, + ok=True, + exit_code=0, + detail="pass", + ) + + with mock.patch.object(rehearsal, "run_command", side_effect=fake_run): + results = rehearsal.run_rehearsal(args) + + self.assertEqual(len(results), 2) + self.assertEqual(recorded[0][0], "commercial-cancellation-automated-proof-floor") + self.assertEqual( + recorded[0][2], + [ + "python3", + "scripts/release_control/commercial_cancellation_reactivation_proof.py", + "--json", + ], + ) + self.assertEqual(recorded[1][0], "commercial-cancellation-playwright-live-journey") + self.assertEqual( + recorded[1][2], + [ + "npm", + "test", + "--", + "tests/14-commercial-cancellation-reactivation.spec.ts", + "--project=chromium", + ], + ) + + +class ReportTests(unittest.TestCase): + def test_render_markdown_report_mentions_live_journey(self) -> None: + report = rehearsal.render_markdown_report( + "Commercial Cancellation/Reactivation Rehearsal", + [ + rehearsal.CommandResult( + name="commercial-cancellation-playwright-live-journey", + cwd="/tmp/integration", + command=["npm", "test"], + ok=True, + exit_code=0, + detail="pass", + ) + ], + ) + self.assertIn("commercial-cancellation-playwright-live-journey", report) + self.assertIn("14-commercial-cancellation-reactivation.spec.ts", report) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/hosted_signup_billing_replay_rehearsal.py b/scripts/release_control/internal/hosted_signup_billing_replay_rehearsal.py new file mode 100644 index 000000000..d2ff30d48 --- /dev/null +++ b/scripts/release_control/internal/hosted_signup_billing_replay_rehearsal.py @@ -0,0 +1,611 @@ +#!/usr/bin/env python3 +"""Exercise the hosted signup and billing replay gate on a live Pulse runtime.""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any +from urllib import error, request + + +@dataclass +class CheckResult: + name: str + ok: bool + detail: str + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run a live hosted-signup and billing-replay rehearsal against a hosted " + "Pulse instance and optionally write a markdown report." + ) + ) + parser.add_argument("--base-url", required=True, help="Hosted Pulse base URL") + parser.add_argument( + "--fail-closed-base-url", + help="Optional second Pulse base URL expected to fail hosted signup when public URL is missing", + ) + parser.add_argument("--signup-email", required=True, help="Email to use for public signup") + parser.add_argument("--org-name", required=True, help="Organization name to use for public signup") + parser.add_argument( + "--timeout", + type=float, + default=30.0, + help="HTTP timeout in seconds", + ) + parser.add_argument("--api-token", help="Optional X-API-Token for authenticated admin checks") + parser.add_argument("--bearer-token", help="Optional Authorization bearer token for admin checks") + parser.add_argument("--cookie", help="Optional Cookie header for admin checks") + parser.add_argument( + "--expected-checkout-base", + help="Optional prefix expected for trial-start redirect action_url", + ) + parser.add_argument( + "--billing-org-id", + help="Optional org ID to use for billing-state checks instead of the signup org_id", + ) + parser.add_argument( + "--expected-trial-subscription-state", + default="trial", + help="Expected subscription_state immediately after hosted signup", + ) + parser.add_argument( + "--expected-trial-plan-version", + default="cloud_trial", + help="Expected plan_version immediately after hosted signup", + ) + parser.add_argument( + "--prelink-webhook-payload-file", + help="Optional JSON file containing a Stripe event payload expected to fail closed before linkage", + ) + parser.add_argument( + "--prelink-webhook-secret", + help="Optional Stripe webhook secret for the pre-link payload", + ) + parser.add_argument( + "--prelink-webhook-expected-status", + type=int, + default=500, + help="Expected HTTP status for the pre-link webhook delivery", + ) + parser.add_argument( + "--postlink-webhook-payload-file", + help="Optional JSON file containing a Stripe event payload expected to succeed after linkage", + ) + parser.add_argument( + "--postlink-webhook-secret", + help="Optional Stripe webhook secret for the post-link payload", + ) + parser.add_argument( + "--postlink-webhook-expected-status", + type=int, + default=200, + help="Expected HTTP status for the post-link webhook delivery", + ) + parser.add_argument( + "--expected-postlink-subscription-state", + help="Optional expected subscription_state after the post-link webhook succeeds", + ) + parser.add_argument( + "--expected-postlink-plan-version", + help="Optional expected plan_version after the post-link webhook succeeds", + ) + parser.add_argument( + "--report-out", + help="Optional markdown report destination", + ) + parser.add_argument( + "--report-title", + default="Hosted Signup Billing Replay Rehearsal", + help="Markdown title used when writing --report-out", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit JSON instead of human-readable output", + ) + return parser.parse_args(argv) + + +def normalize_base_url(raw: str) -> str: + return raw.rstrip("/") + + +def build_auth_headers(args: argparse.Namespace) -> dict[str, str]: + headers: dict[str, str] = {} + if args.api_token: + headers["X-API-Token"] = args.api_token + if args.bearer_token: + headers["Authorization"] = f"Bearer {args.bearer_token}" + if args.cookie: + headers["Cookie"] = args.cookie + return headers + + +def summarize_body(body: bytes) -> str: + text = body.decode("utf-8", errors="replace").strip() + if not text: + return "" + first_line = text.splitlines()[0].strip() + if len(first_line) > 240: + return first_line[:237] + "..." + return first_line + + +def fetch( + method: str, + url: str, + *, + timeout: float, + json_body: dict[str, Any] | None = None, + body: bytes | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, bytes, dict[str, str]]: + req_headers = dict(headers or {}) + request_body = body + if json_body is not None: + request_body = json.dumps(json_body).encode("utf-8") + req_headers.setdefault("Content-Type", "application/json") + req = request.Request(url, data=request_body, headers=req_headers, method=method) + try: + with request.urlopen(req, timeout=timeout) as resp: + payload = resp.read() + return resp.status, payload, {key.lower(): value for key, value in resp.headers.items()} + except error.HTTPError as exc: + payload = exc.read() + return exc.code, payload, {key.lower(): value for key, value in exc.headers.items()} + except error.URLError as exc: + raise RuntimeError(f"{method} {url} failed: {exc.reason}") from exc + + +def fetch_json( + method: str, + url: str, + *, + timeout: float, + json_body: dict[str, Any] | None = None, + body: bytes | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, Any]]: + status, payload, _headers = fetch( + method, + url, + timeout=timeout, + json_body=json_body, + body=body, + headers=headers, + ) + try: + parsed = json.loads(payload.decode("utf-8")) if payload else {} + except json.JSONDecodeError as exc: + raise RuntimeError(f"{method} {url} returned non-JSON body: {summarize_body(payload)}") from exc + if not isinstance(parsed, dict): + raise RuntimeError(f"{method} {url} did not return a JSON object") + return status, parsed + + +def make_stripe_signature(payload: bytes, secret: str, timestamp: int | None = None) -> str: + ts = int(time.time() if timestamp is None else timestamp) + signed_payload = f"{ts}.".encode("utf-8") + payload + digest = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest() + return f"t={ts},v1={digest}" + + +def safe_check(name: str, fn) -> CheckResult: + try: + return fn() + except Exception as exc: # pragma: no cover - exercised via callers/tests + return CheckResult(name=name, ok=False, detail=str(exc)) + + +def check_fail_closed(args: argparse.Namespace) -> CheckResult: + status, payload = fetch_json( + "POST", + f"{normalize_base_url(args.fail_closed_base_url)}/api/public/signup", + timeout=args.timeout, + json_body={"email": args.signup_email, "org_name": args.org_name}, + ) + if status != 503: + return CheckResult( + name="fail-closed-signup-without-public-url", + ok=False, + detail=f"status={status}, expected 503", + ) + if str(payload.get("code", "")).strip() != "public_url_missing": + return CheckResult( + name="fail-closed-signup-without-public-url", + ok=False, + detail=f"code={payload.get('code')!r}, expected 'public_url_missing'", + ) + return CheckResult( + name="fail-closed-signup-without-public-url", + ok=True, + detail="signup failed closed with code=public_url_missing", + ) + + +def check_trial_start_redirect(args: argparse.Namespace, auth_headers: dict[str, str]) -> CheckResult: + if not auth_headers: + return CheckResult( + name="self-hosted-trial-redirect-to-hosted", + ok=False, + detail="authenticated admin credentials are required for this check", + ) + status, payload = fetch_json( + "POST", + f"{normalize_base_url(args.base_url)}/api/license/trial/start", + timeout=args.timeout, + headers=auth_headers, + json_body={}, + ) + if status != 409: + return CheckResult( + name="self-hosted-trial-redirect-to-hosted", + ok=False, + detail=f"status={status}, expected 409", + ) + code = str(payload.get("code", "")).strip() + if code != "trial_signup_required": + return CheckResult( + name="self-hosted-trial-redirect-to-hosted", + ok=False, + detail=f"code={code!r}, expected 'trial_signup_required'", + ) + details = payload.get("details") + if not isinstance(details, dict): + return CheckResult( + name="self-hosted-trial-redirect-to-hosted", + ok=False, + detail="missing details.action_url in response", + ) + action_url = str(details.get("action_url", "")).strip() + if not action_url: + return CheckResult( + name="self-hosted-trial-redirect-to-hosted", + ok=False, + detail="details.action_url was empty", + ) + if args.expected_checkout_base and not action_url.startswith(args.expected_checkout_base): + return CheckResult( + name="self-hosted-trial-redirect-to-hosted", + ok=False, + detail=f"action_url={action_url!r} did not start with {args.expected_checkout_base!r}", + ) + return CheckResult( + name="self-hosted-trial-redirect-to-hosted", + ok=True, + detail=f"returned action_url={action_url}", + ) + + +def check_public_signup(args: argparse.Namespace) -> tuple[CheckResult, str]: + status, payload = fetch_json( + "POST", + f"{normalize_base_url(args.base_url)}/api/public/signup", + timeout=args.timeout, + json_body={"email": args.signup_email, "org_name": args.org_name}, + ) + if status != 201: + return ( + CheckResult(name="public-hosted-signup", ok=False, detail=f"status={status}, payload={payload!r}"), + "", + ) + org_id = str(payload.get("org_id", "")).strip() + message = str(payload.get("message", "")).strip() + if not org_id: + return ( + CheckResult(name="public-hosted-signup", ok=False, detail="response missing org_id"), + "", + ) + if message != "Check your email for a magic link to finish signing in.": + return ( + CheckResult( + name="public-hosted-signup", + ok=False, + detail=f"unexpected signup message {message!r}", + ), + org_id, + ) + return ( + CheckResult(name="public-hosted-signup", ok=True, detail=f"created org_id={org_id}"), + org_id, + ) + + +def check_magic_link_request(args: argparse.Namespace) -> CheckResult: + status, payload = fetch_json( + "POST", + f"{normalize_base_url(args.base_url)}/api/public/magic-link/request", + timeout=args.timeout, + json_body={"email": args.signup_email}, + ) + if status != 200: + return CheckResult( + name="public-magic-link-request", + ok=False, + detail=f"status={status}, payload={payload!r}", + ) + if payload.get("success") is not True: + return CheckResult( + name="public-magic-link-request", + ok=False, + detail=f"success={payload.get('success')!r}, expected true", + ) + return CheckResult( + name="public-magic-link-request", + ok=True, + detail="magic-link request succeeded", + ) + + +def check_hosted_org_list(base_url: str, timeout: float, auth_headers: dict[str, str], expected_org_id: str) -> CheckResult: + if not auth_headers: + return CheckResult( + name="hosted-org-list", + ok=False, + detail="authenticated admin credentials are required for this check", + ) + status, payload = fetch_json( + "GET", + f"{normalize_base_url(base_url)}/api/hosted/organizations", + timeout=timeout, + headers=auth_headers, + ) + if status != 200: + return CheckResult(name="hosted-org-list", ok=False, detail=f"status={status}, payload={payload!r}") + orgs: object + if isinstance(payload, list): + orgs = payload + else: + orgs = payload.get("organizations", payload) + if not isinstance(orgs, list): + return CheckResult(name="hosted-org-list", ok=False, detail="payload did not contain an organization list") + for entry in orgs: + if isinstance(entry, dict) and str(entry.get("org_id", "")).strip() == expected_org_id: + return CheckResult(name="hosted-org-list", ok=True, detail=f"org_id={expected_org_id} is visible") + return CheckResult(name="hosted-org-list", ok=False, detail=f"org_id={expected_org_id} not found") + + +def check_billing_state( + *, + base_url: str, + timeout: float, + auth_headers: dict[str, str], + org_id: str, + expected_subscription_state: str | None, + expected_plan_version: str | None, + name: str, +) -> CheckResult: + if not auth_headers: + return CheckResult(name=name, ok=False, detail="authenticated admin credentials are required for this check") + status, payload = fetch_json( + "GET", + f"{normalize_base_url(base_url)}/api/admin/orgs/{org_id}/billing-state", + timeout=timeout, + headers=auth_headers, + ) + if status != 200: + return CheckResult(name=name, ok=False, detail=f"status={status}, payload={payload!r}") + got_state = str(payload.get("subscription_state", "")).strip() + got_plan = str(payload.get("plan_version", "")).strip() + if expected_subscription_state and got_state != expected_subscription_state: + return CheckResult( + name=name, + ok=False, + detail=f"subscription_state={got_state!r}, expected {expected_subscription_state!r}", + ) + if expected_plan_version and got_plan != expected_plan_version: + return CheckResult( + name=name, + ok=False, + detail=f"plan_version={got_plan!r}, expected {expected_plan_version!r}", + ) + return CheckResult( + name=name, + ok=True, + detail=f"subscription_state={got_state!r} plan_version={got_plan!r}", + ) + + +def load_json_payload(path: str) -> bytes: + raw = Path(path).read_bytes() + json.loads(raw.decode("utf-8")) + return raw + + +def check_signed_webhook_delivery( + *, + base_url: str, + timeout: float, + payload_file: str, + secret: str, + expected_status: int, + name: str, +) -> CheckResult: + payload = load_json_payload(payload_file) + signature = make_stripe_signature(payload, secret) + status, body, _headers = fetch( + "POST", + f"{normalize_base_url(base_url)}/api/stripe/webhook", + timeout=timeout, + body=payload, + headers={ + "Content-Type": "application/json", + "Stripe-Signature": signature, + }, + ) + if status != expected_status: + return CheckResult( + name=name, + ok=False, + detail=f"status={status}, expected {expected_status}, body={summarize_body(body)}", + ) + return CheckResult( + name=name, + ok=True, + detail=f"status={status}, body={summarize_body(body)}", + ) + + +def run_rehearsal(args: argparse.Namespace) -> list[CheckResult]: + auth_headers = build_auth_headers(args) + results: list[CheckResult] = [] + + if args.fail_closed_base_url: + results.append(safe_check("fail-closed-signup-without-public-url", lambda: check_fail_closed(args))) + + if auth_headers: + results.append( + safe_check( + "self-hosted-trial-redirect-to-hosted", + lambda: check_trial_start_redirect(args, auth_headers), + ) + ) + + signup_result, signup_org_id = check_public_signup(args) + results.append(signup_result) + if signup_org_id: + results.append(safe_check("public-magic-link-request", lambda: check_magic_link_request(args))) + + billing_org_id = args.billing_org_id or signup_org_id + if auth_headers and signup_org_id: + results.append( + safe_check( + "hosted-org-list", + lambda: check_hosted_org_list(args.base_url, args.timeout, auth_headers, signup_org_id), + ) + ) + if auth_headers and billing_org_id: + results.append( + safe_check( + "billing-state-after-signup", + lambda: check_billing_state( + base_url=args.base_url, + timeout=args.timeout, + auth_headers=auth_headers, + org_id=billing_org_id, + expected_subscription_state=args.expected_trial_subscription_state, + expected_plan_version=args.expected_trial_plan_version, + name="billing-state-after-signup", + ), + ) + ) + + if args.prelink_webhook_payload_file and args.prelink_webhook_secret: + results.append( + safe_check( + "prelink-webhook-delivery", + lambda: check_signed_webhook_delivery( + base_url=args.base_url, + timeout=args.timeout, + payload_file=args.prelink_webhook_payload_file, + secret=args.prelink_webhook_secret, + expected_status=args.prelink_webhook_expected_status, + name="prelink-webhook-delivery", + ), + ) + ) + if args.postlink_webhook_payload_file and args.postlink_webhook_secret: + results.append( + safe_check( + "postlink-webhook-delivery", + lambda: check_signed_webhook_delivery( + base_url=args.base_url, + timeout=args.timeout, + payload_file=args.postlink_webhook_payload_file, + secret=args.postlink_webhook_secret, + expected_status=args.postlink_webhook_expected_status, + name="postlink-webhook-delivery", + ), + ) + ) + if auth_headers and billing_org_id and ( + args.expected_postlink_subscription_state or args.expected_postlink_plan_version + ): + results.append( + safe_check( + "billing-state-after-webhook", + lambda: check_billing_state( + base_url=args.base_url, + timeout=args.timeout, + auth_headers=auth_headers, + org_id=billing_org_id, + expected_subscription_state=args.expected_postlink_subscription_state, + expected_plan_version=args.expected_postlink_plan_version, + name="billing-state-after-webhook", + ), + ) + ) + + return results + + +def render_markdown_report( + *, + title: str, + base_url: str, + signup_email: str, + org_name: str, + results: list[CheckResult], +) -> str: + lines = [ + f"# {title}", + "", + "## Inputs", + "", + f"- Base URL: `{base_url}`", + f"- Signup email: `{signup_email}`", + f"- Org name: `{org_name}`", + "", + "## Results", + "", + ] + for result in results: + status = "PASS" if result.ok else "FAIL" + lines.append(f"- `{status}` `{result.name}`") + lines.append(f" - {result.detail}") + lines.extend( + [ + "", + "## Manual Follow-up", + "", + "- If the webhook replay was only partially exercised, complete the missing replay/linkage path on the same hosted runtime and append the result.", + "- If the live runtime differs from the final hosted environment, rerun the same command against the actual external hosted surface before closing the gate.", + ] + ) + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + results = run_rehearsal(args) + if args.report_out: + Path(args.report_out).write_text( + render_markdown_report( + title=args.report_title, + base_url=args.base_url, + signup_email=args.signup_email, + org_name=args.org_name, + results=results, + ), + encoding="utf-8", + ) + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2)) + else: + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"{status} {result.name}: {result.detail}") + return 0 if results and all(result.ok for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/hosted_signup_billing_replay_rehearsal_test.py b/scripts/release_control/internal/hosted_signup_billing_replay_rehearsal_test.py new file mode 100644 index 000000000..d83d656a4 --- /dev/null +++ b/scripts/release_control/internal/hosted_signup_billing_replay_rehearsal_test.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import hosted_signup_billing_replay_rehearsal as mod + + +def run_main(argv: list[str]) -> int: + with contextlib.redirect_stdout(io.StringIO()): + return mod.main(argv) + + +class HostedSignupBillingReplayRehearsalTest(unittest.TestCase): + def test_make_stripe_signature_is_stable_for_fixed_timestamp(self) -> None: + sig = mod.make_stripe_signature(b'{"id":"evt_1"}', "whsec_test", timestamp=123) + self.assertEqual( + sig, + "t=123,v1=94d2453cc7960463874f8b9ebad526244005352e0a5320ad624d93ecc2df970d", + ) + + def test_run_rehearsal_full_flow(self) -> None: + prelink_payload = json.dumps({"id": "evt_pre", "type": "checkout.session.completed"}).encode("utf-8") + with tempfile.TemporaryDirectory() as tmp: + prelink_path = Path(tmp) / "pre.json" + prelink_path.write_bytes(prelink_payload) + + args = mod.parse_args( + [ + "--base-url", + "https://pulse.example.com", + "--fail-closed-base-url", + "https://pulse-bad.example.com", + "--signup-email", + "owner@example.com", + "--org-name", + "Pulse Labs", + "--api-token", + "token", + "--expected-checkout-base", + "https://billing.example.com/start-pro-trial", + "--prelink-webhook-payload-file", + str(prelink_path), + "--prelink-webhook-secret", + "whsec_test", + "--prelink-webhook-expected-status", + "500", + ] + ) + + fetch_json_calls: list[tuple[str, str, dict[str, str] | None]] = [] + fetch_calls: list[tuple[str, str, dict[str, str] | None, bytes | None]] = [] + + def fake_fetch_json(method: str, url: str, **kwargs): + fetch_json_calls.append((method, url, kwargs.get("headers"))) + if url == "https://pulse-bad.example.com/api/public/signup": + return 503, {"code": "public_url_missing"} + if url == "https://pulse.example.com/api/license/trial/start": + return 409, { + "code": "trial_signup_required", + "details": {"action_url": "https://billing.example.com/start-pro-trial?org_id=default"}, + } + if url == "https://pulse.example.com/api/public/signup": + return 201, { + "org_id": "org-123", + "message": "Check your email for a magic link to finish signing in.", + } + if url == "https://pulse.example.com/api/public/magic-link/request": + return 200, {"success": True} + if url == "https://pulse.example.com/api/hosted/organizations": + return 200, [{"org_id": "default"}, {"org_id": "org-123"}] + if url == "https://pulse.example.com/api/admin/orgs/org-123/billing-state": + return 200, {"subscription_state": "trial", "plan_version": "cloud_trial"} + raise AssertionError(f"unexpected fetch_json call: {method} {url}") + + def fake_fetch(method: str, url: str, **kwargs): + fetch_calls.append((method, url, kwargs.get("headers"), kwargs.get("body"))) + if url == "https://pulse.example.com/api/stripe/webhook": + return 500, b'{"code":"stripe_processing_failed"}', {"content-type": "application/json"} + raise AssertionError(f"unexpected fetch call: {method} {url}") + + with ( + mock.patch.object(mod, "fetch_json", side_effect=fake_fetch_json), + mock.patch.object(mod, "fetch", side_effect=fake_fetch), + ): + results = mod.run_rehearsal(args) + + self.assertTrue(all(result.ok for result in results), [r.detail for r in results]) + trial_headers = next(headers for _method, url, headers in fetch_json_calls if url.endswith("/api/license/trial/start")) + self.assertEqual(trial_headers["X-API-Token"], "token") + webhook_headers = next(headers for _method, url, headers, _body in fetch_calls if url.endswith("/api/stripe/webhook")) + self.assertIn("Stripe-Signature", webhook_headers) + + def test_run_rehearsal_checks_postlink_billing_state(self) -> None: + post_payload = json.dumps({"id": "evt_post", "type": "checkout.session.completed"}).encode("utf-8") + with tempfile.TemporaryDirectory() as tmp: + post_path = Path(tmp) / "post.json" + post_path.write_bytes(post_payload) + billing_state_calls = 0 + + def fake_fetch_json(method: str, url: str, **kwargs): + nonlocal billing_state_calls + if url.endswith("/api/license/trial/start"): + return 409, { + "code": "trial_signup_required", + "details": {"action_url": "https://billing.example.com/start-pro-trial?org_id=default"}, + } + if url.endswith("/api/public/signup"): + return 201, { + "org_id": "org-123", + "message": "Check your email for a magic link to finish signing in.", + } + if url.endswith("/api/public/magic-link/request"): + return 200, {"success": True} + if url.endswith("/api/hosted/organizations"): + return 200, [{"org_id": "org-123"}] + if url.endswith("/api/admin/orgs/org-123/billing-state"): + billing_state_calls += 1 + if billing_state_calls == 1: + return 200, {"subscription_state": "trial", "plan_version": "cloud_trial"} + return 200, {"subscription_state": "active", "plan_version": "cloud_starter"} + raise AssertionError(f"unexpected fetch_json call: {method} {url}") + + def fake_fetch(method: str, url: str, **kwargs): + if url.endswith("/api/stripe/webhook"): + return 200, b'{"received":true,"status":"processed"}', {} + raise AssertionError(f"unexpected fetch call: {method} {url}") + + with ( + mock.patch.object(mod, "fetch_json", side_effect=fake_fetch_json), + mock.patch.object(mod, "fetch", side_effect=fake_fetch), + ): + exit_code = run_main( + [ + "--base-url", + "https://pulse.example.com", + "--signup-email", + "owner@example.com", + "--org-name", + "Pulse Labs", + "--api-token", + "token", + "--postlink-webhook-payload-file", + str(post_path), + "--postlink-webhook-secret", + "whsec_test", + "--postlink-webhook-expected-status", + "200", + "--expected-postlink-subscription-state", + "active", + "--expected-postlink-plan-version", + "cloud_starter", + ] + ) + self.assertEqual(exit_code, 0) + + def test_render_markdown_report(self) -> None: + report = mod.render_markdown_report( + title="Hosted Signup Billing Replay Rehearsal", + base_url="https://pulse.example.com", + signup_email="owner@example.com", + org_name="Pulse Labs", + results=[ + mod.CheckResult(name="public-hosted-signup", ok=True, detail="created org_id=org-123"), + mod.CheckResult(name="postlink-webhook-delivery", ok=False, detail="status=500"), + ], + ) + self.assertIn("# Hosted Signup Billing Replay Rehearsal", report) + self.assertIn("`PASS` `public-hosted-signup`", report) + self.assertIn("`FAIL` `postlink-webhook-delivery`", report) + + def test_main_writes_report(self) -> None: + def fake_fetch_json(method: str, url: str, **kwargs): + if url.endswith("/api/public/signup"): + return 201, { + "org_id": "org-123", + "message": "Check your email for a magic link to finish signing in.", + } + if url.endswith("/api/public/magic-link/request"): + return 200, {"success": True} + raise AssertionError(f"unexpected fetch_json call: {method} {url}") + + with tempfile.TemporaryDirectory() as tmp: + report_path = Path(tmp) / "report.md" + with mock.patch.object(mod, "fetch_json", side_effect=fake_fetch_json): + exit_code = mod.main( + [ + "--base-url", + "https://pulse.example.com", + "--signup-email", + "owner@example.com", + "--org-name", + "Pulse Labs", + "--report-out", + str(report_path), + ] + ) + self.assertEqual(exit_code, 0) + self.assertIn("# Hosted Signup Billing Replay Rehearsal", report_path.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/mobile_relay_auth_approvals_proof.py b/scripts/release_control/internal/mobile_relay_auth_approvals_proof.py new file mode 100644 index 000000000..1c7ca9d48 --- /dev/null +++ b/scripts/release_control/internal/mobile_relay_auth_approvals_proof.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Run the automated proof bundle for the mobile relay auth/approvals gate.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass +class CommandSpec: + name: str + cwd: str + command: list[str] + + +@dataclass +class CommandResult: + name: str + cwd: str + command: list[str] + ok: bool + exit_code: int + detail: str + + +def default_pulse_dir() -> Path: + return Path(__file__).resolve().parents[2] + + +def default_pulse_mobile_dir() -> Path: + return default_pulse_dir().parent / "pulse-mobile" + + +def default_pulse_enterprise_dir() -> Path: + return default_pulse_dir().parent / "pulse-enterprise" + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the automated proof bundle for the mobile relay auth/approvals release gate." + ) + parser.add_argument("--pulse-mobile-dir", default=str(default_pulse_mobile_dir())) + parser.add_argument("--pulse-enterprise-dir", default=str(default_pulse_enterprise_dir())) + parser.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output") + return parser.parse_args(argv) + + +def build_command_specs(args: argparse.Namespace) -> list[CommandSpec]: + pulse_mobile_dir = Path(args.pulse_mobile_dir).resolve() + pulse_enterprise_dir = Path(args.pulse_enterprise_dir).resolve() + return [ + CommandSpec( + name="enterprise-approval-handlers", + cwd=str(pulse_enterprise_dir), + command=[ + "go", + "test", + "./internal/aiautofix", + "-run", + "TestHandleListApprovals|TestHandleApproveAndExecuteInvestigationFix|TestHandleApprove", + "-count=1", + ], + ), + CommandSpec( + name="mobile-api-client", + cwd=str(pulse_mobile_dir), + command=[ + "npm", + "test", + "--", + "--runTestsByPath", + "src/api/__tests__/client.test.ts", + ], + ), + CommandSpec( + name="mobile-relay-runtime", + cwd=str(pulse_mobile_dir), + command=[ + "npm", + "test", + "--", + "--runTestsByPath", + "src/hooks/__tests__/useRelay.test.ts", + "src/hooks/__tests__/relayPushRefresh.test.ts", + "src/notifications/__tests__/notificationRouting.test.ts", + "src/stores/__tests__/mobileAccessState.test.ts", + ], + ), + CommandSpec( + name="mobile-secure-persistence-and-approvals", + cwd=str(pulse_mobile_dir), + command=[ + "npm", + "test", + "--", + "--runTestsByPath", + "src/__tests__/mobileRelayAuthApprovals.rehearsal.test.ts", + "src/utils/__tests__/secureStorage.test.ts", + "src/hooks/__tests__/useRelayLifecycle.test.ts", + "src/hooks/__tests__/approvalActionPolicy.test.ts", + "src/stores/__tests__/instanceStore.test.ts", + "src/stores/__tests__/authStore.test.ts", + "src/stores/__tests__/approvalStore.test.ts", + ], + ), + CommandSpec( + name="mobile-wire-protocol", + cwd=str(pulse_mobile_dir), + command=[ + "npm", + "test", + "--", + "--runTestsByPath", + "src/relay/__tests__/client.test.ts", + "src/relay/__tests__/client-hardening.test.ts", + "src/relay/__tests__/protocol-contract.test.ts", + ], + ), + ] + + +def summarize_output(stdout: str, stderr: str) -> str: + text = "\n".join(part.strip() for part in (stdout, stderr) if part.strip()).strip() + if not text: + return "pass" + lines = [line.strip() for line in text.splitlines() if line.strip()] + summary = lines[-1] + if len(summary) > 240: + return summary[:237] + "..." + return summary + + +def run_command(spec: CommandSpec) -> CommandResult: + proc = subprocess.run(spec.command, cwd=spec.cwd, capture_output=True, text=True, check=False) + return CommandResult( + name=spec.name, + cwd=spec.cwd, + command=spec.command, + ok=proc.returncode == 0, + exit_code=proc.returncode, + detail=summarize_output(proc.stdout, proc.stderr), + ) + + +def run_proof(args: argparse.Namespace) -> list[CommandResult]: + return [run_command(spec) for spec in build_command_specs(args)] + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + results = run_proof(args) + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2)) + else: + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"{status} {result.name}: {result.detail}") + return 0 if all(result.ok for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/mobile_relay_auth_approvals_proof_test.py b/scripts/release_control/internal/mobile_relay_auth_approvals_proof_test.py new file mode 100644 index 000000000..011cc039a --- /dev/null +++ b/scripts/release_control/internal/mobile_relay_auth_approvals_proof_test.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Tests for the mobile relay auth/approvals proof wrapper.""" + +from __future__ import annotations + +import unittest + +import mobile_relay_auth_approvals_proof as proof + + +class MobileRelayAuthApprovalsProofTest(unittest.TestCase): + def test_build_command_specs_are_sorted_and_cross_repo(self) -> None: + args = proof.parse_args([]) + specs = proof.build_command_specs(args) + self.assertEqual([spec.name for spec in specs], sorted(spec.name for spec in specs)) + self.assertTrue(all(spec.cwd.endswith(("pulse-mobile", "pulse-enterprise")) for spec in specs)) + self.assertTrue(any(spec.cwd.endswith("pulse-enterprise") for spec in specs)) + self.assertTrue(any(spec.cwd.endswith("pulse-mobile") for spec in specs)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/msp_provider_tenant_management_rehearsal.py b/scripts/release_control/internal/msp_provider_tenant_management_rehearsal.py new file mode 100644 index 000000000..8bac4a4a7 --- /dev/null +++ b/scripts/release_control/internal/msp_provider_tenant_management_rehearsal.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +"""Exercise the MSP provider tenant management gate on a live Pulse runtime.""" + +from __future__ import annotations + +import argparse +import json +import socket +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any +from urllib import error, request + + +@dataclass +class CheckResult: + name: str + ok: bool + detail: str + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run a live MSP provider-tenant rehearsal against a Pulse control-plane " + "runtime and optionally write a markdown report." + ) + ) + parser.add_argument("--base-url", required=True, help="Pulse control-plane base URL") + parser.add_argument("--account-id", required=True, help="MSP account ID to exercise") + parser.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout in seconds") + parser.add_argument("--api-token", help="Optional X-API-Token for authenticated checks") + parser.add_argument("--bearer-token", help="Optional bearer token for authenticated checks") + parser.add_argument("--cookie", help="Optional raw Cookie header for authenticated checks") + parser.add_argument( + "--workspace-name", + action="append", + default=[], + help="Workspace name to create under the MSP account; may be passed multiple times", + ) + parser.add_argument( + "--expected-plan-version", + default="msp_starter", + help="Expected canonical MSP plan version on created or returned workspaces", + ) + parser.add_argument( + "--member-email", + action="append", + default=[], + help="Member email to invite under the MSP account; may be passed multiple times", + ) + parser.add_argument( + "--member-role", + action="append", + default=[], + help="Role for the matching --member-email entry; defaults to tech when omitted", + ) + parser.add_argument( + "--expected-account-kind", + default="msp", + help="Expected account.kind value on portal responses", + ) + parser.add_argument( + "--public-signup-email", + help="Optional email to use for the public-cloud boundary signup check", + ) + parser.add_argument( + "--public-signup-org-name", + help="Optional org_name to use for the public-cloud boundary signup check", + ) + parser.add_argument( + "--public-signup-tier", + default="power", + help="Tier value to submit to public signup for the MSP/public boundary check", + ) + parser.add_argument( + "--public-signup-expected-status", + type=int, + default=400, + help="Expected HTTP status for the MSP/public signup boundary check", + ) + parser.add_argument( + "--public-signup-expected-code", + default="tier_unavailable", + help="Expected error code for the MSP/public signup boundary check", + ) + parser.add_argument("--report-out", help="Optional markdown report destination") + parser.add_argument( + "--report-title", + default="MSP Provider Tenant Management Rehearsal", + help="Markdown title used when writing --report-out", + ) + parser.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output") + return parser.parse_args(argv) + + +def normalize_base_url(raw: str) -> str: + return raw.rstrip("/") + + +def build_auth_headers(args: argparse.Namespace) -> dict[str, str]: + headers: dict[str, str] = {} + if args.api_token: + headers["X-API-Token"] = args.api_token + if args.bearer_token: + headers["Authorization"] = f"Bearer {args.bearer_token}" + if args.cookie: + headers["Cookie"] = args.cookie + return headers + + +def summarize_body(body: bytes) -> str: + text = body.decode("utf-8", errors="replace").strip() + if not text: + return "" + first_line = text.splitlines()[0].strip() + if len(first_line) > 240: + return first_line[:237] + "..." + return first_line + + +def fetch( + method: str, + url: str, + *, + timeout: float, + json_body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, bytes, dict[str, str]]: + req_headers = dict(headers or {}) + body = None + if json_body is not None: + body = json.dumps(json_body).encode("utf-8") + req_headers.setdefault("Content-Type", "application/json") + req = request.Request(url, data=body, headers=req_headers, method=method) + try: + with request.urlopen(req, timeout=timeout) as resp: + payload = resp.read() + return resp.status, payload, {key.lower(): value for key, value in resp.headers.items()} + except error.HTTPError as exc: + payload = exc.read() + return exc.code, payload, {key.lower(): value for key, value in exc.headers.items()} + except socket.timeout as exc: + raise RuntimeError(f"{method} {url} timed out after {timeout:.1f}s") from exc + except error.URLError as exc: + raise RuntimeError(f"{method} {url} failed: {exc.reason}") from exc + + +def fetch_json( + method: str, + url: str, + *, + timeout: float, + json_body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, Any]: + status, payload, _headers = fetch(method, url, timeout=timeout, json_body=json_body, headers=headers) + try: + return status, json.loads(payload.decode("utf-8")) if payload else {} + except json.JSONDecodeError as exc: + raise RuntimeError(f"{method} {url} returned non-JSON body: {summarize_body(payload)}") from exc + + +def safe_check(name: str, fn) -> CheckResult: + try: + return fn() + except Exception as exc: # pragma: no cover + return CheckResult(name=name, ok=False, detail=str(exc)) + + +def check_account_tenants(base_url: str, timeout: float, headers: dict[str, str], account_id: str) -> tuple[int, list[dict[str, Any]]]: + status, payload = fetch_json( + "GET", + f"{normalize_base_url(base_url)}/api/accounts/{account_id}/tenants", + timeout=timeout, + headers=headers, + ) + if not isinstance(payload, list): + raise RuntimeError("tenant list payload was not a JSON array") + return status, payload + + +def check_member_list(base_url: str, timeout: float, headers: dict[str, str], account_id: str) -> tuple[int, list[dict[str, Any]]]: + status, payload = fetch_json( + "GET", + f"{normalize_base_url(base_url)}/api/accounts/{account_id}/members", + timeout=timeout, + headers=headers, + ) + if not isinstance(payload, list): + raise RuntimeError("member list payload was not a JSON array") + return status, payload + + +def list_tenants_check(args: argparse.Namespace, headers: dict[str, str]) -> CheckResult: + if not headers: + return CheckResult(name="msp-tenant-list", ok=False, detail="authenticated credentials are required") + status, tenants = check_account_tenants(args.base_url, args.timeout, headers, args.account_id) + if status != 200: + return CheckResult(name="msp-tenant-list", ok=False, detail=f"status={status}") + return CheckResult(name="msp-tenant-list", ok=True, detail=f"tenant_count={len(tenants)}") + + +def create_workspace_check(args: argparse.Namespace, headers: dict[str, str], display_name: str) -> CheckResult: + status, payload = fetch_json( + "POST", + f"{normalize_base_url(args.base_url)}/api/accounts/{args.account_id}/tenants", + timeout=args.timeout, + headers=headers, + json_body={"display_name": display_name}, + ) + if status != 201: + return CheckResult( + name=f"msp-create-workspace:{display_name}", + ok=False, + detail=f"status={status}, payload={payload!r}", + ) + if not isinstance(payload, dict): + return CheckResult(name=f"msp-create-workspace:{display_name}", ok=False, detail="payload was not a JSON object") + plan_version = str(payload.get("plan_version", "")).strip() + tenant_id = str(payload.get("id", "")).strip() + account_id = str(payload.get("account_id", "")).strip() + if not tenant_id: + return CheckResult(name=f"msp-create-workspace:{display_name}", ok=False, detail="response missing tenant id") + if account_id != args.account_id: + return CheckResult( + name=f"msp-create-workspace:{display_name}", + ok=False, + detail=f"account_id={account_id!r}, expected {args.account_id!r}", + ) + if args.expected_plan_version and plan_version != args.expected_plan_version: + return CheckResult( + name=f"msp-create-workspace:{display_name}", + ok=False, + detail=f"plan_version={plan_version!r}, expected {args.expected_plan_version!r}", + ) + return CheckResult( + name=f"msp-create-workspace:{display_name}", + ok=True, + detail=f"tenant_id={tenant_id} plan_version={plan_version!r}", + ) + + +def invite_member_check(args: argparse.Namespace, headers: dict[str, str], email: str, role: str) -> CheckResult: + status, payload = fetch_json( + "POST", + f"{normalize_base_url(args.base_url)}/api/accounts/{args.account_id}/members", + timeout=args.timeout, + headers=headers, + json_body={"email": email, "role": role}, + ) + if status != 201: + return CheckResult( + name=f"msp-invite-member:{email}", + ok=False, + detail=f"status={status}, payload={payload!r}", + ) + return CheckResult(name=f"msp-invite-member:{email}", ok=True, detail=f"role={role}") + + +def portal_dashboard_check(args: argparse.Namespace, headers: dict[str, str], expected_min_total: int) -> CheckResult: + status, payload = fetch_json( + "GET", + f"{normalize_base_url(args.base_url)}/api/portal/dashboard?account_id={args.account_id}", + timeout=args.timeout, + headers=headers, + ) + if status != 200: + return CheckResult(name="msp-portal-dashboard", ok=False, detail=f"status={status}, payload={payload!r}") + if not isinstance(payload, dict): + return CheckResult(name="msp-portal-dashboard", ok=False, detail="payload was not a JSON object") + account = payload.get("account") + summary = payload.get("summary") + if not isinstance(account, dict) or not isinstance(summary, dict): + return CheckResult(name="msp-portal-dashboard", ok=False, detail="missing account or summary object") + kind = str(account.get("kind", "")).strip() + total = int(summary.get("total", 0)) + if args.expected_account_kind and kind != args.expected_account_kind: + return CheckResult( + name="msp-portal-dashboard", + ok=False, + detail=f"account.kind={kind!r}, expected {args.expected_account_kind!r}", + ) + if total < expected_min_total: + return CheckResult( + name="msp-portal-dashboard", + ok=False, + detail=f"summary.total={total}, expected at least {expected_min_total}", + ) + return CheckResult(name="msp-portal-dashboard", ok=True, detail=f"account.kind={kind!r} summary.total={total}") + + +def workspace_detail_check(args: argparse.Namespace, headers: dict[str, str], tenant_id: str, expected_name: str) -> CheckResult: + status, payload = fetch_json( + "GET", + f"{normalize_base_url(args.base_url)}/api/portal/workspaces/{tenant_id}?account_id={args.account_id}", + timeout=args.timeout, + headers=headers, + ) + if status != 200: + return CheckResult(name=f"msp-workspace-detail:{tenant_id}", ok=False, detail=f"status={status}, payload={payload!r}") + if not isinstance(payload, dict): + return CheckResult(name=f"msp-workspace-detail:{tenant_id}", ok=False, detail="payload was not a JSON object") + account = payload.get("account") + workspace = payload.get("workspace") + if not isinstance(account, dict) or not isinstance(workspace, dict): + return CheckResult(name=f"msp-workspace-detail:{tenant_id}", ok=False, detail="missing account or workspace object") + kind = str(account.get("kind", "")).strip() + display_name = str(workspace.get("display_name", "")).strip() + plan_version = str(workspace.get("plan_version", "")).strip() + if args.expected_account_kind and kind != args.expected_account_kind: + return CheckResult( + name=f"msp-workspace-detail:{tenant_id}", + ok=False, + detail=f"account.kind={kind!r}, expected {args.expected_account_kind!r}", + ) + if expected_name and display_name != expected_name: + return CheckResult( + name=f"msp-workspace-detail:{tenant_id}", + ok=False, + detail=f"display_name={display_name!r}, expected {expected_name!r}", + ) + if args.expected_plan_version and plan_version != args.expected_plan_version: + return CheckResult( + name=f"msp-workspace-detail:{tenant_id}", + ok=False, + detail=f"plan_version={plan_version!r}, expected {args.expected_plan_version!r}", + ) + return CheckResult( + name=f"msp-workspace-detail:{tenant_id}", + ok=True, + detail=f"display_name={display_name!r} plan_version={plan_version!r}", + ) + + +def public_signup_boundary_check(args: argparse.Namespace) -> CheckResult: + if not args.public_signup_email or not args.public_signup_org_name: + return CheckResult(name="public-cloud-boundary", ok=True, detail="skipped (no public signup inputs provided)") + status, payload = fetch_json( + "POST", + f"{normalize_base_url(args.base_url)}/api/public/signup", + timeout=args.timeout, + json_body={ + "email": args.public_signup_email, + "org_name": args.public_signup_org_name, + "tier": args.public_signup_tier, + }, + ) + if status != args.public_signup_expected_status: + return CheckResult( + name="public-cloud-boundary", + ok=False, + detail=f"status={status}, expected {args.public_signup_expected_status}, payload={payload!r}", + ) + if not isinstance(payload, dict): + return CheckResult(name="public-cloud-boundary", ok=False, detail="payload was not a JSON object") + code = str(payload.get("code", "")).strip() + if args.public_signup_expected_code and code != args.public_signup_expected_code: + return CheckResult( + name="public-cloud-boundary", + ok=False, + detail=f"code={code!r}, expected {args.public_signup_expected_code!r}", + ) + return CheckResult(name="public-cloud-boundary", ok=True, detail=f"status={status} code={code!r}") + + +def member_pairs(args: argparse.Namespace) -> list[tuple[str, str]]: + roles = list(args.member_role) + while len(roles) < len(args.member_email): + roles.append("tech") + return list(zip(args.member_email, roles)) + + +def run_rehearsal(args: argparse.Namespace) -> list[CheckResult]: + headers = build_auth_headers(args) + results: list[CheckResult] = [] + created_workspace_ids: list[tuple[str, str]] = [] + + results.append(safe_check("msp-tenant-list", lambda: list_tenants_check(args, headers))) + + for workspace_name in args.workspace_name: + result = safe_check( + f"msp-create-workspace:{workspace_name}", + lambda workspace_name=workspace_name: create_workspace_check(args, headers, workspace_name), + ) + results.append(result) + if result.ok: + detail_parts = result.detail.split() + tenant_id = detail_parts[0].split("=", 1)[1] + created_workspace_ids.append((tenant_id, workspace_name)) + + if headers: + try: + status, tenants = check_account_tenants(args.base_url, args.timeout, headers, args.account_id) + except Exception: + expected_total = max(1, len(created_workspace_ids)) + else: + if status == 200: + expected_total = len(tenants) + else: + expected_total = max(1, len(created_workspace_ids)) + else: + expected_total = max(1, len(created_workspace_ids)) + + for email, role in member_pairs(args): + results.append(safe_check(f"msp-invite-member:{email}", lambda email=email, role=role: invite_member_check(args, headers, email, role))) + + results.append(safe_check("msp-member-list", lambda: _member_list_expectations(args, headers))) + results.append(safe_check("msp-portal-dashboard", lambda: portal_dashboard_check(args, headers, expected_total))) + + for tenant_id, workspace_name in created_workspace_ids: + results.append( + safe_check( + f"msp-workspace-detail:{tenant_id}", + lambda tenant_id=tenant_id, workspace_name=workspace_name: workspace_detail_check( + args, + headers, + tenant_id, + workspace_name, + ), + ) + ) + + results.append(safe_check("public-cloud-boundary", lambda: public_signup_boundary_check(args))) + return results + + +def _member_list_expectations(args: argparse.Namespace, headers: dict[str, str]) -> CheckResult: + if not headers: + return CheckResult(name="msp-member-list", ok=False, detail="authenticated credentials are required") + status, members = check_member_list(args.base_url, args.timeout, headers, args.account_id) + if status != 200: + return CheckResult(name="msp-member-list", ok=False, detail=f"status={status}") + emails = {str(item.get('email', '')).strip() for item in members if isinstance(item, dict)} + missing = [email for email, _role in member_pairs(args) if email not in emails] + if missing: + return CheckResult(name="msp-member-list", ok=False, detail=f"missing members: {', '.join(missing)}") + return CheckResult(name="msp-member-list", ok=True, detail=f"member_count={len(members)}") + + +def render_markdown_report( + *, + title: str, + base_url: str, + account_id: str, + results: list[CheckResult], +) -> str: + lines = [ + f"# {title}", + "", + "## Inputs", + "", + f"- Base URL: `{base_url}`", + f"- Account ID: `{account_id}`", + "", + "## Results", + "", + ] + for result in results: + status = "PASS" if result.ok else "FAIL" + lines.append(f"- `{status}` `{result.name}`") + lines.append(f" - {result.detail}") + lines.extend( + [ + "", + "## Manual Follow-up", + "", + "- If this was run against a local or staging-only control plane, rerun it against the real external MSP environment before closing the gate.", + "- If account creation or billing bootstrap still required pre-seeding, record exactly which parts remained Stripe- or operations-driven outside the rehearsal surface.", + ] + ) + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + results = run_rehearsal(args) + if args.report_out: + Path(args.report_out).write_text( + render_markdown_report( + title=args.report_title, + base_url=args.base_url, + account_id=args.account_id, + results=results, + ), + encoding="utf-8", + ) + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2)) + else: + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"{status} {result.name}: {result.detail}") + return 0 if results and all(result.ok for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/msp_provider_tenant_management_rehearsal_test.py b/scripts/release_control/internal/msp_provider_tenant_management_rehearsal_test.py new file mode 100644 index 000000000..23da6dc04 --- /dev/null +++ b/scripts/release_control/internal/msp_provider_tenant_management_rehearsal_test.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import contextlib +import io +import json +import socket +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import msp_provider_tenant_management_rehearsal as mod + + +def run_main(argv: list[str]) -> int: + with contextlib.redirect_stdout(io.StringIO()): + return mod.main(argv) + + +class MSPProviderTenantManagementRehearsalTest(unittest.TestCase): + def test_run_rehearsal_full_flow(self) -> None: + calls: list[tuple[str, str, dict | None]] = [] + tenants_state = [ + {"id": "t-1", "display_name": "Client One", "account_id": "acct_1", "plan_version": "msp_starter"}, + {"id": "t-2", "display_name": "Client Two", "account_id": "acct_1", "plan_version": "msp_starter"}, + ] + members_state = [ + {"email": "owner@example.com", "role": "owner"}, + ] + + def fake_fetch_json(method: str, url: str, **kwargs): + calls.append((method, url, kwargs.get("headers"))) + if url.endswith("/api/accounts/acct_1/tenants") and method == "GET": + return 200, tenants_state + if url.endswith("/api/accounts/acct_1/tenants") and method == "POST": + payload = kwargs["json_body"] + tenant = { + "id": "t-3", + "display_name": payload["display_name"], + "account_id": "acct_1", + "plan_version": "msp_starter", + } + tenants_state.append(tenant) + return 201, tenant + if url.endswith("/api/accounts/acct_1/members") and method == "POST": + payload = kwargs["json_body"] + members_state.append({"email": payload["email"], "role": payload["role"]}) + return 201, {"ok": True} + if url.endswith("/api/accounts/acct_1/members") and method == "GET": + return 200, members_state + if url.endswith("/api/portal/dashboard?account_id=acct_1"): + return 200, { + "account": {"kind": "msp"}, + "summary": {"total": 3}, + } + if url.endswith("/api/portal/workspaces/t-3?account_id=acct_1"): + return 200, { + "account": {"kind": "msp"}, + "workspace": {"display_name": "Client Three", "plan_version": "msp_starter"}, + } + if url.endswith("/api/public/signup"): + return 400, {"code": "tier_unavailable"} + raise AssertionError(f"unexpected call: {method} {url}") + + with mock.patch.object(mod, "fetch_json", side_effect=fake_fetch_json): + args = mod.parse_args( + [ + "--base-url", + "https://pulse.example.com", + "--account-id", + "acct_1", + "--bearer-token", + "token", + "--workspace-name", + "Client Three", + "--member-email", + "tech@example.com", + "--member-role", + "tech", + "--public-signup-email", + "public@example.com", + "--public-signup-org-name", + "Public Boundary", + ] + ) + results = mod.run_rehearsal(args) + + self.assertTrue(all(result.ok for result in results), [result.detail for result in results]) + headers = next(headers for method, url, headers in calls if method == "POST" and url.endswith("/api/accounts/acct_1/tenants")) + self.assertEqual(headers["Authorization"], "Bearer token") + + def test_run_rehearsal_fails_when_created_workspace_drifts_plan(self) -> None: + def fake_fetch_json(method: str, url: str, **kwargs): + if url.endswith("/api/accounts/acct_1/tenants") and method == "GET": + return 200, [] + if url.endswith("/api/accounts/acct_1/tenants") and method == "POST": + return 201, { + "id": "t-3", + "display_name": "Client Three", + "account_id": "acct_1", + "plan_version": "cloud_starter", + } + if url.endswith("/api/accounts/acct_1/members"): + return 200, [] + if url.endswith("/api/portal/dashboard?account_id=acct_1"): + return 200, {"account": {"kind": "msp"}, "summary": {"total": 1}} + if url.endswith("/api/public/signup"): + return 400, {"code": "tier_unavailable"} + raise AssertionError(f"unexpected call: {method} {url}") + + with mock.patch.object(mod, "fetch_json", side_effect=fake_fetch_json): + exit_code = run_main( + [ + "--base-url", + "https://pulse.example.com", + "--account-id", + "acct_1", + "--api-token", + "token", + "--workspace-name", + "Client Three", + ] + ) + self.assertEqual(exit_code, 1) + + def test_main_returns_failure_when_workspace_create_raises_runtime_error(self) -> None: + def fake_fetch_json(method: str, url: str, **kwargs): + if url.endswith("/api/accounts/acct_1/tenants") and method == "GET": + return 200, [] + if url.endswith("/api/accounts/acct_1/tenants") and method == "POST": + raise RuntimeError("POST https://pulse.example.com/api/accounts/acct_1/tenants returned non-JSON body: internal error") + if url.endswith("/api/accounts/acct_1/members") and method == "GET": + return 200, [] + if url.endswith("/api/portal/dashboard?account_id=acct_1"): + return 200, {"account": {"kind": "msp"}, "summary": {"total": 1}} + raise AssertionError(f"unexpected call: {method} {url}") + + with mock.patch.object(mod, "fetch_json", side_effect=fake_fetch_json): + exit_code = run_main( + [ + "--base-url", + "https://pulse.example.com", + "--account-id", + "acct_1", + "--api-token", + "token", + "--workspace-name", + "Client Three", + ] + ) + self.assertEqual(exit_code, 1) + + def test_render_markdown_report(self) -> None: + report = mod.render_markdown_report( + title="MSP Provider Tenant Management Rehearsal", + base_url="https://pulse.example.com", + account_id="acct_1", + results=[ + mod.CheckResult(name="msp-tenant-list", ok=True, detail="tenant_count=2"), + mod.CheckResult(name="public-cloud-boundary", ok=False, detail="status=500"), + ], + ) + self.assertIn("# MSP Provider Tenant Management Rehearsal", report) + self.assertIn("`PASS` `msp-tenant-list`", report) + self.assertIn("`FAIL` `public-cloud-boundary`", report) + + def test_main_writes_report(self) -> None: + def fake_fetch_json(method: str, url: str, **kwargs): + if url.endswith("/api/accounts/acct_1/tenants") and method == "GET": + return 200, [] + if url.endswith("/api/accounts/acct_1/members") and method == "GET": + return 200, [] + if url.endswith("/api/portal/dashboard?account_id=acct_1"): + return 200, {"account": {"kind": "msp"}, "summary": {"total": 1}} + raise AssertionError(f"unexpected call: {method} {url}") + + with tempfile.TemporaryDirectory() as tmp: + report_path = Path(tmp) / "report.md" + with mock.patch.object(mod, "fetch_json", side_effect=fake_fetch_json): + exit_code = mod.main( + [ + "--base-url", + "https://pulse.example.com", + "--account-id", + "acct_1", + "--api-token", + "token", + "--report-out", + str(report_path), + ] + ) + self.assertEqual(exit_code, 0) + self.assertIn("# MSP Provider Tenant Management Rehearsal", report_path.read_text(encoding="utf-8")) + + def test_main_returns_failure_on_timeout_without_traceback(self) -> None: + with mock.patch.object(mod.request, "urlopen", side_effect=socket.timeout("timed out")): + exit_code = run_main( + [ + "--base-url", + "https://pulse.example.com", + "--account-id", + "acct_1", + "--api-token", + "token", + ] + ) + self.assertEqual(exit_code, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/relay_registration_reconnect_drain_proof.py b/scripts/release_control/internal/relay_registration_reconnect_drain_proof.py new file mode 100644 index 000000000..fdcb338f8 --- /dev/null +++ b/scripts/release_control/internal/relay_registration_reconnect_drain_proof.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Run the automated proof bundle for the relay registration/reconnect gate.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass +class CommandSpec: + name: str + cwd: str + command: list[str] + + +@dataclass +class CommandResult: + name: str + cwd: str + command: list[str] + ok: bool + exit_code: int + detail: str + + +def default_pulse_dir() -> Path: + return Path(__file__).resolve().parents[2] + + +def default_pulse_mobile_dir() -> Path: + return default_pulse_dir().parent / "pulse-mobile" + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the automated proof bundle for the relay registration/reconnect/drain release gate." + ) + parser.add_argument("--pulse-dir", default=str(default_pulse_dir())) + parser.add_argument("--pulse-mobile-dir", default=str(default_pulse_mobile_dir())) + parser.add_argument("--frontend-dir", help="Optional override for the pulse frontend-modern directory") + parser.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output") + return parser.parse_args(argv) + + +def frontend_dir_from_args(args: argparse.Namespace) -> Path: + if args.frontend_dir: + return Path(args.frontend_dir).resolve() + return Path(args.pulse_dir).resolve() / "frontend-modern" + + +def build_command_specs(args: argparse.Namespace) -> list[CommandSpec]: + pulse_dir = Path(args.pulse_dir).resolve() + pulse_mobile_dir = Path(args.pulse_mobile_dir).resolve() + frontend_dir = frontend_dir_from_args(args) + return [ + CommandSpec( + name="relay-backend-api-guards", + cwd=str(pulse_dir), + command=[ + "go", + "test", + "./internal/api", + "-run", + "TestRelayEndpointsRequireLicenseFeature|TestRelayOnboardingEndpointsRequireLicenseFeature|TestRelayLicenseGatingResponseFormat|TestOnboardingQRPayloadStructure|TestOnboardingValidateSuccessAndFailure|TestOnboardingDeepLinkFormat", + "-count=1", + ], + ), + CommandSpec( + name="relay-backend-runtime", + cwd=str(pulse_dir), + command=[ + "go", + "test", + "./internal/relay", + "-run", + "TestClient_E2E_MultiMobileClientRelay|TestClient_AbruptDisconnectCancelsInFlightHandlers|TestClient_AbruptDisconnectMultipleChannelCleanup|TestClient_DrainDuringInFlightData|TestClient_DrainWithMultipleInFlightChannels|TestClientRegister_SessionResumeRejectionClearsCachedSession|TestRunLoop_SessionResumeRejectionFallsBackToFreshRegister", + "-count=1", + ], + ), + CommandSpec( + name="relay-frontend-runtime", + cwd=str(frontend_dir), + command=[ + "npx", + "vitest", + "run", + "src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx", + "src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx", + "src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx", + ], + ), + CommandSpec( + name="relay-managed-runtime", + cwd=str(pulse_dir), + command=[ + "go", + "test", + "./internal/relay", + "-run", + "TestManagedRuntimeRelayRegistrationReconnectDrain", + "-count=1", + ], + ), + CommandSpec( + name="relay-mobile-client", + cwd=str(pulse_mobile_dir), + command=[ + "npm", + "test", + "--", + "--runTestsByPath", + "src/relay/__tests__/client.test.ts", + "src/relay/__tests__/client-hardening.test.ts", + "src/relay/__tests__/protocol-contract.test.ts", + ], + ), + ] + + +def summarize_output(stdout: str, stderr: str) -> str: + text = "\n".join(part.strip() for part in (stdout, stderr) if part.strip()).strip() + if not text: + return "pass" + lines = [line.strip() for line in text.splitlines() if line.strip()] + summary = lines[-1] + if len(summary) > 240: + return summary[:237] + "..." + return summary + + +def run_command(spec: CommandSpec) -> CommandResult: + proc = subprocess.run(spec.command, cwd=spec.cwd, capture_output=True, text=True, check=False) + return CommandResult( + name=spec.name, + cwd=spec.cwd, + command=spec.command, + ok=proc.returncode == 0, + exit_code=proc.returncode, + detail=summarize_output(proc.stdout, proc.stderr), + ) + + +def run_proof(args: argparse.Namespace) -> list[CommandResult]: + return [run_command(spec) for spec in build_command_specs(args)] + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + results = run_proof(args) + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2)) + else: + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"{status} {result.name}: {result.detail}") + return 0 if all(result.ok for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/relay_registration_reconnect_drain_proof_test.py b/scripts/release_control/internal/relay_registration_reconnect_drain_proof_test.py new file mode 100644 index 000000000..41e9aeacc --- /dev/null +++ b/scripts/release_control/internal/relay_registration_reconnect_drain_proof_test.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Tests for the relay registration/reconnect proof wrapper.""" + +from __future__ import annotations + +import unittest + +import relay_registration_reconnect_drain_proof as proof + + +class RelayRegistrationReconnectDrainProofTest(unittest.TestCase): + def test_build_command_specs_are_sorted_and_cover_expected_workspaces(self) -> None: + args = proof.parse_args([]) + specs = proof.build_command_specs(args) + self.assertEqual([spec.name for spec in specs], sorted(spec.name for spec in specs)) + self.assertIn("relay-managed-runtime", [spec.name for spec in specs]) + self.assertTrue(any(spec.cwd.endswith("frontend-modern") for spec in specs)) + self.assertTrue(any(spec.cwd.endswith("pulse-mobile") for spec in specs)) + self.assertTrue(any(spec.cwd.endswith("pulse") for spec in specs)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/resolve_release_promotion.py b/scripts/release_control/internal/resolve_release_promotion.py new file mode 100644 index 000000000..a902ea0c7 --- /dev/null +++ b/scripts/release_control/internal/resolve_release_promotion.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Resolve and validate shared release-promotion metadata for governed workflows.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import time +from pathlib import Path +from typing import Callable + +from repo_file_io import REPO_ROOT, git_env + + +SEMVER_PRERELEASE_RE = re.compile(r"-(?:rc|alpha|beta)\.\d+$") + + +def normalize_tag(value: str) -> str: + value = (value or "").strip() + if not value: + return "" + if value.startswith("v"): + return value + return f"v{value}" + + +def is_prerelease_version(version: str) -> bool: + return bool(SEMVER_PRERELEASE_RE.search(version)) + + +def tag_exists(tag: str) -> bool: + result = subprocess.run( + ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], + cwd=REPO_ROOT, + env=git_env(), + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def tag_commit(tag: str) -> str: + result = subprocess.run( + ["git", "rev-list", "-n1", f"refs/tags/{tag}"], + cwd=REPO_ROOT, + env=git_env(), + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def head_descends_from(commit: str) -> bool: + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", commit, "HEAD"], + cwd=REPO_ROOT, + env=git_env(), + ) + return result.returncode == 0 + + +def tag_created_unix(tag: str) -> int: + result = subprocess.run( + ["git", "for-each-ref", "--format=%(creatordate:unix)", f"refs/tags/{tag}"], + cwd=REPO_ROOT, + env=git_env(), + check=True, + capture_output=True, + text=True, + ) + value = result.stdout.strip().splitlines() + if not value or not value[0].strip(): + raise ValueError(f"Could not determine creation time for promoted prerelease tag {tag}.") + return int(value[0].strip()) + + +def normalize_whitespace(value: str) -> str: + return " ".join((value or "").split()) + + +def resolve_metadata( + *, + version: str, + promoted_from_tag_input: str, + rollback_version_input: str, + ga_date_input: str, + v5_eos_date_input: str, + hotfix_exception: bool, + hotfix_reason_input: str, + release_notes_input: str, + tag_exists_fn: Callable[[str], bool] = tag_exists, + tag_commit_fn: Callable[[str], str] = tag_commit, + head_descends_from_fn: Callable[[str], bool] = head_descends_from, + tag_created_unix_fn: Callable[[str], int] = tag_created_unix, + now_unix_fn: Callable[[], int] = lambda: int(time.time()), +) -> dict[str, str]: + tag = normalize_tag(version) + rollback_tag = normalize_tag(rollback_version_input) + ga_date = (ga_date_input or "").strip() + v5_eos_date = (v5_eos_date_input or "").strip() + hotfix_reason = normalize_whitespace(hotfix_reason_input) + release_notes = release_notes_input or "" + is_prerelease = is_prerelease_version(version) + + if not rollback_tag: + raise ValueError( + "rollback_version is required for every release rehearsal and promotion so rollback can be executed explicitly." + ) + if SEMVER_PRERELEASE_RE.search(rollback_tag): + raise ValueError( + f"rollback_version must point to a stable release tag, not a prerelease ({rollback_tag})." + ) + if not tag_exists_fn(rollback_tag): + raise ValueError(f"rollback_version {rollback_tag} does not exist as a repository tag.") + rollback_command = f"./scripts/install.sh --version {rollback_tag}" + + promoted_from_tag = "" + soak_hours = "" + if is_prerelease: + if hotfix_exception: + raise ValueError("hotfix_exception applies only to stable promotions.") + else: + promoted_from_tag = normalize_tag(promoted_from_tag_input) + if not promoted_from_tag: + raise ValueError( + "Stable promotion requires promoted_from_tag naming the RC being promoted." + ) + if not re.match(rf"^v{re.escape(version)}-rc\.\d+$", promoted_from_tag): + raise ValueError( + f"promoted_from_tag must reference an RC tag for the same stable version ({version}), got {promoted_from_tag}." + ) + if not tag_exists_fn(promoted_from_tag): + raise ValueError( + f"promoted_from_tag {promoted_from_tag} does not exist as a repository tag." + ) + + promoted_commit = tag_commit_fn(promoted_from_tag) + if not head_descends_from_fn(promoted_commit): + raise ValueError( + f"Stable promotion {tag} must descend from promoted prerelease tag {promoted_from_tag}." + ) + + promoted_tag_ts = tag_created_unix_fn(promoted_from_tag) + soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600) + soak_hours = str(soak_hours_value) + + if hotfix_exception: + if not hotfix_reason: + raise ValueError("hotfix_reason is required when hotfix_exception is true.") + elif soak_hours_value < 72: + raise ValueError( + f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since {promoted_from_tag}; minimum is 72 hours unless hotfix_exception is true." + ) + + if version == "6.0.0": + if not re.match(r"^\d{4}-\d{2}-\d{2}$", ga_date): + raise ValueError( + "Stable v6.0.0 requires ga_date in YYYY-MM-DD form so the GA publish notice is explicit." + ) + if not re.match(r"^\d{4}-\d{2}-\d{2}$", v5_eos_date): + raise ValueError( + "Stable v6.0.0 requires v5_eos_date in YYYY-MM-DD form so the support window is published explicitly." + ) + if release_notes: + if "maintenance-only support" not in release_notes.lower(): + raise ValueError( + "Stable v6.0.0 release_notes must include the Pulse v5 maintenance-only support notice." + ) + if ga_date not in release_notes: + raise ValueError( + f"Stable v6.0.0 release_notes must include the exact ga_date ({ga_date})." + ) + if v5_eos_date not in release_notes: + raise ValueError( + f"Stable v6.0.0 release_notes must include the exact v5_eos_date ({v5_eos_date})." + ) + + return { + "promoted_from_tag": promoted_from_tag, + "rollback_tag": rollback_tag, + "rollback_command": rollback_command, + "ga_date": ga_date, + "v5_eos_date": v5_eos_date, + "hotfix_exception": "true" if hotfix_exception else "false", + "hotfix_reason": hotfix_reason, + "soak_hours": soak_hours, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--promoted-from-tag", default="") + parser.add_argument("--rollback-version", default="") + parser.add_argument("--ga-date", default="") + parser.add_argument("--v5-eos-date", default="") + parser.add_argument("--hotfix-exception", action="store_true") + parser.add_argument("--hotfix-reason", default="") + parser.add_argument("--release-notes-file", default="") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + release_notes = "" + if args.release_notes_file: + release_notes = Path(args.release_notes_file).read_text(encoding="utf-8") + + metadata = resolve_metadata( + version=args.version, + promoted_from_tag_input=args.promoted_from_tag, + rollback_version_input=args.rollback_version, + ga_date_input=args.ga_date, + v5_eos_date_input=args.v5_eos_date, + hotfix_exception=args.hotfix_exception, + hotfix_reason_input=args.hotfix_reason, + release_notes_input=release_notes, + ) + + for key, value in metadata.items(): + print(f"{key}={value}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/resolve_release_promotion_test.py b/scripts/release_control/internal/resolve_release_promotion_test.py new file mode 100644 index 000000000..e3ed24b32 --- /dev/null +++ b/scripts/release_control/internal/resolve_release_promotion_test.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Unit tests for shared release-promotion metadata resolution.""" + +from __future__ import annotations + +import unittest + +import resolve_release_promotion as resolver + + +class ResolveReleasePromotionTest(unittest.TestCase): + def test_prerelease_requires_explicit_stable_rollback(self) -> None: + metadata = resolver.resolve_metadata( + version="6.0.0-rc.2", + promoted_from_tag_input="", + rollback_version_input="5.1.14", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: tag == "v5.1.14", + ) + self.assertEqual(metadata["rollback_tag"], "v5.1.14") + self.assertEqual(metadata["rollback_command"], "./scripts/install.sh --version v5.1.14") + self.assertEqual(metadata["promoted_from_tag"], "") + self.assertEqual(metadata["soak_hours"], "") + + def test_stable_requires_matching_promoted_rc_and_soak(self) -> None: + metadata = resolver.resolve_metadata( + version="6.0.0", + promoted_from_tag_input="6.0.0-rc.2", + rollback_version_input="5.1.14", + ga_date_input="2026-03-20", + v5_eos_date_input="2026-06-18", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="maintenance-only support 2026-03-20 2026-06-18", + tag_exists_fn=lambda tag: tag in {"v6.0.0-rc.2", "v5.1.14"}, + tag_commit_fn=lambda tag: "abc123", + head_descends_from_fn=lambda commit: commit == "abc123", + tag_created_unix_fn=lambda tag: 100, + now_unix_fn=lambda: 100 + (73 * 3600), + ) + self.assertEqual(metadata["promoted_from_tag"], "v6.0.0-rc.2") + self.assertEqual(metadata["soak_hours"], "73") + + def test_stable_requires_release_notes_notice_when_supplied(self) -> None: + with self.assertRaisesRegex( + ValueError, + "release_notes must include the Pulse v5 maintenance-only support notice", + ): + resolver.resolve_metadata( + version="6.0.0", + promoted_from_tag_input="6.0.0-rc.2", + rollback_version_input="5.1.14", + ga_date_input="2026-03-20", + v5_eos_date_input="2026-06-18", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="missing notice 2026-03-20 2026-06-18", + tag_exists_fn=lambda tag: True, + tag_commit_fn=lambda tag: "abc123", + head_descends_from_fn=lambda commit: True, + tag_created_unix_fn=lambda tag: 100, + now_unix_fn=lambda: 100 + (73 * 3600), + ) + + def test_stable_hotfix_requires_reason(self) -> None: + with self.assertRaisesRegex(ValueError, "hotfix_reason is required"): + resolver.resolve_metadata( + version="6.0.1", + promoted_from_tag_input="6.0.1-rc.1", + rollback_version_input="6.0.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=True, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: True, + tag_commit_fn=lambda tag: "abc123", + head_descends_from_fn=lambda commit: True, + tag_created_unix_fn=lambda tag: 100, + now_unix_fn=lambda: 100 + (2 * 3600), + ) + + def test_stable_rejects_short_soak_without_hotfix(self) -> None: + with self.assertRaisesRegex(ValueError, "minimum is 72 hours unless hotfix_exception is true"): + resolver.resolve_metadata( + version="6.0.1", + promoted_from_tag_input="6.0.1-rc.1", + rollback_version_input="6.0.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: True, + tag_commit_fn=lambda tag: "abc123", + head_descends_from_fn=lambda commit: True, + tag_created_unix_fn=lambda tag: 100, + now_unix_fn=lambda: 100 + (2 * 3600), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/unified_agent_rc_rehearsal.py b/scripts/release_control/internal/unified_agent_rc_rehearsal.py new file mode 100644 index 000000000..5f7a564f9 --- /dev/null +++ b/scripts/release_control/internal/unified_agent_rc_rehearsal.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python3 +"""Validate the live unified-agent RC crossover against expected release assets.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable +from urllib import error, parse, request + + +DEFAULT_RELEASE_BASE_URL = "https://github.com/rcourtman/Pulse/releases/download" + + +@dataclass +class CheckResult: + name: str + ok: bool + detail: str + + +def safe_check(name: str, fn) -> CheckResult: + try: + return fn() + except Exception as exc: # pragma: no cover - exercised via caller tests + return CheckResult(name=name, ok=False, detail=str(exc)) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Verify that a live Pulse RC instance serves the expected unified-agent " + "version and release assets for the v5-to-v6 crossover rehearsal." + ) + ) + parser.add_argument("--base-url", required=True, help="Pulse server base URL") + parser.add_argument( + "--expected-version", + required=True, + help="Expected Pulse version from /api/agent/version, e.g. 6.0.0-rc.1", + ) + parser.add_argument( + "--release-base-url", + default=DEFAULT_RELEASE_BASE_URL, + help="Release asset base URL to compare against", + ) + parser.add_argument( + "--arch", + action="append", + default=[], + help="Optional unified-agent architecture to verify via /download/pulse-agent?arch=...", + ) + parser.add_argument( + "--update-info-dir", + help="Optional directory containing .pulse-update-info on the upgraded machine", + ) + parser.add_argument( + "--expected-updated-from", + help="Optional expected previous agent version inside .pulse-update-info", + ) + parser.add_argument( + "--timeout", + type=float, + default=30.0, + help="HTTP timeout in seconds", + ) + parser.add_argument( + "--api-token", + help="Optional API token sent as X-API-Token for authenticated rehearsal checks", + ) + parser.add_argument( + "--bearer-token", + help="Optional API token sent as Authorization: Bearer for authenticated rehearsal checks", + ) + parser.add_argument( + "--cookie", + help="Optional raw Cookie header for authenticated rehearsal checks", + ) + parser.add_argument( + "--expected-active-agents", + type=int, + help=( + "Optional expected active Pulse Unified Agent count after upgrade; " + "verifies both /api/license/entitlements and /api/license/agent-ledger" + ), + ) + parser.add_argument( + "--expected-agent-name", + action="append", + default=[], + help=( + "Optional agent display name expected in /api/license/agent-ledger after " + "the upgrade; may be passed multiple times" + ), + ) + parser.add_argument( + "--expected-online-agents", + type=int, + help=( + "Optional expected number of online agents in /api/license/agent-ledger " + "after the upgrade" + ), + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit JSON instead of human-readable output", + ) + parser.add_argument( + "--report-out", + help="Optional path to write a markdown rehearsal report", + ) + parser.add_argument( + "--report-title", + default="Unified Agent RC Rehearsal", + help="Markdown title used when writing --report-out", + ) + return parser.parse_args(argv) + + +def normalize_base_url(url: str) -> str: + return url.rstrip("/") + + +def summarize_http_error_body(body: bytes) -> str: + message = body.decode("utf-8", errors="replace").strip() + if not message: + return "" + first_line = message.splitlines()[0].strip() + if first_line.startswith("" + if len(first_line) > 240: + return first_line[:237] + "..." + return first_line + + +def build_auth_headers(args: argparse.Namespace) -> dict[str, str]: + headers: dict[str, str] = {} + if args.api_token: + headers["X-API-Token"] = args.api_token + if args.bearer_token: + headers["Authorization"] = f"Bearer {args.bearer_token}" + if args.cookie: + headers["Cookie"] = args.cookie + return headers + + +def fetch_bytes( + url: str, *, timeout: float, headers: dict[str, str] | None = None +) -> tuple[bytes, dict[str, str]]: + req = request.Request(url, method="GET", headers=headers or {}) + try: + with request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + headers = {key.lower(): value for key, value in resp.headers.items()} + return body, headers + except error.HTTPError as exc: + body = exc.read() + message = summarize_http_error_body(body) + raise RuntimeError(f"{url} returned HTTP {exc.code}: {message}") from exc + except error.URLError as exc: + raise RuntimeError(f"failed to fetch {url}: {exc.reason}") from exc + + +def fetch_json( + url: str, *, timeout: float, headers: dict[str, str] | None = None +) -> dict[str, object]: + body, _headers = fetch_bytes(url, timeout=timeout, headers=headers) + try: + payload = json.loads(body.decode("utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{url} did not return valid JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"{url} did not return a JSON object") + return payload + + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def release_asset_url(release_base_url: str, version: str, asset_name: str) -> str: + return f"{normalize_base_url(release_base_url)}/v{version}/{asset_name}" + + +def agent_binary_asset_name(arch: str) -> str: + asset = f"pulse-agent-{arch}" + if arch.startswith("windows-"): + asset += ".exe" + return asset + + +def compare_asset( + *, + name: str, + live_url: str, + release_url: str, + timeout: float, + expect_checksum_header: bool = False, +) -> CheckResult: + live_body, live_headers = fetch_bytes(live_url, timeout=timeout) + release_body, _release_headers = fetch_bytes(release_url, timeout=timeout) + + live_hash = sha256_hex(live_body) + release_hash = sha256_hex(release_body) + if live_hash != release_hash: + return CheckResult( + name=name, + ok=False, + detail=( + f"live asset hash {live_hash} does not match release asset hash " + f"{release_hash} ({release_url})" + ), + ) + + if expect_checksum_header: + checksum_header = live_headers.get("x-checksum-sha256", "").strip() + if checksum_header != live_hash: + return CheckResult( + name=name, + ok=False, + detail=( + f"X-Checksum-Sha256={checksum_header or ''} does not " + f"match live asset hash {live_hash}" + ), + ) + + served_from = live_headers.get("x-served-from") + served_note = f"; served_from={served_from}" if served_from else "" + return CheckResult( + name=name, + ok=True, + detail=f"matched release asset hash {live_hash}{served_note}", + ) + + +def check_version(base_url: str, expected_version: str, timeout: float) -> CheckResult: + payload = fetch_json(f"{base_url}/api/agent/version", timeout=timeout) + version = str(payload.get("version", "")).strip() + if version != expected_version: + return CheckResult( + name="agent-version-endpoint", + ok=False, + detail=f"reported version {version!r}, expected {expected_version!r}", + ) + return CheckResult( + name="agent-version-endpoint", + ok=True, + detail=f"reported expected version {version}", + ) + + +def check_update_info(update_info_dir: str, expected_updated_from: str) -> CheckResult: + info_path = Path(update_info_dir) / ".pulse-update-info" + if not info_path.exists(): + return CheckResult( + name="local-update-info", + ok=False, + detail=f"{info_path} is missing", + ) + actual = info_path.read_text(encoding="utf-8").strip() + if actual != expected_updated_from: + return CheckResult( + name="local-update-info", + ok=False, + detail=f"{info_path} contains {actual!r}, expected {expected_updated_from!r}", + ) + return CheckResult( + name="local-update-info", + ok=True, + detail=f"{info_path} contains expected previous version {actual}", + ) + + +def check_active_agent_accounting( + *, + base_url: str, + timeout: float, + auth_headers: dict[str, str], + expected_active_agents: int, +) -> CheckResult: + if not auth_headers: + return CheckResult( + name="active-agent-accounting", + ok=False, + detail=( + "expected active-agent verification requires --api-token, " + "--bearer-token, or --cookie" + ), + ) + + entitlements = fetch_json( + f"{base_url}/api/license/entitlements", + timeout=timeout, + headers=auth_headers, + ) + limits = entitlements.get("limits") + if not isinstance(limits, list): + return CheckResult( + name="active-agent-accounting", + ok=False, + detail="/api/license/entitlements returned no limits array", + ) + + max_agents_current: int | None = None + for item in limits: + if not isinstance(item, dict): + continue + if str(item.get("key", "")).strip() != "max_agents": + continue + current = item.get("current") + if not isinstance(current, int): + return CheckResult( + name="active-agent-accounting", + ok=False, + detail="max_agents current usage was missing or not an integer", + ) + max_agents_current = current + break + + if max_agents_current is None: + return CheckResult( + name="active-agent-accounting", + ok=False, + detail="/api/license/entitlements did not include max_agents", + ) + + ledger = fetch_json( + f"{base_url}/api/license/agent-ledger", + timeout=timeout, + headers=auth_headers, + ) + agents = ledger.get("agents") + if not isinstance(agents, list): + return CheckResult( + name="active-agent-accounting", + ok=False, + detail="/api/license/agent-ledger returned no agents array", + ) + ledger_total = ledger.get("total") + if not isinstance(ledger_total, int): + ledger_total = len(agents) + + ok = ( + max_agents_current == expected_active_agents + and ledger_total == expected_active_agents + and len(agents) == expected_active_agents + and max_agents_current == ledger_total + ) + detail = ( + f"entitlements max_agents.current={max_agents_current}, " + f"agent-ledger total={ledger_total}, " + f"agent-ledger agents={len(agents)}, " + f"expected={expected_active_agents}" + ) + return CheckResult(name="active-agent-accounting", ok=ok, detail=detail) + + +def check_agent_ledger_identity( + *, + base_url: str, + timeout: float, + auth_headers: dict[str, str], + expected_agent_names: list[str], + expected_online_agents: int | None, +) -> CheckResult: + if not auth_headers: + return CheckResult( + name="agent-ledger-identity", + ok=False, + detail=( + "agent ledger identity verification requires --api-token, " + "--bearer-token, or --cookie" + ), + ) + + ledger = fetch_json( + f"{base_url}/api/license/agent-ledger", + timeout=timeout, + headers=auth_headers, + ) + agents = ledger.get("agents") + if not isinstance(agents, list): + return CheckResult( + name="agent-ledger-identity", + ok=False, + detail="/api/license/agent-ledger returned no agents array", + ) + + normalized_expected_names = [name.strip() for name in expected_agent_names if name.strip()] + names = [str(agent.get("name", "")).strip() for agent in agents if isinstance(agent, dict)] + online_agents = [ + agent + for agent in agents + if isinstance(agent, dict) and str(agent.get("status", "")).strip() == "online" + ] + + failures: list[str] = [] + for expected_name in normalized_expected_names: + matches = sum(1 for name in names if name == expected_name) + if matches == 0: + failures.append(f"missing expected agent name {expected_name!r}") + elif matches > 1: + failures.append(f"agent name {expected_name!r} appeared {matches} times") + + if expected_online_agents is not None and len(online_agents) != expected_online_agents: + failures.append( + f"online agent count {len(online_agents)} did not match expected {expected_online_agents}" + ) + + detail_parts = [ + f"ledger names={names!r}", + f"online_agents={len(online_agents)}", + ] + if normalized_expected_names: + detail_parts.append(f"expected_names={normalized_expected_names!r}") + if expected_online_agents is not None: + detail_parts.append(f"expected_online_agents={expected_online_agents}") + if failures: + detail_parts.append("failures=" + "; ".join(failures)) + + return CheckResult( + name="agent-ledger-identity", + ok=not failures, + detail=", ".join(detail_parts), + ) + + +def run_rehearsal(args: argparse.Namespace) -> list[CheckResult]: + base_url = normalize_base_url(args.base_url) + release_base_url = normalize_base_url(args.release_base_url) + auth_headers = build_auth_headers(args) + results = [ + safe_check( + "agent-version-endpoint", + lambda: check_version(base_url, args.expected_version, args.timeout), + ), + safe_check( + "install-sh-asset", + lambda: compare_asset( + name="install-sh-asset", + live_url=f"{base_url}/install.sh", + release_url=release_asset_url(release_base_url, args.expected_version, "install.sh"), + timeout=args.timeout, + ), + ), + safe_check( + "install-ps1-asset", + lambda: compare_asset( + name="install-ps1-asset", + live_url=f"{base_url}/install.ps1", + release_url=release_asset_url(release_base_url, args.expected_version, "install.ps1"), + timeout=args.timeout, + ), + ), + ] + + for arch in args.arch: + quoted_arch = parse.quote(arch, safe="") + results.append( + safe_check( + f"agent-binary-{arch}", + lambda arch=arch, quoted_arch=quoted_arch: compare_asset( + name=f"agent-binary-{arch}", + live_url=f"{base_url}/download/pulse-agent?arch={quoted_arch}", + release_url=release_asset_url( + release_base_url, args.expected_version, agent_binary_asset_name(arch) + ), + timeout=args.timeout, + expect_checksum_header=True, + ), + ) + ) + + if args.update_info_dir or args.expected_updated_from: + if not args.update_info_dir or not args.expected_updated_from: + results.append( + CheckResult( + name="local-update-info", + ok=False, + detail="both --update-info-dir and --expected-updated-from are required together", + ) + ) + else: + results.append( + safe_check( + "local-update-info", + lambda: check_update_info(args.update_info_dir, args.expected_updated_from), + ) + ) + + if args.expected_active_agents is not None: + results.append( + safe_check( + "active-agent-accounting", + lambda: check_active_agent_accounting( + base_url=base_url, + timeout=args.timeout, + auth_headers=auth_headers, + expected_active_agents=args.expected_active_agents, + ), + ) + ) + + if args.expected_agent_name or args.expected_online_agents is not None: + results.append( + safe_check( + "agent-ledger-identity", + lambda: check_agent_ledger_identity( + base_url=base_url, + timeout=args.timeout, + auth_headers=auth_headers, + expected_agent_names=args.expected_agent_name, + expected_online_agents=args.expected_online_agents, + ), + ) + ) + + return results + + +def render_text(results: Iterable[CheckResult]) -> str: + lines: list[str] = [] + for result in results: + prefix = "PASS" if result.ok else "FAIL" + lines.append(f"[{prefix}] {result.name}: {result.detail}") + lines.append( + "Manual follow-up still required: confirm the upgraded v5-installed agent " + "reconnects as one canonical v6 identity, surfaces `updated_from` exactly once, " + "and leaves user-visible active-agent counts aligned with runtime enforcement." + ) + return "\n".join(lines) + + +def render_markdown_report( + *, + title: str, + base_url: str, + expected_version: str, + release_base_url: str, + results: Iterable[CheckResult], +) -> str: + lines = [ + f"# {title}", + "", + f"- Base URL: `{base_url}`", + f"- Expected version: `{expected_version}`", + f"- Release asset base: `{release_base_url}`", + "", + "## Automated Checks", + "", + ] + for result in results: + status = "PASS" if result.ok else "FAIL" + lines.append(f"- `{status}` `{result.name}`: {result.detail}") + lines.extend( + [ + "", + "## Manual Follow-up", + "", + "- Confirm the upgraded v5-installed agent reconnects as one canonical v6 identity.", + "- Confirm `updated_from` appears exactly once on the first canonical v6 report and clears on the next report.", + "- Confirm settings/billing active-agent counts still match runtime enforcement after the upgrade.", + ] + ) + return "\n".join(lines) + + +def write_report(path: str, content: str) -> None: + report_path = Path(path) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(content + "\n", encoding="utf-8") + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + results = run_rehearsal(args) + ok = all(result.ok for result in results) + + if args.report_out: + write_report( + args.report_out, + render_markdown_report( + title=args.report_title, + base_url=normalize_base_url(args.base_url), + expected_version=args.expected_version, + release_base_url=normalize_base_url(args.release_base_url), + results=results, + ), + ) + + if args.json: + print(json.dumps({"ok": ok, "results": [asdict(result) for result in results]}, indent=2)) + else: + print(render_text(results)) + + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/unified_agent_rc_rehearsal_test.py b/scripts/release_control/internal/unified_agent_rc_rehearsal_test.py new file mode 100644 index 000000000..649ea3092 --- /dev/null +++ b/scripts/release_control/internal/unified_agent_rc_rehearsal_test.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import contextlib +import io +import json +import tempfile +import threading +import unittest +from dataclasses import asdict +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from unified_agent_rc_rehearsal import ( + agent_binary_asset_name, + check_update_info, + main, + render_markdown_report, + release_asset_url, + run_rehearsal, + summarize_http_error_body, +) + + +def run_main(argv: list[str]) -> int: + with contextlib.redirect_stdout(io.StringIO()): + return main(argv) + + +class _FixtureHandler(BaseHTTPRequestHandler): + routes: dict[str, tuple[int, bytes, dict[str, str]]] = {} + request_headers: dict[str, dict[str, str]] = {} + + def do_GET(self) -> None: # noqa: N802 + self.request_headers[self.path] = { + key.lower(): value for key, value in self.headers.items() + } + status, body, headers = self.routes.get(self.path, (404, b"missing", {})) + self.send_response(status) + for key, value in headers.items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: # noqa: A003 + return + + +class UnifiedAgentRCRehearsalTest(unittest.TestCase): + def setUp(self) -> None: + self.server = ThreadingHTTPServer(("127.0.0.1", 0), _FixtureHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.base_url = f"http://127.0.0.1:{self.server.server_port}" + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + + def set_routes(self, routes: dict[str, tuple[int, bytes, dict[str, str]]]) -> None: + _FixtureHandler.routes = routes + _FixtureHandler.request_headers = {} + + def test_release_asset_url(self) -> None: + got = release_asset_url("https://example.invalid/releases/download/", "6.0.0-rc.1", "install.sh") + self.assertEqual(got, "https://example.invalid/releases/download/v6.0.0-rc.1/install.sh") + + def test_agent_binary_asset_name_windows(self) -> None: + self.assertEqual(agent_binary_asset_name("windows-amd64"), "pulse-agent-windows-amd64.exe") + self.assertEqual(agent_binary_asset_name("linux-amd64"), "pulse-agent-linux-amd64") + + def test_run_rehearsal_passes_with_matching_assets(self) -> None: + install = b"#!/bin/sh\necho install\n" + ps1 = b"Write-Output 'install'\n" + binary = b"pulse-agent-binary" + checksum = __import__("hashlib").sha256(binary).hexdigest() + + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, ps1, {}), + "/pulse/download/pulse-agent?arch=linux-amd64": ( + 200, + binary, + {"X-Checksum-Sha256": checksum, "X-Served-From": "github-fallback"}, + ), + "/releases/v6.0.0-rc.1/install.sh": (200, install, {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}), + "/releases/v6.0.0-rc.1/pulse-agent-linux-amd64": (200, binary, {}), + } + ) + + args = type( + "Args", + (), + { + "base_url": f"{self.base_url}/pulse", + "expected_version": "6.0.0-rc.1", + "release_base_url": f"{self.base_url}/releases", + "arch": ["linux-amd64"], + "update_info_dir": None, + "expected_updated_from": None, + "timeout": 5.0, + "api_token": None, + "bearer_token": None, + "cookie": None, + "expected_active_agents": None, + "expected_agent_name": [], + "expected_online_agents": None, + "json": False, + }, + )() + results = run_rehearsal(args) + self.assertTrue(all(result.ok for result in results), [asdict(result) for result in results]) + + def test_run_rehearsal_fails_on_binary_checksum_header_mismatch(self) -> None: + install = b"#!/bin/sh\n" + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, b"ps1", {}), + "/pulse/download/pulse-agent?arch=linux-amd64": ( + 200, + b"binary", + {"X-Checksum-Sha256": "bad"}, + ), + "/releases/v6.0.0-rc.1/install.sh": (200, install, {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, b"ps1", {}), + "/releases/v6.0.0-rc.1/pulse-agent-linux-amd64": (200, b"binary", {}), + } + ) + + exit_code = run_main( + [ + "--base-url", + f"{self.base_url}/pulse", + "--expected-version", + "6.0.0-rc.1", + "--release-base-url", + f"{self.base_url}/releases", + "--arch", + "linux-amd64", + "--json", + ] + ) + self.assertEqual(exit_code, 1) + + def test_run_rehearsal_reports_missing_release_asset_without_crashing(self) -> None: + install = b"#!/bin/sh\n" + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, b"ps1", {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, b"ps1", {}), + } + ) + + exit_code = run_main( + [ + "--base-url", + f"{self.base_url}/pulse", + "--expected-version", + "6.0.0-rc.1", + "--release-base-url", + f"{self.base_url}/releases", + "--json", + ] + ) + self.assertEqual(exit_code, 1) + + def test_summarize_http_error_body_omits_html_page(self) -> None: + body = b"\nmissing" + self.assertEqual(summarize_http_error_body(body), "") + + def test_check_update_info(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + info_path = Path(tmp) / ".pulse-update-info" + info_path.write_text("5.1.14\n", encoding="utf-8") + result = check_update_info(tmp, "5.1.14") + self.assertTrue(result.ok) + + def test_check_update_info_requires_matching_content(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + info_path = Path(tmp) / ".pulse-update-info" + info_path.write_text("5.1.13\n", encoding="utf-8") + result = check_update_info(tmp, "5.1.14") + self.assertFalse(result.ok) + + def test_render_markdown_report(self) -> None: + report = render_markdown_report( + title="Unified Agent RC Rehearsal", + base_url="https://pulse.example.com", + expected_version="6.0.0-rc.1", + release_base_url="https://github.com/example/releases/download", + results=[ + type("R", (), {"name": "agent-version-endpoint", "ok": True, "detail": "matched"})(), + type("R", (), {"name": "agent-binary-linux-amd64", "ok": False, "detail": "checksum mismatch"})(), + ], + ) + self.assertIn("# Unified Agent RC Rehearsal", report) + self.assertIn("`PASS` `agent-version-endpoint`", report) + self.assertIn("`FAIL` `agent-binary-linux-amd64`", report) + self.assertIn("## Manual Follow-up", report) + + def test_main_writes_report(self) -> None: + install = b"#!/bin/sh\necho install\n" + ps1 = b"Write-Output 'install'\n" + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, ps1, {}), + "/releases/v6.0.0-rc.1/install.sh": (200, install, {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}), + } + ) + with tempfile.TemporaryDirectory() as tmp: + report_path = Path(tmp) / "report.md" + exit_code = main( + [ + "--base-url", + f"{self.base_url}/pulse", + "--expected-version", + "6.0.0-rc.1", + "--release-base-url", + f"{self.base_url}/releases", + "--report-out", + str(report_path), + ] + ) + self.assertEqual(exit_code, 0) + content = report_path.read_text(encoding="utf-8") + self.assertIn("# Unified Agent RC Rehearsal", content) + self.assertIn("`PASS` `install-sh-asset`", content) + + def test_run_rehearsal_verifies_active_agent_accounting_via_api_token(self) -> None: + install = b"#!/bin/sh\necho install\n" + ps1 = b"Write-Output 'install'\n" + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, ps1, {}), + "/pulse/api/license/entitlements": ( + 200, + json.dumps( + { + "limits": [ + {"key": "max_agents", "limit": 10, "current": 3, "state": "ok"} + ] + } + ).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/api/license/agent-ledger": ( + 200, + json.dumps( + { + "agents": [{"name": "a"}, {"name": "b"}, {"name": "c"}], + "total": 3, + "limit": 10, + } + ).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/releases/v6.0.0-rc.1/install.sh": (200, install, {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}), + } + ) + + exit_code = run_main( + [ + "--base-url", + f"{self.base_url}/pulse", + "--expected-version", + "6.0.0-rc.1", + "--release-base-url", + f"{self.base_url}/releases", + "--api-token", + "token-123", + "--expected-active-agents", + "3", + "--json", + ] + ) + self.assertEqual(exit_code, 0) + self.assertEqual( + _FixtureHandler.request_headers["/pulse/api/license/entitlements"].get("x-api-token"), + "token-123", + ) + self.assertEqual( + _FixtureHandler.request_headers["/pulse/api/license/agent-ledger"].get("x-api-token"), + "token-123", + ) + + def test_run_rehearsal_fails_when_active_agent_accounting_mismatches(self) -> None: + install = b"#!/bin/sh\necho install\n" + ps1 = b"Write-Output 'install'\n" + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, ps1, {}), + "/pulse/api/license/entitlements": ( + 200, + json.dumps( + { + "limits": [ + {"key": "max_agents", "limit": 10, "current": 2, "state": "ok"} + ] + } + ).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/api/license/agent-ledger": ( + 200, + json.dumps( + { + "agents": [{"name": "a"}, {"name": "b"}, {"name": "c"}], + "total": 3, + "limit": 10, + } + ).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/releases/v6.0.0-rc.1/install.sh": (200, install, {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}), + } + ) + + exit_code = run_main( + [ + "--base-url", + f"{self.base_url}/pulse", + "--expected-version", + "6.0.0-rc.1", + "--release-base-url", + f"{self.base_url}/releases", + "--api-token", + "token-123", + "--expected-active-agents", + "3", + "--json", + ] + ) + self.assertEqual(exit_code, 1) + + def test_run_rehearsal_requires_auth_for_active_agent_accounting(self) -> None: + install = b"#!/bin/sh\necho install\n" + ps1 = b"Write-Output 'install'\n" + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, ps1, {}), + "/releases/v6.0.0-rc.1/install.sh": (200, install, {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}), + } + ) + + exit_code = run_main( + [ + "--base-url", + f"{self.base_url}/pulse", + "--expected-version", + "6.0.0-rc.1", + "--release-base-url", + f"{self.base_url}/releases", + "--expected-active-agents", + "3", + "--json", + ] + ) + self.assertEqual(exit_code, 1) + + def test_run_rehearsal_verifies_expected_agent_name_and_online_status(self) -> None: + install = b"#!/bin/sh\necho install\n" + ps1 = b"Write-Output 'install'\n" + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, ps1, {}), + "/pulse/api/license/agent-ledger": ( + 200, + json.dumps( + { + "agents": [ + { + "name": "workstation-01", + "type": "agent", + "status": "online", + "last_seen": "2026-03-12T12:00:00Z", + "source": "agent", + } + ], + "total": 1, + "limit": 10, + } + ).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/releases/v6.0.0-rc.1/install.sh": (200, install, {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}), + } + ) + + exit_code = run_main( + [ + "--base-url", + f"{self.base_url}/pulse", + "--expected-version", + "6.0.0-rc.1", + "--release-base-url", + f"{self.base_url}/releases", + "--api-token", + "token-123", + "--expected-agent-name", + "workstation-01", + "--expected-online-agents", + "1", + "--json", + ] + ) + self.assertEqual(exit_code, 0) + + def test_run_rehearsal_fails_when_expected_agent_name_is_duplicated(self) -> None: + install = b"#!/bin/sh\necho install\n" + ps1 = b"Write-Output 'install'\n" + self.set_routes( + { + "/pulse/api/agent/version": ( + 200, + json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/pulse/install.sh": (200, install, {}), + "/pulse/install.ps1": (200, ps1, {}), + "/pulse/api/license/agent-ledger": ( + 200, + json.dumps( + { + "agents": [ + {"name": "workstation-01", "status": "online"}, + {"name": "workstation-01", "status": "online"}, + ], + "total": 2, + "limit": 10, + } + ).encode("utf-8"), + {"Content-Type": "application/json"}, + ), + "/releases/v6.0.0-rc.1/install.sh": (200, install, {}), + "/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}), + } + ) + + exit_code = run_main( + [ + "--base-url", + f"{self.base_url}/pulse", + "--expected-version", + "6.0.0-rc.1", + "--release-base-url", + f"{self.base_url}/releases", + "--api-token", + "token-123", + "--expected-agent-name", + "workstation-01", + "--json", + ] + ) + self.assertEqual(exit_code, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/verify_commit_slice.py b/scripts/release_control/internal/verify_commit_slice.py new file mode 100644 index 000000000..67be06dea --- /dev/null +++ b/scripts/release_control/internal/verify_commit_slice.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Run pre-commit against a proposed commit slice in an isolated git index.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import subprocess +import sys +import tempfile +from typing import Iterable + + +REPO_ROOT = Path(__file__).resolve().parents[2] +HOOK_PATH = REPO_ROOT / ".husky" / "pre-commit" + + +def git_env(index_path: Path) -> dict[str, str]: + env = os.environ.copy() + env["GIT_INDEX_FILE"] = str(index_path) + return env + + +def repo_relative_path(path: str | Path) -> str: + candidate = Path(path) + if candidate.is_absolute(): + candidate = candidate.relative_to(REPO_ROOT) + return candidate.as_posix() + + +def git(index_path: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + check=check, + capture_output=True, + text=True, + env=git_env(index_path), + ) + + +def stage_paths(index_path: Path, paths: Iterable[str]) -> None: + normalized = [repo_relative_path(path) for path in paths] + if not normalized: + return + git(index_path, "add", "-f", "--", *normalized) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Verify a proposed commit slice by running .husky/pre-commit in a copied index." + ) + parser.add_argument( + "--add-updated", + action="store_true", + help="Stage all tracked modifications (`git add -u`) into the copied index before explicit paths.", + ) + parser.add_argument( + "--show-staged", + action="store_true", + help="Print the staged file list in the copied index before running the hook.", + ) + parser.add_argument( + "paths", + nargs="*", + help="Explicit repo-relative or absolute paths to stage into the copied index.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(list(argv or sys.argv[1:])) + with tempfile.NamedTemporaryFile(prefix="pulse-index.", delete=True) as tmp_index: + index_path = Path(tmp_index.name) + git(index_path, "read-tree", "HEAD") + if args.add_updated: + git(index_path, "add", "-u") + stage_paths(index_path, args.paths) + + if args.show_staged: + staged = git(index_path, "diff", "--cached", "--name-only").stdout.strip() + if staged: + print(staged) + + result = subprocess.run( + [str(HOOK_PATH)], + cwd=REPO_ROOT, + env=git_env(index_path), + text=True, + ) + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release_control/internal/verify_commit_slice_test.py b/scripts/release_control/internal/verify_commit_slice_test.py new file mode 100644 index 000000000..c8ef9d2e5 --- /dev/null +++ b/scripts/release_control/internal/verify_commit_slice_test.py @@ -0,0 +1,100 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from verify_commit_slice import main, repo_relative_path + + +class VerifyCommitSliceTest(unittest.TestCase): + def git(self, repo_root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess: + env = os.environ.copy() + env.pop("GIT_INDEX_FILE", None) + return subprocess.run( + ["git", *args], + cwd=repo_root, + check=check, + capture_output=True, + text=True, + env=env, + ) + + def init_repo(self, repo_root: Path) -> None: + self.git(repo_root, "init") + self.git(repo_root, "config", "user.name", "Pulse Test") + self.git(repo_root, "config", "user.email", "pulse-test@example.com") + + def test_repo_relative_path_normalizes_absolute_paths(self) -> None: + repo_root = Path("/tmp/pulse") + with patch("verify_commit_slice.REPO_ROOT", repo_root): + self.assertEqual(repo_relative_path(repo_root / "docs/file.txt"), "docs/file.txt") + + def test_main_stages_requested_paths_in_isolated_index(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_root = Path(tmpdir) + hook = repo_root / ".husky" / "pre-commit" + tracked = repo_root / "tracked.txt" + explicit = repo_root / "explicit.txt" + ignored = repo_root / "ignored.txt" + + hook.parent.mkdir(parents=True, exist_ok=True) + tracked.write_text("tracked\n", encoding="utf-8") + explicit.write_text("explicit\n", encoding="utf-8") + ignored.write_text("ignored\n", encoding="utf-8") + (repo_root / ".gitignore").write_text("ignored.txt\n", encoding="utf-8") + hook.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "git diff --cached --name-only | sort > slice.out\n", + encoding="utf-8", + ) + hook.chmod(0o755) + + self.init_repo(repo_root) + self.git(repo_root, "add", ".gitignore", "tracked.txt", ".husky/pre-commit") + self.git(repo_root, "commit", "-m", "initial") + + tracked.write_text("tracked updated\n", encoding="utf-8") + + with patch("verify_commit_slice.REPO_ROOT", repo_root), patch( + "verify_commit_slice.HOOK_PATH", hook + ): + self.assertEqual( + main(["--add-updated", "explicit.txt", "ignored.txt"]), + 0, + ) + + self.assertEqual( + (repo_root / "slice.out").read_text(encoding="utf-8").splitlines(), + ["explicit.txt", "ignored.txt", "tracked.txt"], + ) + live_index = self.git(repo_root, "diff", "--cached", "--name-only").stdout.strip() + self.assertEqual(live_index, "") + + def test_main_propagates_hook_failure(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_root = Path(tmpdir) + hook = repo_root / ".husky" / "pre-commit" + tracked = repo_root / "tracked.txt" + + hook.parent.mkdir(parents=True, exist_ok=True) + tracked.write_text("tracked\n", encoding="utf-8") + hook.write_text("#!/usr/bin/env bash\nexit 7\n", encoding="utf-8") + hook.chmod(0o755) + + self.init_repo(repo_root) + self.git(repo_root, "add", "tracked.txt", ".husky/pre-commit") + self.git(repo_root, "commit", "--no-verify", "-m", "initial") + + tracked.write_text("tracked updated\n", encoding="utf-8") + + with patch("verify_commit_slice.REPO_ROOT", repo_root), patch( + "verify_commit_slice.HOOK_PATH", hook + ): + self.assertEqual(main(["--add-updated"]), 7) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/work_claim.py b/scripts/release_control/internal/work_claim.py new file mode 100644 index 000000000..e2619804e --- /dev/null +++ b/scripts/release_control/internal/work_claim.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Create or reserve governed work claims against the active Pulse profile.""" + +from __future__ import annotations + +import argparse +from copy import deepcopy +from datetime import datetime, timedelta, timezone +import json +import re +import sys +from pathlib import Path +from typing import Any + +from control_plane import DEFAULT_CONTROL_PLANE +from status_audit import audit_status_payload, load_status_payload, status_schema_contract + + +STATUS_PATH = Path(DEFAULT_CONTROL_PLANE["status_path"]) + + +def _slug(value: str) -> str: + compact = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()) + compact = compact.strip("-") + return compact or "work" + + +def _claim_sort_key(claim: dict[str, Any]) -> tuple[str, str]: + return (str(claim["claimed_at"]), str(claim["id"]).casefold()) + + +def build_work_claim( + *, + work_kind: str, + work_id: str, + summary: str, + agent_id: str, + target_id: str, + now_utc: datetime, + duration_hours: int, + claim_id: str | None = None, +) -> dict[str, Any]: + claimed_at = now_utc.replace(microsecond=0) + expires_at = claimed_at + timedelta(hours=duration_hours) + claim_id = claim_id or f"claim-{_slug(work_kind)}-{_slug(work_id)}-{claimed_at.strftime('%Y%m%d%H%M%S')}" + timestamp = claimed_at.isoformat().replace("+00:00", "Z") + return { + "id": claim_id, + "agent_id": agent_id, + "summary": summary, + "target_id": target_id, + "claimed_at": timestamp, + "heartbeat_at": timestamp, + "expires_at": expires_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "work_item": { + "kind": work_kind, + "id": work_id, + }, + } + + +def apply_claim( + payload: dict[str, Any], + claim: dict[str, Any], + *, + replace_claim_ids: list[str], +) -> dict[str, Any]: + updated = deepcopy(payload) + raw_claims = list(updated.get("work_claims", [])) + raw_claims = [entry for entry in raw_claims if str(entry.get("id", "")) not in set(replace_claim_ids)] + raw_claims.append(claim) + raw_claims.sort(key=_claim_sort_key) + updated["work_claims"] = raw_claims + return updated + + +def reserve_claim( + *, + payload: dict[str, Any], + work_kind: str, + work_id: str, + summary: str, + agent_id: str, + target_id: str | None, + duration_hours: int, + claim_id: str | None, + replace_claim_ids: list[str], + now_utc: datetime | None = None, +) -> tuple[dict[str, Any], dict[str, Any], list[str]]: + baseline_report = audit_status_payload(payload, schema_contract=status_schema_contract()) + baseline_errors = set(str(error) for error in baseline_report.get("errors", [])) + now_utc = now_utc or datetime.now(timezone.utc) + target_id = target_id or str(DEFAULT_CONTROL_PLANE["active_target_id"]) + claim = build_work_claim( + work_kind=work_kind, + work_id=work_id, + summary=summary, + agent_id=agent_id, + target_id=target_id, + now_utc=now_utc, + duration_hours=duration_hours, + claim_id=claim_id, + ) + updated_payload = apply_claim(payload, claim, replace_claim_ids=replace_claim_ids) + updated_report = audit_status_payload(updated_payload, schema_contract=status_schema_contract()) + new_errors = [str(error) for error in updated_report.get("errors", []) if str(error) not in baseline_errors] + return claim, updated_payload, new_errors + + +def write_status_payload(payload: dict[str, Any], *, path: Path = STATUS_PATH) -> None: + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Create or reserve a governed work claim.") + parser.add_argument("--kind", required=True, help="Work item kind, such as lane or release-gate.") + parser.add_argument("--id", required=True, help="Work item id, such as L14.") + parser.add_argument("--summary", required=True, help="Short human summary for the claim.") + parser.add_argument("--agent-id", required=True, help="Stable agent identifier recorded in the claim.") + parser.add_argument("--target-id", help="Override the target id; defaults to the active target.") + parser.add_argument("--claim-id", help="Override the generated claim id.") + parser.add_argument( + "--duration-hours", + type=int, + default=2, + help="Claim duration before expiry. Defaults to 2 hours.", + ) + parser.add_argument( + "--replace-claim-id", + action="append", + default=[], + help="Existing claim id to replace in the same write.", + ) + parser.add_argument( + "--write", + action="store_true", + help="Write the updated work_claims list back to status.json when validation passes.", + ) + parser.add_argument( + "--pretty", + action="store_true", + help="Render a concise human summary instead of JSON.", + ) + return parser.parse_args(argv) + + +def render_pretty(claim: dict[str, Any], errors: list[str], *, wrote: bool) -> str: + lines = [ + "work_claim:", + f" id={claim['id']}", + f" target={claim['target_id']}", + f" work={claim['work_item']['kind']}:{claim['work_item']['id']}", + f" agent={claim['agent_id']}", + f" expires_at={claim['expires_at']}", + f" wrote={'yes' if wrote else 'no'}", + ] + if errors: + lines.append("errors:") + for error in errors: + lines.append(f" - {error}") + else: + lines.append("status: ready") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(list(argv or [])) + payload = load_status_payload() + claim, updated_payload, errors = reserve_claim( + payload=payload, + work_kind=args.kind, + work_id=args.id, + summary=args.summary, + agent_id=args.agent_id, + target_id=args.target_id, + duration_hours=args.duration_hours, + claim_id=args.claim_id, + replace_claim_ids=list(args.replace_claim_id), + ) + wrote = False + if not errors and args.write: + write_status_payload(updated_payload) + wrote = True + + if args.pretty: + print(render_pretty(claim, errors, wrote=wrote)) + else: + print( + json.dumps( + { + "claim": claim, + "errors": errors, + "wrote": wrote, + }, + indent=2, + sort_keys=True, + ) + ) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/release_control/internal/work_claim_test.py b/scripts/release_control/internal/work_claim_test.py new file mode 100644 index 000000000..4a3df4401 --- /dev/null +++ b/scripts/release_control/internal/work_claim_test.py @@ -0,0 +1,98 @@ +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from work_claim import apply_claim, build_work_claim, parse_args, reserve_claim + + +class WorkClaimTest(unittest.TestCase): + def test_parse_args_accepts_write_and_replace(self) -> None: + args = parse_args( + [ + "--kind", + "lane", + "--id", + "L14", + "--summary", + "Tighten trust proof routing.", + "--agent-id", + "codex-gpt5", + "--replace-claim-id", + "claim-old", + "--write", + "--pretty", + ] + ) + self.assertEqual(args.kind, "lane") + self.assertEqual(args.id, "L14") + self.assertEqual(args.replace_claim_id, ["claim-old"]) + self.assertTrue(args.write) + self.assertTrue(args.pretty) + + def test_build_work_claim_uses_active_target_shape(self) -> None: + claim = build_work_claim( + work_kind="lane", + work_id="L14", + summary="Tighten trust proof routing.", + agent_id="codex-gpt5", + target_id="v6-rc-stabilization", + now_utc=datetime(2026, 3, 13, 18, 0, 0, tzinfo=timezone.utc), + duration_hours=2, + ) + self.assertEqual(claim["target_id"], "v6-rc-stabilization") + self.assertEqual(claim["work_item"], {"kind": "lane", "id": "L14"}) + self.assertEqual(claim["claimed_at"], "2026-03-13T18:00:00Z") + self.assertEqual(claim["heartbeat_at"], "2026-03-13T18:00:00Z") + self.assertEqual(claim["expires_at"], "2026-03-13T20:00:00Z") + + def test_apply_claim_replaces_and_sorts(self) -> None: + payload = { + "work_claims": [ + { + "id": "claim-b", + "claimed_at": "2026-03-13T10:00:00Z", + }, + { + "id": "claim-a", + "claimed_at": "2026-03-13T09:00:00Z", + }, + ] + } + claim = { + "id": "claim-c", + "claimed_at": "2026-03-13T08:00:00Z", + } + updated = apply_claim(payload, claim, replace_claim_ids=["claim-b"]) + self.assertEqual([entry["id"] for entry in updated["work_claims"]], ["claim-c", "claim-a"]) + + def test_reserve_claim_reports_new_audit_errors_only(self) -> None: + payload = {"work_claims": [{"id": "claim-existing", "claimed_at": "2026-03-13T17:00:00Z"}]} + baseline_report = {"errors": ["unchanged baseline error"]} + updated_report = { + "errors": [ + "unchanged baseline error", + "active work claims ['claim-existing', 'claim-lane-l14-20260313180000'] overlap on lane:L14", + ] + } + with patch("work_claim.audit_status_payload", side_effect=[baseline_report, updated_report]): + claim, _updated, errors = reserve_claim( + payload=payload, + work_kind="lane", + work_id="L14", + summary="Tighten trust proof routing.", + agent_id="codex-gpt5", + target_id="v6-rc-stabilization", + duration_hours=2, + claim_id="claim-lane-l14-20260313180000", + replace_claim_ids=[], + now_utc=datetime(2026, 3, 13, 18, 0, 0, tzinfo=timezone.utc), + ) + self.assertEqual(claim["id"], "claim-lane-l14-20260313180000") + self.assertEqual( + errors, + ["active work claims ['claim-existing', 'claim-lane-l14-20260313180000'] overlap on lane:L14"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/worktree_base.py b/scripts/release_control/internal/worktree_base.py new file mode 100644 index 000000000..8184880e1 --- /dev/null +++ b/scripts/release_control/internal/worktree_base.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Ensure the canonical clean landing worktree exists for a base branch.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys + +from repo_file_io import REPO_ROOT +from worktree_claim import WORKTREES_ROOT, list_worktrees + + +def git(*args: str, cwd: Path, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=check, + capture_output=True, + text=True, + ) + + +def base_slug(branch_name: str) -> str: + return "base__" + branch_name.replace("/", "__") + + +def canonical_base_worktree_path(*, repo_root: Path, branch_name: str) -> Path: + return WORKTREES_ROOT / repo_root.name / base_slug(branch_name) + + +def find_worktree_by_path(*, repo_root: Path, path: Path) -> dict[str, str] | None: + target = path.resolve() + for entry in list_worktrees(repo_root=repo_root): + if Path(entry.get("worktree", "")).resolve() == target: + return entry + return None + + +def is_clean_worktree(*, repo_root: Path) -> bool: + return not git("status", "--porcelain", cwd=repo_root).stdout.strip() + + +def create_base_worktree(*, repo_root: Path, branch_name: str, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["git", "worktree", "add", str(path), branch_name], + cwd=repo_root, + check=True, + ) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Ensure the canonical clean landing worktree exists for a base branch." + ) + parser.add_argument( + "--base-branch", + default="pulse/v6", + help="Base branch that should own the canonical landing worktree. Defaults to pulse/v6.", + ) + parser.add_argument( + "--write", + action="store_true", + help="Create the canonical landing worktree if it does not exist and validation passes.", + ) + parser.add_argument( + "--pretty", + action="store_true", + help="Render a concise human summary instead of JSON.", + ) + return parser.parse_args(argv) + + +def render_pretty(*, branch_name: str, path: Path, existed: bool, clean: bool, errors: list[str], wrote: bool) -> str: + lines = [ + "worktree_base:", + f" base_branch={branch_name}", + f" path={path}", + f" existed={'yes' if existed else 'no'}", + f" clean={'yes' if clean else 'no'}", + f" wrote={'yes' if wrote else 'no'}", + ] + if errors: + lines.append("errors:") + for error in errors: + lines.append(f" - {error}") + else: + lines.append("status: ready") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(list(argv or [])) + path = canonical_base_worktree_path(repo_root=REPO_ROOT, branch_name=args.base_branch) + entry = find_worktree_by_path(repo_root=REPO_ROOT, path=path) + errors: list[str] = [] + existed = entry is not None + clean = False + + if entry is not None: + branch_ref = entry.get("branch", "") + if branch_ref != f"refs/heads/{args.base_branch}": + errors.append(f"canonical landing worktree is not on {args.base_branch}: {branch_ref or 'detached'}") + clean = is_clean_worktree(repo_root=path) + if not clean: + errors.append(f"canonical landing worktree is dirty: {path}") + elif path.exists(): + errors.append(f"canonical landing path exists on disk but is not a registered worktree: {path}") + + wrote = False + if not errors and not existed and args.write: + create_base_worktree(repo_root=REPO_ROOT, branch_name=args.base_branch, path=path) + wrote = True + existed = True + clean = is_clean_worktree(repo_root=path) + + if args.pretty: + print(render_pretty(branch_name=args.base_branch, path=path, existed=existed, clean=clean, errors=errors, wrote=wrote)) + else: + print( + json.dumps( + { + "base_branch": args.base_branch, + "path": str(path), + "existed": existed, + "clean": clean, + "errors": errors, + "wrote": wrote, + }, + indent=2, + sort_keys=True, + ) + ) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/release_control/internal/worktree_base_test.py b/scripts/release_control/internal/worktree_base_test.py new file mode 100644 index 000000000..66cbabf43 --- /dev/null +++ b/scripts/release_control/internal/worktree_base_test.py @@ -0,0 +1,27 @@ +import tempfile +import unittest +from pathlib import Path + +from worktree_base import base_slug, canonical_base_worktree_path, parse_args + + +class WorktreeBaseTest(unittest.TestCase): + def test_parse_args_accepts_write(self) -> None: + args = parse_args(["--base-branch", "pulse/v6", "--write", "--pretty"]) + self.assertEqual(args.base_branch, "pulse/v6") + self.assertTrue(args.write) + self.assertTrue(args.pretty) + + def test_base_slug_replaces_slashes(self) -> None: + self.assertEqual(base_slug("pulse/v6"), "base__pulse__v6") + + def test_canonical_base_worktree_path_uses_workspace_root(self) -> None: + path = canonical_base_worktree_path( + repo_root=Path("/Volumes/Development/pulse/repos/pulse"), + branch_name="pulse/v6", + ) + self.assertEqual(path, Path("/Volumes/Development/pulse/worktrees/pulse/base__pulse__v6")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/worktree_claim.py b/scripts/release_control/internal/worktree_claim.py new file mode 100644 index 000000000..a526ecf6e --- /dev/null +++ b/scripts/release_control/internal/worktree_claim.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Reserve a governed slice and create an isolated git worktree for it.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys +from typing import Any + +from repo_file_io import REPO_ROOT +from work_claim import reserve_claim, write_status_payload, _slug + + +WORKSPACE_ROOT = REPO_ROOT.parents[1] +WORKTREES_ROOT = WORKSPACE_ROOT / "worktrees" +DEFAULT_BASE_BRANCH = "pulse/v6" + + +def build_branch_name(*, agent_id: str, work_kind: str, work_id: str) -> str: + return f"pulse/{_slug(agent_id)}/{_slug(work_kind)}-{_slug(work_id)}" + + +def branch_path_slug(branch_name: str) -> str: + if branch_name.startswith("pulse/"): + branch_name = branch_name[len("pulse/") :] + return branch_name.replace("/", "__") + + +def build_worktree_path(*, repo_root: Path, branch_name: str) -> Path: + return WORKTREES_ROOT / repo_root.name / branch_path_slug(branch_name) + + +def parse_worktree_list(output: str) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + current: dict[str, str] = {} + for line in output.splitlines(): + if not line.strip(): + if current: + entries.append(current) + current = {} + continue + key, _, value = line.partition(" ") + current[key] = value + if current: + entries.append(current) + return entries + + +def list_worktrees(*, repo_root: Path = REPO_ROOT) -> list[dict[str, str]]: + result = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return parse_worktree_list(result.stdout) + + +def validate_worktree_target(*, repo_root: Path, branch_name: str, path: Path) -> list[str]: + errors: list[str] = [] + path = path.resolve() + for entry in list_worktrees(repo_root=repo_root): + existing_path = Path(entry.get("worktree", "")).resolve() + branch_ref = entry.get("branch", "") + if existing_path == path: + errors.append(f"worktree path already exists in git worktree list: {path}") + if branch_ref == f"refs/heads/{branch_name}": + errors.append(f"branch already checked out in another worktree: {branch_name}") + if path.exists(): + errors.append(f"worktree path already exists on disk: {path}") + return errors + + +def create_worktree(*, repo_root: Path, path: Path, branch_name: str, base_branch: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["git", "worktree", "add", "-b", branch_name, str(path), base_branch], + cwd=repo_root, + check=True, + ) + + +def render_pretty(*, claim: dict[str, Any], branch_name: str, path: Path, errors: list[str], wrote: bool) -> str: + lines = [ + "worktree_claim:", + f" claim_id={claim['id']}", + f" target={claim['target_id']}", + f" work={claim['work_item']['kind']}:{claim['work_item']['id']}", + f" agent={claim['agent_id']}", + f" branch={branch_name}", + f" path={path}", + f" wrote={'yes' if wrote else 'no'}", + ] + if errors: + lines.append("errors:") + for error in errors: + lines.append(f" - {error}") + else: + lines.append("status: ready") + return "\n".join(lines) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Reserve a governed slice and create an isolated git worktree for it." + ) + parser.add_argument("--kind", required=True, help="Work item kind, such as lane or release-gate.") + parser.add_argument("--id", required=True, help="Work item id, such as L15.") + parser.add_argument("--summary", required=True, help="Short human summary for the claim.") + parser.add_argument("--agent-id", required=True, help="Stable agent identifier recorded in the claim.") + parser.add_argument("--target-id", help="Override the target id; defaults to the active target.") + parser.add_argument("--claim-id", help="Override the generated claim id.") + parser.add_argument("--branch", help="Override the generated branch name.") + parser.add_argument("--path", help="Override the generated worktree path.") + parser.add_argument("--base-branch", default=DEFAULT_BASE_BRANCH, help="Base branch for the new worktree.") + parser.add_argument( + "--duration-hours", + type=int, + default=2, + help="Claim duration before expiry. Defaults to 2 hours.", + ) + parser.add_argument( + "--replace-claim-id", + action="append", + default=[], + help="Existing claim id to replace in the same write.", + ) + parser.add_argument( + "--write", + action="store_true", + help="Write the updated work_claims list and create the worktree when validation passes.", + ) + parser.add_argument( + "--pretty", + action="store_true", + help="Render a concise human summary instead of JSON.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(list(argv or [])) + from status_audit import load_status_payload + + payload = load_status_payload() + claim, updated_payload, errors = reserve_claim( + payload=payload, + work_kind=args.kind, + work_id=args.id, + summary=args.summary, + agent_id=args.agent_id, + target_id=args.target_id, + duration_hours=args.duration_hours, + claim_id=args.claim_id, + replace_claim_ids=list(args.replace_claim_id), + ) + branch_name = args.branch or build_branch_name( + agent_id=args.agent_id, + work_kind=args.kind, + work_id=args.id, + ) + path = Path(args.path) if args.path else build_worktree_path(repo_root=REPO_ROOT, branch_name=branch_name) + errors.extend(validate_worktree_target(repo_root=REPO_ROOT, branch_name=branch_name, path=path)) + + wrote = False + if not errors and args.write: + write_status_payload(updated_payload) + create_worktree(repo_root=REPO_ROOT, path=path, branch_name=branch_name, base_branch=args.base_branch) + wrote = True + + if args.pretty: + print(render_pretty(claim=claim, branch_name=branch_name, path=path, errors=errors, wrote=wrote)) + else: + print( + json.dumps( + { + "claim": claim, + "branch": branch_name, + "path": str(path), + "errors": errors, + "wrote": wrote, + }, + indent=2, + sort_keys=True, + ) + ) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/release_control/internal/worktree_claim_test.py b/scripts/release_control/internal/worktree_claim_test.py new file mode 100644 index 000000000..fe161a480 --- /dev/null +++ b/scripts/release_control/internal/worktree_claim_test.py @@ -0,0 +1,100 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from worktree_claim import ( + branch_path_slug, + build_branch_name, + build_worktree_path, + parse_args, + parse_worktree_list, + validate_worktree_target, +) + + +class WorktreeClaimTest(unittest.TestCase): + def test_parse_args_accepts_write_and_overrides(self) -> None: + args = parse_args( + [ + "--kind", + "lane", + "--id", + "L15", + "--summary", + "Tighten storage recovery coherence.", + "--agent-id", + "claude-code", + "--branch", + "pulse/claude-code/lane-l15", + "--path", + "/tmp/pulse-l15", + "--write", + "--pretty", + ] + ) + self.assertEqual(args.kind, "lane") + self.assertEqual(args.id, "L15") + self.assertEqual(args.branch, "pulse/claude-code/lane-l15") + self.assertEqual(args.path, "/tmp/pulse-l15") + self.assertTrue(args.write) + self.assertTrue(args.pretty) + + def test_build_branch_name_uses_pulse_prefix(self) -> None: + self.assertEqual( + build_branch_name(agent_id="Claude Code", work_kind="lane", work_id="L15"), + "pulse/claude-code/lane-l15", + ) + + def test_build_worktree_path_uses_workspace_root(self) -> None: + path = build_worktree_path( + repo_root=Path("/Volumes/Development/pulse/repos/pulse"), + branch_name="pulse/claude-code/lane-l15", + ) + self.assertEqual( + path, + Path("/Volumes/Development/pulse/worktrees/pulse/claude-code__lane-l15"), + ) + + def test_branch_path_slug_drops_prefix_and_replaces_slashes(self) -> None: + self.assertEqual(branch_path_slug("pulse/codex-gpt5/lane-l16"), "codex-gpt5__lane-l16") + + def test_parse_worktree_list_reads_porcelain_records(self) -> None: + entries = parse_worktree_list( + "worktree /tmp/pulse-main\nHEAD abc123\nbranch refs/heads/pulse/v6\n\n" + "worktree /tmp/pulse-l15\nHEAD def456\nbranch refs/heads/pulse/claude/lane-l15\n\n" + ) + self.assertEqual(len(entries), 2) + self.assertEqual(entries[1]["worktree"], "/tmp/pulse-l15") + self.assertEqual(entries[1]["branch"], "refs/heads/pulse/claude/lane-l15") + + def test_validate_worktree_target_rejects_existing_branch_or_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + existing = Path(tmp) / "existing" + existing.mkdir() + with patch( + "worktree_claim.list_worktrees", + return_value=[ + { + "worktree": str(existing), + "branch": "refs/heads/pulse/claude-code/lane-l15", + } + ], + ): + errors = validate_worktree_target( + repo_root=Path("/Volumes/Development/pulse/repos/pulse"), + branch_name="pulse/claude-code/lane-l15", + path=existing, + ) + self.assertEqual( + errors, + [ + f"worktree path already exists in git worktree list: {existing.resolve()}", + "branch already checked out in another worktree: pulse/claude-code/lane-l15", + f"worktree path already exists on disk: {existing.resolve()}", + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/internal/worktree_finish.py b/scripts/release_control/internal/worktree_finish.py new file mode 100644 index 000000000..3d5898c33 --- /dev/null +++ b/scripts/release_control/internal/worktree_finish.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Land an isolated worktree's finished slice onto the base branch worktree.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys +from typing import Any + +from repo_file_io import REPO_ROOT +from worktree_base import canonical_base_worktree_path +from worktree_claim import list_worktrees + + +def git(*args: str, cwd: Path, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=check, + capture_output=True, + text=True, + ) + + +def current_branch(*, repo_root: Path) -> str: + return git("branch", "--show-current", cwd=repo_root).stdout.strip() + + +def current_head(*, repo_root: Path) -> str: + return git("rev-parse", "HEAD", cwd=repo_root).stdout.strip() + + +def is_clean_worktree(*, repo_root: Path) -> bool: + return not git("status", "--porcelain", cwd=repo_root).stdout.strip() + + +def branch_worktree_path(*, repo_root: Path, branch_name: str, preferred_path: Path | None = None) -> Path | None: + preferred = preferred_path.resolve() if preferred_path is not None else None + fallback: Path | None = None + for entry in list_worktrees(repo_root=repo_root): + if entry.get("branch") != f"refs/heads/{branch_name}": + continue + candidate = Path(entry["worktree"]).resolve() + if preferred is not None and candidate == preferred: + return candidate + if fallback is None: + fallback = candidate + return fallback + + +def commits_ahead_of_base(*, repo_root: Path, base_branch: str) -> list[str]: + output = git("rev-list", "--reverse", f"{base_branch}..HEAD", cwd=repo_root).stdout.strip() + return [line for line in output.splitlines() if line.strip()] + + +def cherry_pick_commits(*, base_worktree: Path, commits: list[str]) -> None: + for commit in commits: + subprocess.run(["git", "cherry-pick", commit], cwd=base_worktree, check=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Land an isolated worktree's finished slice onto the base branch worktree." + ) + parser.add_argument( + "--base-branch", + default="pulse/v6", + help="Base branch that receives the finished slice. Defaults to pulse/v6.", + ) + parser.add_argument( + "--write", + action="store_true", + help="Cherry-pick the finished commit(s) onto the base branch worktree when validation passes.", + ) + parser.add_argument( + "--pretty", + action="store_true", + help="Render a concise human summary instead of JSON.", + ) + return parser.parse_args(argv) + + +def render_pretty( + *, + current_worktree: Path, + current_branch_name: str, + base_branch: str, + base_worktree: Path | None, + commits: list[str], + errors: list[str], + wrote: bool, +) -> str: + lines = [ + "worktree_finish:", + f" current_worktree={current_worktree}", + f" current_branch={current_branch_name}", + f" base_branch={base_branch}", + f" base_worktree={base_worktree if base_worktree is not None else 'missing'}", + f" commit_count={len(commits)}", + f" wrote={'yes' if wrote else 'no'}", + ] + if commits: + lines.append("commits:") + for commit in commits: + lines.append(f" - {commit}") + if errors: + lines.append("errors:") + for error in errors: + lines.append(f" - {error}") + else: + lines.append("status: ready") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(list(argv or [])) + errors: list[str] = [] + current_worktree = REPO_ROOT.resolve() + current_branch_name = current_branch(repo_root=current_worktree) + preferred_base = canonical_base_worktree_path(repo_root=current_worktree, branch_name=args.base_branch) + base_worktree = branch_worktree_path( + repo_root=current_worktree, branch_name=args.base_branch, preferred_path=preferred_base + ) + + if not current_branch_name: + errors.append("current worktree is not on a branch") + if current_branch_name == args.base_branch: + errors.append("current worktree is already on the base branch") + if base_worktree is None: + errors.append( + f"no canonical landing worktree found for base branch {args.base_branch}; run worktree_base.py first" + ) + elif base_worktree == current_worktree: + errors.append("base branch worktree resolves to the current worktree") + elif base_worktree != preferred_base.resolve(): + errors.append( + f"base branch is checked out at {base_worktree}, not at canonical landing path {preferred_base.resolve()}" + ) + + commits: list[str] = [] + if not errors: + commits = commits_ahead_of_base(repo_root=current_worktree, base_branch=args.base_branch) + if not commits: + errors.append(f"no commits ahead of {args.base_branch} to land") + if not is_clean_worktree(repo_root=current_worktree): + errors.append("current worktree has uncommitted changes") + if base_worktree is not None and not is_clean_worktree(repo_root=base_worktree): + errors.append(f"base worktree is dirty: {base_worktree}") + + wrote = False + if not errors and args.write and base_worktree is not None: + cherry_pick_commits(base_worktree=base_worktree, commits=commits) + wrote = True + + if args.pretty: + print( + render_pretty( + current_worktree=current_worktree, + current_branch_name=current_branch_name, + base_branch=args.base_branch, + base_worktree=base_worktree, + commits=commits, + errors=errors, + wrote=wrote, + ) + ) + else: + print( + json.dumps( + { + "current_worktree": str(current_worktree), + "current_branch": current_branch_name, + "base_branch": args.base_branch, + "base_worktree": str(base_worktree) if base_worktree is not None else None, + "commits": commits, + "errors": errors, + "wrote": wrote, + }, + indent=2, + sort_keys=True, + ) + ) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/release_control/internal/worktree_finish_test.py b/scripts/release_control/internal/worktree_finish_test.py new file mode 100644 index 000000000..202afda40 --- /dev/null +++ b/scripts/release_control/internal/worktree_finish_test.py @@ -0,0 +1,47 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from worktree_finish import commits_ahead_of_base, parse_args, render_pretty + + +class WorktreeFinishTest(unittest.TestCase): + def test_parse_args_accepts_write_and_base_branch(self) -> None: + args = parse_args(["--base-branch", "pulse/v6", "--write", "--pretty"]) + self.assertEqual(args.base_branch, "pulse/v6") + self.assertTrue(args.write) + self.assertTrue(args.pretty) + + def test_commits_ahead_of_base_splits_rev_list_output(self) -> None: + with patch( + "worktree_finish.git", + return_value=type("Result", (), {"stdout": "abc123\ndef456\n"})(), + ): + self.assertEqual( + commits_ahead_of_base( + repo_root=Path("/Volumes/Development/pulse/repos/pulse"), + base_branch="pulse/v6", + ), + ["abc123", "def456"], + ) + + def test_render_pretty_includes_commit_count_and_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + worktree = Path(tmp) / "source" + base = Path(tmp) / "base" + output = render_pretty( + current_worktree=worktree, + current_branch_name="pulse/claude-code/lane-l15", + base_branch="pulse/v6", + base_worktree=base, + commits=["abc123"], + errors=["base worktree is dirty"], + wrote=False, + ) + self.assertIn("commit_count=1", output) + self.assertIn("base worktree is dirty", output) + + +if __name__ == "__main__": + unittest.main()