docs(release-control): track governed proof assets

This commit is contained in:
rcourtman
2026-03-27 11:59:43 +00:00
parent 86caf03197
commit 35bae3e62b
67 changed files with 9603 additions and 4 deletions
+11 -4
View File
@@ -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/
@@ -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.
@@ -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:
- `...`
- `...`
@@ -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.
@@ -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 <hosted-url> --signup-email <email> --org-name <org> ...`
- 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 <control-plane-url> --account-id <account> ...`
- 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.
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
@@ -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.
@@ -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/<tenant_id>/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.
@@ -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=<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.
@@ -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 <price_id>` 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.
@@ -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.
@@ -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`.
@@ -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.
@@ -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.
@@ -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 <id> 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.
@@ -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.
@@ -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=<deep-link>`.
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.
@@ -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.
@@ -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.
@@ -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 <session> --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.
@@ -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 <session> --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/<tenant_id>?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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
clients 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.
@@ -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.
@@ -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.
@@ -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 <redacted> --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.
@@ -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`.
@@ -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.
@@ -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
}
}
}
}
}
}
@@ -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())
@@ -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()
@@ -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())
@@ -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()
@@ -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 "<empty response>"
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())
@@ -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()
@@ -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())
@@ -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()
@@ -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 "<empty response>"
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())
@@ -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()
@@ -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())
@@ -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()
@@ -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())
@@ -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()
@@ -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 "<empty response>"
first_line = message.splitlines()[0].strip()
if first_line.startswith("<!DOCTYPE html") or first_line.startswith("<html"):
return "<html error page omitted>"
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 '<missing>'} 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())
@@ -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"<!DOCTYPE html>\n<html><body>missing</body></html>"
self.assertEqual(summarize_http_error_body(body), "<html error page omitted>")
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()
@@ -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())
@@ -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()
@@ -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:]))
@@ -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()
@@ -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:]))
@@ -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()
@@ -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:]))
@@ -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()
@@ -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:]))
@@ -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()