Repository navigation
Releases: chiam-ck/bss-cli
Release list
v2.3.0 — light and dark appearance for both portals
Both the customer portal and operator cockpit now support System / Light / Dark appearance across all six brand palettes.
- System follows OS appearance; a saved browser choice applies before first paint and synchronizes across same-origin tabs.
- Light palettes, semantic alert surfaces, paired chat/button colors, and native form controls update together. Dynamically inserted HTMX/SSE content inherits the selected appearance.
- The appearance control is accessible in desktop and mobile navigation. Branding previews show both portal modes.
BSS_PORTAL_APPEARANCE=system|light|darksets the deployment default; explicit browser choices take precedence.
Existing branding IDs and dark email/CLI palettes remain unchanged. No database migration is required. Browser preferences are stored per origin, so separate portal hostnames/ports retain independent choices.
Validation: 552 Rust tests passed (48 existing live/opt-in tests ignored), formatting/clippy/doctrine checks passed, preference regressions and all 12 app/palette browser combinations (including danger-action contrast) passed, and actual customer/cockpit pages passed phone/tablet/desktop rendering checks. The full isolated hero suite passed three consecutive times: 19/19 scenarios per run (57 executions), including LLM/operator/customer flows and telemetry. GitHub CI passed on the final feature commit.
The troubleshooting hero prompt now explicitly requests the customer-scoped subscription lookup before selecting a top-up; its existing tool and state assertions are unchanged.
v2.2.0 — provider integrations and bundled demo setup
Real-provider signup works end to end, with a bundled development and public-demo setup. Resend delivery callbacks, Didit V3 verification, and restricted Stripe keys now work together through the customer portal; a hosted signup was verified through payment, completed order, and active subscription.
Highlights
- Signed Resend delivery webhooks.
/webhooks/resendverifies raw-body Svix signatures, stores redacted events idempotently, and returns a retryable response when storage is unavailable. - Didit V3 compatibility. Session and decision requests use V3 endpoints and plural identity results. Signature verification handles sorted Unicode JSON, with a full raw-body fallback; envelope-only signatures are refused. Attestation requires an approved callback and API decision with one complete approved identity document.
- Reliable, private KYC callback storage. Event and corroboration writes commit atomically. Only the envelope and digest are retained. Duplicate events cannot roll back status, and deliveries without event IDs use a stable content hash that ignores refreshed retry timestamps.
- Restricted Stripe API keys.
rk_test_andrk_live_work under the existing test/live mismatch, production-test-key, and live-card-reuse guards. Regression tests exercise the actual startup path. - Bundled demo commands.
scripts/devbrings together the CLI, services, PostgreSQL/pgvector, RabbitMQ, and Jaeger.scripts/demolayers a Cloudflare Tunnel and Caddy gateway over that stack. Management ports remain on loopback; public ingress reaches the customer portal and signed provider callbacks. - Restart and build-context fixes. Infrastructure containers gain
unless-stoppedpolicies. Credentials, local mailboxes, and operator settings stay out of Docker build contexts. Public URL configuration is overridable through.env.
Documentation and changes since v2.1.3
New local-development and public-integration guides cover prerequisites, provider permissions, secure credential handling, deployment commands, and recovery limits. The decision-log chunker fixture was refreshed for the new entry.
This release also includes the previously committed base-Compose consolidation, fresh-machine runbook, live-test scaffolding corrections, and direct OpenRouter configuration after LiteLLM retirement.
Compatibility
SemVer minor. BSS_RELEASE 2.1.3 → 2.2.0. No database migration or new BSS business verb.
Didit session/decision calls now require V3. The attestation path supports one complete approved identity document; wallet-only workflows and multiple approved identity documents are not supported. Stored Didit webhook bodies intentionally contain the envelope and digest rather than raw verification data. Full raw-body legacy webhook signatures remain supported.
Existing mock payment methods must be replaced through Stripe-hosted card setup when switching providers. The deployment remains a single-host demo: persistent storage and restart policies do not provide off-host backups or failover, and a full host reboot recovery test has not yet been performed.
Gate
cargo fmt --all --check · workspace/all-targets/all-features clippy -D warnings (0 warnings) · release-profile workspace/all-features tests 547 pass / 0 fail (48 opt-in tests ignored) · 14 doctrine guards · Compose validation · shell syntax · staged credential scan.
Verified on the running demo: Resend sent/delivered callbacks; Didit hosted approval; Stripe test payment and signed charge callback; a completed BSS order and active subscription; OpenRouter CLI and operator web-chat catalog tool calling. Targeted database-backed webhook persistence, redaction, deduplication, and status-progression tests also passed.
The destructive hero suite was not rerun against the configured provider-backed demo for this cut. Earlier local mock backend and portal scenarios passed with reset and clock changes disabled. No release binaries or container assets are attached, matching the previous releases.
See DECISIONS, local development, and public integrations.
v2.1.3 — connection-pool leak defence
Database connection pool made leak-proof.
A shared 100-slot Postgres (BYOI on tech-vm) was exhausted by ~86 idle bss connections while this host held only one live socket — orphans left behind when containers were SIGKILL'd on docker restart, whose FIN/RST never reached Postgres through the bridge NAT, so the server counted the dead sockets as idle until TCP keepalive (~2h). The starved pool killed every later-starting service (com, som, subscription, portal-csr all died on PoolTimedOut / "cockpit store connect failed").
bss-db's pool (crates/bss-db/src/pool.rs), which set only min/max_connections, gains three leak-defence layers:
- client-side
idle_timeout(300s) — bursts drain back down max_lifetime(1800s) — hard recycle- server-side
idle_session_timeout(10min, sent as a startup-coption) — the orphan backstop: Postgres itself drops any session idle past the window even when the owning client is already gone
min_connections dropped 5→1 (11 processes pin ~11 warm slots instead of ~55), and test_before_acquire is explicit so a server-reaped connection is transparently replaced rather than surfaced as a query error.
Verified live: server footprint 103→21 total, bss 86→12; backstop confirmed applied (idle_session_timeout = 10min) through the real bss_db::connect path.
SemVer patch: reliability only — no product verb, wire contract, event, payload, or DB schema change. All binaries link bss-db, so all 11 images rebuilt.
Also folds the vestigial docker-compose.rust.yml overlay into the base compose file (deployment-drift fix — the split image tags let this fix ship built-but-undeployed for hours).
v2.1.2 — hero-suite wipe made whole; v2.1.1 attribution corrected
The hero suite's data wipe is made whole, and v2.1.1's account of what happened is corrected.
What actually deleted the customers
v2.1.1 said the missing crm.customer rows were removed "out-of-band" and that the cause was unexplained. Both were wrong. The operator rejected the claim — they hadn't touched those customers — and they were right.
18 of the 19 hero scenarios declare setup.reset_operational_data: true, which fans admin.reset_operational_data out to 7 services. CRM's ResetPlan TRUNCATEs every row of customer, party, contact_medium, individual, interaction, customer_identity, case and ticket — not scenario-scoped, no "only rows this run created" filter, gated only by BSS_ALLOW_ADMIN_RESET=true. make scenarios-hero ran during v2.0.0 release verification on 2026-07-21; the last CRM admin.operational_data_reset marker is 02:37:38.853Z, 0.4s before the final hero scenario created its own customer.
Why the first investigation missed it, recorded so it isn't repeated:
- The
TRUNCATESQL isformat!-built from a table-name list, so greppingDELETE FROM crm.customerfinds nothing. Searching for the literal instead of the capability was the actual error. audit.domain_eventis deliberately spared, socustomer.createdevents survive for customers that no longer exist — which reads exactly like an out-of-band delete.- Having exonerated
seed-demo --resetandmake reset-db, it concluded "therefore nothing in-tree" rather than "therefore some other in-tree path", stopping one query short ofWHERE event_type='admin.operational_data_reset'.
The wipe was half-done
It cleared customers but left portal_auth logins pointing at them. That half-ness is precisely what produced the orphaned identities behind v2.1.1 — a partial wipe is a broken state, not a conservative one.
The reset now also truncates identity, session, login_token, login_attempt, email_change_pending and step_up_pending_action. portal_action is deliberately kept — it's the portal's audit trail, spared by the same rule that spares audit.domain_event.
It runs through the scenario runner's existing scenario-only BSS_DB_URL seam, not as a new admin endpoint: portal_auth is owned by the portals, which are public customer-facing web apps with no API-token middleware, and a "delete every login" route on port 9001 would be a worse hazard than the one being fixed.
Still live, by design
Running the hero suite against a database holding real customers destroys them. Nothing stops that, and per the operator's call it stays that way — resetting the world is what a scenario suite is for. A follow-up commit adds a disclaimer at both entry points (make scenarios* banner + make help, and a stderr warning from the reset itself for the direct bss scenario run-all path) rather than a guard.
Compatibility
SemVer patch. Scenario-runner and docs only — no product verb, wire contract, event, payload or DB schema change, and no change to any service or portal binary. BSS_RELEASE 2.1.1 → 2.1.2.
Gate
cargo fmt --all --check · clippy -D warnings (0) · cargo test --workspace --no-fail-fast 541 pass / 0 fail · 14 doctrine guards.
Also regenerates the bss-knowledge chunker golden, which v2.1.1 shipped broken (96 chunks vs 95) because its full-suite run predated its own DECISIONS.md edit. Unlike that cut, this gate was run on the final tree.
The TRUNCATE was validated inside BEGIN … ROLLBACK against a live DB — identities 11→0, sessions 17→0, portal_action untouched, all restored on rollback. The real reset was deliberately not run: doing so would have repeated the harm under investigation.
v2.1.1 — signup self-heals a dangling identity link
Signup no longer dead-ends when a login points at a customer that no longer exists. An identity whose linked crm.customer row is gone made the returning-customer branch fail on CRM's 422 customer.kyc.not_found — a rule with no copy registered, so it rendered the generic "Sorry, something went wrong". The identity was bricked permanently, and because reserve-at-pick runs before the customer lookup, every retry burned another MSISDN hold.
Highlights
- Self-heal instead of dead-end. Signup now treats that rule as a stale link:
ERRORlogportal.signup.identity_link_dangling, aportal_actionaudit row, drop the link, fall through to the normal create-customer path, re-bind. The customer completes signup unaided; the underlying data fault stays loudly visible rather than papered over. - One narrow carve-out, no takeover surface.
bss_portal_auth::relink_orphaned_to_customeris the single exception tolink_to_customer's "links are 1:1 and not reassignable from this surface" rule. The UPDATE is a compare-and-swap on the ghost customer id, so the only reachable transition is ghost → new customer. An identity bound to a live customer can't be moved through it — the CAS misses and returnsAlreadyLinkednaming the real holder — even though the caller picksnew_customer_id. - Copy for
customer.kyc.not_found. Signup repairs the state, but the CSR portal and thecustomer.get_kyc_statustool read a linked customer without repairing it, so the rule needed real copy rather than the generic fallback. - Mock-mode PAN guard re-applied on the fall-through, since it's skipped upstream while the identity still looks like a returning customer.
Why it happened
Not a signup regression — no code change caused it. The trigger was a data state. The v2.1.1 notes originally said the cause was unknown / done out-of-band; that was wrong, and v2.1.2 corrects it: make scenarios-hero truncated the customers during v2.0.0 release verification.
Guards
The self-heal keys off CRM's exact rule string across a service boundary — a rename in services/crm/src/service.rs would silently restore the original defect — so dangling_link_rule_is_pinned_and_has_copy pins the literal and its copy-map entry in CI. CAS semantics are pinned by three cases in the live audit_link_live round-trip: CAS miss refuses and leaves the link untouched, CAS hit repairs, unknown identity errors rather than silently no-opping.
Compatibility
SemVer patch. No new verb, no wire contract, event, payload, or DB schema change. relink_orphaned_to_customer is internal-crate API with no external surface. customer_self_serve untouched. BSS_RELEASE 2.1.0 → 2.1.1.
Gate
cargo fmt --all --check · clippy -D warnings (0) · 14 doctrine guards · live audit_link_live run against a real DB.
Known defect in this cut: it was tagged with chunker_matches_python_oracle failing (96 chunks vs 95). The full suite was run before the DECISIONS.md entry was appended and only the two changed crates re-run afterward, so this tag's commit message claim of "540 pass / 0 fail" did not describe the tree that shipped. Fixed in v2.1.2.
v2.1.0 — catalog management in the cockpit
Catalog management reaches the cockpit. Offering CRUD was previously CLI-only (bss admin catalog *) and browser-only (the v1.6.1 CSR Catalog screen) — the conversational surface didn't have it. It does now, under propose-then-/confirm.
Highlights
- Four verbs on the operator LLM surface.
catalog.add_offering,catalog.add_price,catalog.window_offering, and a newcatalog.retire_offering. The first three were registered and in theoperator_cockpitprofile since v0.7 but sat inLLM_HIDDEN_TOOLS, so the model never saw them; retire had no tool at all. /confirmreplaces invisibility as the gate. A strictly stronger control: the operator reads the staged args before the write lands, where hiding just meant the capability didn't exist. No catalog write has a one-call undo —add_pricecan only be superseded, never removed, andlist_active_offeringsfeeds order creation, so a bad row is sellable the instant it lands.- Multi-turn elicitation — the agent asks, it doesn't assume. Ask for "a new 5GB plan" and it comes back for the plan id, price, and allowances rather than inventing them.
- Never customer-facing. Not in
customer_self_serve; asserted in both directions.
Elicitation, and why it's enforced twice
The orchestrator emits a permissive function schema (additionalProperties: true), so the description prose is the model's only arg contract. Both layers matter:
- The four tool descriptions are explicit "ask, never assume" contracts — never derive a plan id from the name, never price by analogy with an existing plan, never assume allowances typical for a tier.
tools::require_fieldsgates server-side, naming every absent field at once so the model asks one question instead of probing.add_offeringadditionally rejects a plan that grants no allowance (pass0for a deliberate omission);window_offeringrejects both bounds absent, treating an explicitnullas a deliberate clear.
An elicitation bounce is reported with is_error = false (ToolError::is_elicitation) so clarifying questions don't burn the 3-strike failure-bail budget. The first implementation assumed "absent from FAILURE_MARKERS" was sufficient — but is_failure_result is is_error || markers and every Err sets is_error, so three clarifying questions in one turn killed the turn with a "couldn't recover" panel. Caught by the new loop test, not by review.
Documentation fixes
- HANDBOOK §8.1 gained the chat path alongside the CLI and browser routes.
- HANDBOOK §8.1a was actively wrong and is removed. It claimed
bss admin catalog add-offeringhad no--data-roaming-mbflag (v0.20 added it) and told operators to run rawINSERT INTO catalog.bundle_allowance— bypassing the policy layer, a doctrine violation — with a "recommended" fallback pointing at a Python package deleted at v2.0.0. - Knowledge index reindexed. It had been stale since 2026-07-19, predating the v2.0.0 release, so
knowledge.searchwas serving pre-2.0 content. Also note: retrieval is Tier-0 Postgres FTS withplainto_tsquery, which ANDs every term — a question containing one out-of-corpus word returns zero hits, not a near miss. Retire/decommission/withdraw/remove synonyms were added to the docs for that reason. The provisioned pgvector column remains unpopulated (the Tier-1 embedder pass is unbuilt).
Compatibility
SemVer minor, backward compatible. No wire contract, event, payload, or DB schema change. BSS_RELEASE 2.0.0 → 2.1.0.
Gate
cargo fmt --all --check · clippy -D warnings (0) · cargo test workspace 540 pass / 0 fail · 14 doctrine guards.
The hero scenario suite was not run for this cut — it requires mock payment/KYC providers and the deploy carries a live BSS_PAYMENT_PROVIDER=stripe. The change is confined to the LLM tool surface, which the hero flows don't exercise; it was verified live against the running stack instead (tool visible, elicitation asks rather than assumes, destructive gate held with nothing written, retire endpoint reachable, knowledge.search retrieves and cites the new content).
See DECISIONS 2026-07-22.
v2.0.0 — All-Rust rewrite
BSS-CLI is now all-Rust. The complete Python → Rust rewrite is done and mainline. This was a behaviour-frozen port (strangler-fig): the motto, scope, write-policy, TMF surfaces, domain events, DB schema, and the LLM tool surface are unchanged — only the implementation stack changed. Existing deployments upgrade in place.
Highlights
- Complete rewrite, same behaviour. Wire contracts, event routing keys, payloads, the Postgres schema, and every LLM tool are byte-compatible with v1.8. Your scenarios, integrations, and data keep working.
- Dramatically lighter. The whole 11-service app plane now idles under 100 MiB — a headline motto-#6 win.
- Faster cold start and lower tail latency (numbers below).
- Alembic retired in favour of the
sqlxmigrator — no Python/greenlet in the runtime story. - Beyond the port: a new Open Order / MSISDN-reservation signup funnel, a signup confirmation screen (real eSIM QR), and async-plane self-healing — see "Beyond the rewrite" below.
Performance (motto #6 — re-measured, idle-vs-idle)
| Budget | v1.8 (Python) | v2.0 (Rust) | Δ |
|---|---|---|---|
| Full-stack RAM (11 services) < 4 GB | 1204 MiB | ~91 MiB idle (≤~160 under load) | ~13× smaller — 2.2% of budget |
| Cold start (all 11) < 30 s | 6.36 s | 3.08 s | ~2.1× faster |
p99 /health < 50 ms |
12.8 ms | 6.16 ms | ~2× lower tail |
| p99 real DB read < 50 ms | — | 8.47 ms | under budget |
Beyond the rewrite
v2.0 isn't only a port — it also shipped a new signup feature and hardened the async plane.
Open Order & MSISDN reservation (new feature)
Numbers no longer collide during a live signup, and an interrupted signup becomes a first-class, resumable record. (Roadmap feature — new behaviour, not in the prior Python line.)
- Reserve-at-pick. Picking a number places a soft hold (distinct from the hard provisioning reserve) via a single atomic, self-healing
UPDATE— concurrent pickers can't both win the same MSISDN. - Persisted, resumable order. One open order per customer (keyed on the verified email). Starting a second is blocked (
open_order.already_open); returning resumes the same plan + held number. - "My open order" account screen. Customers view / resume / cancel their own pending order — ownership-bound (the id comes from the session, never the request) — with a resume link on the dashboard. Clearing a stuck order no longer needs API access.
- 24h expiry sweep. A worker (
FOR UPDATE SKIP LOCKED, multi-replica safe,bss_clock-driven — same discipline as renewal) auto-releases abandoned holds, so a stuck order never permanently strands a number.
Signup confirmation
- A post-signup confirmation screen with a real PNG eSIM QR code + activation poll, plus the MSISDN picker.
- An explicit "Review and confirm" step before the card is charged — previously a returning customer adding a line was charged + activated instantly with no pause. Signup block errors (taken number / existing open order) that used to render silently now show a banner with a "View your open order" link.
Async-plane self-healing & idempotency
The message plane now recovers from a broker blip on its own (lapin, unlike Python's aio-pika robust connection, doesn't auto-recover) — the reliability the soak gated on:
- Publish path self-heals: the channel/connection sit behind a mutex and are recreated on demand (re-declaring the durable exchange), so a transient broker blip no longer wedges the outbox relay.
- Consume path auto-resubscribes after a drop with a shared exponential backoff (1 s → 30 s) — before, a dropped consumer silently stopped consuming until a restart.
- SOM
task.completedis idempotent (already-activated → ack-and-skip), breaking a duplicate-delivery "park storm" that could starve fresh orders from ever completing.
Also fixed during the cutover
Stripe add-card Checkout flow (blank page → working), several Jinja2 → MiniJinja template-flip artifacts, cockpit transcript rehydration (a stale-turn bug in long sessions), and the REPL .env auto-load + branding/banner polish.
Stack (Python → Rust)
- FastAPI → axum; SQLAlchemy/asyncpg → sqlx; Alembic → sqlx migrator (
bss admin migrate) - Pydantic → serde; LangGraph → a hand-rolled ReAct loop (
bss-orchestrator) - Typer/Rich → clap + reedline + ASCII renderers; aio-pika → lapin
- FastAPI portals → axum + MiniJinja + HTMX (same templates, no npm/bundler)
- OpenTelemetry hand-stitched at 4 seams (no auto-instrumentors);
tracingJSON logs - Multi-stage
cargo-chefDockerfiles, non-root distroless final stage
Upgrading
- Fresh install:
bss admin migratethenmake seed. - Existing (Alembic-created) DB:
bss admin migrate --baselineonce to stamp the baseline without re-running it, then you're on the sqlx migrator going forward. Runbook:docs/runbooks/rust-schema-baseline.md. - Build the CLI:
cargo build --release -p bss-cli(orcargo install --path cli).make up/make up-allbring up the all-Rust stack.
Removals / changes
- The original Python implementation was archived, not deleted — recoverable via git tag
python-oracle-final+ tarball. Seedocs/PYTHON-ORACLE.md. - Retired Python-only dev tooling: the
py-*/python-check/check-clock/lint-typesmake targets and the Python Playwrightmake e2e*suite (the Rust ship gate ismake scenarios-hero). - The demo seed was ported to
bss admin seed-demo(+--reset) — 3 demo customers + 2 promotions + VIP assignment, BSS + loyalty in lockstep. Its Python-onlyloyalty-wipemode (and thedemo-restoreone-button) did not carry over — loyalty-cli owns its own DB reset.
Quality gate at release
fmt · clippy -D warnings · cargo test (120 suites, 0 failed) · 14 doctrine guards · hero suite 19/19.
The nominal 14-day soak was cut early by operator decision; all gates were green at cut. See
DECISIONS.md(2026-07-21) andphases/2.0/07-SOAK.md. If a latent regression surfaces, the golden-diff oracle is onegit worktree add … python-oracle-finalaway.
v1.8.1 — returning-customer signup resume
Patch release: fixes the returning-customer dead end in the self-serve signup funnel.
Fixed
- A customer whose first signup died mid-chain (KYC declined, or abandoned before the card step) hit a permanent dead end on retry: the portal skipped KYC + COF on the strength of the identity→customer link alone and COM rejected the order with
order.create.no_payment_methodbehind a generic error.POST /signupnow reads the customer's actual KYC status and payment methods and resumes at the first incomplete step (PR #51). - The v0.11 second-line shortcut still applies when KYC + card genuinely exist — no re-attestation, no re-carding.
- Signup form no longer promises "charged to the card on file" to customers who have no card; mock mode renders the card input for them.
Added
- Customer-facing error copy for
order.create.no_payment_method,kyc.declined,kyc.expired, andpolicy.payment.method.invalid_card(all previously rendered as the generic apology).
Verification: 5 new funnel tests; all 24 suites green (~2,000 tests); doctrine-check + ruff clean.
🤖 Generated with Claude Code
v1.8.0 — Branding customization
The product "face" becomes operator config — name, theme, logo — edited visually at cockpit → Branding, stored as [branding] in .bss-cli/settings.toml, hot-reloaded everywhere without restarts.
Highlights
- 6 curated dark themes (phosphor default, amber-crt, ice, magenta, paper, solarized-dark) in the new
bss-brandingpackage — single source of truth for portal CSS variables and transactional-email colors, WCAG-contrast test-pinned. - Logo: built-in text glyphs ($ ● ▲ ✦ ► + custom 1–3 chars) render everywhere; optional PNG/JPEG/WebP upload replaces the glyph in the browser portal headers (magic-byte gated, ≤256 KB, never SVG). Emails and the CLI keep the glyph.
- Cockpit Branding screen — swatch picker, mark picker, logo upload, HTMX live preview (writes nothing until Save). CLI parity:
bss branding show / themes / set-theme / set-name / set-mark. - Version demoted — headers drop
vX.Y.Z; abss-cli v1.8.0footer footnote carries product attribution (never rebranded). - Chat greeting unified —
BSS_OPERATOR_NAMEbecomes an explicit override; unset, the bot greets with[branding].brand_name. - New
accent_altslot splits the decorative secondary accent (IDs, tool chrome) from the fixed semantic warn amber; leftover hardcoded phosphor greens converted to palette vars. - Self-serve portal gains the
?v=static-asset cache-buster.
Doctrine
CLAUDE.md Phase 0 amendment (settings.toml read rule) + new "Branding (v1.8)" block; 3 new make doctrine-check guards; DECISIONS 2026-07-05; phases/V1_8_0.md. Compose mounts .bss-cli/ read-only into portal-self-serve + subscription.
Upgrade notes
docker compose build && docker compose up -d(new env/volumes on portal-self-serve + subscription).BSS_OPERATOR_NAMEin.envnow pins the chat greeting explicitly — unset it to follow the portal brand.- No DB migration.
🤖 Generated with Claude Code
v1.7.0 — retire product offering
Retire product offering (10 files, +329)
Until now there was no way to retire a product offering — setting valid_to to a past date hid it from "active catalog" queries but never changed lifecycle_status, so the cockpit still showed a misleading active badge. Two paths now exist, both policy-gated server-side.
Explicit retire (new command + cockpit button)
bss admin catalog retire-offering --id PLAN_X
POST /admin/catalog/offering/{id}/retire— new endpointCatalogClient.admin_retire_offering()— client method- Cockpit detail page — "Retire…" danger panel (two-step confirm) in the Validity window sidebar, hidden when already retired
- Sets
lifecycle_status="retired"+is_sellable=False, stampsvalid_to=now()when the current window is still open - Idempotency guard: re-retiring an already-retired offering raises
POLICY_VIOLATION(catalog.offering.already_retired)
Auto-transition (your original workflow, now fixed)
Setting valid_to to a past date via window-offering (CLI or cockpit "Set window" form) now also flips lifecycle_status → "retired" + is_sellable → False in the same UPDATE. Future valid_to does NOT auto-retire; clearing valid_to on a retired offering does NOT un-retire.
What is NOT affected
Existing subscriptions are untouched — they charge their price snapshot (v0.7 doctrine). New orders cannot select a retired offering.
Edge cases covered
| Case | Behaviour |
|---|---|
| Retiring already-retired | PolicyViolation |
| Retiring nonexistent | PolicyViolation |
| Future valid_to | No auto-retire |
| Clearing valid_to on retired | Stays retired |
| Existing subscriptions | Unaffected |
🤖 Generated with Claude Code