Skip to Content

← All archived runs

Run: saas-vendor-storyline

run.md

Run: saas-vendor-storyline

  • branch: claude/saas-vendor-storyline-pipeline-447zaj
  • pr: #818

02_define/output/spec.md

Spec: Author the SaaS-vendor services-arm storyline

  • slug: saas-vendor-storyline
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • touches: packages/services/src/db/services/demo-data/storyline, packages/services/src/db/services/demo-data/index.ts
  • complexity: complex

Problem

Marketing pitches Sustentus at SaaS vendors who sell licences rather than services, while the demo world every prospect actually sees models an IT consultancy — Calderon Technology Partners, whose catalogue is SAP, Oracle, Salesforce and Microsoft. The audit recorded the contradiction as ICP ambiguity A1 (.icm/intake/demo-data-quality/_source/audit-report.md, Phase 2) and Jamie settled it in decision D6: reframe the demo world as a software vendor whose services arm delivers implementations of its own product. Until that lands, the demo argues against the pitch on the initiative it exists to serve — Refine the bridge / Q2-2026 Objective 1, Establish Product-Market Fit with Vendor Partners — and two smaller data defects ride along with it: every seeded lead carries languages: ["GB"] (a country code in a language field), and shared copy pools recycle visibly once enough records are on one screen (D8).

