Releases: chiam-ck/bss-cli
Release list
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
v1.6.2 — email lookup, degeneration guards, green make test + lint
Cockpit loop incident → three-layer fix (PR #46)
Asked to investigate an escalation identified only by email, the cockpit agent had no email resolver — customer.list(name_contains=…) matches the display name only — and gemma-4-31b at temperature 0 with no completion cap degenerated into a single 53k-character repetition loop (~10 minutes, one LLM call). The v1.5 three-strike bail never tripped: every replayed customer.list → [] was a success.
customer.find_by_email— new TMF629 lookup (GET /customer/by-email?email=…, query param so plus-addressing survives) exposed throughbss-clientsand the tool registry.operator_cockpitprofile only; customers keep identifying via session. The cockpit search box gained the same email lane. TOOL_SURFACE.md Phase 0 amendment recorded in DECISIONS.md.- Completion bounds —
BSS_LLM_MAX_TOKENS(default 2048) caps every completion;BSS_LLM_FREQUENCY_PENALTYships as an off-by-default knob. - Stuck-loop bail — three consecutive identical (tool, args, result) triples end the stream with the same
agent_loop_bailoutpanel as the failure bail. Result is part of the key, so progressing polls never trip.
Verified live: the incident prompt now resolves find_by_email → customer.get → case.show_transcript_for and answers in ~2 minutes.
Pre-existing rot cleared (PR #47)
clock.nowwas broken since inception — the tool def shadowed its ownbss_clock.nowimport, so the tool called itself and every invocation raisedAttributeError. Surfaced by ruff F811 during lint adoption; fixed with a regression test that actually invokes the tool.- Dev-DB test coupling — CRM inventory mutation tests now seed their own MSISDN rows inside the rollback transaction instead of targeting recyclable seed numbers; the catalog
write_sessionfixture moved to commit→flush rollback isolation, neutralizing FK-referenced scenario leftovers via in-txn UPDATE instead of DELETE.make testis fully green (24 suites) against a lived-in dev DB for the first time. make lintis now real — ruff + mypy were configured but never declared as dependencies; 503 accumulated findings swept to zero (E402 ignored with written rationale: env-bootstrap-before-import is a house pattern).lint= ruff, enforced and green; mypy moved to alint-typestarget documented as known-red (~120–270 strict errors per component — a future typing project). Bonus: an assertion-free payment test claiming the opposite of the actual casing contract was renamed and pinned.
Notes
v1.6.0 — Cockpit CRM workbench
The operator cockpit browser (localhost:9002) grows into a full CRM around the chat. The conversational UX stays the centrepiece; the UI supplements it with real screens and real CRUD (operator-directed Phase 0 amendment — DECISIONS 2026-06-10 ×2).
CRM screens
| Screen | What you get |
|---|---|
| Customers | search (name/MSISDN) + state filter + pagination → 360 with KYC, contact-medium CRUD, name edit, subscriptions w/ balance bars, orders, cases, card-on-file, interactions |
| Cases | queue w/ filters → workbench: notes, take/await/resume/resolve, priority, close, full ticket lifecycle (open/assign/ack/start/resolve/close/cancel) w/ agent dropdown |
| Orders | create / submit / cancel, cross-customer queue (new COM list filters), COM items + SOM decomposition detail |
| Catalog | plans w/ allowances + price history, VAS, promotions; add-offering / add-price / set-validity-window admin |
| Subscriptions | balance bars, SOM services, usage tail, eSIM re-display; plan-change / renew-now / VAS top-up / terminate |
Write doctrine
Direct CRUD everywhere — one route → one policy-gated bss-clients call; PolicyViolation messages flash back verbatim. Destructive and money-moving verbs sit behind a two-step UI confirm (the expanded danger panel posts confirm=yes; routes refuse bare POSTs — doctrine test-pinned in both directions). The LLM path keeps propose-then-/confirm unchanged, and every screen carries "Ask the agent" handoffs (POST /cockpit/handoff) that open a focused session with the verb drafted — never auto-sent. Promo assignment stays conversational (the v1.3 loyalty pairing a bare form would skip).
Cockpit UX overhaul
- Design system — accent links (no browser-default blue on the dark palette), elevated panel cards with small-caps labels, three button weights + danger, dot badges, two-column detail grids, dense mono-identifier tables.
- Fixed-viewport app shell — the page never scrolls; panes do. Shell height driven by
window.visualViewport(the only signal that sees the iPadOS keyboard), no autofocus on touch, 16px touch inputs (kills iOS focus-zoom), safe-area insets, per-boot asset cache-buster. The chat compose box stays on screen on any device, keyboard up or down. - Disconnect-proof turns — the agent runs in a detached server task; a dropped SSE connection can no longer cancel a turn. Heartbeats every 10s, a visible "working…" indicator from the first instant, and a 45s client watchdog that silently re-attaches and renders the finished reply. No more nudging the chat.
Bug fixes the workbench surfaced
- CRM
PATCH /case/{id}only accepted triggers →case.update_priorityhad 422'd since v0.13; field updates are now policy-gated (case.update.case_is_closed,case.update.invalid_priority) and emitcase.updated. - The entire ticket client surface had drifted from the CRM service:
open_ticket422'd on every call,assign_ticketwas a silent no-op,transition_ticket422'd on every call. Fixed with a ticket state→trigger map (in_progressdisambiguated via one read). CRMClient.list_casessentagentId; the service readsassignedAgentId— the agent filter was silently ignored.CRMClient.transition_casecouldn't expressresume; directtrigger=override added.- The v0.13 case page read camelCase keys off the snake_case Case DTO, silently blanking fields;
bss_csr.views.fieldreads both spellings. - COM
GET /productOrderrequiredcustomerId; now optional withstate/limit/offsetfor the cross-customer queue.
Ops
- New runbook:
docs/runbooks/litellm-proxy.md— LiteLLM model-route rules (thedeepseek/prefix hits DeepSeek's native API, which rejects dotted tool names; useopenrouter/...routes) and the Docker network re-attach after a proxy recreate. - Deployment default flipped to
openrouter/google/gemma-4-31b-itvia the LiteLLM proxy (env-level; the code default is unchanged).
Verification
make test green across all suites; doctrine-check green; 19/19 hero scenarios incl. the four LLM-driven ones; Playwright across five viewports (iPad portrait/landscape, keyboard-height proxy, desktop, phone) plus disconnect-survival and watchdog-recovery scenarios; operator-verified on a physical iPad.
🤖 Generated with Claude Code
v1.5.0 — multi-step cockpit orchestration + BSS_REPL_LLM_AUTONOMY
v1.4.x landed the test infrastructure for the cockpit. v1.5.0 unlocks multi-step orchestration on top of it — operators can now ask compound questions ("investigate CASE-…", "register CUST + create order", "show customer and their subscriptions") and the cockpit agent chains the work. The cost: a 5-piece refactor across the orchestrator, the cockpit prompt, the destructive-tool gate, and the cockpit chrome filter. The whole release is additive — no new container, no schema, no migration (head stays at 0030).
Design lifted from the proven loyalty-cli v0.11 pattern; adapted for bss-cli surfaces (REPL + browser veneer).
The five pieces
1. BSS_REPL_LLM_AUTONOMY env var
BSS_REPL_LLM_AUTONOMY=granular # default — each destructive in a compound
# action gates on its own /confirm
BSS_REPL_LLM_AUTONOMY=batched # opt-in — the FIRST destructive in a
# /confirm-resumed loop gates; subsequent
# destructive steps execute autonomouslyFail-closed at orchestrator/cockpit boot (AutonomyMisconfigured) — same shape as BSS_API_TOKEN=changeme. Per-process scope; per-session /autonomy {granular,batched} slash override deferred to v1.5.1.
2. Autonomy-aware destructive gating
safety.wrap_destructive grows autonomy_mode + loop_state kwargs. build_tools creates one LoopState per build_graph invocation and shares it across every destructive wrapper in that graph. Granular re-blocks after the first destructive fires; batched authorises the whole loop. DESTRUCTIVE_TOOLS is unchanged — autonomy controls how many /confirms a compound action needs, not which tools require one.
3. ITERATIVE FLOW prompt block + softened v0.19 "Done." rule
The v0.19 anti-hallucination rule used to force "one short sentence and STOP" after every renderer-backed read. v1.5 softens it: the agent MAY emit another tool_call instead of a terminating text reply when the operator's prompt clearly requires more steps. Anti-duplication contract unchanged. Three bss-shaped worked examples ship in the prompt (read chain, case investigation, compound write). Doctrine guard pins ITERATIVE FLOW to the operator surface only — must NOT leak into customer_chat_prompt.py.
4. 3-strike loop bail
MAX_CONSECUTIVE_TOOL_FAILURES = 3 in bss_orchestrator.session. After three consecutive failure-shaped tool results (real exceptions OR structured POLICY_VIOLATION / DESTRUCTIVE_OPERATION_BLOCKED / CLIENT_ERROR), astream_once terminates with a clear AgentEventError instead of letting the LLM thrash forever. Counter resets on any success.
5. Cockpit chrome filter + bubble overrides
New bss_cockpit.chrome_filter module — strips cockpit-emitted chrome ((no reply), the empty-final recovery bubble, the citation-guard fallback, the route error fallback) from history rehydration AND live emits. Also catches the narrated-tool-call mimicry Gemma produces ("I propose to terminate ... subscription.terminate(...)") that pre-v1.5 stalled /confirm loops silently.
Plus bubble overrides so the operator can tell apart:
> terminate subscription SUB-0005
Pending /confirm for subscription.terminate — type /confirm to authorise the next turn.
╭─ bss ai ────────────────────────────────────────────────────────────────────╮
│ Proposed subscription.terminate(subscription_id='SUB-0005'). │
│ Type /confirm to authorise. │
╰─────────────────────────────────────────────────────────────────────────────╯
> /confirm
/confirm staged for subscription.terminate args={'subscription_id': 'SUB-0005'}
╭─ bss ai ────────────────────────────────────────────────────────────────────╮
│ Executed subscription.terminate(subscription_id='SUB-0005'). │
╰─────────────────────────────────────────────────────────────────────────────╯
Pre-v1.5 both bubbles said Done. — one of them was lying.
Real-world bug fixes uncovered en route
- Route + REPL pending-destructive staging used to derive from STARTED-side capture + a
not allow_destructive_this_turnheuristic. That silently lost the granular re-gate signal on/confirm-resumed turns. Rewired to derive strictly fromCOMPLETED-with-DESTRUCTIVE_OPERATION_BLOCKEDin both surfaces. Pre-v1.5 had a latent bug where the cockpit miscategorised a successfully-executed destructive as still-pending under specific timing; fixed as a side effect. - REPL
/confirmslash command was a no-op marker (returned a never-consumed string). The operator had to type a second prompt after/confirmto actually re-evoke the destructive. The REPL now drives a real turn with the synthetic-confirm prompt on/confirm, matching the browser cockpit's slash-command interceptor behaviour.
How to test it
# Granular (default) — propose, /confirm, repeat per destructive
bss
> /new test
> terminate subscription SUB-0005
# → "Pending /confirm for subscription.terminate" yellow line
# → bubble: "Proposed subscription.terminate(SUB-0005). Type /confirm to authorise."
> /confirm
# → "/confirm staged for subscription.terminate args=..."
# → resumed turn fires
# → bubble: "Executed subscription.terminate(SUB-0005)."
# Batched — one /confirm authorises a compound plan
export BSS_REPL_LLM_AUTONOMY=batched
bss
> /new test
> terminate subscription SUB-0006 and subscription SUB-0007
> /confirm
# → bubble: "Executed 2 destructive actions, last was subscription.terminate(SUB-0007)."E2E equivalents land in packages/bss-e2e/tests/test_v15_multi_step.py:
make e2e # 12 passed, 1 skipped (batched gated), 0 failed in ~38s
make e2e-batched # 12 passed, 1 skipped (granular gated), 0 failed in ~38s
Doctrine touches
- CLAUDE.md gains a new Cockpit (v1.5 — multi-step autonomy) anti-pattern block: autonomy module ownership, ITERATIVE FLOW scope (operator-only),
MAX_CONSECUTIVE_TOOL_FAILUREScontract,_ASSISTANT_CHROME_PREFIXESinventory lock. - ARCHITECTURE.md § Operator cockpit gains a v1.5 paragraph on both autonomy modes + the 3-strike bail + the chrome filter.
- HANDBOOK.md gains §8.20 "Multi-step cockpit actions (v1.5)" with worked examples + recovery semantics + when-to-flip-to-batched guidance.
- DECISIONS.md 2026-05-26 entry covers the five pieces + three rejected alternatives (plan-emit-then-confirm-once, composite read tools, per-tool autonomy annotations).
Test results
| Suite | Pre-v1.5 | v1.5.0 |
|---|---|---|
orchestrator |
403 | 420 (+17 across autonomy / safety / loop_bailout / iterative_flow_scope) |
bss-cockpit |
66 | 95 (+29 across chrome_filter — every observed mimicry shape pinned) |
cli |
127 | 127 (unchanged) |
make e2e |
12 pass / 1 skip | 12 pass / 1 skip (granular + case-investigation new specs green; batched gated by env) |
make e2e-batched |
n/a | 12 pass / 1 skip (batched spec exercised; granular auto-skipped) |
What's next — v1.5.x
- v1.5.1: per-session
/autonomy {granular,batched}slash override (lets operators flip mode without restarting the orchestrator/REPL). - v1.5.2: per-tool autonomy annotations (e.g.
admin.reset_operational_dataalways-granular even under batched). - v1.6 candidate: composite read tools like
case.investigate(case_id)— server-side fast path for high-frequency multi-read investigations.
Full changelog
- #43 — v1.5.0 — multi-step cockpit orchestration + BSS_REPL_LLM_AUTONOMY (14 commits, 26 files, +2475 / −61)
🤖 Generated with Claude Code
v1.4.1 — All four v1.4.0 xfails resolved, visual artefacts
v1.4.0 shipped 6/10 e2e specs green + 4 deferred xfails. v1.4.1 closes the gap on all four, and pivots the report format from pytest-style stack traces to a per-spec visual gallery (screenshots + Playwright trace + recorded video) so an operator can actually see what the browser saw.
All 10 specs green
$ make e2e
10 passed in 36.6 s
Two new architectural pieces
1. Admin bss promo exhaust verb — terminal FSM transition active → exhausted. Migration 0030 adds the state to catalog.promotion.state. Idempotent on re-runs. Real operator tool, not just an e2e hook.
bss promo exhaust PROMO_E2E_EXHAUSTED
# → ✓ PROMO_E2E_EXHAUSTED is now exhausted (was active).
# New orders will see no discount.
2. Mock-orchestrator seam (BSS_LLM_FIXTURE_PATH) — orchestrator/bss_orchestrator/llm_mock.py adds MockChatModel (proper BaseChatModel subclass). When the env var points at a JSON fixture, build_chat_model() returns the mock instead of constructing a real ChatOpenAI. Tools still execute against real services — only LLM responses are scripted.
{
"responses": [
{
"name": "tool-roundtrip-customer-list",
"match": "list customers",
"steps": [
{"tool_calls": [{"name": "customer.list", "args": {"name_contains": "Demo"}}]},
{"content": "Here are the customers matching 'Demo'."}
]
}
]
}Unset by default — production / dev / scenarios all run against the real LLM.
Visual artefacts (the v1.4.0 → v1.4.1 pivot)
v1.4.0 wrote pytest --html reports. That was the wrong artefact for documenting a browser automation suite. v1.4.1 makes every make e2e run produce:
docs/e2e-reports/20260525T173800Z/
├── index.html ← open this in any browser
├── junit.xml ← for CI ingestion later
├── test-signup-golden-path-smoke/
│ ├── 01-signed-in.png
│ ├── 02-signup-form-blank.png
│ ├── 03-signup-form-filled.png
│ ├── 04-confirmation-with-esim-qr.png
│ ├── 05-dashboard-active-line.png
│ ├── trace.zip ← playwright show-trace
│ └── video.webm ← drag into any browser
└── … (9 more specs)
index.html is a self-contained gallery (no JS deps) with:
- Hero pill — color-coded summary: green
all 10 passed, amber for mixed, red for failures. - Summary table — every spec in one row: status badge · spec name (click to jump) · duration. Sourced from junit.xml.
- Per-spec sections — screenshot grid + embedded
<video>+trace.zipdownload. Per-spec status badge inline.
The end of make e2e prints the file://…/index.html URL so you can open it in one click.
Spec coverage now
| # | Spec | v1.4.0 | v1.4.1 |
|---|---|---|---|
| 1 | signup golden path | ✓ | ✓ |
| 2 | public promo applied at signup | ✓ | ✓ |
| 3 | targeted promo on dashboard | ✓ | ✓ |
| 4 | step-up auth gate | ✓ | ✓ |
| 5 | exhausted promo degrades to full price | ⏸ xfail | ✓ resolved |
| 6 | cockpit sessions index opens | ✓ | ✓ |
| 7 | cockpit tool roundtrip (customer.list) |
⏸ xfail | ✓ resolved |
| 8 | cockpit propose-then-confirm | ⏸ xfail | ✓ resolved |
| 9 | cockpit knowledge citation / fallback | ⏸ xfail | ✓ resolved |
| 10 | cockpit slash-command parity (/focus) |
✓ | ✓ |
Doctrine posture
- Additive — no doctrine doc touches (CLAUDE.md / ARCHITECTURE.md / DATA_MODEL.md / TOOL_SURFACE.md untouched).
- Migration 0030 is metadata-only (CHECK constraint replacement) — no data migration, no service downtime.
BSS_LLM_FIXTURE_PATHis opt-in. Real-provider runs are bit-for-bit unchanged.bss promo exhaustis intentionally terminal (noreactivateverb until a real use case appears).- Wall-clock
datetime.now()calls inbss_e2e/report.pyandconftest.pycarry# noqa: bss-clock— they're filesystem-name / display-string metadata, never state-machine inputs.
How to run
make e2e # bring up override stack → demo-restore →
# pytest with screenshots/trace/video →
# generate gallery → tear down → restore.
# Trap on EXIT/INT/TERM so Ctrl-C still cleans up.
make e2e CLEAN=1 # full `down -v` first (drops volumes).
make e2e-down # manual escape-hatch if a previous run hung.Pre-condition: your .env should not carry a real sk_live_* Stripe key — the v0.16 startup template-scan refuses to boot under mock providers when one is present.
Files changed (19)
DECISIONS.md + v1.4.1 entries
Makefile + BSS_E2E_REPORT_DIR plumbing
README.md + visual-artefact section
cli/bss_cli/commands/promo.py + bss promo exhaust verb
docker-compose.e2e.yml + BSS_LLM_FIXTURE_PATH on portal-csr
docker-compose.e2e.yml + fixtures bind-mount
docs/e2e-reports/README.md + new artefact layout
orchestrator/bss_orchestrator/llm.py + fixture-path check
orchestrator/bss_orchestrator/llm_mock.py (new) MockChatModel
packages/bss-clients/bss_clients/catalog.py + exhaust_promotion method
packages/bss-e2e/bss_e2e/report.py (new) gallery generator
packages/bss-e2e/fixtures/cockpit_e2e.json (new) scripted LLM responses
packages/bss-e2e/tests/conftest.py + tracing/video/snap fixtures
packages/bss-e2e/tests/test_*.py + snap() calls + 3 xfails dropped
packages/bss-models/alembic/versions/0030_…py (new) migration 0030
packages/bss-models/bss_models/__init__.py BSS_RELEASE → 1.4.1
services/catalog/bss_catalog/promotion_service.py + exhaust_promotion service method
services/catalog/bss_catalog/routes/promotion.py + POST /promotion/{id}/exhaust
What's next — v1.4.x
- GH Actions integration (e2e on PR + upload artefact zip).
- Spec coverage expansion: parallel-spec safety, MNP / port-request UI, knowledge-index rebuild flow.
Full changelog
- #42 — feat(v1.4.1): resolve all four v1.4.0 xfails — admin exhaust verb + mock LLM seam + visual artefacts + gallery hero/summary
v1.4.0 — Playwright e2e suite
First automated end-to-end coverage of the customer signup funnel + operator cockpit veneer. make e2e brings up the stack in mock-providers mode, runs the suite, tears down, restores the normal stack. Six specs green in ~23 seconds; four LLM-dependent / admin-tooling-dependent specs land as xfail with written v1.4.1 follow-ups.
Doctrine-additive — no service surface changes, no migrations, no doctrine doc touches. New uv workspace member packages/bss-e2e/, new docker-compose.e2e.yml override, new make e2e / make e2e-down targets.
What's green (6 specs)
Self-serve portal (localhost:9001)
| Spec | What it asserts |
|---|---|
| signup golden path | Magic-link login → prebaked KYC → mock COF → MSISDN reserve → activation. Dashboard renders .line-card--active with the new MSISDN. |
| public promo applied at signup | E2E_PUBLIC10 typed into the HTMX field → live preview populates → order completes. |
| targeted promo on dashboard | Walk a normal signup → CatalogClient.assign_promotion (v1.3.0 pairing-upfront) → dashboard surfaces the issued offer. |
step-up auth gate (name_update) |
Sensitive action triggers step-up → OTP from mailbox (subject-filtered to disambiguate from the login OTP) → auth_step_up_replay.html auto-submits the original POST → name updated. |
Cockpit browser veneer (localhost:9002)
| Spec | What it asserts |
|---|---|
| sessions index opens | / renders + "+ New conversation" CTA visible (no login wall — single-operator-by-design). |
slash-command parity (/focus) |
Set focus via form.thread-focus-form → banner renders → clear with empty submit → banner disappears. |
What's deferred (4 xfails — named for v1.4.1)
- exhausted promo degrades to full price — needs an admin
catalog promotion exhaustverb. The v1.1.3 graceful-degrade path is covered by service-level tests; this UI assertion waits on the missing admin API. - tool roundtrip / propose-then-confirm / knowledge citation — LLM response shape is non-deterministic. Asserting on tool-call rendering needs recorded fixtures or a mock-orchestrator seam. The
_RE_KNOWLEDGE_CLAIMguard +bss_orchestrator.safety.DESTRUCTIVE_TOOLSare covered bybss-cockpitunit tests.
How to run
make e2e # bring up override stack → demo-restore → pytest → restore
make e2e CLEAN=1 # full down -v first (drops volumes)
make e2e-down # manual escape-hatch if a previous run hungReports land in docs/e2e-reports/<UTC-ts>/ (git-ignored except for the README pointer). The trap … EXIT INT TERM on the Makefile target survives Ctrl-C and brings the normal stack back up.
Pre-condition: your .env should not carry a real sk_live_* Stripe key — the v0.16 startup template-scan refuses to boot under BSS_PAYMENT_PROVIDER=mock if one is present.
Lessons captured in the codebase
Three real product seams emerged while wiring real specs and got fixes / docs alongside the test code, not papered over:
- MSISDN dynamic reservation — hardcoded
81000900collided with a stale subscription on the second run.pick_available_msisdn()queriesinventory.msisdn_poolat fixture time, every spec picks a fresh number. is_returning=Truetrap — pre-creating a customer before login flips the funnel into the second-line-shortcut path, which skips KYC + COF expecting them on file. Pre-created customer had neither, order parked silently. Targeted-promo spec restructured to assign AFTER signup completes (which matches production anyway).- Step-up auto-replay —
auth_step_up_replay.htmlauto-submits the original sensitive POST on OTP confirmation; manually re-submitting burns the one-shot cookie twice and re-triggers the gate. Spec just waits for the auto-replay redirect.
Bundled v1.3.2 doctrine fix
This release also rolls up the fix(v1.3.2) doctrine refactor (PR #39, merged but never tagged separately):
bss_seed.demono longer readsBSS_LOYALTY_API_TOKENfromos.environinside Python. The Makefile shell reads$BSS_LOYALTY_BASE_URL/$BSS_LOYALTY_API_TOKENand threads them as--loyalty-base-url/--loyalty-tokenCLI flags. The doctrine guard "tokens loaded once at startup" now passes by structure, not by# noqaexception.
Verify trace
$ make doctrine-check
✓ all 18 guards pass
$ make e2e
6 passed, 4 skipped in 23.16s
What's next — v1.4.1
The four xfails carry written reasons. Natural v1.4.1 scope:
catalog promotion exhaustadmin verb (small, 1–2 hours) — unlocks the exhausted-promo spec.- Mock-orchestrator seam OR recorded LLM fixtures (architectural, 3–4 hours) — unlocks the three cockpit LLM specs.
Full changelog
v1.3.1 — Unassign + synced demo seed
BSS-CLI v1.3.1 — Unassign + synced demo seed
Patch on v1.3.0.
Added
bss promo unassign --promo X --customers a,b,c— reverses a targeted-promo assignment cleanly. Deletes the BSS eligibility row AND clears the upfront-minted loyalty offer. Loyalty FSM correctness: triesoffer.expirefirst (the terminal lane for an issued-but-unclaimed offer); falls back tooffer.revokeif the customer already claimed it (placed an order with the promo). A loyalty refusal at clear time degrades to aloyalty_expire_failed_driftwarning — never blocks the BSS-side delete (the gate must close even if loyalty drifts; operator reconciles later, replay is safe via deterministic offer ids).LoyaltyClient.expire_offeradded (issued → expiredin loyalty's FSM). v1.3.0 broughtadvance_to_claimedback for the claim lane; this completes the state-machine surface BSS exercises.- Synced demo seed:
make seed-demo/make seed-demo-reset. New modulepackages/bss-seed/bss_seed/demo.pyproduces a coherent demo dataset across BSS + loyalty in lockstep — 3 demo customers (auto-synced into loyalty via CRM's eager-sync), 1 public typed promo (PROMO_DEMO_WELCOME/ codeDEMO_WELCOME10), 1 targeted promo (PROMO_DEMO_VIP), and 2 customers assigned to the targeted one (upfront pairing minted in loyalty per v1.3.0). Idempotent. Demo-prefix surgical reset (*[email protected]+PROMO_DEMO_*) — never touches operator data. BSS-only mode supported: whenBSS_LOYALTY_API_TOKENis unset, the customer half still runs; the promo lane skips with a log line.
Tests
- 1826 tests green. New regression: unassign expires issued offers; falls back to revoke for claimed; degrades when loyalty refuses.
Backwards compatibility
- Reference-data seed (
make seed) and the existing repo-root helpers (seed_targeted_campaign.py/backfill_loyalty_customers.py) unchanged.
🤖 Generated with Claude Code
v1.3.0 — Customer↔offer pairing upfront
BSS-CLI v1.3.0 — Customer↔offer pairing upfront
What changed
bss promo assignnow also mints the loyalty customer↔offer pairing viaoffer.issue. Loyalty's per-customer views and campaign rosters reflect the assignment the moment the operator runs it — no more "BSS thinks they're paired but loyalty has no idea until they order."- Activation switches to
offer.advance_to_claimedfor the targeted lane. The path retired in v1.1.1 is back — but only for this lane. Public typed codes are unchanged: stilloffer.claim(source=promo_code)at activation. - Doctrine reversal (explicit): partially reverses the 2026-05-22 v1.1.1 "consume collapsed to one path" amendment. Claim-by-code remains the sole path for public typed codes; targeted gets back its issue-then-advance flow with the audit/visibility wins.
- New column
catalog.promotion_eligibility.loyalty_offer_id(migration 0029) stores the deterministic offer idOFF-<customer_id>-<promotion_id>so COM can advance at activation. - Graceful degradation: if
offer.issuerefuses or loyalty is unreachable at assign time, the BSS eligibility row still goes in withloyalty_offer_id=NULLand acatalog.promotion.loyalty_issue_failed_degradewarning is logged. At activation COM transparently falls back to claim-by-code — same behaviour as a pre-v1.3.0 row.
Bundled fix
fix(subscription): partial unique on msisdn/iccid (exclude terminated rows)(migration 0028). Cancelled subscriptions retain their old phone numbers for audit. Before this, the broadUNIQUE (msisdn)constraint blocked inventory from recycling those numbers — every signup that picked a recycled number bricked atsubscription.createafter KYC + payment had succeeded, stranding the orderin_progress. Replaced with partial unique indices excluding terminated rows.
Backwards compatibility
- Pre-v1.3.0 eligibility rows have
loyalty_offer_id=NULL. The fallback path covers them — no backfill required. - Public typed code activation path unchanged.
Tests
- 1823 tests green. New regression: assign mints upfront, loyalty-issue degrade falls back cleanly, targeted activation advances the pre-paired offer, pre-v1.3.0 rows go through claim-by-code.
🤖 Generated with Claude Code