Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Releases: chiam-ck/bss-cli

v1.8.1 — returning-customer signup resume

Choose a tag to compare

@chiam-ck chiam-ck released this 07 Jul 10:01
9026f22

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_method behind a generic error. POST /signup now 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, and policy.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

Choose a tag to compare

@chiam-ck chiam-ck released this 05 Jul 07:43
b02bf86

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-branding package — 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; a bss-cli v1.8.0 footer footnote carries product attribution (never rebranded).
  • Chat greeting unifiedBSS_OPERATOR_NAME becomes an explicit override; unset, the bot greets with [branding].brand_name.
  • New accent_alt slot 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_NAME in .env now 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

Choose a tag to compare

@chiam-ck chiam-ck released this 15 Jun 08:11

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 endpoint
  • CatalogClient.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, stamps valid_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

Choose a tag to compare

@chiam-ck chiam-ck released this 12 Jun 08:33
8e2a7bb

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 through bss-clients and the tool registry. operator_cockpit profile 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 boundsBSS_LLM_MAX_TOKENS (default 2048) caps every completion; BSS_LLM_FREQUENCY_PENALTY ships as an off-by-default knob.
  • Stuck-loop bail — three consecutive identical (tool, args, result) triples end the stream with the same agent_loop_bailout panel 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.now was broken since inception — the tool def shadowed its own bss_clock.now import, so the tool called itself and every invocation raised AttributeError. 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_session fixture moved to commit→flush rollback isolation, neutralizing FK-referenced scenario leftovers via in-txn UPDATE instead of DELETE. make test is fully green (24 suites) against a lived-in dev DB for the first time.
  • make lint is 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 a lint-types target 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

  • Local venvs: use uv sync --all-packages — plain uv sync prunes workspace members.
  • Merges: #46, #47, #48 (release bump).

v1.6.0 — Cockpit CRM workbench

Choose a tag to compare

@chiam-ck chiam-ck released this 10 Jun 02:10
7cc5392

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_priority had 422'd since v0.13; field updates are now policy-gated (case.update.case_is_closed, case.update.invalid_priority) and emit case.updated.
  • The entire ticket client surface had drifted from the CRM service: open_ticket 422'd on every call, assign_ticket was a silent no-op, transition_ticket 422'd on every call. Fixed with a ticket state→trigger map (in_progress disambiguated via one read).
  • CRMClient.list_cases sent agentId; the service reads assignedAgentId — the agent filter was silently ignored.
  • CRMClient.transition_case couldn't express resume; direct trigger= override added.
  • The v0.13 case page read camelCase keys off the snake_case Case DTO, silently blanking fields; bss_csr.views.field reads both spellings.
  • COM GET /productOrder required customerId; now optional with state/limit/offset for the cross-customer queue.

Ops

  • New runbook: docs/runbooks/litellm-proxy.md — LiteLLM model-route rules (the deepseek/ prefix hits DeepSeek's native API, which rejects dotted tool names; use openrouter/... routes) and the Docker network re-attach after a proxy recreate.
  • Deployment default flipped to openrouter/google/gemma-4-31b-it via 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

Choose a tag to compare

@chiam-ck chiam-ck released this 26 May 06:49

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 autonomously

Fail-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_turn heuristic. That silently lost the granular re-gate signal on /confirm-resumed turns. Rewired to derive strictly from COMPLETED-with-DESTRUCTIVE_OPERATION_BLOCKED in 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 /confirm slash command was a no-op marker (returned a never-consumed string). The operator had to type a second prompt after /confirm to 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_FAILURES contract, _ASSISTANT_CHROME_PREFIXES inventory 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_data always-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

Choose a tag to compare

@chiam-ck chiam-ck released this 26 May 01:06

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.zip download. 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_PATH is opt-in. Real-provider runs are bit-for-bit unchanged.
  • bss promo exhaust is intentionally terminal (no reactivate verb until a real use case appears).
  • Wall-clock datetime.now() calls in bss_e2e/report.py and conftest.py carry # 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

Choose a tag to compare

@chiam-ck chiam-ck released this 25 May 10:10

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 exhaust verb. 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_CLAIM guard + bss_orchestrator.safety.DESTRUCTIVE_TOOLS are covered by bss-cockpit unit 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 hung

Reports 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:

  1. MSISDN dynamic reservation — hardcoded 81000900 collided with a stale subscription on the second run. pick_available_msisdn() queries inventory.msisdn_pool at fixture time, every spec picks a fresh number.
  2. is_returning=True trap — 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).
  3. Step-up auto-replayauth_step_up_replay.html auto-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.demo no longer reads BSS_LOYALTY_API_TOKEN from os.environ inside Python. The Makefile shell reads $BSS_LOYALTY_BASE_URL / $BSS_LOYALTY_API_TOKEN and threads them as --loyalty-base-url / --loyalty-token CLI flags. The doctrine guard "tokens loaded once at startup" now passes by structure, not by # noqa exception.

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 exhaust admin 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

  • #41feat(v1.4.0): playwright e2e suite — 6 specs green, 4 deferred xfail
  • #39fix(v1.3.2): demo seed loyalty token via CLI flags, not os.environ
  • v1.4 scaffolding (plan doc + skeleton) — merged ahead of phase 2

v1.3.1 — Unassign + synced demo seed

Choose a tag to compare

@chiam-ck chiam-ck released this 25 May 07:59

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: tries offer.expire first (the terminal lane for an issued-but-unclaimed offer); falls back to offer.revoke if the customer already claimed it (placed an order with the promo). A loyalty refusal at clear time degrades to a loyalty_expire_failed_drift warning — 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_offer added (issued → expired in loyalty's FSM). v1.3.0 brought advance_to_claimed back for the claim lane; this completes the state-machine surface BSS exercises.
  • Synced demo seed: make seed-demo / make seed-demo-reset. New module packages/bss-seed/bss_seed/demo.py produces 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 / code DEMO_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: when BSS_LOYALTY_API_TOKEN is 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

Choose a tag to compare

@chiam-ck chiam-ck released this 25 May 07:33

BSS-CLI v1.3.0 — Customer↔offer pairing upfront

What changed

  • bss promo assign now also mints the loyalty customer↔offer pairing via offer.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_claimed for the targeted lane. The path retired in v1.1.1 is back — but only for this lane. Public typed codes are unchanged: still offer.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 id OFF-<customer_id>-<promotion_id> so COM can advance at activation.
  • Graceful degradation: if offer.issue refuses or loyalty is unreachable at assign time, the BSS eligibility row still goes in with loyalty_offer_id=NULL and a catalog.promotion.loyalty_issue_failed_degrade warning 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 broad UNIQUE (msisdn) constraint blocked inventory from recycling those numbers — every signup that picked a recycled number bricked at subscription.create after KYC + payment had succeeded, stranding the order in_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