Stub 9 (#815, merged) extended the Storyline contract and the generator so every dark surface seeds from content alone. This run supplies that content.

Proposed change

Author a new storyline module against the merged contract and re-point the swap point at it. The world is a fictional B2B software vendor: the catalogue is its own product's modules, and every engagement is an implementation, integration, migration, training or optimization of that product for one of its customers — mirroring the service menu marketing already publishes (Rapid Implementation, Team Training, Ongoing Optimization, Premium Support).

What the ICP research validated carries over unchanged: a Northern-European customer base across recognisable sectors, EUR throughout, roughly €30k–€150k fixed-price engagements, ~25 live engagements plus a ~36-engagement closed tail over 18 months, and the three narrative accounts the analytics depend on — an anchor account, a failing-CSAT account, and a declining at-risk account. Every name is fictional and every address sits on an .example domain (RFC 2606), so nothing can receive mail by accident. The six Clerk-provisioned persona logins (demo+…@apoyar.eu) are carried across verbatim and matched by email; the storyline never creates or renames them.

Per-engagement copy is authored for every engagement — blockers, action items, customer and team messages, change controls and CSAT verbatims — so no single screen shows the same sentence twice (D8). The shared pools stay as the contract's fallback but no engagement relies on them.

Two code changes ride with the content, both minimal:

  • language joins the contract. languages: ["GB"] is hardcoded in the generator (demo-data/index.ts:1590), so it cannot be fixed by data alone. StorylineAccount gains an optional language (ISO 639-1, lowercase) and the generator reads account.language ?? "en" at the lead-create site and in the SDM coverage cell it derives. That axis is not cosmetic: outreach cells are keyed (platform, product, region, language), and one hardcoded value collapses every seeded lead into a single language column. Calderon keeps working untouched via the default.
  • The contract suite follows the active storyline. storyline/default.test.ts asserts ~40 invariants against defaultStoryline by name. It is re-pointed at activeStoryline so the suite always guards whichever world is live, and its three narrative-account keys (saltmarsh, tannerrowe, meridian) become named constants at the top of the file, updated to the new module's keys.

The authored content tables go in the PR description for Jamie's explicit sign-off before merge — the same gate Calderon's tables passed (.icm/runs/demo-seed-storyline/03_define/output/spec.md).

Acceptance criteria

  • A new module under demo-data/storyline/ exports a Storyline for the SaaS-vendor world, and storyline/index.ts re-points activeStoryline at it. defaultStoryline (Calderon) is still exported and still compiles.
  • StorylineAccount gains an optional language (ISO 639-1, lowercase); the generator reads account.language ?? "en" for lead.languages and for the coverage cell it derives. No other generator behaviour changes.
  • The catalogue names the vendor's own product modules, with services mirroring the published menu; every valid product → service path resolves to at least one skill, and platform, product and skill names are each distinct.
  • ~25 live engagements covering all nine journey stages; every qualified-out engagement carries a reason and no value, every live one carries a value in the €30k–€150k band.
  • A closed tail of ~36 engagements inside the last 18 months, with engagements in both the current 30-day window (closedMonthsAgo: 1) and the prior one (2), and some paid engagements left without a CSAT response so revenueAwaitingCSAT is non-zero.
  • The analytics tell a story: closed value in the last 30 days exceeds the prior 30, the failing-CSAT account scores under 8 on every response, the at-risk account has closed nothing within the last three months, and the anchor account appears in both the pipeline and the tail.
  • Dark-surface coverage is complete: all three invoice states, each on its own in_progress engagement and at most two per engagement; quote_draft plus a quote_rejected on a quoting engagement; at least one blocked milestone on an in_progress engagement; at least one engagement with overdue action items and one with staleDays above 49; all four expert-evidence states with a reason on every rejected row; exactly four outreach dispositions covering all four action states.
  • Every persona seat has authored notifications; the CSM, SDM and Expert seats pad past 50 rows; the CSM seat mixes read and unread; priorDay.atRiskDelta is non-zero.
  • Every pipeline and closed engagement authors its own copy — no engagement falls back to the shared pools for blockers, action items, messages, change descriptions or CSAT verbatims.
  • Accounts and experts are fictional, on .example domains, with distinct emails; each account carries a country, a sales region and a language; exactly one account and one expert are marked isPersona, matching the existing Clerk logins by email.
  • The contract suite runs against activeStoryline with the narrative keys as constants, and passes; the determinism tests pass, and the module contains no Math.random, Date.now or other non-reproducible value, so two resets produce the same world.
  • The authored content tables are in the PR description and Jamie's sign-off is recorded on the PR before merge.

Out of scope

  • Deleting the Calderon module — it stays behind the swap point as the reference/spare storyline.
  • Keeping Calderon under test. Re-pointing the suite at activeStoryline is the agreed trade-off: one home for the contract invariants, and the spare is no longer asserted against.
  • Reconciling the marketing and docs ICP copy (audit Phase 2, A1–A8) — copy work, deferred at the whole-scope level.
  • Any generator mechanics beyond reading account.language; the contract from #815 is taken as given and is not extended further.
  • Clerk provisioning of the persona logins — they exist already and are matched by email only.

Open questions

  • The vendor's name, its product's name and the module filename are authored at Build and reviewed as part of the content tables on the PR. Non-blocking: the sign-off gate is where they are agreed.

03_build/output/notes.md

Build notes: saas-vendor-storyline

  • commits: feat: saas-vendor-storyline — author the Orrery Software world, feat: saas-vendor-storyline — read the account language from the contract, test: saas-vendor-storyline — point the contract suite at the active storyline

What changed

  • demo-data/storyline/saas-vendor.ts (new): the Orrery Software world — a fictional B2B software vendor whose services arm implements its own product. Ten Northern-European accounts across five regions and five languages, six delivery consultants, a 25-engagement live pipeline covering all nine journey stages, and a 36-engagement backdated tail across eighteen months. Every one of the 61 engagements authors its own copy (blockers, action items, customer and team messages, change controls, CSAT verbatims), so nothing on a single screen repeats — 145 distinct blocker labels with no duplicate.
  • demo-data/storyline/index.ts: activeStoryline re-pointed at the new module. defaultStoryline (Calderon) still exported as the reference/spare world.
  • demo-data/storyline/types.ts: StorylineAccount gains an optional language (ISO 639-1). Defaults to en when a storyline omits it, so Calderon is unaffected.
  • demo-data/index.ts: the lead-create site reads account?.language ?? "en" instead of the hardcoded ["GB"]. That value is one of the four SDM coverage-cell dimensions, so the literal collapsed every seeded lead into a single language column; it was also a country code sitting in a language field.
  • demo-data/storyline/default.test.tsactive.test.ts: the contract suite now runs against activeStoryline, with the three narrative account keys as named constants at the top of the file. Five tests added for criteria the inherited suite did not cover (window delta, account completeness, .example domains, authored copy on every engagement, blocker-label uniqueness), and the persona test now asserts the Clerk logins by address rather than a storyline-specific key.

Acceptance criteria status

  • New module exports a Storyline; index.ts re-points activeStoryline; defaultStoryline still exported — Calderon is untouched on disk.
  • StorylineAccount.language added; generator reads account?.language ?? "en" for lead.languages and the derived coverage cell. No other generator behaviour changed.
  • Catalogue is Orrery's own product suites and modules (four platforms, twelve modules), with services mirroring the published menu. All 8 services covered by at least one of 10 skills; every skill names a defined service; platform, product and skill names all distinct.
  • 25 live engagements, 3/3/3/3/4/3/2/2/2 across the nine stages. Both qualified-out engagements carry a reason and no value; every live engagement has a value in the €34k–€145k range.
  • 36 closed engagements across months 1–18, with 4 in the current window and 4 in the prior one; 4 paid engagements left without a CSAT response.
  • Analytics tell a story: M1 closes 318k against M2's 272k; Thornhill scores 6, 7, 6 (all under the cutoff); Merrivale's most recent close is 4 months ago and its values decline 58k → 45k → 34k; Havenbrook appears 4 times in the pipeline and 5 times in the tail.
  • Dark-surface coverage: three invoice states, one per in_progress engagement; quote_draft plus quote_rejected on a quoting engagement; one blocked milestone on in_progress; two engagements with overdue action items and one at staleDays: 70 (past the 49-day threshold); four evidence rows covering all four states with a reason on the rejected one; exactly four outreach dispositions.
  • All six persona seats have notifications; CSM/SDM/Expert pad to 56/54/52; the CSM seat mixes read and unread; priorDay.atRiskDelta is 2.
  • Every pipeline and closed engagement authors its own copy — asserted by two new tests rather than by inspection.
  • Accounts and experts are fictional on .example domains with distinct emails (16, no duplicates); every account carries country, region and language; exactly one persona account and one persona expert, both matching the existing Clerk addresses.
  • Suite runs against activeStoryline with narrative keys as constants. Module contains no Math.random, Date.now or new Date( — verified by grep, so two resets reproduce the world.
  • Jamie's sign-off on the content tables — the tables are posted on the PR; the tick is his and belongs to the merge gate, not to Build.

Notes for Verify

  • The one open criterion is the sign-off, which is a Ship-gate item by design. Everything else is met.
  • Structural verification was done by script, not by eye. The checks above (counts, stage distribution, window totals, invoice/quote placement, blocker uniqueness, catalogue closure, persona counts, determinism) were run against the authored file before commit; the same invariants are now asserted by active.test.ts, which CI runs. I did not run the suite locally — the Quality workflow is the signal.
  • Calderon is deliberately no longer under test. That was the agreed trade-off when the suite was re-pointed at activeStoryline (spec → Out of scope). It still compiles, and tsc covers that much; if it is ever reactivated, the suite follows it automatically.
  • Worth a close look: the generator now reads account?.language. The fallback keeps Calderon identical, but the SDM coverage grid is the surface that changes shape most — it should now show five language columns (nl, sv, en, de, da, fr) instead of one, and the outreach dispositions cycle across more cells than before.
  • The .example assertion deliberately exempts the two persona rows, whose addresses are real Clerk logins on apoyar.eu.

04_verify/output/verify.md

Verify: saas-vendor-storyline

  • production-readiness: run — 1 blocking finding (seeded experts matched no seeded lead), 3 advisories. Blocker fixed on branch; 2 advisories actioned on Jamie's call, 2 recorded below.
  • code-review: high effort (spec complexity: complex) — 5 findings. 2 fixed on branch, 2 escalated to Jamie and then fixed on his call, 1 recorded as latent.
  • security-review: not required — the diff adds no auth, payments, PII or route-policy change. It changes one notification's link target, which is a data value read by an existing policy, not a policy. All seeded people and companies are fictional on RFC 2606 .example domains.
  • playwright: TODO — manual DoD smoke performed instead

DoD smoke (on the preview — each line says who verified it)

Agent-run

  • Preview responds — web-git-claude-saas-vendor-storyline-pipeline-447zaj returns 200 and redirects to /sign-in, so every acceptance criterion below this line is behind auth (agent)
  • New module exports a Storyline; activeStoryline re-pointed; defaultStoryline still exported — traced in storyline/index.ts, asserted by the contract suite (agent)
  • StorylineAccount.language read by the generator for lead.languages and the coverage cell — traced at demo-data/index.ts lead-create site (agent)
  • Catalogue closure, stage spread (3/3/3/3/4/3/2/2/2), value band, 18-month tail with both windows populated, dark-surface coverage, notification padding, per-engagement copy (145 distinct blocker labels, no duplicate) — asserted by active.test.ts, green in CI (agent)
  • Analytics beats: M1 318k vs M2 272k, Thornhill 6/7/6, Merrivale's most recent close 4 months back declining 58→45→34, Havenbrook in both pipeline and tail — asserted in CI (agent)
  • Determinism — no Math.random, Date.now or new Date( in the module (agent, grep)

Operator-demonstrated — NOT PERFORMED; waived at Ship

Jamie authorised the merge without this half (see 05_ship/output/release.md). The lines below stay unticked because they were never run — not because they passed. The reset-and-approve matching check is the one to do first after deploy.

The agent has no preview credentials, so none of the following is claimed. Each needs a demo-tenant reset first (admin → demo data → reset), because the storyline swap only takes effect on a reseed and a Populate-without-reset leaves stale sdm-outreach-state rows keyed on the old language.

  • auth: each affected persona still signs in and reaches their dashboard (operator)
  • The vendor dashboard reads positive vs the prior window; Thornhill shows on Failing CSAT; Merrivale shows on renewals / revenue-at-risk (operator)
  • Customer persona (Havenbrook) — workspace shows the blocked milestone (operator)
  • Expert persona (Bergqvist) — work queue shows the overdue items and the red SLA band on the customs engagement (operator)
  • Finances — draft invoice in the approvals queue; disputed and rejected chips render (operator)
  • SDM coverage grid shows multiple language columns rather than one (operator)
  • Matching regression check — open a BRD-stage seeded lead, approve it, confirm the match list survives with real experts rather than collapsing to fitScore: 0 (operator). This is the one that would have caught the blocking finding, and nothing in CI exercises it.
  • Create a lead in-app, approve its BRD, confirm it matches experts — the path the language default fix exists for (operator)
  • notifications: none expected — the seeder writes notification documents directly through the models, not through the notify* wrappers, so no email or Ably publish fires from a reseed (agent-traced; operator to confirm the bell renders and paginates past 50 rows)
  • payments: not touched (operator to confirm nothing regressed if convenient)

Findings & cleanup

Fixed on branch

  • Seeded experts matched no seeded lead (16c23b2) — the storyline's own regression, and the most serious finding of the pass. filterEligibleExperts treats language as a hard filter; it had been passing by accident because every lead said GB and every expert inherited the ["gb"] schema default. Giving leads real codes broke the accident. Worse than an empty list: runLeadExpertMatcher fires on BRD approval and upserts onto the same key the seeder wrote good matches to, so approving a BRD would have overwritten seeded matches with fitScore: 0. Fixed by adding StorylineExpert.languages, authoring it on the six consultants, and writing it in the generator — as an explicit update, not just the create factory, because Clerk-backed personas survive every reset and would otherwise keep ["gb"] forever. Two contract tests now pin the invariant.
  • Expert persona permanently unavailable (9fb3247) — isAvailable defaults to false and was create-only, so the signed-in expert was excluded from matching on any pre-existing tenant. Same root cause, same fix, folded into the same update.
  • CSM notification linked to a vendor-only route (9fb3247) — /vendor/csat is roles: ["vendor"], so the CSM clicking their own bell was bounced. Inherited from the Calderon storyline. Now /csat, permission-gated on csat.view.
  • ["GB"] language defaults, platform-wide (6ca2623) — escalated to Jamie as a scope decision; his call was to fix it here rather than file it. lead.languages and user.languages both move to ["en"], and they must move together — changing either alone excludes every expert from every lead, since today's data passes the gate only because both sides say gb. Migration 1786924800000-language-codes-not-country-codes backfills, deliberately narrow: only rows still holding the exact single-element default, so authored values survive and down reverses a precise set. Renamed off a timestamp that collided with #817's migration after merging main.
  • Titles contradicted the product chip (6ca2623) — escalated to Jamie; his call was to retitle. The generator derives each lead's product from a hash of account:title, so titles naming a module ("Orrery Core rollout…") disagreed with the chip beside them on roughly half the board. Fifteen titles now describe the work; module names stay in the proposal prose, where nothing contradicts them. PR content tables updated to match.

Accepted / recorded — not fixed here

  • utils/languages.ts is a trap, not a bug. Its 22 entries are uppercase and include no Swedish or Danish, and getLanguageByCode matches case-sensitively — so sv, da and even nl would miss. Harmless today: all three helpers have zero call sites outside the package's own build output, which the production-readiness and code-review passes disagreed about until I checked. The moment anything renders a language label the seeded world goes blank. Worth either extending and case-normalising that list or deleting it as dead code — its own small chore, not this run.
  • Stale outreach rows on a Populate-without-reset. SdmOutreachState is upserted on a key that includes language, so a tenant seeded before this change keeps rows on the old value and a Populate writes new ones beside them. They render nothing (cells derive from seeded leads) but accumulate. resetDemoTenant purges the collection, so the mitigation is to reset, not populate, on the first deploy of this storyline — noted in the smoke steps above and worth a line in the ship note.
  • Calderon is no longer under test. Agreed at Define and recorded in the spec's Out of scope; tsc still proves it satisfies Storyline, and it now benefits from the same ["en"] fallback on both sides, so it is consistent rather than newly broken. Delete it if it is not genuinely a spare.

Notes for Ship

  • apps/docs/app/technical/demo-environment/page.mdx describes the demo world in Calderon's terms and needs updating for Orrery Software — docs-sync's job at Ship.
  • Rollback is clean for the storyline itself: revert the one-line activeStoryline re-point and the world returns to Calderon. The language migration is separate and has a working down.
  • main moved twice during this run (#817, #819) and was merged in at 6ca2623.

Context budget: went beyond the Inputs table into matching/filter-eligible-experts.ts, models/{user,lead}.ts, demo-reset/index.ts, route-policies.ts and utils/languages.ts to verify the reviews' findings rather than take them on trust. Two of the five code-review findings did not survive that check as stated.

05_ship/output/investor-update.md

The demo world now matches who we sell to

Who it's for: every persona — the demo tenant is what prospects see What shipped: the demo world is now a software vendor implementing its own product, not a consultancy integrating other vendors'. Why it matters: Refine the Bridge, Q2 Objective 1 — the demo no longer argues against the pitch.

Ten Northern-European customers, 25 live engagements, 18 months of authored history.

Dig deeper: https://github.com/sustentus/sustentus/pull/818 · https://help.sustentus.com/changelog/2026-08-17-saas-vendor-storyline

05_ship/output/release.md

Ship: saas-vendor-storyline

  • pr: #818 · merge: authorised — Jamie ticked Ready to merge; this commit rides the squash.
  • CI: green. Getting there took a CI fix of its own. From ~13:34 the checks/actions API returned 403 Resource not accessible by integration to both the session token and the GitHub MCP path, and the same condition failed four consecutive Quality runs inside dorny/paths-filter's listFiles call — before any check body ran, and not clearable by re-running. The workflow requested pull-requests: read correctly, so the fault was above it; 2defd54 removes the dependency by passing base, so the step diffs against the base branch with git instead. Jamie confirmed the resulting run green, and GitHub's own mergeable_state moved blockedclean. The agent could not read the check runs directly at any point — that 403 never lifted.
  • technical docs: apps/docs/app/technical/demo-environment/page.mdx — persona runbook table (vendor, expert and anchor-customer rows), the storyline description, the swap procedure, and a new rule recording that languages must line up or the world matches nobody.
  • business docs: no business docs impact — the change swaps demo content and fixes a data-shape bug; no persona capability, service-journey step or feature-role-matrix entry changes.
  • release notes: both — changelog entry apps/help/app/changelog/2026-08-17-saas-vendor-storyline/page.mdx and the ship note at 05_ship/output/investor-update.md.
  • sent: none — the ship note sends on merge, and the merge has not been authorised.

Acceptance check (vs spec)

Structural criteria are asserted by active.test.ts and were verified locally by script before each commit. They have not been confirmed by a green CI run on the current head, and the signed-in half of the Definition-of-Done smoke has not been performed at all.

  • New module exports a Storyline; activeStoryline re-pointed; defaultStoryline still exported — traced in storyline/index.ts
  • StorylineAccount.language added; generator reads account?.language ?? "en"
  • Catalogue is the vendor's own modules; every service covered by a skill; names distinct
  • 25 live engagements across all nine stages; values in band; qualified-out carry a reason
  • 36 closed across 18 months; both windows populated; some paid awaiting CSAT
  • Analytics beats — M1 318k vs M2 272k; Thornhill 6/7/6; Merrivale silent 4 months, declining 58→45→34; Havenbrook in pipeline and tail
  • Dark-surface coverage complete — invoice, quote, blocked milestone, overdue, stale, evidence, outreach
  • Notifications on all six seats; CSM/SDM/Expert past the 50-row boundary; CSM mixes read state
  • Every engagement authors its own copy — 145 distinct blocker labels, no duplicate
  • Fictional on .example; distinct emails; country, region and language on every account
  • Suite runs against activeStoryline; no Math.random, Date.now or new Date(
  • Content tables signed off — the tables are in the PR description and Jamie ticked Ready to merge against them
  • Operator DoD smoke — waived, not performed. See below.

The one thing this run shipped without

The Definition of Done asks for every acceptance criterion demonstrated on the preview. The signed-in half never was: no persona sign-in, no demo-tenant reset, no walkthrough of the analytics, workspace, finances or coverage surfaces, and — the one that matters most — no BRD-approval matching check. Every operator line in 04_verify/output/verify.md is still unticked, and they are unticked because they were not done, not because they failed.

That check is not ceremony. The blocking finding this run produced (16c23b2) was invisible on every static screen and would only have shown itself by approving a BRD on a seeded lead and watching the match list collapse to fitScore: 0. Nothing in CI exercises the seeder against a database, so the suite cannot stand in for it. The fix is believed good and is pinned by two contract tests, but "believed good" is what it remains.

Jamie was told this twice and authorised the merge anyway; that is his call to make and it is recorded here rather than quietly dropped. First action after deploy should be the reset-and- approve check — if it fails, revert is one line (activeStoryline back to defaultStoryline in storyline/index.ts); the language migration is independent and has a working down.

Everything else Ship owed is in this PR: docs synced, changelog written, ship note written with both Dig deeper links filled. The merge carries all of it and ship-note.yaml sends the note to #product-update.