mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
docs(release-control): track governed proof assets
This commit is contained in:
+11
-4
@@ -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/
|
||||
|
||||
+234
@@ -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.
|
||||
+153
@@ -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.
|
||||
+112
@@ -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.
|
||||
+62
@@ -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.
|
||||
+46
@@ -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
|
||||
+66
@@ -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.
|
||||
+96
@@ -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.
|
||||
+75
@@ -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.
|
||||
+144
@@ -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.
|
||||
+144
@@ -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`.
|
||||
+75
@@ -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.
|
||||
+53
@@ -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.
|
||||
+62
@@ -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.
|
||||
+113
@@ -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.
|
||||
+84
@@ -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.
|
||||
+89
@@ -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.
|
||||
+54
@@ -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.
|
||||
+86
@@ -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.
|
||||
+82
@@ -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.
|
||||
+63
@@ -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.
|
||||
+50
@@ -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.
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Relay Registration Reconnect Drain Record
|
||||
|
||||
- Date: `2026-03-13`
|
||||
- Gate: `relay-registration-reconnect-drain`
|
||||
- Evidence tier: `managed-runtime-exercise`
|
||||
- Environment:
|
||||
- Pulse desktop repo: `/Volumes/Development/pulse/repos/pulse`
|
||||
- Relay server repo: `/Volumes/Development/pulse/repos/pulse-pro/relay-server`
|
||||
- Desktop relay runtime: `internal/relay`
|
||||
- Desktop relay onboarding/settings surfaces:
|
||||
- `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`
|
||||
- `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`
|
||||
- Mobile relay client repo: `/Volumes/Development/pulse/repos/pulse-mobile`
|
||||
|
||||
## Managed Runtime Exercise
|
||||
|
||||
- `go test ./internal/relay -run TestManagedRuntimeRelayRegistrationReconnectDrain -count=1`
|
||||
- `python3 scripts/release_control/relay_registration_reconnect_drain_proof.py`
|
||||
|
||||
The managed-runtime test builds and launches the real `pulse-pro/relay-server`
|
||||
binary, then drives it from the real `internal/relay` client with a real local
|
||||
HTTP backend and a real app-side WebSocket connection. It does not use the
|
||||
mock relay server used by the unit-level relay tests.
|
||||
|
||||
## Exercised Flow
|
||||
|
||||
1. Started a real `pulse-pro/relay-server` process with an ephemeral data dir
|
||||
and a generated Ed25519 public key for legacy relay license validation.
|
||||
2. Started the real desktop relay client against that server and waited for
|
||||
canonical registration to complete.
|
||||
3. Opened a real `/ws/app` WebSocket session and proxied a `/api/status`
|
||||
request through the relay to confirm the healthy baseline path.
|
||||
4. Killed the relay server abruptly and restarted it with the same data dir,
|
||||
then confirmed the desktop relay client reconnected and proxied traffic
|
||||
successfully again.
|
||||
5. Killed the relay server and restarted it with a fresh data dir, forcing the
|
||||
client’s cached session token to become stale. Confirmed the client logged
|
||||
`relay session resume rejected, retrying fresh registration`, cleared the
|
||||
stale resume path, re-registered cleanly, and proxied traffic successfully
|
||||
again.
|
||||
6. Opened a fresh app-side relay connection, sent an in-flight proxied request
|
||||
to a deliberately slow local endpoint, then terminated the relay server
|
||||
gracefully to trigger its drain path.
|
||||
7. Confirmed the desktop relay client logged `Relay server draining, will reconnect`,
|
||||
the in-flight local request was cancelled through the relay connection
|
||||
context, a replacement relay server process accepted the reconnect, and the
|
||||
client returned to `active_channels=0` before a final healthy proxy
|
||||
round-trip succeeded.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Fresh registration succeeded on the real relay server binary.
|
||||
- Normal reconnect after abrupt relay restart recovered cleanly.
|
||||
- Stale session resume was rejected and the client fell back to a fresh
|
||||
registration path instead of getting trapped in a dead session loop.
|
||||
- Server drain cancelled in-flight work predictably and the client reconnected
|
||||
to a replacement relay server without leaving a stuck active channel behind.
|
||||
- The relay client remained capable of proxying live traffic after each phase.
|
||||
|
||||
## Notes
|
||||
|
||||
- This record supersedes the earlier `2026-03-12` relay record for closure
|
||||
confidence because it exercises the real relay server binary rather than only
|
||||
the mock relay harness plus targeted runtime tests.
|
||||
- The older `2026-03-12` record still remains useful as lower-level automated
|
||||
pressure coverage, but it is no longer the strongest evidence for this gate.
|
||||
+86
@@ -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.
|
||||
+51
@@ -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.
|
||||
+42
@@ -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.
|
||||
+54
@@ -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`.
|
||||
+57
@@ -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())
|
||||
+89
@@ -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()
|
||||
Reference in New Issue
Block a user